From bdd966d2fe44495d3a6c5c9579e1eb93f82ac744 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:07:57 -0300 Subject: [PATCH 1/9] feat: add error code ranges for kit contracts Each kit contract owns a block of error codes, and a failure inside a called contract reaches the caller with that contract's code. The blocks live in strata-bindings with a check that they never overlap, and error_enum! declares an error enum whose build fails when a variant's code leaves its contract's block. --- crates/strata-bindings/src/errors.rs | 100 +++++++++++++++++++++++++++ crates/strata-bindings/src/lib.rs | 4 ++ 2 files changed, 104 insertions(+) create mode 100644 crates/strata-bindings/src/errors.rs diff --git a/crates/strata-bindings/src/errors.rs b/crates/strata-bindings/src/errors.rs new file mode 100644 index 00000000..1197ca54 --- /dev/null +++ b/crates/strata-bindings/src/errors.rs @@ -0,0 +1,100 @@ +//! The block of error codes each kit contract owns. A failure inside a called +//! contract reaches the caller with that contract's code, so the blocks never +//! overlap. + +/// An inclusive block of contract error codes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ErrorRange { + pub first: u32, + pub last: u32, +} + +impl ErrorRange { + pub const fn contains(&self, code: u32) -> bool { + self.first <= code && code <= self.last + } +} + +pub const ORACLE_ERRORS: ErrorRange = ErrorRange { + first: 3000, + last: 3999, +}; +pub const VAULT_ERRORS: ErrorRange = ErrorRange { + first: 6000, + last: 6999, +}; +pub const TOKEN_ERRORS: ErrorRange = ErrorRange { + first: 7000, + last: 7999, +}; +pub const COMPLIANCE_ERRORS: ErrorRange = ErrorRange { + first: 8000, + last: 8999, +}; + +const _: () = { + let ranges = [ORACLE_ERRORS, VAULT_ERRORS, TOKEN_ERRORS, COMPLIANCE_ERRORS]; + let mut i = 1; + while i < ranges.len() { + assert!( + ranges[i - 1].last < ranges[i].first, + "two contracts' error ranges overlap" + ); + i += 1; + } +}; + +/// Declares a contract's error enum. The build fails if a variant's code +/// leaves the range the contract owns. +/// +/// ``` +/// strata_bindings::error_enum! { +/// strata_bindings::VAULT_ERRORS, +/// #[repr(u32)] +/// pub enum DemoError { +/// First = 6000, +/// Last = 6999, +/// } +/// } +/// assert_eq!(DemoError::Last as u32, 6999); +/// ``` +/// +/// ```compile_fail,E0080 +/// strata_bindings::error_enum! { +/// strata_bindings::VAULT_ERRORS, +/// #[repr(u32)] +/// pub enum DemoError { +/// First = 6000, +/// FromTheToken = 7000, +/// } +/// } +/// ``` +#[macro_export] +macro_rules! error_enum { + ( + $range:path, + $(#[$attr:meta])* + $vis:vis enum $name:ident { + // `tt`, not `literal`: `contracterror` reads each code as a bare literal. + $($(#[$variant_attr:meta])* $variant:ident = $code:tt),+ $(,)? + } + ) => { + $(#[$attr])* + $vis enum $name { + $($(#[$variant_attr])* $variant = $code,)+ + } + + const _: () = { + let range: $crate::ErrorRange = $range; + $(assert!( + range.contains($code), + concat!( + stringify!($name), + "::", + stringify!($variant), + " is outside its contract's error range", + ), + );)+ + }; + }; +} diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index eee072f1..98f582c1 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,7 +1,11 @@ #![no_std] +mod errors; + use soroban_sdk::{contracttrait, contracttype, Address, Env}; +pub use errors::{ErrorRange, COMPLIANCE_ERRORS, ORACLE_ERRORS, TOKEN_ERRORS, VAULT_ERRORS}; + #[contracttype] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum OracleState { From c138dfa320199342344588f537a9f9096f9a8431 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:32 -0300 Subject: [PATCH 2/9] feat: declare each error enum within its range The oracle, vault, share token and compliance enums go through error_enum!, so a variant outside 3000-3999, 6000-6999, 7000-7999 or 8000-8999 fails the build. Every variant now says when it happens: InvalidConfig and NoRecord get a doc, and AmountTooLarge, AccountingMismatch, InvalidAmount, WindDownActive, NotInitialized and the oracle's AdminRequired describe what the code checks. Each enum's doc names its range. --- contracts/async-vault/src/error.rs | 325 +++++++++++++++-------------- contracts/compliance/src/lib.rs | 69 +++--- contracts/nav-oracle/src/lib.rs | 125 ++++++----- contracts/share-token/src/error.rs | 106 +++++----- 4 files changed, 328 insertions(+), 297 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 6d8420b4..43f91984 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -1,161 +1,170 @@ use soroban_sdk::contracterror; -/// Vault failures. Codes are stable once assigned and are never reused; a -/// removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum VaultError { - /// The constructor was given the same address for two authorities that - /// must be held separately. - RolesNotDistinct = 6000, - /// The holder has no request in the given epoch. - RequestNotFound = 6001, - /// An amount or share quantity of zero or less was specified. - InvalidAmount = 6007, - /// The holder already has an unclaimed request in this epoch. A - /// second request is rejected rather than added to the first, so that at - /// most one request per holder per epoch holds by construction. - RequestOutstanding = 6009, - /// The epoch total would exceed `i128::MAX`. - AmountTooLarge = 6014, - /// No epoch is stored under the given id. - EpochNotFound = 6029, - /// An entry the constructor writes is absent from instance storage, which - /// means the instance was archived or the contract was never constructed. - NotInitialized = 6030, - /// The oracle returned a share price of zero or less. Unreachable with a - /// correctly configured feed; kept because the oracle sits behind a - /// settable address. - InvalidSharePrice = 6031, - /// The epoch is not `Open`, so it cannot be closed or take new requests. - EpochNotOpen = 6032, - /// The epoch is not `Pending`, so it cannot be priced. An - /// epoch must be closed before it can be fulfilled. - EpochNotPending = 6038, - /// The epoch counter would exceed `u64::MAX`. - EpochOverflow = 6033, - /// The epoch has not been fulfilled yet, so it has no share price to - /// claim against. - EpochNotFulfilled = 6034, - /// The request was already claimed. Claiming is idempotent by rejection, - /// not by silently minting nothing twice. - AlreadyClaimed = 6035, - /// The vault does not hold enough to pay this claim yet. The liability - /// stands and the claim succeeds once the reserve covers it. - ClaimNotCovered = 6037, - /// The amount would deploy assets already owed to holders whose exit has - /// been priced but not yet claimed. - ReserveCommittedToExits = 6005, - /// No custodian has been set, so capital has nowhere to go. - CustodianNotSet = 6012, - /// The epoch is priced, so the request can no longer be cancelled. The - /// holder claims instead. - AlreadyPriced = 6039, - /// The call needs the admin. `unpause` refuses any other caller. - /// `renounce_admin` is always refused, because the vault must always have - /// an admin: governance hands over in two steps rather than stepping down - /// into nobody. - AdminRequired = 6040, - /// The epoch is sealed and the feed can price it, so the price it will take - /// is already knowable. Cancelling now would be declining a price after - /// seeing it. Fulfil and claim instead. - PriceAvailable = 6041, - /// The epoch closed less than its notice ago, so its price is not fixed - /// yet and every request in it is still waiting. - NoticeNotElapsed = 6042, - /// The standing valuation was accepted before the epoch closed. Pricing - /// against it would use a number the close already outran. - AttestationBeforeClose = 6043, - /// The feed is paused or stale, so the epoch has no price to take. - FeedNotValid = 6044, - /// The notice exceeds the longest the vault accepts. A notice no investor - /// could outlive would strand their exit with no way to undo it. - NoticeTooLong = 6045, - /// The vault is winding down. Entry, pricing and transfers to the custodian - /// are closed for the rest of its life. - WindDownActive = 6046, - /// No wind-down proposal stands. - WindDownNotProposed = 6047, - /// A proposal already stands. Re-proposing would let governance push the - /// activation date back indefinitely. - WindDownAlreadyProposed = 6048, - /// The announcement has not run its delay yet. - WindDownDelayNotElapsed = 6049, - /// The wind-down has not been activated, so there is nothing to distribute. - WindDownNotActive = 6050, - /// No round has been finalised, so there is no snapshot to claim against. - DistributionNotStarted = 6051, - /// The round would distribute nothing, or there is no supply to divide by. - NothingToDistribute = 6052, - /// The delay exceeds the longest the vault accepts. A delay nobody could - /// outlive would mean the vault never closes. - WindDownDelayTooLong = 6053, - /// Nothing to surrender and nothing owed. Claiming is idempotent by - /// rejection, as elsewhere in this contract. - NoEntitlement = 6054, - /// A code change already waits. Replacing one means cancelling it first, so - /// the standing eta is never quietly moved. - UpgradeProposalExists = 6055, - /// No pending change has this id. - ChangeNotFound = 6056, - /// The change's delay has not elapsed. - ChangeNotReady = 6057, - /// The delay is below the shortest the vault accepts. - UpgradeDelayTooShort = 6058, - /// The delay is shorter than the notice, so an investor could not complete - /// an exit before the change applies. Checked when a change is proposed and - /// again when it applies, since the notice may have risen in between. - UpgradeDelayBelowNotice = 6059, - /// The delay exceeds the longest the vault accepts. - UpgradeDelayTooLong = 6060, - /// The notice exceeds the upgrade delay, so an investor could not complete - /// an exit before an upgrade lands. - NoticeAboveUpgradeDelay = 6061, - /// A deposit request would breach the vault's configured deposit cap. - DepositCapExceeded = 6062, - /// As many changes as the timelock holds already wait. Apply or cancel one - /// first. - TooManyPendingChanges = 6063, - /// Setup is still open, so the timelock protects nothing yet: the vault - /// takes no deposit and queues no change. Governance finishes setup first. - SetupOpen = 6064, - /// A direct call cannot make this change. After setup it goes through - /// `propose_change`; setting a role admin is never allowed. - UseTimelock = 6065, - /// Setup has finished, and it never reopens. - SetupClosed = 6066, - /// A wiring check `health()` reports is false (token, oracle and compliance - /// bound, sole token manager, allowlisted, not frozen, matching decimals, - /// distinct addresses, a feed that answers, a custodian) or the token has - /// shares outstanding, so setup cannot finish. - SetupIncomplete = 6067, - /// The holder fails the share token's identity check, so the vault takes no - /// deposit request from them and returns no escrowed shares to them. - NotAllowlisted = 6068, - /// The wind-down delay is below the shortest the vault accepts. - WindDownDelayTooShort = 6069, - /// A running total would go below zero, which means the vault's books - /// disagree with themselves. Fails loudly instead of clamping to zero. - AccountingMismatch = 6070, - /// The holder is frozen. What the vault owes them is held, not paid, - /// returned or moved, until compliance lifts the freeze. A partial freeze - /// refuses only what would touch frozen shares. - Frozen = 6071, - /// The proposed feed does not name this vault as its own, its setup is - /// still open, or it does not answer `state()` and `freshness_duration()`. - /// Its admin could then change its limits with no delay, or no epoch could - /// be priced from it. - FeedNotBound = 6072, - /// The holder may not receive shares (delisted, frozen or refused by the - /// compliance rules), so the escrowed shares stay with the vault and the - /// request stays open. - HolderNotEligible = 6073, - /// An admin hand-over offered during setup is still open. Setup finishes - /// once it is accepted, cancelled or expired, so no nominee from setup can - /// accept after changes start to wait. - AdminTransferPending = 6074, - /// The custodian would be the vault itself. Capital deployed to it would - /// never leave, and the books would count it as both held and deployed. - InvalidCustodian = 6075, +strata_bindings::error_enum! { + strata_bindings::VAULT_ERRORS, + + /// Vault failures, coded 6000 to 6999. Codes are stable once assigned and + /// are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum VaultError { + /// The constructor was given the same address for two authorities that + /// must be held separately. + RolesNotDistinct = 6000, + /// The holder has no request in the given epoch. + RequestNotFound = 6001, + /// An amount or share quantity of zero or less was specified, or a + /// negative deposit cap. + InvalidAmount = 6007, + /// The holder already has an unclaimed request in this epoch. A + /// second request is rejected rather than added to the first, so that at + /// most one request per holder per epoch holds by construction. + RequestOutstanding = 6009, + /// A sum, count or conversion would not fit its type: a running total or + /// a conversion of assets or shares past `i128`, or an epoch's count of + /// open requests past `u32`. + AmountTooLarge = 6014, + /// No epoch is stored under the given id. + EpochNotFound = 6029, + /// An entry the constructor writes is missing from instance storage. A + /// constructed vault always has it. + NotInitialized = 6030, + /// The oracle returned a share price of zero or less. Unreachable with a + /// correctly configured feed; kept because the oracle sits behind a + /// settable address. + InvalidSharePrice = 6031, + /// The epoch is not `Open`, so it cannot be closed or take new requests. + EpochNotOpen = 6032, + /// The epoch is not `Pending`, so it cannot be priced. An + /// epoch must be closed before it can be fulfilled. + EpochNotPending = 6038, + /// The epoch counter would exceed `u64::MAX`. + EpochOverflow = 6033, + /// The epoch has not been fulfilled yet, so it has no share price to + /// claim against. + EpochNotFulfilled = 6034, + /// The request was already claimed. Claiming is idempotent by rejection, + /// not by silently minting nothing twice. + AlreadyClaimed = 6035, + /// The vault does not hold enough to pay this claim yet. The liability + /// stands and the claim succeeds once the reserve covers it. + ClaimNotCovered = 6037, + /// The amount would deploy assets already owed to holders whose exit has + /// been priced but not yet claimed. + ReserveCommittedToExits = 6005, + /// No custodian has been set, so capital has nowhere to go. + CustodianNotSet = 6012, + /// The epoch is priced, so the request can no longer be cancelled. The + /// holder claims instead. + AlreadyPriced = 6039, + /// The call needs the admin. `unpause` refuses any other caller. + /// `renounce_admin` is always refused, because the vault must always have + /// an admin: governance hands over in two steps rather than stepping down + /// into nobody. + AdminRequired = 6040, + /// The epoch is sealed and the feed can price it, so the price it will take + /// is already knowable. Cancelling now would be declining a price after + /// seeing it. Fulfil and claim instead. + PriceAvailable = 6041, + /// The epoch closed less than its notice ago, so its price is not fixed + /// yet and every request in it is still waiting. + NoticeNotElapsed = 6042, + /// The standing valuation was accepted before the epoch closed. Pricing + /// against it would use a number the close already outran. + AttestationBeforeClose = 6043, + /// The feed is paused or stale, so the epoch has no price to take. + FeedNotValid = 6044, + /// The notice exceeds the longest the vault accepts. A notice no investor + /// could outlive would strand their exit with no way to undo it. + NoticeTooLong = 6045, + /// The vault is winding down. New requests, epoch closes, pricing, + /// transfers to the custodian and governance changes are closed for the + /// rest of its life. + WindDownActive = 6046, + /// No wind-down proposal stands. + WindDownNotProposed = 6047, + /// A proposal already stands. Re-proposing would let governance push the + /// activation date back indefinitely. + WindDownAlreadyProposed = 6048, + /// The announcement has not run its delay yet. + WindDownDelayNotElapsed = 6049, + /// The wind-down has not been activated, so there is nothing to distribute. + WindDownNotActive = 6050, + /// No round has been finalised, so there is no snapshot to claim against. + DistributionNotStarted = 6051, + /// The round would distribute nothing, or there is no supply to divide by. + NothingToDistribute = 6052, + /// The delay exceeds the longest the vault accepts. A delay nobody could + /// outlive would mean the vault never closes. + WindDownDelayTooLong = 6053, + /// Nothing to surrender and nothing owed. Claiming is idempotent by + /// rejection, as elsewhere in this contract. + NoEntitlement = 6054, + /// A code change already waits. Replacing one means cancelling it first, so + /// the standing eta is never quietly moved. + UpgradeProposalExists = 6055, + /// No pending change has this id. + ChangeNotFound = 6056, + /// The change's delay has not elapsed. + ChangeNotReady = 6057, + /// The delay is below the shortest the vault accepts. + UpgradeDelayTooShort = 6058, + /// The delay is shorter than the notice, so an investor could not complete + /// an exit before the change applies. Checked when a change is proposed and + /// again when it applies, since the notice may have risen in between. + UpgradeDelayBelowNotice = 6059, + /// The delay exceeds the longest the vault accepts. + UpgradeDelayTooLong = 6060, + /// The notice exceeds the upgrade delay, so an investor could not complete + /// an exit before an upgrade lands. + NoticeAboveUpgradeDelay = 6061, + /// A deposit request would breach the vault's configured deposit cap. + DepositCapExceeded = 6062, + /// As many changes as the timelock holds already wait. Apply or cancel one + /// first. + TooManyPendingChanges = 6063, + /// Setup is still open, so the timelock protects nothing yet: the vault + /// takes no deposit and queues no change. Governance finishes setup first. + SetupOpen = 6064, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 6065, + /// Setup has finished, and it never reopens. + SetupClosed = 6066, + /// A wiring check `health()` reports is false (token, oracle and compliance + /// bound, sole token manager, allowlisted, not frozen, matching decimals, + /// distinct addresses, a feed that answers, a custodian) or the token has + /// shares outstanding, so setup cannot finish. + SetupIncomplete = 6067, + /// The holder fails the share token's identity check, so the vault takes no + /// deposit request from them and returns no escrowed shares to them. + NotAllowlisted = 6068, + /// The wind-down delay is below the shortest the vault accepts. + WindDownDelayTooShort = 6069, + /// A running total would go below zero, or the deposited capital the cap + /// is checked against would go below `i128::MIN`. Either means the vault's + /// books disagree with themselves; it fails loudly instead of clamping. + AccountingMismatch = 6070, + /// The holder is frozen. What the vault owes them is held, not paid, + /// returned or moved, until compliance lifts the freeze. A partial freeze + /// refuses only what would touch frozen shares. + Frozen = 6071, + /// The proposed feed does not name this vault as its own, its setup is + /// still open, or it does not answer `state()` and `freshness_duration()`. + /// Its admin could then change its limits with no delay, or no epoch could + /// be priced from it. + FeedNotBound = 6072, + /// The holder may not receive shares (delisted, frozen or refused by the + /// compliance rules), so the escrowed shares stay with the vault and the + /// request stays open. + HolderNotEligible = 6073, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 6074, + /// The custodian would be the vault itself. Capital deployed to it would + /// never leave, and the books would count it as both held and deployed. + InvalidCustodian = 6075, + } } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 34f9b4d0..5b7822b6 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -20,38 +20,43 @@ use strata_bindings::VaultBinding; pub use governance::{ComplianceChange, CompliancePendingChange, ADMIN_ACCEPT_LEDGERS}; -/// Compliance contract failures. Codes are stable once assigned and are never -/// reused; a removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ComplianceContractError { - /// No pending change has this id. - ChangeNotFound = 8001, - /// The change's delay has not elapsed. - ChangeNotReady = 8002, - /// As many changes as the timelock holds already wait. - TooManyPendingChanges = 8003, - /// Changes are instant until setup finishes; there is nothing to propose yet. - SetupOpen = 8004, - /// A direct call cannot make this change after setup: it goes through - /// `propose_change`. - UseTimelock = 8005, - /// Only possible while setup is open, and only once. - SetupClosed = 8006, - /// Setup cannot finish before the contract is bound to its vault. - VaultNotBound = 8007, - /// The vault is winding down, so no change may be proposed or applied. - WindDownActive = 8008, - /// The vault is paused, which holds every timelocked change. - VaultPaused = 8009, - /// The change's delay is shorter than the vault's notice, so an investor - /// could not complete an exit before it lands. - DelayBelowNotice = 8010, - /// An admin hand-over offered during setup is still open. Setup finishes - /// once it is accepted, cancelled or expired, so no nominee from setup can - /// accept after changes start to wait. - AdminTransferPending = 8011, +strata_bindings::error_enum! { + strata_bindings::COMPLIANCE_ERRORS, + + /// Compliance contract failures, coded 8000 to 8999. Codes are stable once + /// assigned and are never reused; a removed variant leaves its number + /// retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum ComplianceContractError { + /// No pending change has this id. + ChangeNotFound = 8001, + /// The change's delay has not elapsed. + ChangeNotReady = 8002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 8003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 8004, + /// A direct call cannot make this change after setup: it goes through + /// `propose_change`. + UseTimelock = 8005, + /// Only possible while setup is open, and only once. + SetupClosed = 8006, + /// Setup cannot finish before the contract is bound to its vault. + VaultNotBound = 8007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 8008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 8009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 8010, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 8011, + } } #[contractevent] diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index f94deabb..99623779 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -32,62 +32,75 @@ pub const MAX_FRESHNESS_SECS: u64 = 35 * strata_timelock::DAY_DURATION; /// below where the deviation arithmetic could overflow. pub const MAX_ANSWER: i128 = 1_000_000 * WAD; -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum OracleError { - InvalidConfig = 3001, - /// `nav_per_share <= 0`, or outside `[min_answer, max_answer]`. - NavOutOfBand = 3002, - /// Attested before `cooldown_secs` elapsed since the last record. - CooldownActive = 3003, - /// The move from the last record exceeds `max_up_bps` (a rise) or - /// `max_down_bps` (a fall). - DeviationExceeded = 3004, - /// `expires_at` not in the future — the record would be born stale. - ExpiresInPast = 3005, - NoRecord = 3006, - /// `ensure_consumable` found the feed in a state other than `Valid`. - NotConsumable = 3007, - /// `clear_latest` was called while the NAV was not paused. - NavNotPaused = 3008, - /// The oracle must always have an admin. Hand it over instead: propose and - /// apply an `Admin` change (`transfer_admin_role` during setup), then the - /// nominee calls `accept_admin_transfer`. - AdminRequired = 3009, - /// The NAV is paused. Unpause it first; a report never lifts it. - NavPaused = 3010, - /// No pending change has this id. - ChangeNotFound = 3011, - /// The change's delay has not elapsed. - ChangeNotReady = 3012, - /// As many changes as the timelock holds already wait. - TooManyPendingChanges = 3013, - /// Changes are instant until setup finishes; there is nothing to propose yet. - SetupOpen = 3014, - /// A direct call cannot make this change. After setup it goes through - /// `propose_change`; setting a role admin is never allowed. - UseTimelock = 3015, - /// Only possible while setup is open, and only once. - SetupClosed = 3016, - /// Setup cannot finish before the oracle is bound to its vault. - VaultNotBound = 3017, - /// The vault is winding down, so no change may be proposed or applied. - WindDownActive = 3018, - /// The vault is paused, which holds every timelocked change. - VaultPaused = 3019, - /// The change's delay is shorter than the vault's notice, so an investor - /// could not complete an exit before it lands. - DelayBelowNotice = 3020, - /// An upgrade already waits. Cancel it before proposing another. - UpgradeAlreadyPending = 3021, - /// An admin hand-over offered during setup is still open. Setup finishes - /// once it is accepted, cancelled or expired, so no nominee from setup can - /// accept after changes start to wait. - AdminTransferPending = 3025, - /// The configuration the constructor writes is absent from instance - /// storage. - NotInitialized = 3026, +strata_bindings::error_enum! { + strata_bindings::ORACLE_ERRORS, + + /// Oracle failures, coded 3000 to 3999. Codes are stable once assigned and + /// are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum OracleError { + /// A config breaks a bound: `freshness_duration` from 1 s to 35 days, + /// `cooldown_secs` from 1 s to `freshness_duration`, `min_answer` above + /// zero and at most `max_answer`, `max_answer` at most `MAX_ANSWER`, + /// and each move cap from 1 to 10,000 bps. + InvalidConfig = 3001, + /// `nav_per_share <= 0`, or outside `[min_answer, max_answer]`. + NavOutOfBand = 3002, + /// Attested before `cooldown_secs` elapsed since the last record. + CooldownActive = 3003, + /// The move from the last record exceeds `max_up_bps` (a rise) or + /// `max_down_bps` (a fall). + DeviationExceeded = 3004, + /// `expires_at` not in the future — the record would be born stale. + ExpiresInPast = 3005, + /// No report is stored: none has been accepted yet, or `clear_latest` + /// removed it. `latest`, `nav_per_share` and `attested_at` fail with it. + NoRecord = 3006, + /// `ensure_consumable` found the feed in a state other than `Valid`. + NotConsumable = 3007, + /// `clear_latest` was called while the NAV was not paused. + NavNotPaused = 3008, + /// `renounce_admin` is always refused: the oracle must always have an + /// admin. Hand it over instead: propose and apply an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. + AdminRequired = 3009, + /// The NAV is paused. Unpause it first; a report never lifts it. + NavPaused = 3010, + /// No pending change has this id. + ChangeNotFound = 3011, + /// The change's delay has not elapsed. + ChangeNotReady = 3012, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 3013, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 3014, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 3015, + /// Only possible while setup is open, and only once. + SetupClosed = 3016, + /// Setup cannot finish before the oracle is bound to its vault. + VaultNotBound = 3017, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 3018, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 3019, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 3020, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 3021, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 3025, + /// The configuration the constructor writes is absent from instance + /// storage. + NotInitialized = 3026, + } } #[contractevent] diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 7d9779b5..276c38b7 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -1,54 +1,58 @@ use soroban_sdk::contracterror; -/// Share token failures. Codes are stable once assigned and are never reused; a -/// removed variant leaves its number retired. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum ShareTokenError { - /// The call needs the admin. `pause`, `unpause`, `set_compliance` and - /// `set_identity_verifier` refuse any other caller. `renounce_admin` is - /// always refused, because the token must always have an admin: hand it - /// over instead by proposing and applying an `Admin` change - /// (`transfer_admin_role` during setup), then the nominee calls - /// `accept_admin_transfer`. - AdminRequired = 7000, - /// No pending change has this id. - ChangeNotFound = 7001, - /// The change's delay has not elapsed. - ChangeNotReady = 7002, - /// As many changes as the timelock holds already wait. - TooManyPendingChanges = 7003, - /// Changes are instant until setup finishes; there is nothing to propose yet. - SetupOpen = 7004, - /// A direct call cannot make this change. After setup it goes through - /// `propose_change`; setting a role admin is never allowed. - UseTimelock = 7005, - /// Only possible while setup is open, and only once. - SetupClosed = 7006, - /// Setup cannot finish before the token is bound to its vault. - VaultNotBound = 7007, - /// The vault is winding down, so no change may be proposed or applied. - WindDownActive = 7008, - /// The vault is paused, which holds every timelocked change. - VaultPaused = 7009, - /// The change's delay is shorter than the vault's notice, so an investor - /// could not complete an exit before it lands. - DelayBelowNotice = 7010, - /// An upgrade already waits. Cancel it before proposing another. - UpgradeAlreadyPending = 7011, - /// The vault's own address cannot be frozen, and a legal transfer or a - /// recovery never moves shares out of it or into it: its balance is other - /// investors' escrow. - VaultAddress = 7012, - /// A legal transfer or a recovery needs the compliance authority and the - /// admin as two different accounts, so no single key moves value. - CoSignRequired = 7013, - /// A legal transfer names the order behind it: a reference of 1 to 128 - /// bytes. - InvalidReference = 7015, - /// An admin hand-over offered during setup is still open. Setup finishes - /// once it is accepted, cancelled or expired, so no nominee from setup can - /// accept after changes start to wait. - AdminTransferPending = 7016, +strata_bindings::error_enum! { + strata_bindings::TOKEN_ERRORS, + + /// Share token failures, coded 7000 to 7999. Codes are stable once assigned + /// and are never reused; a removed variant leaves its number retired. + #[contracterror] + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + #[repr(u32)] + pub enum ShareTokenError { + /// The call needs the admin. `pause`, `unpause`, `set_compliance` and + /// `set_identity_verifier` refuse any other caller. `renounce_admin` is + /// always refused, because the token must always have an admin: hand it + /// over instead by proposing and applying an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. + AdminRequired = 7000, + /// No pending change has this id. + ChangeNotFound = 7001, + /// The change's delay has not elapsed. + ChangeNotReady = 7002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 7003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 7004, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 7005, + /// Only possible while setup is open, and only once. + SetupClosed = 7006, + /// Setup cannot finish before the token is bound to its vault. + VaultNotBound = 7007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 7008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 7009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 7010, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 7011, + /// The vault's own address cannot be frozen, and a legal transfer or a + /// recovery never moves shares out of it or into it: its balance is other + /// investors' escrow. + VaultAddress = 7012, + /// A legal transfer or a recovery needs the compliance authority and the + /// admin as two different accounts, so no single key moves value. + CoSignRequired = 7013, + /// A legal transfer names the order behind it: a reference of 1 to 128 + /// bytes. + InvalidReference = 7015, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 7016, + } } From df23868319e150121b19644887c6e2f9d546cd22 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:41 -0300 Subject: [PATCH 3/9] docs: document the oracle entrypoints Every oracle entrypoint and the trait methods it overrides carry a doc in the contract spec: a summary, the arguments with units, the errors, the events and who signs. --- contracts/nav-oracle/src/lib.rs | 324 ++++++++++++++++++++++++++++++-- 1 file changed, 310 insertions(+), 14 deletions(-) diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 99623779..fc6c0983 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -196,9 +196,28 @@ pub struct NavOracleContract; #[contractimpl(contracttrait)] impl AccessControl for NavOracleContract { - /// Instant during setup; after it, propose the grant through the timelock. - /// Revoking stays instant: taking an attester or a guardian away moves no - /// value. + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. Revoking stays instant: taking an attester or a + /// guardian away moves no value. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `attester` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); strata_storage::bump_instance(e); @@ -206,8 +225,32 @@ impl AccessControl for NavOracleContract { extend::role_entries(e, &account, &role); } - /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the - /// current admin, so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); @@ -215,6 +258,14 @@ impl AccessControl for NavOracleContract { } /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { panic_with_error!(e, OracleError::UseTimelock); } @@ -223,6 +274,10 @@ impl AccessControl for NavOracleContract { /// stuck record and never unpause the NAV again. The way out is a hand-over: /// propose and apply an `Admin` change (`transfer_admin_role` during setup), /// then the nominee calls `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, OracleError::AdminRequired); } @@ -230,10 +285,13 @@ impl AccessControl for NavOracleContract { #[contractimpl(contracttrait)] impl VaultBinding for NavOracleContract { + /// The vault whose timelock this oracle's changes wait for, once + /// `bind_vault` has named it. fn vault(e: &Env) -> Option
{ strata_timelock::setup::source(e) } + /// Whether setup is still open, so admin changes are instant. fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } @@ -241,6 +299,26 @@ impl VaultBinding for NavOracleContract { #[contractimpl] impl NavOracleContract { + /// Creates the oracle with its admin, its two roles and its limits. + /// + /// # Arguments + /// + /// * `admin` - Reconfigures the oracle and lifts a NAV pause. + /// * `attester` - Gets the `attester` role, which submits reports. + /// * `guardian` - Gets the `guardian` role, which pauses the NAV. + /// * `config` - The limits reports must meet; see `config`. + /// + /// # Errors + /// + /// * `InvalidConfig` - `config` breaks a bound. + /// + /// # Events + /// + /// * `RoleGranted` for `attester` and for `guardian`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, admin: Address, @@ -260,12 +338,30 @@ impl NavOracleContract { upgradeable::set_schema_version(e, 1); } + /// The storage layout version, `1` until an upgrade migrates it. pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } - /// Names the vault whose clock this oracle's timelock reads. Once, and only - /// during setup. + /// Names the vault whose timelock this oracle's changes wait for. Once, + /// and only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); @@ -273,6 +369,21 @@ impl NavOracleContract { } /// Ends the window in which admin changes are instant. It never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { strata_storage::bump_instance(e); @@ -280,34 +391,108 @@ impl NavOracleContract { } /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a role grant or revoke, an admin hand-over or + /// a new config. + /// + /// # Errors + /// + /// * `InvalidConfig` - A `Config` change breaks a bound. + /// * `UpgradeAlreadyPending` - An upgrade already waits. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: OracleChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while the vault is paused - /// or winding down. + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `InvalidConfig` - A `Config` change breaks a bound. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `ConfigSet`, `RoleGranted`, + /// `RoleRevoked` or `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } + /// The stored report: `nav_per_share` in WAD, `timestamp` and + /// `expires_at` in seconds. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. pub fn latest(e: &Env) -> NavReport { get_latest(e).unwrap_or_else(|| panic_with_error!(e, OracleError::NoRecord)) } + /// True when no report is stored, it has expired, or its value is outside + /// the configured band. The NAV pause is not counted; `state` reports it. pub fn is_stale(e: &Env) -> bool { match get_latest(e) { None => true, @@ -315,10 +500,43 @@ impl NavOracleContract { } } + /// Succeeds only while `state` is `Valid`. + /// + /// # Errors + /// + /// * `NotConsumable` - The NAV is paused, or the report is missing, + /// expired or out of band. pub fn ensure_consumable(e: &Env) { guards::consumable(e); } + /// Stores a valuation for the vault to price epochs from. The stored + /// `expires_at` is the earlier of the report's and the time of acceptance + /// plus `freshness_duration`. + /// + /// # Arguments + /// + /// * `report` - `nav_per_share` in WAD: asset base units per share base + /// unit. `expires_at` in seconds: the last moment the attester vouches + /// for it. `timestamp` is ignored; the ledger time is stored. + /// * `caller` - An `attester`. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` is not an `attester`. + /// * `NavPaused` - The NAV is paused. + /// * `NavOutOfBand` - `nav_per_share` is outside the band. + /// * `ExpiresInPast` - `expires_at` is not in the future. + /// * `CooldownActive` - The last report is younger than `cooldown_secs`. + /// * `DeviationExceeded` - The move from the last report exceeds its cap. + /// + /// # Events + /// + /// * `NavAttested` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { guards::nav_not_paused(e); @@ -349,12 +567,43 @@ impl NavOracleContract { .publish(e); } + /// Pauses the NAV: the feed reports `Paused`, no report is accepted and + /// the vault prices nothing. Only the admin lifts it. + /// + /// # Arguments + /// + /// * `caller` - A `guardian`. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` is not a `guardian`. + /// + /// # Events + /// + /// * `NavPausedSet`, with `paused` true. + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "guardian")] pub fn pause_nav(e: &Env, caller: Address) { state::set_nav_paused(e, true); NavPausedSet { paused: true }.publish(e); } + /// Pauses the NAV, or lifts the pause. + /// + /// # Arguments + /// + /// * `paused` - True pauses, false lifts the pause. + /// + /// # Events + /// + /// * `NavPausedSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_nav_paused(e: &Env, paused: bool) { state::set_nav_paused(e, paused); @@ -364,6 +613,18 @@ impl NavOracleContract { /// Removes the stored record, so one in-band value can land again whatever /// its distance from the last. Only while the NAV is paused, so /// resuming is always a deliberate second act. + /// + /// # Errors + /// + /// * `NavNotPaused` - The NAV is not paused. + /// + /// # Events + /// + /// * `LatestCleared` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn clear_latest(e: &Env) { guards::nav_paused(e); @@ -371,13 +632,35 @@ impl NavOracleContract { LatestCleared {}.publish(e); } + /// The limits a report must meet: `freshness_duration` and + /// `cooldown_secs` in seconds, `max_up_bps` and `max_down_bps` in basis + /// points, `min_answer` and `max_answer` in WAD. pub fn config(e: &Env) -> OracleConfig { get_config(e) } - /// Instant during setup. Afterwards, instant only when the new config - /// changes nothing but a shorter `freshness_duration`; every other change - /// waits. Propose it as `OracleChange::Config`. + /// Replaces the limits. Instant during setup. Afterwards, instant only + /// when the new config changes nothing but a shorter + /// `freshness_duration`; every other change waits. Propose it as + /// `OracleChange::Config`. + /// + /// # Arguments + /// + /// * `config` - The new limits, in the units `config` returns. + /// + /// # Errors + /// + /// * `InvalidConfig` - `config` breaks a bound. + /// * `UseTimelock` - After setup, it changes more than a shorter + /// `freshness_duration`. + /// + /// # Events + /// + /// * `ConfigSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { guards::valid_config(e, &config); @@ -388,20 +671,33 @@ impl NavOracleContract { #[contractimpl(contracttrait)] impl OracleFeed for NavOracleContract { + /// `Paused` while the NAV is paused. Otherwise `Valid` when a report is + /// stored, unexpired and in band, and `Stale` when not. fn state(e: &Env) -> OracleState { compute_state(e) } + /// The stored report's value in WAD: asset base units per share base + /// unit. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. fn nav_per_share(e: &Env) -> i128 { Self::latest(e).nav_per_share } - /// Not the valuation date the reporter had in mind: the moment the - /// contract took the report. + /// The ledger time, in seconds, the oracle took the stored report. Not + /// the valuation date the reporter had in mind. + /// + /// # Errors + /// + /// * `NoRecord` - No report is stored. fn attested_at(e: &Env) -> u64 { Self::latest(e).timestamp } + /// How long a report stays valid after it is accepted, in seconds. fn freshness_duration(e: &Env) -> u64 { get_config(e).freshness_duration } From a6beb35e3333c081b4c05106f4457dcfd12757c8 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:52 -0300 Subject: [PATCH 4/9] docs: document compliance and the verifier Every entrypoint of the compliance and identity verifier stubs, and each trait method they override, carries a doc in the contract spec. --- contracts/compliance/src/lib.rs | 325 ++++++++++++++++++++++++- contracts/identity-verifier/src/lib.rs | 51 +++- 2 files changed, 362 insertions(+), 14 deletions(-) diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 5b7822b6..63b19938 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -94,13 +94,39 @@ pub struct Compliance; #[contractimpl] impl Compliance { + /// Creates the contract with its admin. + /// + /// # Arguments + /// + /// * `admin` - Changes the modules and the bound tokens. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); strata_storage::bump_instance(e); } - /// Names the vault whose clock this contract's timelock reads. Once, and - /// only during setup. + /// Names the vault whose timelock this contract's changes wait for. Once, + /// and only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); @@ -109,6 +135,21 @@ impl Compliance { /// Ends the window in which module and binding changes are instant. It /// never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { strata_storage::bump_instance(e); @@ -116,44 +157,147 @@ impl Compliance { } /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - A module to add or remove, a token to bind or unbind, or + /// an admin hand-over. + /// + /// # Errors + /// + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: ComplianceChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while the vault is paused - /// or winding down. + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `ComplianceError` or `TokenBinderError` - The module or token change + /// is refused as `add_module_to`, `remove_module_from`, `bind_token` or + /// `unbind_token` would refuse it. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `ModuleAdded`, + /// `ModuleRemoved`, `TokenBound`, `TokenUnbound` or + /// `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } - /// Instant during setup; after it, propose an `Admin` change. Cancelling a - /// pending hand-over (`live_until_ledger == 0`) keeps the current admin, - /// so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. pub fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } + /// Makes the nominee of a pending hand-over the admin. + /// + /// # Errors + /// + /// * `RoleTransferError::NoPendingTransfer` - No offer is pending. + /// * `RoleTransferError::TransferExpired` - The offer's last ledger has + /// passed. + /// + /// # Events + /// + /// * `AdminTransferCompleted` + /// + /// # Authorization + /// + /// The nominee signs. pub fn accept_admin_transfer(e: &Env) { strata_storage::bump_instance(e); access_control::accept_admin_transfer(e); } + /// The admin. pub fn get_admin(e: &Env) -> Option
{ access_control::get_admin(e) } @@ -161,19 +305,42 @@ impl Compliance { #[contractimpl(contracttrait)] impl VaultBinding for Compliance { + /// The vault whose timelock this contract's changes wait for, once + /// `bind_vault` has named it. fn vault(e: &Env) -> Option
{ strata_timelock::setup::source(e) } + /// Whether setup is still open, so module and binding changes are + /// instant. fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } } -/// Binding and unbinding are instant during setup; after it, propose a -/// `BindToken` or `UnbindToken` change. #[contractimpl(contracttrait)] impl token_binder::TokenBinder for Compliance { + /// Links `token` to these rules. Instant during setup; after it, propose a + /// `BindToken` change. + /// + /// # Arguments + /// + /// * `token` - The share token. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `TokenBinderError::TokenAlreadyBound` - `token` is already bound. + /// * `TokenBinderError::MaxTokensReached` - No room for another token. + /// + /// # Events + /// + /// * `TokenBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -181,6 +348,26 @@ impl token_binder::TokenBinder for Compliance { token_binder::bind_token(e, &token); } + /// Unlinks `token`, after which its moves fail the hooks. Instant during + /// setup; after it, propose an `UnbindToken` change. + /// + /// # Arguments + /// + /// * `token` - A bound token. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `TokenBinderError::TokenNotFound` - `token` is not bound. + /// + /// # Events + /// + /// * `TokenUnbound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -190,11 +377,32 @@ impl token_binder::TokenBinder for Compliance { } /// The token calls the check before and the hook after every mint, burn and -/// transfer, which is what keeps this instance alive. Adding or removing a -/// module is instant during setup; after it, propose an `AddModule` or -/// `RemoveModule` change. +/// transfer, which is what keeps this instance alive. #[contractimpl(contracttrait)] impl compliance::Compliance for Compliance { + /// Registers `module` on `hook`. Instant during setup; after it, propose + /// an `AddModule` change. + /// + /// # Arguments + /// + /// * `hook` - The check or the hook the module runs on. + /// * `module` - The module contract. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `ComplianceError::ModuleAlreadyRegistered` - `module` is already on + /// `hook`. + /// * `ComplianceError::ModuleBoundExceeded` - `hook` has no room left. + /// + /// # Events + /// + /// * `ModuleAdded` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -202,6 +410,27 @@ impl compliance::Compliance for Compliance { compliance::storage::add_module_to(e, hook, module); } + /// Removes `module` from `hook`. Instant during setup; after it, propose a + /// `RemoveModule` change. + /// + /// # Arguments + /// + /// * `hook` - The check or the hook the module runs on. + /// * `module` - The module contract. + /// * `operator` - Not read; the admin signs instead. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `ComplianceError::ModuleNotRegistered` - `module` is not on `hook`. + /// + /// # Events + /// + /// * `ModuleRemoved` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { guards::direct_change_allowed(e); @@ -209,26 +438,98 @@ impl compliance::Compliance for Compliance { compliance::storage::remove_module_from(e, hook, module); } + /// Runs the `Transferred` modules after a bound token moves shares. + /// + /// # Arguments + /// + /// * `from`, `to` - The two ends of the move. + /// * `amount` - Share base units moved. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address) { strata_storage::bump_instance(e); compliance::storage::transferred(e, from, to, amount, token); } + /// Runs the `Created` modules after a bound token mints shares. + /// + /// # Arguments + /// + /// * `to` - The account credited. + /// * `amount` - Share base units minted. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. fn created(e: &Env, to: Address, amount: i128, token: Address) { strata_storage::bump_instance(e); compliance::storage::created(e, to, amount, token); } + /// Runs the `Destroyed` modules after a bound token burns shares. + /// + /// # Arguments + /// + /// * `from` - The account debited. + /// * `amount` - Share base units burned. + /// * `token` - The calling token. + /// + /// # Errors + /// + /// * `ComplianceError::TokenNotBound` - `token` is not bound here. + /// * A module's own error. + /// + /// # Authorization + /// + /// `token` signs, as the calling contract. fn destroyed(e: &Env, from: Address, amount: i128, token: Address) { strata_storage::bump_instance(e); compliance::storage::destroyed(e, from, amount, token); } + /// Whether every `CanTransfer` module accepts the move; true when none is + /// registered. + /// + /// # Arguments + /// + /// * `from`, `to` - The two ends of the move. + /// * `amount` - Share base units. + /// * `token` - The token asking. + /// + /// # Errors + /// + /// * A module's own error. fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool { strata_storage::bump_instance(e); compliance::storage::can_transfer(e, from, to, amount, token) } + /// Whether every `CanCreate` module accepts the mint; true when none is + /// registered. + /// + /// # Arguments + /// + /// * `to` - The account to credit. + /// * `amount` - Share base units. + /// * `token` - The token asking. + /// + /// # Errors + /// + /// * A module's own error. fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool { strata_storage::bump_instance(e); compliance::storage::can_create(e, to, amount, token) diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index bfb7af99..77e1e0ac 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -24,18 +24,41 @@ pub struct IdentityVerifier; #[contractimpl] impl IdentityVerifier { + /// Creates the verifier with its admin. + /// + /// # Arguments + /// + /// * `admin` - Edits the allowlist. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor(e: &Env, admin: Address) { access_control::set_admin(e, &admin); strata_storage::bump_instance(e); } /// Adds or removes an account from the allowlist. + /// + /// # Arguments + /// + /// * `account` - The account. + /// * `allowed` - True adds it, false removes it. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn allow(e: &Env, account: Address, allowed: bool) { strata_storage::bump_instance(e); strata_storage::set_persistent(e, &DataKey::Allowed(account), &allowed); } + /// Whether `account` is on the allowlist. + /// + /// # Arguments + /// + /// * `account` - The account. pub fn is_allowed(e: &Env, account: Address) -> bool { strata_storage::bump_instance(e); strata_storage::get_persistent(e, &DataKey::Allowed(account)).unwrap_or(false) @@ -44,8 +67,17 @@ impl IdentityVerifier { #[contractimpl(contracttrait)] impl identity_verification::IdentityVerifier for IdentityVerifier { - /// The share token calls this on every mint and transfer, so it is what - /// keeps the instance alive. + /// Succeeds when `account` is on the allowlist. The share token calls this + /// on every mint and transfer, so it is what keeps the instance alive. + /// + /// # Arguments + /// + /// * `account` - The account. + /// + /// # Errors + /// + /// * `RWAError::IdentityVerificationFailed` - `account` is not on the + /// allowlist. fn verify_identity(e: &Env, account: &Address) { strata_storage::bump_instance(e); let allowed: bool = @@ -55,10 +87,25 @@ impl identity_verification::IdentityVerifier for IdentityVerifier { } } + /// Always `None`: this stub names no recovery account, so the share + /// token's `recover_balance` fails with `RWAError::IdentityMismatch`. + /// + /// # Arguments + /// + /// * `old_account` - Not read. fn recovery_target(_e: &Env, _old_account: &Address) -> Option
{ None } + /// Does nothing: this stub keeps no claim topics or issuers. + /// + /// # Arguments + /// + /// * `claim_topics_and_issuers`, `operator` - Not read. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] fn set_claim_topics_and_issuers( e: &Env, From 06c6f09055bba5b0acae02234a8e5eaeafa2bf4c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:08:52 -0300 Subject: [PATCH 5/9] docs: document the share token entrypoints Every share token entrypoint, and each OpenZeppelin method it overrides, carries a doc in the contract spec, including the library errors a call can return. --- contracts/share-token/src/contract.rs | 586 +++++++++++++++++++++++++- 1 file changed, 569 insertions(+), 17 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 09abb995..a8f4e952 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -26,6 +26,25 @@ pub struct ShareToken; #[contractimpl] impl ShareToken { + /// Creates the token with 7 decimals, its admin, its compliance authority + /// and the two contracts every move is checked against. + /// + /// # Arguments + /// + /// * `name`, `symbol` - The token's metadata. + /// * `admin` - Pauses and changes the token. + /// * `compliance_authority` - Gets the `compliance` role: freezes, and + /// with the admin, legal transfers and recoveries. + /// * `compliance` - The compliance contract. + /// * `identity_verifier` - The identity verifier. + /// + /// # Events + /// + /// * `RoleGranted`, `ComplianceSet` and `IdentityVerifierSet`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, name: String, @@ -46,12 +65,30 @@ impl ShareToken { strata_storage::bump_instance(e); } + /// The storage layout version, `1` until an upgrade migrates it. pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } - /// Names the vault whose clock this token's timelock reads. Once, and only - /// during setup. + /// Names the vault whose timelock this token's changes wait for. Once, and + /// only during setup. + /// + /// # Arguments + /// + /// * `vault` - The vault. A contract that does not answer + /// `timelock_clock` fails the call. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has finished, or a vault is already bound. + /// + /// # Events + /// + /// * `VaultBound` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn bind_vault(e: &Env, vault: Address) { strata_storage::bump_instance(e); @@ -59,6 +96,21 @@ impl ShareToken { } /// Ends the window in which admin changes are instant. It never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `VaultNotBound` - No vault is bound. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { strata_storage::bump_instance(e); @@ -66,33 +118,124 @@ impl ShareToken { } /// Queues a change behind the vault's timelock and returns its id. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a role grant or revoke, an admin hand-over, + /// or a new compliance contract or identity verifier. + /// + /// # Errors + /// + /// * `UpgradeAlreadyPending` - An upgrade already waits. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `WindDownActive` - The vault is winding down. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: TokenChange) -> u32 { strata_storage::bump_instance(e); governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while the vault is paused - /// or winding down. + /// Lands a change whose delay has run. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `VaultPaused` - The vault is paused. + /// * `DelayBelowNotice` - The change's delay is shorter than the vault's + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time the vault spent paused + /// since, has not run. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `RoleGranted`, + /// `RoleRevoked`, `AdminTransferInitiated`, `ComplianceSet` or + /// `IdentityVerifierSet`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { strata_storage::bump_instance(e); governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land on + /// the vault's clock as it reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } - /// Moves shares under a legal order, at once. The compliance authority - /// asks and the admin co-signs the same call. Never out of or into the - /// vault's address, and only to an account the identity verifier accepts. + /// Moves shares under a legal order, at once. The pause, freezes and the + /// compliance rules do not apply. + /// + /// # Arguments + /// + /// * `from` - The account debited. + /// * `to` - The account credited. + /// * `amount` - Share base units. + /// * `reference` - The order's reference, 1 to 128 bytes. + /// * `caller` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `compliance`. + /// * `CoSignRequired` - `caller` is the admin. + /// * `InvalidReference` - `reference` is empty or too long. + /// * `VaultAddress` - `from` or `to` is the vault. + /// * `RWAError::IdentityVerificationFailed` - `to` is not allowlisted. + /// * `RWAError::InsufficientBalance` - `amount` exceeds the balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer`, then `LegalTransfer`. + /// + /// # Authorization + /// + /// `caller` and the admin sign, as two different accounts. #[only_role(caller, "compliance")] pub fn legal_transfer( e: &Env, @@ -122,10 +265,13 @@ impl ShareToken { #[contractimpl(contracttrait)] impl VaultBinding for ShareToken { + /// The vault whose timelock this token's changes wait for, once + /// `bind_vault` has named it. fn vault(e: &Env) -> Option
{ strata_timelock::setup::source(e) } + /// Whether setup is still open, so admin changes are instant. fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } @@ -133,12 +279,50 @@ impl VaultBinding for ShareToken { #[contractimpl(contracttrait)] impl Pausable for ShareToken { + /// Stops `transfer` and `transfer_from`. Mints, burns, the vault's escrow + /// moves and legal transfers go on. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::EnforcedPause` - The token is already paused. + /// + /// # Events + /// + /// * `Paused` + /// + /// # Authorization + /// + /// `caller` signs. fn pause(e: &Env, caller: Address) { guards::caller_is_admin(e, &caller); caller.require_auth(); strata_storage::bump_instance(e); pausable::pause(e); } + + /// Lets `transfer` and `transfer_from` run again. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::ExpectedPause` - The token is not paused. + /// + /// # Events + /// + /// * `Unpaused` + /// + /// # Authorization + /// + /// `caller` signs. fn unpause(e: &Env, caller: Address) { guards::caller_is_admin(e, &caller); caller.require_auth(); @@ -153,18 +337,94 @@ impl Pausable for ShareToken { impl FungibleToken for ShareToken { type ContractType = RWA; + /// Moves shares between two accounts. Refused while the token is paused, + /// when either end is frozen or fails the identity check, and when the + /// compliance rules refuse the move. + /// + /// # Arguments + /// + /// * `from` - The sender. + /// * `to` - The receiver. A muxed address is accepted; the event leaves + /// out its id. + /// * `amount` - Share base units. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The token is paused. + /// * `RWAError::AddressFrozen` - `from` or `to` is frozen. + /// * `RWAError::InsufficientFreeTokens` - `amount` exceeds `from`'s + /// unfrozen shares. + /// * `RWAError::IdentityVerificationFailed` - `from` or `to` is not + /// allowlisted. + /// * `RWAError::TransferNotCompliant` - The compliance rules refuse it. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer` + /// + /// # Authorization + /// + /// `from` signs. fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { strata_storage::bump_instance(e); ::transfer(e, &from, &to, amount); extend::balances(e, &[&from, &to.address()]); } + /// Moves shares against `spender`'s allowance, with the checks of + /// `transfer`. + /// + /// # Arguments + /// + /// * `spender` - Spends its allowance. + /// * `from` - The sender. + /// * `to` - The receiver. + /// * `amount` - Share base units. + /// + /// # Errors + /// + /// * The errors of `transfer`. + /// * `FungibleTokenError::InsufficientAllowance` - `amount` exceeds the + /// allowance. + /// + /// # Events + /// + /// * `Transfer` + /// + /// # Authorization + /// + /// `spender` signs. fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, amount: i128) { strata_storage::bump_instance(e); ::transfer_from(e, &spender, &from, &to, amount); extend::balances(e, &[&from, &to]); } + /// Sets how many of `owner`'s shares `spender` may move, replacing any + /// earlier allowance. + /// + /// # Arguments + /// + /// * `owner` - The holder. + /// * `spender` - The account allowed to spend. + /// * `amount` - Share base units. + /// * `live_until_ledger` - The last ledger number the allowance lasts. + /// + /// # Errors + /// + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `FungibleTokenError::InvalidLiveUntilLedger` - The ledger is beyond + /// the longest entry lifetime, or has passed while `amount` is positive. + /// + /// # Events + /// + /// * `Approve` + /// + /// # Authorization + /// + /// `owner` signs. fn approve(e: &Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) { strata_storage::bump_instance(e); ::approve( @@ -179,7 +439,27 @@ impl FungibleToken for ShareToken { #[contractimpl(contracttrait)] impl AccessControl for ShareToken { - /// Instant during setup; after it, propose the grant through the timelock. + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `manager` (the vault) or `compliance`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); strata_storage::bump_instance(e); @@ -187,16 +467,61 @@ impl AccessControl for ShareToken { extend::role_entries(e, &account, &role); } - /// Instant for `compliance`. Revoking `manager` waits like a grant, - /// because the vault needs it to pay claims. + /// Takes `role` from `account`. Instant for `compliance`. Revoking + /// `manager` waits like a grant after setup, because the vault needs it + /// to pay claims: propose a `RevokeRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that loses the role. + /// * `role` - `manager` or `compliance`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - `manager`, after setup. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// * `AccessControlError::RoleNotHeld` - `account` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_revoke_allowed(e, &role); strata_storage::bump_instance(e); access_control::revoke_role(e, &account, &role, &caller); } - /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the - /// current admin, so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); @@ -204,6 +529,14 @@ impl AccessControl for ShareToken { } /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { panic_with_error!(e, ShareTokenError::UseTimelock); } @@ -212,6 +545,10 @@ impl AccessControl for ShareToken { /// the manager role and never replace the vault that holds it. The way out /// is a hand-over: propose and apply an `Admin` change (`transfer_admin_role` /// during setup), then the nominee calls `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, ShareTokenError::AdminRequired); } @@ -219,29 +556,131 @@ impl AccessControl for ShareToken { #[contractimpl(contracttrait)] impl RWAToken for ShareToken { + /// Mints shares: the vault paying a priced deposit. The pause does not + /// apply. + /// + /// # Arguments + /// + /// * `to` - The account credited. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::IdentityVerificationFailed` - `to` is not allowlisted. + /// * `RWAError::MintNotCompliant` - The compliance rules refuse it. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `FungibleTokenError::MathOverflow` - The supply would overflow. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Mint` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn mint(e: &Env, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::mint(e, &to, amount); extend::balances(e, &[&to]); } + + /// Burns shares, frozen ones included, without their holder's + /// signature: the vault burns the escrow it holds, and a holder's shares + /// surrendered in a wind-down. The pause does not apply. + /// + /// # Arguments + /// + /// * `user_address` - The account debited. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::InsufficientBalance` - `amount` exceeds the balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Burn`; `TokensUnfrozen` first when frozen shares burn. + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::burn(e, &user_address, amount); extend::balances(e, &[&user_address]); } - /// The vault escrows redemptions through it. Compliance moves shares with - /// `legal_transfer` instead. + + /// Moves shares without the sender's signature: the vault escrows + /// redemptions and returns escrow through it. The pause, freezes, the + /// identity check and the compliance rules' check do not apply. Compliance + /// moves shares with `legal_transfer` instead. + /// + /// # Arguments + /// + /// * `from` - The sender. + /// * `to` - The receiver. + /// * `amount` - Share base units. + /// * `operator` - A `manager`: the vault. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` is not a `manager`. + /// * `RWAError::InsufficientBalance` - `amount` exceeds `from`'s balance. + /// * `FungibleTokenError::LessThanZero` - `amount` is negative. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer`; `TokensUnfrozen` first when frozen shares move. + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "manager")] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); extend::balances(e, &[&from, &to]); } - /// Moves a whole balance to the account the identity verifier names, so - /// it needs the admin's co-signature like a legal transfer, and never - /// moves shares out of or into the vault's address. + + /// Moves a lost account's whole balance, and any freeze on it, to the + /// recovery account the identity verifier names. Returns false when there + /// was nothing to move. + /// + /// # Arguments + /// + /// * `old_account` - The lost account. + /// * `new_account` - Its recovery account. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `CoSignRequired` - `operator` is the admin. + /// * `VaultAddress` - Either account is the vault. + /// * `RWAError::IdentityVerificationFailed` - `new_account` is not + /// allowlisted. + /// * `RWAError::IdentityMismatch` - The verifier names another recovery + /// account, or none. + /// * `ComplianceError::TokenNotBound` - Compliance does not list the token. + /// + /// # Events + /// + /// * `Transfer` and `RecoverySuccess`, with the freeze events when a + /// freeze carries over. + /// + /// # Authorization + /// + /// `operator` and the admin sign, as two different accounts. #[only_role(operator, "compliance")] fn recover_balance( e: &Env, @@ -259,6 +698,29 @@ impl RWAToken for ShareToken { extend::frozen_tokens(e, &new_account); recovered } + + /// Freezes or unfreezes an account: a frozen account can neither send nor + /// receive a `transfer`, and the vault holds what it owes the account. + /// Never freezes the vault's address once the token is bound to it. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `freeze` - True freezes, false unfreezes. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `VaultAddress` - Freezing the vault's address. + /// + /// # Events + /// + /// * `AddressFrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { guards::not_freezing_vault(e, &user_address, freeze); @@ -266,6 +728,31 @@ impl RWAToken for ShareToken { RWA::set_address_frozen(e, &user_address, freeze); extend::address_frozen(e, &user_address); } + + /// Freezes part of an account's shares, which then cannot be transferred. + /// Never on the vault's address once the token is bound to it. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `amount` - Share base units added to the frozen amount. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `VaultAddress` - `user_address` is the vault. + /// * `RWAError::LessThanZero` - `amount` is negative. + /// * `RWAError::InsufficientBalance` - The frozen amount would exceed the + /// balance. + /// + /// # Events + /// + /// * `TokensFrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { guards::not_vault_address(e, &user_address); @@ -273,12 +760,56 @@ impl RWAToken for ShareToken { RWA::freeze_partial_tokens(e, &user_address, amount); extend::frozen_tokens(e, &user_address); } + + /// Unfreezes part of an account's frozen shares. + /// + /// # Arguments + /// + /// * `user_address` - The account. + /// * `amount` - Share base units taken off the frozen amount. + /// * `operator` - A `compliance` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `operator` lacks `compliance`. + /// * `RWAError::LessThanZero` - `amount` is negative. + /// * `RWAError::InsufficientFreeTokens` - `amount` exceeds the frozen + /// shares. + /// + /// # Events + /// + /// * `TokensUnfrozen` + /// + /// # Authorization + /// + /// `operator` signs. #[only_role(operator, "compliance")] fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { strata_storage::bump_instance(e); RWA::unfreeze_partial_tokens(e, &user_address, amount); extend::frozen_tokens(e, &user_address); } + + /// Points the token at another compliance contract. Instant during setup; + /// after it, propose a `Compliance` change. + /// + /// # Arguments + /// + /// * `compliance` - The compliance contract. + /// * `operator` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `operator` is not the admin. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `ComplianceSet` + /// + /// # Authorization + /// + /// `operator` signs. fn set_compliance(e: &Env, compliance: Address, operator: Address) { guards::caller_is_admin(e, &operator); operator.require_auth(); @@ -286,6 +817,27 @@ impl RWAToken for ShareToken { strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } + + /// Points the token at another identity verifier. Instant during setup; + /// after it, propose an `IdentityVerifier` change. + /// + /// # Arguments + /// + /// * `identity_verifier` - The identity verifier. + /// * `operator` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `operator` is not the admin. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `IdentityVerifierSet` + /// + /// # Authorization + /// + /// `operator` signs. fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { guards::caller_is_admin(e, &operator); operator.require_auth(); From d3c5c8b7f93d3017021f8df73778b99d558d2d40 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:09:07 -0300 Subject: [PATCH 6/9] docs: document the vault entrypoints Every vault entrypoint, view and overridden trait method carries a doc in the contract spec. Amounts are in asset base units, shares in share base units, prices in WAD and times in seconds. Each doc lists the errors the call can return in a consistent state, its events and who signs. --- contracts/async-vault/src/lib.rs | 768 +++++++++++++++++++++++++++++-- 1 file changed, 735 insertions(+), 33 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 72fb288c..6c148082 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -70,6 +70,30 @@ pub struct AsyncVault; #[contractimpl] impl AsyncVault { + /// Creates the vault with its asset, share token, oracle and authorities, + /// and opens epoch 1. + /// + /// # Arguments + /// + /// * `asset` - The deposit asset's token contract. + /// * `share_token` - The share token. + /// * `oracle` - The feed epochs are priced from. + /// * `roles` - `governance` becomes the admin; `manager`, `treasury` and + /// `guardian` get their roles. `compliance` and `attester` are only + /// checked for distinctness. + /// + /// # Errors + /// + /// * `RolesNotDistinct` - `treasury` equals `guardian` or `governance`, or + /// `compliance` equals `governance` or `treasury`. + /// + /// # Events + /// + /// * `RoleGranted` for `manager`, `treasury` and `guardian`. + /// + /// # Authorization + /// + /// None: it runs once, at deployment. pub fn __constructor( e: &Env, asset: Address, @@ -102,70 +126,95 @@ impl AsyncVault { upgradeable::set_schema_version(e, 1); } + /// The deposit asset's token contract. pub fn asset(e: &Env) -> Address { state::get_addr(e, &DataKey::Asset) } + /// The share token. pub fn share_token(e: &Env) -> Address { state::get_addr(e, &DataKey::ShareToken) } + /// The feed epochs are priced from. pub fn oracle(e: &Env) -> Address { state::get_addr(e, &DataKey::Oracle) } + /// The admin: governance. pub fn governance(e: &Env) -> Option
{ access_control::get_admin(e) } + /// The first holder of `manager`, which closes epochs. pub fn manager(e: &Env) -> Option
{ role_holder(e, &MANAGER_ROLE) } + /// The first holder of `treasury`, which deploys capital to the + /// custodian. pub fn treasury(e: &Env) -> Option
{ role_holder(e, &TREASURY_ROLE) } + /// The first holder of `guardian`, which pauses the vault. pub fn guardian(e: &Env) -> Option
{ role_holder(e, &GUARDIAN_ROLE) } + /// Where `deploy_to_custodian` sends capital, once set. pub fn custodian(e: &Env) -> Option
{ state::get_addr_opt(e, &DataKey::Custodian) } + /// Asset base units sent to the custodian less those funded back; + /// negative once more came back than went out. pub fn net_deployed(e: &Env) -> i128 { state::net_deployed(e) } + /// Asset base units the vault holds that nothing is owed against: what is + /// left after cancellable escrow, priced redemptions and credited + /// wind-down rounds, and never below zero. pub fn free_reserve(e: &Env) -> i128 { treasury::free_reserve(e) } + /// Asset base units owed to priced redemptions not yet claimed. pub fn committed(e: &Env) -> i128 { state::committed(e) } + /// Asset base units owed to priced redemptions beyond the liquid reserve. + /// While it is positive, nothing can be deployed to the custodian. pub fn uncovered(e: &Env) -> i128 { treasury::uncovered(e) } + /// Asset base units the vault holds, less the escrow depositors can still + /// cancel. pub fn liquid_reserve(e: &Env) -> i128 { treasury::liquid_reserve(e) } + /// Asset base units escrowed by deposits in unpriced epochs. pub fn cancellable_escrow(e: &Env) -> i128 { state::cancellable_escrow(e) } + /// Share base units priced for deposits and not yet claimed. pub fn pending_mint_shares(e: &Env) -> i128 { state::pending_mint_shares(e) } + /// Share base units of priced redemptions not yet claimed. pub fn pending_burn_shares(e: &Env) -> i128 { state::pending_burn_shares(e) } + /// Share base units outside the vault, plus those priced for deposits and + /// not yet minted: the token's supply, less the vault's own balance, plus + /// `pending_mint_shares`. pub fn total_economic_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); let client = FungibleTokenClient::new(e, &share_token); @@ -177,10 +226,30 @@ impl AsyncVault { .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } + /// The cap on deposited capital, in asset base units; `None` means no cap. pub fn deposit_cap(e: &Env) -> Option { state::deposit_cap(e) } + /// Caps deposited capital: what the vault holds plus what it has deployed, + /// less what priced redemptions are owed. A deposit request that would take + /// it past the cap is refused. Instant, also after setup. + /// + /// # Arguments + /// + /// * `cap` - Asset base units, or `None` for no cap. + /// + /// # Errors + /// + /// * `InvalidAmount` - `cap` is negative. + /// + /// # Events + /// + /// * `DepositCapUpdated` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_deposit_cap(e: &Env, cap: Option) { guards::non_negative_cap(e, cap); @@ -193,8 +262,25 @@ impl AsyncVault { .publish(e); } - /// Instant during setup; afterwards the custodian changes through the - /// timelock. Never the vault itself (`InvalidCustodian`). + /// Sets where deployed capital goes. Instant during setup; afterwards the + /// custodian changes through the timelock. Never the vault itself. + /// + /// # Arguments + /// + /// * `custodian` - The account that receives deployed capital. + /// + /// # Errors + /// + /// * `InvalidCustodian` - `custodian` is the vault. + /// * `UseTimelock` - Setup has finished. + /// + /// # Events + /// + /// * `CustodianSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_custodian(e: &Env, custodian: Address) { guards::custodian_not_the_vault(e, &custodian); @@ -202,8 +288,27 @@ impl AsyncVault { treasury::set_custodian(e, &custodian); } - /// Raising is instant. Lowering shortens the warning holders get before a + /// Sets how long a wind-down proposal waits before it can start. Raising + /// is instant. Lowering shortens the warning holders get before a /// wind-down starts, so after setup it waits for the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds, from 7 to 90 days. + /// + /// # Errors + /// + /// * `WindDownDelayTooShort` - `secs` is under 7 days. + /// * `WindDownDelayTooLong` - `secs` is over 90 days. + /// * `UseTimelock` - Lowering after setup. + /// + /// # Events + /// + /// * `WindDownDelaySet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64) { guards::wind_down_delay_in_bounds(e, secs); @@ -211,61 +316,172 @@ impl AsyncVault { wind_down::set_delay(e, secs); } + /// Seconds a wind-down proposal waits before it can start. pub fn wind_down_delay(e: &Env) -> u64 { wind_down::delay(e) } + /// The wind-down once proposed: whether it is proposed or active, the time + /// in seconds it may start, and the rounds finalised. pub fn wind_down(e: &Env) -> Option { wind_down::info(e) } + /// Announces a wind-down, which anyone can start once the delay has run. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is already winding down. + /// * `WindDownAlreadyProposed` - A proposal already stands. + /// + /// # Events + /// + /// * `WindDownProposed`, with `active_at` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_wind_down(e: &Env) { wind_down::propose(e); } + /// Withdraws the standing wind-down proposal. + /// + /// # Errors + /// + /// * `WindDownActive` - The wind-down has started. + /// * `WindDownNotProposed` - No proposal stands. + /// + /// # Events + /// + /// * `WindDownProposalCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_wind_down_proposal(e: &Env) { wind_down::cancel_proposal(e); } - /// Open to anyone once the delay has passed, so the operator cannot stall - /// the wind-down it announced. + /// Starts the wind-down: from here on the vault takes no request and + /// prices nothing, and holders exit through wind-down rounds. Open to + /// anyone once the delay has passed, so the operator cannot stall the + /// wind-down it announced. + /// + /// # Errors + /// + /// * `WindDownActive` - The wind-down has already started. + /// * `WindDownNotProposed` - No proposal stands. + /// * `WindDownDelayNotElapsed` - The delay has not run. + /// + /// # Events + /// + /// * `WindDownActivated` + /// + /// # Authorization + /// + /// None: anyone may call. pub fn activate_wind_down(e: &Env) { wind_down::activate(e); } - /// Open to anyone once active, distributing the free reserve in proportion - /// to the supply snapshot. + /// Distributes the free reserve in proportion to the supply snapshot the + /// first round takes, and returns the asset base units the round credits. + /// Open to anyone once the wind-down is active. + /// + /// # Errors + /// + /// * `WindDownNotActive` - The wind-down has not started. + /// * `NothingToDistribute` - No supply to divide by, or the free reserve + /// credits nothing. + /// * `AmountTooLarge` - A figure of the round would overflow. + /// + /// # Events + /// + /// * `WindDownRoundFinalized` + /// + /// # Authorization + /// + /// None: anyone may call. pub fn finalize_wind_down_round(e: &Env) -> i128 { wind_down::finalize_round(e) } + /// Asset base units the rounds credited that holders have not claimed. pub fn wind_down_owed(e: &Env) -> i128 { wind_down::owed(e) } + /// Share base units the first round's snapshot divides by; zero before it. pub fn wind_down_supply(e: &Env) -> i128 { wind_down::supply_snapshot(e) } + /// Asset base units credited per share base unit so far, in WAD. pub fn wind_down_acc(e: &Env) -> i128 { wind_down::acc(e) } /// Surrenders the free shares the holder holds and pays their share of - /// every round finalised since they last claimed. A frozen holder is - /// refused; a partial freeze surrenders only the free shares. + /// every round finalised since they last claimed. Returns the asset base + /// units paid. A frozen holder is refused; a partial freeze surrenders + /// only the free shares. + /// + /// # Arguments + /// + /// * `holder` - The shareholder. + /// + /// # Errors + /// + /// * `DistributionNotStarted` - No round has been finalised. + /// * `Frozen` - `holder` is frozen. + /// * `NoEntitlement` - Nothing to surrender and nothing owed. + /// + /// # Events + /// + /// * `WindDownClaimed`, after the token's `Burn` when shares are + /// surrendered. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_wind_down(e: &Env, holder: Address) -> i128 { wind_down::claim(e, &holder) } + /// What `claim_wind_down` would pay `holder` now, in asset base units: + /// zero before the first round and for a frozen holder. + /// + /// # Arguments + /// + /// * `holder` - The shareholder. pub fn wind_down_claimable(e: &Env, holder: Address) -> i128 { wind_down::claimable(e, &holder) } - /// Raising is instant. Lowering shortens the wait every closed epoch gives - /// before it prices, so after setup it waits for the timelock. + /// Sets the notice: how long a closed epoch waits before it can be priced. + /// Raising is instant. Lowering shortens the wait of every epoch closed + /// afterwards, so after setup it waits for the timelock. + /// + /// # Arguments + /// + /// * `secs` - Seconds; at most 30 days and at most the upgrade delay. + /// + /// # Errors + /// + /// * `UseTimelock` - Lowering after setup. + /// * `NoticeTooLong` - `secs` is over 30 days. + /// * `NoticeAboveUpgradeDelay` - `secs` exceeds the upgrade delay. + /// + /// # Events + /// + /// * `NoticeSet` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn set_notice(e: &Env, secs: u64) { guards::not_lowered_after_setup(e, secs, state::notice(e)); @@ -274,15 +490,30 @@ impl AsyncVault { } /// Ends the window in which admin changes are instant. Needs every wiring - /// check `health()` reports (token, oracle and compliance bound, sole token - /// manager, allowlisted, not frozen, matching decimals, distinct addresses, - /// a feed that answers, a custodian), no shares outstanding and no admin - /// hand-over pending. Deposits open from here on, and it never reopens. + /// check `health()` reports, no shares outstanding and no admin hand-over + /// pending. Deposits open from here on, and it never reopens. + /// + /// # Errors + /// + /// * `SetupClosed` - Setup has already finished. + /// * `AdminTransferPending` - An admin hand-over offered during setup is + /// still open. + /// * `SetupIncomplete` - A wiring check fails, or shares are outstanding. + /// + /// # Events + /// + /// * `SetupFinished` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn finish_setup(e: &Env) { governance::finish_setup(e); } + /// Whether setup is still open: admin changes are instant and deposits + /// are refused. pub fn setup_open(e: &Env) -> bool { strata_timelock::setup::is_open(e) } @@ -294,108 +525,461 @@ impl AsyncVault { health::health(e) } - /// Queues a change behind the timelock and returns its id. + /// Queues a change behind the timelock and returns its id. It waits the + /// upgrade delay, plus any time the vault spends paused. + /// + /// # Arguments + /// + /// * `change` - An upgrade, a new upgrade delay, custodian, notice, + /// wind-down delay or oracle, a role grant or revoke, or an admin + /// hand-over. Durations in seconds. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished; change it directly. + /// * `UpgradeDelayTooShort`, `UpgradeDelayTooLong`, + /// `UpgradeDelayBelowNotice`, `NoticeTooLong`, `NoticeAboveUpgradeDelay`, + /// `WindDownDelayTooShort`, `WindDownDelayTooLong`, `FeedNotBound`, + /// `InvalidCustodian` - The change is out of bounds. + /// * `UpgradeProposalExists` - An upgrade or an upgrade delay change, while + /// one of the two already waits. + /// * `TooManyPendingChanges` - The queue is full. + /// + /// # Events + /// + /// * `ChangeProposed`, with its `eta` in seconds. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn propose_change(e: &Env, change: VaultChange) -> u32 { governance::propose(e, change) } + /// Drops a pending change. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// + /// # Events + /// + /// * `ChangeCancelled` + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn cancel_change(e: &Env, id: u32) { governance::cancel(e, id); } - /// Lands a change whose delay has run. Refused while paused or winding - /// down. + /// Lands a change whose delay has run, checking its bounds again. + /// + /// # Arguments + /// + /// * `id` - The id `propose_change` returned. + /// + /// # Errors + /// + /// * `ChangeNotFound` - No pending change has this id. + /// * `WindDownActive` - The vault is winding down. + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `UpgradeDelayBelowNotice` - The change's delay is shorter than the + /// notice now. + /// * `ChangeNotReady` - The delay, plus any time paused since, has not run. + /// * The bound errors of `propose_change`, checked again. + /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account + /// without the role. + /// * An `Upgrade` whose Wasm is not on the network fails the call. + /// + /// # Events + /// + /// * `ChangeApplied`, after the change's own: `CustodianSet`, `NoticeSet`, + /// `WindDownDelaySet`, `RoleGranted`, `RoleRevoked` or + /// `AdminTransferInitiated`. + /// + /// # Authorization + /// + /// The admin signs. #[only_admin] pub fn apply_change(e: &Env, id: u32) { governance::apply(e, id); } + /// Every change still waiting, with the time in seconds it may land as + /// the clock reads now. pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } + /// Seconds a proposed change waits: 7 days until an `UpgradeDelay` change + /// sets another. pub fn upgrade_delay(e: &Env) -> u64 { upgrade::delay(e) } + /// The storage layout version, `1` until an upgrade migrates it. pub fn schema_version(e: &Env) -> u32 { upgradeable::get_schema_version(e) } + /// Sends free reserve to the custodian and returns `net_deployed` after + /// it. It never sends escrow a depositor can cancel or cash owed to + /// priced exits. The pause does not stop it. + /// + /// # Arguments + /// + /// * `caller` - A `treasury` holder. + /// * `assets` - Asset base units. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `treasury`. + /// * `WindDownActive` - The vault is winding down. + /// * `InvalidAmount` - `assets` is zero or negative. + /// * `CustodianNotSet` - No custodian is set. + /// * `ReserveCommittedToExits` - `assets` exceeds the free reserve. + /// * The asset's own error when the custodian cannot receive it. + /// + /// # Events + /// + /// * `Deployed` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "treasury")] pub fn deploy_to_custodian(e: &Env, caller: Address, assets: i128) -> i128 { treasury::deploy(e, assets) } + /// Pays assets into the vault, lowering `net_deployed` by them, and + /// returns `net_deployed` after it. The custodian's capital comes back + /// this way. Anyone may fund, during a pause or a wind-down too. + /// + /// # Arguments + /// + /// * `caller` - The account paying. + /// * `assets` - Asset base units. + /// + /// # Errors + /// + /// * `InvalidAmount` - `assets` is zero or negative. + /// * `AmountTooLarge` - `net_deployed` would fall below `i128::MIN`. + /// * The asset's own error when `caller` cannot pay. + /// + /// # Events + /// + /// * `Funded` + /// + /// # Authorization + /// + /// `caller` signs. pub fn fund(e: &Env, caller: Address, assets: i128) -> i128 { treasury::fund(e, &caller, assets) } + /// Seconds a closed epoch waits before it can be priced. pub fn notice(e: &Env) -> u64 { state::notice(e) } + /// The id of the open epoch, which takes new requests. pub fn current_epoch(e: &Env) -> u64 { state::current_epoch(e) } + /// An epoch: its status, deposits in asset base units, redemptions in share + /// base units, price in WAD, and close and pricing times in seconds. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. pub fn get_epoch(e: &Env, epoch_id: u64) -> Option { state::get_epoch(e, epoch_id) } - /// What pricing promised the epoch and what has been paid against it. + /// What pricing promised the epoch and what has been paid against it: + /// open request counts, shares in share base units, assets in asset base + /// units. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. pub fn get_epoch_settlement(e: &Env, epoch_id: u64) -> Option { settlement::get(e, epoch_id) } + /// A holder's deposit in an epoch: its amount in asset base units, and + /// whether it was claimed. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + /// * `holder` - The depositor. pub fn get_deposit_request(e: &Env, epoch_id: u64, holder: Address) -> Option { state::get_deposit_request(e, epoch_id, &holder) } + /// Escrows `amount` of the asset into the open epoch and returns the + /// epoch's id. The deposit can be priced once the epoch closes and its + /// notice has run. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `amount` - Asset base units. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `WindDownActive` - The vault is winding down. + /// * `SetupOpen` - Setup has not finished. + /// * `InvalidAmount` - `amount` is zero or negative. + /// * `NotAllowlisted` - `holder` fails the identity check. + /// * `Frozen` - `holder` is frozen. + /// * `DepositCapExceeded` - The deposit would pass the cap. + /// * `RequestOutstanding` - `holder` has a deposit in this epoch already. + /// * `AmountTooLarge` - A total would overflow. + /// * The asset's own error when `holder` cannot pay. + /// + /// # Events + /// + /// * `DepositRequested` + /// + /// # Authorization + /// + /// `holder` signs. #[when_not_paused] pub fn request_deposit(e: &Env, holder: Address, amount: i128) -> u64 { deposit::request(e, &holder, amount) } - /// Returns the shares minted to the holder. Zero means none were: a deposit - /// too small for one share was refunded, or the holder is off the allowlist - /// and the shares joined an exit instead (`DepositConvertedToExit`). + /// Mints a priced deposit's shares and returns them, in share base units. + /// Zero means none: a deposit worth less than one share was refunded, or a + /// delisted holder's shares joined an exit (`DepositConvertedToExit`). + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `epoch_id` - The epoch the deposit joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotFulfilled` - The epoch is not priced yet. + /// * `RequestNotFound` - `holder` has no deposit in the epoch. + /// * `AlreadyClaimed` - The deposit was claimed. + /// * `Frozen` - `holder` is frozen. + /// * `ClaimNotCovered` - The free reserve cannot cover a refund yet. + /// * `RWAError::MintNotCompliant` - The compliance rules refuse the mint. + /// * `RWAError::IdentityVerificationFailed` - A conversion while the vault + /// is off the allowlist. + /// + /// # Events + /// + /// * `DepositClaimed`, `DepositConvertedToExit`, or `DepositCancelled` + /// for a refund. + /// * `EpochReconciled` once the epoch's last request settles. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { deposit::claim(e, &holder, epoch_id) } - /// Cancels an unpriced deposit. Never blocked by the pause. + /// Cancels an unpriced deposit and returns the asset base units refunded. + /// Open while the epoch has no readable price: a paused vault or NAV, or + /// an expired report, reopens it. Never blocked by the pause. + /// + /// # Arguments + /// + /// * `holder` - The depositor. + /// * `epoch_id` - The epoch the deposit joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `AlreadyPriced` - The epoch is priced; claim instead. + /// * `PriceAvailable` - The epoch can be priced now. + /// * `RequestNotFound` - `holder` has no deposit in the epoch. + /// * `Frozen` - `holder` is frozen. + /// + /// # Events + /// + /// * `DepositCancelled` + /// + /// # Authorization + /// + /// `holder` signs. pub fn cancel_deposit(e: &Env, holder: Address, epoch_id: u64) -> i128 { deposit::cancel(e, &holder, epoch_id) } - /// Cancels an unpriced redemption. Outside a wind-down the shares come - /// back, even while the token is paused, unless the holder is delisted, - /// frozen or refused by the compliance rules; in one the escrow joins the - /// holder's entitlement, and a frozen holder is held. + /// Cancels an unpriced redemption and returns its share base units. Outside + /// a wind-down the shares come back, even while the token is paused, unless + /// the holder is delisted, frozen or refused by the compliance rules; in one + /// the escrow joins the holder's entitlement, and a frozen holder is held. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `epoch_id` - The epoch the redemption joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `AlreadyPriced` - The epoch is priced; claim instead. + /// * `PriceAvailable` - The epoch can be priced now. + /// * `RequestNotFound` - `holder` has no redemption in the epoch. + /// * `NotAllowlisted` - `holder` is delisted. + /// * `Frozen` - `holder` is frozen. + /// * `HolderNotEligible` - The compliance rules refuse the return. + /// + /// # Events + /// + /// * `RedeemCancelled`, or `RedemptionSurrendered` in a wind-down. + /// + /// # Authorization + /// + /// `holder` signs. pub fn cancel_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { redeem::cancel(e, &holder, epoch_id) } + /// Escrows `shares` into the open epoch's redemptions and returns the + /// epoch's id. Open to a delisted holder, and while the vault or the token + /// is paused. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `shares` - Share base units. + /// + /// # Errors + /// + /// * `WindDownActive` - The vault is winding down. + /// * `InvalidAmount` - `shares` is zero or negative. + /// * `Frozen` - `holder` is frozen, or `shares` needs frozen shares. + /// * `RequestOutstanding` - `holder` has a redemption in this epoch + /// already. + /// * `AmountTooLarge` - A total would overflow. + /// * `RWAError::InsufficientBalance` - `shares` exceeds the balance. + /// + /// # Events + /// + /// * `RedeemRequested`, after the token's `Transfer`. + /// + /// # Authorization + /// + /// `holder` signs. pub fn request_redeem(e: &Env, holder: Address, shares: i128) -> u64 { redeem::request(e, &holder, shares) } + /// A holder's redemption in an epoch: its shares in share base units, and + /// whether it was claimed. + /// + /// # Arguments + /// + /// * `epoch_id` - The epoch. + /// * `holder` - The redeemer. pub fn get_redeem_request(e: &Env, epoch_id: u64, holder: Address) -> Option { state::get_redeem_request(e, epoch_id, &holder) } + /// Pays a priced redemption and returns the asset base units paid. Open + /// while paused and to a delisted holder. Outside a wind-down, shares + /// priced at nothing come back instead. + /// + /// # Arguments + /// + /// * `holder` - The redeemer. + /// * `epoch_id` - The epoch the redemption joined. + /// + /// # Errors + /// + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotFulfilled` - The epoch is not priced yet. + /// * `RequestNotFound` - `holder` has no redemption in the epoch. + /// * `AlreadyClaimed` - The redemption was claimed. + /// * `Frozen` - `holder` is frozen. + /// * `ClaimNotCovered` - The liquid reserve cannot pay it yet. + /// * `HolderNotEligible` - Shares priced at nothing cannot come back. + /// + /// # Events + /// + /// * `RedeemClaimed`, or `RedeemCancelled` when the shares come back. + /// * `EpochReconciled` once the epoch's last request settles. + /// + /// # Authorization + /// + /// `holder` signs. pub fn claim_redeem(e: &Env, holder: Address, epoch_id: u64) -> i128 { redeem::claim(e, &holder, epoch_id) } + /// Seals the open epoch, opens the next, and returns the sealed epoch's + /// id. It can be priced once its notice has run. + /// + /// # Arguments + /// + /// * `caller` - A `manager` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `manager`. + /// * `WindDownActive` - The vault is winding down. + /// + /// # Events + /// + /// * `EpochClosed` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "manager")] pub fn close_epoch(e: &Env, caller: Address) -> u64 { epoch::close(e) } + /// Prices a sealed epoch at the feed's standing report and returns the + /// share price in WAD: asset base units per share base unit. Open to + /// anyone once the notice has run and a report accepted after the close + /// is valid. + /// + /// # Arguments + /// + /// * `epoch_id` - A sealed epoch. + /// + /// # Errors + /// + /// * `PausableError::EnforcedPause` - The vault is paused. + /// * `WindDownActive` - The vault is winding down. + /// * `EpochNotFound` - No such epoch. + /// * `EpochNotPending` - The epoch is open or already priced. + /// * `NoticeNotElapsed` - The notice has not run. + /// * `FeedNotValid` - The feed is paused or stale. + /// * `AttestationBeforeClose` - The report predates the close. + /// * `InvalidSharePrice` - The feed returned zero or less. + /// * `AmountTooLarge` - A total or a conversion would overflow. + /// + /// # Events + /// + /// * `EpochFulfilled` + /// * `EpochReconciled` when the epoch has no open request. + /// + /// # Authorization + /// + /// None: anyone may call. #[when_not_paused] pub fn fulfill_epoch(e: &Env, epoch_id: u64) -> i128 { epoch::fulfill(e, epoch_id) @@ -404,29 +988,102 @@ impl AsyncVault { #[contractimpl(contracttrait)] impl AccessControl for AsyncVault { - /// Instant during setup; after it, propose the grant through the timelock. + /// Grants `role` to `account` at once. Only during setup; after it, propose + /// a `GrantRole` change. + /// + /// # Arguments + /// + /// * `account` - The account that gets the role. + /// * `role` - `manager`, `treasury` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - Setup has finished. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// + /// # Events + /// + /// * `RoleGranted`, unless `account` already holds `role`. + /// + /// # Authorization + /// + /// `caller` signs. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_change_allowed(e); access_control::grant_role(e, &account, &role, &caller); extend::role_entries(e, &account, &role); } - /// Instant for every role but `manager`: taking `treasury` or `guardian` - /// away moves no value. After setup, revoking `manager` waits like a grant, - /// because without it no epoch closes and no exit is priced. + /// Takes `role` from `account`. Instant for every role but `manager`: + /// taking `treasury` or `guardian` away moves no value. After setup, + /// revoking `manager` waits like a grant, because without it no epoch + /// closes and no exit is priced. + /// + /// # Arguments + /// + /// * `account` - The account that loses the role. + /// * `role` - `manager`, `treasury` or `guardian`. + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `UseTimelock` - `manager`, after setup. + /// * `AccessControlError::Unauthorized` - `caller` is not the admin. + /// * `AccessControlError::RoleNotHeld` - `account` does not hold `role`. + /// + /// # Events + /// + /// * `RoleRevoked` + /// + /// # Authorization + /// + /// `caller` signs. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { guards::direct_revoke_allowed(e, &role); access_control::revoke_role(e, &account, &role, &caller); } - /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the - /// current admin, so it stays instant. + /// Offers the admin role to `new_admin`, who takes it with + /// `accept_admin_transfer`. An offer is instant only during setup; after + /// it, propose an `Admin` change. Cancelling an offer stays instant. + /// + /// # Arguments + /// + /// * `new_admin` - The nominee, or the pending nominee when cancelling. + /// * `live_until_ledger` - The last ledger number the nominee can accept + /// in; `0` cancels the pending offer. + /// + /// # Errors + /// + /// * `UseTimelock` - An offer after setup. + /// * `RoleTransferError::InvalidLiveUntilLedger` - The ledger has passed or + /// is beyond the longest entry lifetime. + /// * `RoleTransferError::NoPendingTransfer` - Nothing to cancel. + /// * `RoleTransferError::InvalidPendingAccount` - The cancel names another + /// nominee. + /// + /// # Events + /// + /// * `AdminTransferInitiated` + /// + /// # Authorization + /// + /// The admin signs. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { guards::direct_hand_over_allowed(e, live_until_ledger); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } /// Refused. A role admin could grant that role with no delay. + /// + /// # Arguments + /// + /// * `role`, `admin_role` - Not read. + /// + /// # Errors + /// + /// * `UseTimelock` - Always. fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { panic_with_error!(e, VaultError::UseTimelock); } @@ -436,6 +1093,10 @@ impl AccessControl for AsyncVault { /// is a hand-over: propose and apply an `Admin` change /// (`transfer_admin_role` during setup), then the nominee calls /// `accept_admin_transfer`. + /// + /// # Errors + /// + /// * `AdminRequired` - Always. fn renounce_admin(e: &Env) { panic_with_error!(e, VaultError::AdminRequired); } @@ -443,12 +1104,50 @@ impl AccessControl for AsyncVault { #[contractimpl(contracttrait)] impl Pausable for AsyncVault { + /// Pauses the vault: no deposit request, no pricing, and no timelocked + /// change on the kit lands; pending changes wait longer by the time + /// paused. Claims, cancellations and redemption requests stay open. + /// + /// # Arguments + /// + /// * `caller` - A `guardian` holder. + /// + /// # Errors + /// + /// * `AccessControlError::Unauthorized` - `caller` lacks `guardian`. + /// * `PausableError::EnforcedPause` - The vault is already paused. + /// + /// # Events + /// + /// * `Paused` + /// + /// # Authorization + /// + /// `caller` signs. #[only_role(caller, "guardian")] fn pause(e: &Env, caller: Address) { pausable::pause(e); strata_timelock::pause::start(e); } + /// Lifts the vault's pause. + /// + /// # Arguments + /// + /// * `caller` - The admin. + /// + /// # Errors + /// + /// * `AdminRequired` - `caller` is not the admin. + /// * `PausableError::ExpectedPause` - The vault is not paused. + /// + /// # Events + /// + /// * `Unpaused` + /// + /// # Authorization + /// + /// `caller` signs. fn unpause(e: &Env, caller: Address) { guards::caller_is_admin(e, &caller); caller.require_auth(); @@ -459,6 +1158,9 @@ impl Pausable for AsyncVault { #[contractimpl(contracttrait)] impl ClockSource for AsyncVault { + /// The clock the kit's timelocks run on: the upgrade delay and the notice + /// in seconds, the seconds spent paused, and whether the vault is paused + /// or winding down. fn timelock_clock(e: &Env) -> Clock { governance::clock(e) } From 85948256869b623aa729cfc23bf650c9f8bd5d30 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:09:07 -0300 Subject: [PATCH 7/9] docs: publish the error code ranges The kit spec lists each contract's error block and the codes in use, and says how the entrypoint docs name units, errors and signers. AGENTS.md points new error enums at error_enum!. --- AGENTS.md | 5 ++++- docs/kit-spec.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8f2e454a..568913a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,10 @@ contracts. The addresses emitted by the deploy script are authoritative. are (`custodian`, `vault`, the allowlisted `account`). OpenZeppelin trait methods keep their own names (`operator`, `user_address`). - **Named errors.** Contract code fails with a named error variant, never with - `unwrap`, `expect` or a bare `panic!`. Tests may unwrap. + `unwrap`, `expect` or a bare `panic!`. Tests may unwrap. Each contract + declares its error enum through `strata_bindings::error_enum!`, which fails + the build if a code leaves the contract's block (`docs/kit-spec.md`, section + 7). - **No boolean switches.** A new entrypoint does one thing. Two behaviours get two functions, like `freeze_partial_tokens` and `unfreeze_partial_tokens`, not one function that takes a `bool`. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 5856c667..9d1f47fb 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -494,3 +494,49 @@ governance, which is a trust assumption. It also reads the token's compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's `state` and `freshness_duration` (`OracleFeed`). A replacement for any of these must answer those calls, or the vault's setup cannot finish. + +--- + +## 7. Error Codes and Entrypoint Docs + +Each kit contract owns a block of error codes. A call that fails inside +another contract returns that contract's code: a deposit claim the identity +verifier refuses returns `304`, not a vault code. The blocks never overlap, so +a code always names the contract that raised it. + +| Contract | Error enum | Block | Codes in use | +|---|---|---|---| +| `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3025–3026 | +| `async_vault` | `VaultError` | 6000–6999 | 6000–6001, 6005, 6007, 6009, 6012, 6014, 6029–6035, 6037–6075 | +| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7016 | +| `compliance` | `ComplianceContractError` | 8000–8999 | 8001–8011 | + +The blocks are defined once, in `crates/strata-bindings` (`ORACLE_ERRORS`, +`VAULT_ERRORS`, `TOKEN_ERRORS`, `COMPLIANCE_ERRORS`). Each contract declares +its enum through `strata_bindings::error_enum!`, and the build fails if a code +leaves its contract's block or two blocks overlap. A code is never reused: a +removed variant leaves its number retired. The testnet identity verifier has +no enum of its own; it refuses with OpenZeppelin's +`RWAError::IdentityVerificationFailed` (304). OpenZeppelin's libraries keep +their codes outside the kit's blocks: below 3000 in the modules the kit uses, +and from 4000 to 5999 in its governance crate. + +Every entrypoint the kit defines or overrides carries a doc in its contract's +spec, so generated clients and block explorers show it. The doc is a one-line +summary, then `# Arguments`, `# Errors`, `# Events` and `# Authorization`; a +section that would be empty is left out, so a view has no `# Events` and no +`# Authorization`. Functions that OpenZeppelin's traits add without an +override keep OpenZeppelin's docs. + +- Amounts are in the asset's base units and shares in the share token's base + units. Prices are in WAD: asset base units per share base unit, times 10^18. + Times and durations are in seconds; `live_until_ledger` is a ledger number. +- An error named without its enum is the contract's own. Any other error + carries its enum, as in `AccessControlError::Unauthorized`. +- `NotInitialized` and `AccountingMismatch` are not listed per entrypoint. They + mean the contract's own state is broken, and any call that reads the broken + entry can return them. +- A call into another contract can fail with that contract's error. The docs + name the ones a caller should plan for, such as the asset's own error when + the payer cannot pay. A replacement identity verifier or compliance module + may refuse with codes of its own. From 85b82adf189d1583ec97266a02443ec53c480ecd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:53:27 -0300 Subject: [PATCH 8/9] test: check docs fit the contract spec The contract spec keeps the first 1,024 bytes of a doc and drops the rest without a warning, so an over-long doc loses its end, where the `# Authorization` section sits. The test joins every function, enum and variant doc of the five contracts the way soroban-sdk-macros does and fails on any doc over the limit. --- crates/strata-bindings/tests/spec_docs.rs | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 crates/strata-bindings/tests/spec_docs.rs diff --git a/crates/strata-bindings/tests/spec_docs.rs b/crates/strata-bindings/tests/spec_docs.rs new file mode 100644 index 00000000..8436edb6 --- /dev/null +++ b/crates/strata-bindings/tests/spec_docs.rs @@ -0,0 +1,77 @@ +//! The contract spec keeps the first 1,024 bytes of each doc and drops the rest +//! without a warning, so an over-long doc loses its end, where `# Authorization` +//! sits. + +use std::fs; +use std::path::Path; + +const SPEC_DOC_LIMIT: usize = 1024; + +/// Every entrypoint and error enum of the five contracts lives in these files. +const CONTRACT_SOURCES: [&str; 7] = [ + "contracts/async-vault/src/lib.rs", + "contracts/async-vault/src/error.rs", + "contracts/share-token/src/contract.rs", + "contracts/share-token/src/error.rs", + "contracts/nav-oracle/src/lib.rs", + "contracts/compliance/src/lib.rs", + "contracts/identity-verifier/src/lib.rs", +]; + +/// The doc of every function, enum and enum variant in `source`, joined the +/// way `soroban-sdk-macros` stores it: each line trimmed, then joined by `\n`. +fn item_docs(source: &str) -> Vec<(String, String)> { + let mut docs = Vec::new(); + let mut block: Vec<&str> = Vec::new(); + for line in source.lines().map(str::trim) { + if let Some(text) = line.strip_prefix("///") { + block.push(text.trim()); + continue; + } + if block.is_empty() || line.is_empty() || line.starts_with("#[") { + continue; + } + if let Some(item) = item_name(line) { + docs.push((item, block.join("\n"))); + } + block.clear(); + } + docs +} + +fn item_name(line: &str) -> Option { + if let Some((_, rest)) = line.split_once("fn ") { + return rest.split('(').next().map(|name| format!("fn {name}")); + } + if let Some(rest) = line.strip_prefix("pub enum ") { + return rest + .split_whitespace() + .next() + .map(|name| format!("enum {name}")); + } + let (name, code) = line.split_once(" = ")?; + let is_variant = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && code.trim_end_matches(',').parse::().is_ok(); + is_variant.then(|| format!("variant {name}")) +} + +#[test] +fn every_contract_doc_fits_the_spec() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut too_long = Vec::new(); + for path in CONTRACT_SOURCES { + let source = fs::read_to_string(root.join(path)).unwrap(); + let docs = item_docs(&source); + assert!(!docs.is_empty(), "{path}: no documented item found"); + for (item, doc) in docs { + if doc.len() > SPEC_DOC_LIMIT { + too_long.push(format!("{path}: {item} has {} bytes", doc.len())); + } + } + } + assert!( + too_long.is_empty(), + "the contract spec keeps {SPEC_DOC_LIMIT} bytes of a doc:\n{}", + too_long.join("\n") + ); +} From 527620dd14ba02b4871dad113f4ef3e00846b1c7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:53:59 -0300 Subject: [PATCH 9/9] docs: correct the cancel and payout docs A valid report accepted after the close refuses a cancellation even while the notice runs, so PriceAvailable says that instead of "can be priced now". The constructor reads compliance only for the distinctness check and never reads attester. Payouts name the asset's own error when the holder cannot receive, legal_transfer lists TokensUnfrozen, and the ReserveCommittedToExits and WindDownActive docs and the kit spec's note on shared OpenZeppelin codes say what the code does. --- contracts/async-vault/src/error.rs | 9 +++++---- contracts/async-vault/src/lib.rs | 26 ++++++++++++++++---------- contracts/share-token/src/contract.rs | 3 ++- docs/kit-spec.md | 4 +++- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 43f91984..c20c3903 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -50,8 +50,9 @@ strata_bindings::error_enum! { /// The vault does not hold enough to pay this claim yet. The liability /// stands and the claim succeeds once the reserve covers it. ClaimNotCovered = 6037, - /// The amount would deploy assets already owed to holders whose exit has - /// been priced but not yet claimed. + /// The amount would deploy assets that are not free: escrow depositors + /// can still cancel, or cash owed to holders whose exit has been priced + /// but not yet claimed. ReserveCommittedToExits = 6005, /// No custodian has been set, so capital has nowhere to go. CustodianNotSet = 6012, @@ -79,8 +80,8 @@ strata_bindings::error_enum! { /// could outlive would strand their exit with no way to undo it. NoticeTooLong = 6045, /// The vault is winding down. New requests, epoch closes, pricing, - /// transfers to the custodian and governance changes are closed for the - /// rest of its life. + /// transfers to the custodian, wind-down proposals and timelocked + /// changes are closed for the rest of its life. WindDownActive = 6046, /// No wind-down proposal stands. WindDownNotProposed = 6047, diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6c148082..71045251 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -79,8 +79,8 @@ impl AsyncVault { /// * `share_token` - The share token. /// * `oracle` - The feed epochs are priced from. /// * `roles` - `governance` becomes the admin; `manager`, `treasury` and - /// `guardian` get their roles. `compliance` and `attester` are only - /// checked for distinctness. + /// `guardian` get their roles. `compliance` is read only by the + /// distinctness check; `attester` is not read. /// /// # Errors /// @@ -438,6 +438,7 @@ impl AsyncVault { /// * `DistributionNotStarted` - No round has been finalised. /// * `Frozen` - `holder` is frozen. /// * `NoEntitlement` - Nothing to surrender and nothing owed. + /// * The asset's own error when `holder` cannot receive it. /// /// # Events /// @@ -763,8 +764,8 @@ impl AsyncVault { } /// Mints a priced deposit's shares and returns them, in share base units. - /// Zero means none: a deposit worth less than one share was refunded, or a - /// delisted holder's shares joined an exit (`DepositConvertedToExit`). + /// Zero means none: a deposit worth less than one share base unit was + /// refunded, or a delisted holder's shares joined an exit. /// /// # Arguments /// @@ -780,13 +781,14 @@ impl AsyncVault { /// * `Frozen` - `holder` is frozen. /// * `ClaimNotCovered` - The free reserve cannot cover a refund yet. /// * `RWAError::MintNotCompliant` - The compliance rules refuse the mint. - /// * `RWAError::IdentityVerificationFailed` - A conversion while the vault - /// is off the allowlist. + /// * `RWAError::IdentityVerificationFailed` - The vault is delisted, for a + /// conversion. + /// * The asset's own error when `holder` cannot receive a refund. /// /// # Events /// - /// * `DepositClaimed`, `DepositConvertedToExit`, or `DepositCancelled` - /// for a refund. + /// * `DepositClaimed`, `DepositConvertedToExit` or, for a refund, + /// `DepositCancelled`. /// * `EpochReconciled` once the epoch's last request settles. /// /// # Authorization @@ -809,9 +811,11 @@ impl AsyncVault { /// /// * `EpochNotFound` - No such epoch. /// * `AlreadyPriced` - The epoch is priced; claim instead. - /// * `PriceAvailable` - The epoch can be priced now. + /// * `PriceAvailable` - A report accepted after the close is valid, so the + /// price is known, even while the notice runs. /// * `RequestNotFound` - `holder` has no deposit in the epoch. /// * `Frozen` - `holder` is frozen. + /// * The asset's own error when `holder` cannot receive it. /// /// # Events /// @@ -838,7 +842,8 @@ impl AsyncVault { /// /// * `EpochNotFound` - No such epoch. /// * `AlreadyPriced` - The epoch is priced; claim instead. - /// * `PriceAvailable` - The epoch can be priced now. + /// * `PriceAvailable` - A report accepted after the close is valid, so the + /// price is known, even while the notice runs. /// * `RequestNotFound` - `holder` has no redemption in the epoch. /// * `NotAllowlisted` - `holder` is delisted. /// * `Frozen` - `holder` is frozen. @@ -914,6 +919,7 @@ impl AsyncVault { /// * `Frozen` - `holder` is frozen. /// * `ClaimNotCovered` - The liquid reserve cannot pay it yet. /// * `HolderNotEligible` - Shares priced at nothing cannot come back. + /// * The asset's own error when `holder` cannot receive it. /// /// # Events /// diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index a8f4e952..f3d8dd15 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -231,7 +231,8 @@ impl ShareToken { /// /// # Events /// - /// * `Transfer`, then `LegalTransfer`. + /// * `Transfer`, then `LegalTransfer`; `TokensUnfrozen` first when frozen + /// shares move. /// /// # Authorization /// diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 9d1f47fb..1d286016 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -502,7 +502,9 @@ must answer those calls, or the vault's setup cannot finish. Each kit contract owns a block of error codes. A call that fails inside another contract returns that contract's code: a deposit claim the identity verifier refuses returns `304`, not a vault code. The blocks never overlap, so -a code always names the contract that raised it. +a code inside them always names the kit contract that raised it. OpenZeppelin's +codes are shared: every contract built on its libraries returns the same code +for the same failure, so `304` says what failed, not where. | Contract | Error enum | Block | Codes in use | |---|---|---|---|