diff --git a/AGENTS.md b/AGENTS.md index 20038573..975b3d1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,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/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 6d8420b4..c20c3903 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -1,161 +1,171 @@ 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 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, + /// 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, wind-down proposals and timelocked + /// 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/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 72fb288c..71045251 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` is read only by the + /// distinctness check; `attester` is not read. + /// + /// # 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,173 @@ 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. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # 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 +491,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 +526,466 @@ 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 base unit was + /// refunded, or a delisted holder's shares joined an exit. + /// + /// # 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` - The vault is delisted, for a + /// conversion. + /// * The asset's own error when `holder` cannot receive a refund. + /// + /// # Events + /// + /// * `DepositClaimed`, `DepositConvertedToExit` or, for a refund, + /// `DepositCancelled`. + /// * `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` - 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 + /// + /// * `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` - 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. + /// * `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. + /// * The asset's own error when `holder` cannot receive it. + /// + /// # 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 +994,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 +1099,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 +1110,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 +1164,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) } diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 34f9b4d0..63b19938 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] @@ -89,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); @@ -104,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); @@ -111,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) } @@ -156,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); @@ -176,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); @@ -185,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); @@ -197,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); @@ -204,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, diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index f94deabb..fc6c0983 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] @@ -183,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); @@ -193,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); @@ -202,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); } @@ -210,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); } @@ -217,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) } @@ -228,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, @@ -247,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); @@ -260,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); @@ -267,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, @@ -302,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); @@ -336,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); @@ -351,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); @@ -358,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); @@ -375,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 } diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 6188531e..1a3796d7 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,125 @@ 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`; `TokensUnfrozen` first when frozen + /// shares move. + /// + /// # Authorization + /// + /// `caller` and the admin sign, as two different accounts. #[only_role(caller, "compliance")] pub fn legal_transfer( e: &Env, @@ -122,10 +266,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 +280,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(); @@ -154,18 +339,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( @@ -198,7 +459,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); @@ -206,16 +487,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); @@ -223,6 +549,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); } @@ -231,6 +565,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); } @@ -238,29 +576,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, @@ -278,6 +718,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); @@ -285,6 +748,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); @@ -292,12 +780,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(); @@ -305,6 +837,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(); 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, + } } 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 { 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") + ); +} diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1af200f4..eba68d2d 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -502,3 +502,51 @@ 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 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 | +|---|---|---|---| +| `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.