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.