diff --git a/content/contracts-sui/1.x/access-control.mdx b/content/contracts-sui/1.x/access-control.mdx new file mode 100644 index 00000000..1d778499 --- /dev/null +++ b/content/contracts-sui/1.x/access-control.mdx @@ -0,0 +1,88 @@ +--- +title: RBAC +--- + + +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `access_control` module provides role-based authorization for Sui Move packages. It is the right choice when authority is not a single transferable object, or when several privileged actors need different permissions over shared protocol state. + +## Use cases + +Use `access_control` when your protocol needs: + +- A root role controlled by a package One-Time Witness (OTW). +- Operational roles such as admin, treasurer, operator, guardian, pauser, or keeper. +- Role-admin relationships, where one role grants and revokes another. +- Typed authorization proofs with `Auth` for new functions. +- Signature-preserving checks with `assert_has_role` for existing public functions. +- Delayed root-role transfer to governance or a multisig. +- Delayed root-role renounce when the protocol should be permanently locked. +- Delayed changes to the root-role transfer and renounce delay. + +## Import + +```move +use openzeppelin_access::access_control::{Self, AccessControl, Auth}; +``` + +## Default pattern + +The central object is `AccessControl`. For new code, publish it as a standalone shared object and pass it directly in PTBs. Protected functions receive `&Auth` instead of storing the registry inside the protected object. + +```move +module my_sui_app::roles; + +use openzeppelin_access::access_control::{Self, Auth}; + +public struct ROLES has drop {} + +public struct OperatorRole {} +public struct Treasury has key, store { id: UID } + +const DEFAULT_ADMIN_DELAY_MS: u64 = 24 * 60 * 60 * 1_000; + +fun init(otw: ROLES, ctx: &mut TxContext) { + let mut access = access_control::new(otw, DEFAULT_ADMIN_DELAY_MS, ctx); + access.grant_role<_, OperatorRole>(ctx.sender(), ctx); + + transfer::public_share_object(access); + transfer::share_object(Treasury { id: object::new(ctx) }); +} + +public fun protected_action(_: &mut Treasury, _: &Auth) { + // Authorized by Auth. +} +``` + + +The root role cannot be granted, revoked, or renounced with ordinary role-management calls. Use the delayed root transfer or delayed root renounce flows instead. Finalizing a root renounce leaves the registry without a root holder, so use it only when the protocol should become permanently unmanaged. + + +## Root role operations + +The root role is the fallback admin for every role. Because it can recover role administration, root-role changes go through delayed flows: + +- `begin_default_admin_transfer` schedules transfer to a new root holder. +- `accept_default_admin_transfer` lets the pending holder accept after the configured delay. +- `begin_default_admin_renounce` schedules an intentional lock-in where the current root holder gives up the root role. +- `accept_default_admin_renounce` finalizes that lock-in after the configured delay. +- `cancel_default_admin_transfer` cancels either a pending transfer or a pending renounce. +- `begin_default_admin_delay_change`, `accept_default_admin_delay_change`, and `cancel_default_admin_delay_change` manage the delay itself. + +Role grants and root transfers reject `@0x0`. Use the delayed root-renounce flow when the goal is to intentionally lock the registry. + +## Authorization styles + +Use `Auth` for new functions when you want authorization to be explicit in the function type and reusable across multiple calls in the same PTB. + +Because `Auth` is minted from the singleton registry for that role's home module, protected functions do not need to compare registry object IDs when they receive `&Auth`. + +Use `assert_has_role` when you are retrofitting an existing public function and changing the signature would be disruptive or invalid for package-upgrade compatibility. In that case, the registry is usually stored under an existing `Config` or `State` object and borrowed internally. + +## Learn more + +For a full walkthrough of roles, `Auth`, publishing, upgrades, and PTB usage, see the [Role Based Access Control guide](/contracts-sui/1.x/guides/access-control). + +For function-level signatures, events, and errors, see the [Access API reference](/contracts-sui/1.x/api/access). diff --git a/content/contracts-sui/1.x/access.mdx b/content/contracts-sui/1.x/access.mdx index 13542e74..86e58e15 100644 --- a/content/contracts-sui/1.x/access.mdx +++ b/content/contracts-sui/1.x/access.mdx @@ -3,16 +3,12 @@ title: Access --- -The example code snippets used in this guide are experimental and have not been audited. They are meant to illustrate usage of the OpenZeppelin Sui Package. +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. -The `openzeppelin_access` package provides ownership-transfer wrappers for privileged Sui objects (`T: key + store`), such as admin and treasury capabilities. +The `openzeppelin_access` package provides role-based access control and ownership-transfer wrappers for privileged Sui objects, such as admin capabilities, treasury capabilities, shared protocol state, and governance-controlled operations. -Use this package when direct object transfer is too permissive for your protocol. It gives you explicit transfer workflows that are easier to review, monitor, and constrain with policy. - - -This package is designed for single-owned objects. In `two_step_transfer`, `ctx.sender()` is stored as the owner-of-record for pending requests. Avoid using this policy directly in shared-object executor flows unless your design explicitly maps signer identity to cancel authority. - +Use this package when direct object transfer or single-admin authorization is too permissive for your protocol. It gives you typed role checks and explicit transfer workflows that are easier to review, monitor, and constrain with policy. ## Usage @@ -23,334 +19,44 @@ Add the dependency in `Move.toml`: openzeppelin_access = { r.mvr = "@openzeppelin-move/access" } ``` -Import the transfer policy module you want to use: - -```move -use openzeppelin_access::two_step_transfer; -``` - -## Quickstart example - -```move -module my_sui_app::admin; - -use openzeppelin_access::two_step_transfer; - -public struct AdminCap has key, store { - id: object::UID, -} - -public fun wrap_admin_cap( - cap: AdminCap, - ctx: &mut TxContext, -): two_step_transfer::TwoStepTransferWrapper { - // Wrap the capability object to force a two-step transfer policy. - two_step_transfer::wrap(cap, ctx) -} -``` - -## Choosing a transfer policy - -**Use `two_step_transfer` when:** - -- The transfer can execute immediately once confirmed. -- The principal initiating the transfer is a known, controlled key. -- The risk you are guarding against is human error (wrong or non-existent address), not timing. - -**Use `delayed_transfer` when:** - -- Your protocol requires on-chain lead time before authority changes. -- Users, DAOs, or monitoring systems need a window to detect and respond. -- The delay should be a reliable, inspectable commitment visible to anyone. - -**Combining both:** the modules accept any `T: key + store`, so they compose. You could wrap a capability in `delayed_transfer` for the timing guarantee and use a `two_step_transfer` flow at the scheduling step for address-confirmation safety. - ---- - -## Why controlled transfers matter - -On Sui, `sui::transfer::transfer` is instant and irreversible. There is no confirmation step, no waiting period, and no cancel mechanism. For everyday objects this is fine. For privileged capability objects, such as admin caps, treasury caps, or upgrade authorities, a single mistaken or malicious transfer permanently moves control with no recourse. - -The `openzeppelin_access` package adds two transfer policies that sit between you and that irreversible `transfer` call: - -| Module | What it enforces | Analogy from Solidity | -| --- | --- | --- | -| `two_step_transfer` | Recipient must explicitly accept before the transfer completes | `Ownable2Step` | -| `delayed_transfer` | Mandatory time delay before execution; anyone can observe the pending action | `TimelockController` on ownership | - -If you already know which policy you need, jump directly to [two\_step\_transfer](#two_step_transfer) or [delayed\_transfer](#delayed_transfer). - -## Wrapping and transfer policies - -Both modules use the same underlying mechanism: wrapping a capability inside a new object that enforces a transfer policy on it. - -When you call `wrap`, the capability is stored as a dynamic object field inside the wrapper. This means: - -- **The wrapper becomes the custody object.** You hold the wrapper, not the capability directly. To transfer or recover the capability, you go through the wrapper's policy. -- **The underlying capability retains its on-chain ID.** Off-chain indexers and explorers can still discover and track it via the dynamic object field. Wrapping does not make the capability invisible. -- **The wrapper intentionally omits the `store` ability.** Without `store`, the wrapper cannot be moved via `transfer::public_transfer`. Only the module's own functions (which use the privileged `transfer::transfer` internally) can move it. This is a deliberate design choice that prevents accidental transfers outside the policy. - -A `WrapExecuted` event is emitted when a capability is wrapped, creating an on-chain record of when the policy was applied. - -### Borrowing without unwrapping - -Both modules provide three ways to use the wrapped capability without changing ownership: - -**Immutable borrow** for read-only access: - -```move -let cap_ref = wrapper.borrow(); // &AdminCap -``` - -**Mutable borrow** for updating the capability's internal state: +Import the module you want to use: ```move -let cap_mut = wrapper.borrow_mut(); // &mut AdminCap -``` - -**Temporary move** for functions that require the capability by value. This uses the hot potato pattern: `borrow_val` returns a `Borrow` struct with no abilities (`copy`, `drop`, `store`, `key` are all absent). The Move compiler enforces that it must be consumed by `return_val` before the transaction ends. - -```move -let (cap, borrow_token) = wrapper.borrow_val(); - -/// Use cap in functions that require it by value. -wrapper.return_val(cap, borrow_token); // compiler enforces this call -``` - -If you try to drop the borrow token, return a different capability, or return it to the wrong wrapper, the transaction either won't compile or will abort at runtime. - -## `two_step_transfer` - -A transfer policy that requires the designated recipient to explicitly accept before the wrapper changes hands. The initiator retains cancel authority until acceptance. There is no time delay. The transfer executes immediately once the recipient accepts. - -This is the right choice when the principal initiating the transfer is a known, controlled key (a multisig, a hot wallet operated by the same team) and the risk you are guarding against is sending the capability to a wrong or non-existent address, which would permanently lock the capability with no way to recover it. - -### Step 1: Wrap the capability - -```move -module my_sui_app::admin; - +use openzeppelin_access::access_control::{Self, AccessControl, Auth}; use openzeppelin_access::two_step_transfer; - -public struct AdminCap has key, store { id: UID } - -/// Wrap and immediately initiate a transfer to `new_admin`. -/// The wrapper is consumed by `initiate_transfer` and held -/// inside the shared `PendingOwnershipTransfer` until the -/// recipient accepts or the initiator cancels. -public fun wrap_and_transfer(cap: AdminCap, new_admin: address, ctx: &mut TxContext) { - let wrapper = two_step_transfer::wrap(cap, ctx); - // Emits WrapExecuted - - wrapper.initiate_transfer(new_admin, ctx); - // Emits TransferInitiated -} -``` - -`wrap` stores the `AdminCap` inside a `TwoStepTransferWrapper` and emits a `WrapExecuted` event. Because the wrapper lacks the `store` ability, it cannot be sent via `transfer::public_transfer`. The intended next step is to call `initiate_transfer`, which consumes the wrapper and creates a shared `PendingOwnershipTransfer` object that both parties can interact with. - -### Step 2: Initiate a transfer - -`initiate_transfer` consumes the wrapper by value and creates a shared `PendingOwnershipTransfer` object. The sender's address is recorded as `from` (the cancel authority), and the recipient's address is recorded as `to`. - -```move -/// Called by the current wrapper owner. Consumes the wrapper. -wrapper.initiate_transfer(new_admin_address, ctx); -/// Emits TransferInitiated { wrapper_id, from, to } -``` - -After this call, the wrapper is held inside the pending request via transfer-to-object. The original owner no longer has it in their scope, but they retain the ability to cancel because their address is recorded as `from`. - -The `TransferInitiated` event contains the pending request's ID and the wrapper ID, allowing off-chain indexers to discover the shared `PendingOwnershipTransfer` object for the next step. - -### Step 3: Recipient accepts (or initiator cancels) - -The designated recipient calls `accept_transfer` to complete the handoff. This step uses Sui's [transfer-to-object (TTO)](https://docs.sui.io/guides/developer/objects/transfers/transfer-to-object) pattern: the wrapper was transferred to the `PendingOwnershipTransfer` object in Step 2, so the recipient must provide a `Receiving>` ticket to claim it. The `Receiving` type is Sui's mechanism for retrieving objects that were sent to another object rather than to a wallet. - -```move -/// Called by the address recorded as `to` (new_admin_address). -/// `request` is the shared PendingOwnershipTransfer object. -/// `wrapper_ticket` is the Receiving> for the wrapper -/// that was transferred to the request object. -two_step_transfer::accept_transfer(request, wrapper_ticket, ctx); -// Emits TransferAccepted { wrapper_id, from, to } -``` - -If the initiator changes their mind before the recipient accepts, they can cancel. The cancel call also requires the `Receiving` ticket for the wrapper: - -```move -/// Called by the address recorded as `from` (the original initiator). -two_step_transfer::cancel_transfer(request, wrapper_ticket, ctx); -/// Wrapper is returned to the `from` address. -``` - -### Unwrapping - -To permanently reclaim the raw capability and destroy the wrapper: - -```move -let admin_cap = wrapper.unwrap(ctx); -``` - -This bypasses the transfer flow entirely. Only the current wrapper owner can call it. - -### Security note on shared-object flows - -`initiate_transfer` records `ctx.sender()` as the cancel authority. In normal single-owner usage, this is the wallet holding the wrapper. However, if `initiate_transfer` is called inside a shared-object executor where any user can be the transaction sender, a malicious user could call `initiate_transfer` targeting their own address as recipient. They would become both the pending recipient and the sole cancel authority, locking out the legitimate owner. - -Avoid using `two_step_transfer` in shared-object executor flows unless your design explicitly maps signer identity to cancel authority. - -## `delayed_transfer` - -A transfer policy that enforces a configurable minimum delay between scheduling and executing a transfer. The delay is set at wrap time and cannot be changed afterward. This creates a publicly visible window before any authority change takes effect, giving monitoring systems, DAOs, and individual users time to detect and respond. - -This is the right choice when your protocol requires on-chain lead time before a capability changes hands, for example, to allow an incident response process to detect a compromised key, or to give depositors time to exit before governance parameters change. - -### Step 1: Wrap with a delay - -```move -module my_sui_app::treasury; - use openzeppelin_access::delayed_transfer; - -public struct TreasuryCap has key, store { id: UID } - -const MIN_DELAY_MS: u64 = 86_400_000; // 24 hours - -/// Creates the wrapper and transfers it to ctx.sender() internally -public fun wrap_treasury_cap(cap: TreasuryCap, ctx: &mut TxContext) { - delayed_transfer::wrap(cap, MIN_DELAY_MS, ctx.sender(), ctx); -} ``` -`wrap` creates a `DelayedTransferWrapper`, stores the capability inside it as a dynamic object field, and transfers the wrapper to the specified `recipient` (here, the caller). A `WrapExecuted` event is emitted. Unlike `two_step_transfer::wrap` which returns the wrapper, `delayed_transfer::wrap` handles the transfer internally and has no return value. - -### Step 2: Schedule a transfer +## Modules -```move -/// Called by the current wrapper owner. -wrapper.schedule_transfer(new_owner_address, &clock, ctx); -/// Emits TransferScheduled with execute_after_ms = clock.timestamp_ms() + min_delay_ms -``` + + + Role-based authorization for privileged functions, shared protocol objects, operators, guardians, keepers, and governance executors, with delayed root administration. + + + Ownership-transfer wrapper for single-owned privileged objects that should not move until the recipient explicitly accepts. + + + Ownership-transfer wrapper for single-owned privileged objects whose transfer or unwrap should be visible on-chain for a delay window before execution. + + -The `Clock` object is Sui's shared on-chain clock. The deadline is computed as `clock.timestamp_ms() + min_delay_ms` and stored in the wrapper. Only one action can be pending at a time; scheduling a second without canceling the first aborts with `ETransferAlreadyScheduled`. +## Choosing a module -During the delay window, the `TransferScheduled` event is visible on-chain. Monitoring systems, governance dashboards, or individual users watching the chain can detect the pending transfer and take action (e.g., withdrawing funds from the protocol) before it executes. +| Module | Use it when | +| --- | --- | +| `access_control` | Authority is spread across multiple actors or roles, especially for shared objects, protocol functions, and delayed root-admin operations. | +| `two_step_transfer` | A single-owned privileged object can transfer immediately, but the recipient should explicitly accept first. | +| `delayed_transfer` | A single-owned privileged object should not transfer or unwrap until a visible delay has elapsed. | -The `recipient` in `schedule_transfer` must be a wallet address, not an object ID. If the wrapper is transferred to an object via transfer-to-object (TTO), both the wrapper and the capability inside it become permanently locked. The `delayed_transfer` module does not implement a `Receiving`-based retrieval mechanism, so there is no way to borrow, unwrap, or further transfer a wrapper that has been sent to an object. Always verify that the scheduled recipient is an address controlled by a keypair. +The ownership-transfer modules are designed for single-owned objects. In `two_step_transfer`, `ctx.sender()` is stored as the owner-of-record for pending requests. Avoid using this policy directly in shared-object executor flows unless your design explicitly maps signer identity to cancel authority. -### Step 3: Wait, then execute - -```move -/// Callable after the delay window has passed. -wrapper.execute_transfer(&clock, ctx); -/// Emits OwnershipTransferred. Consumes the wrapper and delivers it to the recipient. -``` - -`execute_transfer` consumes the wrapper by value. After this call, the wrapper has been transferred to the scheduled recipient and no longer exists in the caller's scope. Calling it before `execute_after_ms` aborts with `EDelayNotElapsed`. - -### Scheduling an unwrap - -The same delay enforcement applies to recovering the raw capability: - -```move -/// Schedule the unwrap -wrapper.schedule_unwrap(&clock, ctx); -/// Emits UnwrapScheduled - -/// After the delay has elapsed, executes the unwrap: Emits UnwrapExecuted, wrapper is consumed, and capability is returned. -let treasury_cap = wrapper.unwrap(&clock, ctx); -``` - -### Canceling - -The owner can cancel a pending action at any time before execution: - -```move -wrapper.cancel_schedule(); -``` - -This clears the pending slot immediately, allowing a new action to be scheduled. - -## Putting it together - -Here is a protocol example that uses `delayed_transfer` to wrap its admin capability, ensuring any ownership change is visible on-chain for 24 hours before it takes effect: - -```move -module my_sui_app::governed_protocol; - -use openzeppelin_access::delayed_transfer::{Self, DelayedTransferWrapper}; -use openzeppelin_math::rounding; -use openzeppelin_math::u64 as math_u64; -use sui::clock::Clock; - -const MIN_DELAY_MS: u64 = 86_400_000; // 24 hours -const EMathOverflow: u64 = 0; - -public struct ProtocolAdmin has key, store { - id: UID, - fee_bps: u64, -} - -/// Initialize: create the admin cap and wrap it with a 24-hour transfer delay. -/// `delayed_transfer::wrap` transfers the wrapper to the deployer internally. -fun init(ctx: &mut TxContext) { - let admin = ProtocolAdmin { - id: object::new(ctx), - fee_bps: 30, // 0.3% - }; - delayed_transfer::wrap(admin, MIN_DELAY_MS, ctx.sender(), ctx); -} - -/// Update the fee rate. Borrows the admin cap mutably without changing ownership. -public fun update_fee( - wrapper: &mut DelayedTransferWrapper, - new_fee_bps: u64, -) { - let admin = delayed_transfer::borrow_mut(wrapper); - admin.fee_bps = new_fee_bps; -} - -/// Compute a fee using the admin-configured rate and safe math. -public fun compute_fee( - wrapper: &DelayedTransferWrapper, - amount: u64, -): u64 { - let admin = delayed_transfer::borrow(wrapper); - math_u64::mul_div(amount, admin.fee_bps, 10_000, rounding::up()) - .destroy_or!(abort EMathOverflow) -} - -/// Schedule a transfer to a new admin. Visible on-chain for 24 hours. -public fun schedule_admin_transfer( - wrapper: &mut DelayedTransferWrapper, - new_admin: address, - clock: &Clock, - ctx: &mut TxContext, -) { - wrapper.schedule_transfer(new_admin, clock, ctx); -} -``` - -This module combines both packages: `openzeppelin_math` for the fee calculation (explicit rounding, overflow handling) and `openzeppelin_access` for the ownership policy (24-hour delay, on-chain observability). Users monitoring the chain see the `TransferScheduled` event and can exit before a new admin takes over. - -Build and test: - -```bash -sui move build -sui move test -``` - -## API Reference - -For function-level signatures and parameters, see the [Access API reference](/contracts-sui/1.x/api/access). - ## Next steps -- [Access API reference](/contracts-sui/1.x/api/access) for full function signatures and error codes -- [Integer Math](/contracts-sui/1.x/math) for safe arithmetic primitives used in protocol math -- [Fixed-Point Math](/contracts-sui/1.x/fixed-point) for fractional values, prices, and signed deltas -- [GitHub issue tracker](https://github.com/OpenZeppelin/contracts-sui/issues) to report bugs or request features -- [Sui Discord](https://discord.gg/sui) and [Sui Developer Forum](https://forums.sui.io/) to connect with other builders +- [RBAC](/contracts-sui/1.x/access-control) for role-based authorization. +- [Two-Step Transfer](/contracts-sui/1.x/two-step-transfer) for explicit recipient acceptance. +- [Delayed Transfer](/contracts-sui/1.x/delayed-transfer) for delayed capability transfers and unwraps. +- [Role Based Access Control guide](/contracts-sui/1.x/guides/access-control) for a full walkthrough with publishing, upgrades, and PTBs. +- [Access API reference](/contracts-sui/1.x/api/access) for function signatures, events, and errors. diff --git a/content/contracts-sui/1.x/api/access.mdx b/content/contracts-sui/1.x/api/access.mdx index da80b6aa..32db5fe8 100644 --- a/content/contracts-sui/1.x/api/access.mdx +++ b/content/contracts-sui/1.x/api/access.mdx @@ -4,6 +4,656 @@ title: Access API Reference This page documents the public API of `openzeppelin_access` for OpenZeppelin Contracts for Sui `v1.x`. +### `access_control` [toc] [#access_control] + + + +```move +use openzeppelin_access::access_control; +``` + +Role-based access control registry for Sui Move packages. The root role is the consumer module's One-Time Witness (OTW), and managed roles must be defined in the same module as the root role. + +The registry must be created from a module `init` during the first publish of the package that defines `RootRole`. Package upgrades do not run `init` for newly-added modules. + +The module supports ordinary role grants and revocations, typed `Auth` proofs, delayed root-role transfer, delayed root-role renounce, and delayed changes to the root-operation delay. + +Types + +- [`Auth`](#access_control-Auth) +- [`AccessControl`](#access_control-AccessControl) +- [`RoleData`](#access_control-RoleData) +- [`PendingAdminTransfer`](#access_control-PendingAdminTransfer) +- [`PendingDelayChange`](#access_control-PendingDelayChange) + +Functions + +- [`new(otw, default_admin_delay_ms, ctx)`](#access_control-new) +- [`has_role(ac, account)`](#access_control-has_role) +- [`get_role_admin(ac)`](#access_control-get_role_admin) +- [`assert_has_role(ac, account)`](#access_control-assert_has_role) +- [`grant_role(ac, account, ctx)`](#access_control-grant_role) +- [`revoke_role(ac, account, ctx)`](#access_control-revoke_role) +- [`renounce_role(ac, account, ctx)`](#access_control-renounce_role) +- [`set_role_admin(ac, ctx)`](#access_control-set_role_admin) +- [`begin_default_admin_transfer(ac, new_admin, clock, ctx)`](#access_control-begin_default_admin_transfer) +- [`accept_default_admin_transfer(ac, clock, ctx)`](#access_control-accept_default_admin_transfer) +- [`begin_default_admin_renounce(ac, clock, ctx)`](#access_control-begin_default_admin_renounce) +- [`accept_default_admin_renounce(ac, clock, ctx)`](#access_control-accept_default_admin_renounce) +- [`cancel_default_admin_transfer(ac, ctx)`](#access_control-cancel_default_admin_transfer) +- [`begin_default_admin_delay_change(ac, new_delay_ms, clock, ctx)`](#access_control-begin_default_admin_delay_change) +- [`accept_default_admin_delay_change(ac, clock, ctx)`](#access_control-accept_default_admin_delay_change) +- [`cancel_default_admin_delay_change(ac, ctx)`](#access_control-cancel_default_admin_delay_change) +- [`new_auth(ac, ctx)`](#access_control-new_auth) +- [`auth_addr(auth)`](#access_control-auth_addr) +- [`protected_root(ac)`](#access_control-protected_root) +- [`max_default_admin_delay_ms()`](#access_control-max_default_admin_delay_ms) +- [`default_admin_delay_ms(ac)`](#access_control-default_admin_delay_ms) +- [`has_pending_default_admin_transfer(ac)`](#access_control-has_pending_default_admin_transfer) +- [`is_pending_default_admin_renounce(ac)`](#access_control-is_pending_default_admin_renounce) +- [`pending_default_admin_new_admin(ac)`](#access_control-pending_default_admin_new_admin) +- [`pending_default_admin_execute_after_ms(ac)`](#access_control-pending_default_admin_execute_after_ms) +- [`max_delay_increase_wait_ms()`](#access_control-max_delay_increase_wait_ms) +- [`has_pending_default_admin_delay_change(ac)`](#access_control-has_pending_default_admin_delay_change) +- [`pending_default_admin_delay_change_new_delay_ms(ac)`](#access_control-pending_default_admin_delay_change_new_delay_ms) +- [`pending_default_admin_delay_change_schedule_after_ms(ac)`](#access_control-pending_default_admin_delay_change_schedule_after_ms) + +Events + +- [`RoleGranted`](#access_control-RoleGranted) +- [`RoleRevoked`](#access_control-RoleRevoked) +- [`RoleAdminChanged`](#access_control-RoleAdminChanged) +- [`DefaultAdminTransferScheduled`](#access_control-DefaultAdminTransferScheduled) +- [`DefaultAdminRenounceScheduled`](#access_control-DefaultAdminRenounceScheduled) +- [`DefaultAdminTransferCancelled`](#access_control-DefaultAdminTransferCancelled) +- [`DefaultAdminDelayChangeScheduled`](#access_control-DefaultAdminDelayChangeScheduled) +- [`DefaultAdminDelayChangeCancelled`](#access_control-DefaultAdminDelayChangeCancelled) + +Errors + +- [`EUnauthorized`](#access_control-EUnauthorized) +- [`ECannotManageRootRole`](#access_control-ECannotManageRootRole) +- [`ENoPendingAdminTransfer`](#access_control-ENoPendingAdminTransfer) +- [`ENotPendingAdmin`](#access_control-ENotPendingAdmin) +- [`EDelayNotElapsed`](#access_control-EDelayNotElapsed) +- [`ECannotRenounceForOtherAccount`](#access_control-ECannotRenounceForOtherAccount) +- [`EDelayTooLarge`](#access_control-EDelayTooLarge) +- [`ENotOneTimeWitness`](#access_control-ENotOneTimeWitness) +- [`EForeignRole`](#access_control-EForeignRole) +- [`ENotPendingTransfer`](#access_control-ENotPendingTransfer) +- [`ENotPendingRenounce`](#access_control-ENotPendingRenounce) +- [`ENoPendingDelayChange`](#access_control-ENoPendingDelayChange) +- [`EZeroAddress`](#access_control-EZeroAddress) + +#### Types [!toc] [#access_control-Types] + + +Drop-only typed proof that the transaction sender held `Role` when the proof was minted with `new_auth`. + +Because `new_auth` only mints against the singleton registry for `Role`'s home module, consumers can take `&Auth` as authorization without repeating role checks. + + + +Key object storing role membership, role-admin relationships, root-role pending state, pending delay changes, and the current root-operation delay. + +`RootRole` is the consuming module's OTW type. It pins the registry identity and is the protected root role for the registry. + + + +Per-role membership and admin-role data. + + + +Pending root-role action. `new_admin = some(address)` means transfer, while `new_admin = none` means renounce. + + + +Pending change to `default_admin_delay_ms`, including the new delay and the timestamp after which it can be applied. + + +#### Functions [!toc] [#access_control-Functions] + + +Creates the registry for the consumer module. `RootRole` must be a genuine OTW, and `ctx.sender()` becomes the initial root-role holder. + +The caller is responsible for sharing, embedding, or otherwise positioning the returned `AccessControl` registry. + +The expected call site is the consumer module's `init`, which only runs on the package's first publish. To adopt AccessControl from an already-published protocol, publish a new package that initializes its own registry. + +Aborts with `ENotOneTimeWitness` if `otw` is not an OTW. + +Aborts with `EDelayTooLarge` if `default_admin_delay_ms` exceeds `max_default_admin_delay_ms()`. + + + +Returns whether `account` currently holds `Role`. Returns `false` for roles that have never been registered. + + + +Returns the admin role for `Role`, defaulting to the protected root role when no role data exists yet. + +Aborts with `EForeignRole` if `Role` is not defined in the same module as `RootRole`. + + + +Checks that `account` holds `Role`. + +Aborts with `EUnauthorized` if `account` does not hold `Role`. + + + +Grants `Role` to `account`. Caller must hold the admin role of `Role`. No-op if `account` already has the role. + +Aborts with `EForeignRole` if `Role` is not defined in the same module as `RootRole`. + +Aborts with `ECannotManageRootRole` if `Role` is the root role. + +Aborts with `EUnauthorized` if caller does not hold the admin role of `Role`. + +Aborts with `EZeroAddress` if `account` is `@0x0`. + + + +Revokes `Role` from `account`. Caller must hold the admin role of `Role`. No-op if `account` does not have the role. + +Aborts with `EForeignRole` if `Role` is not defined in the same module as `RootRole`. + +Aborts with `ECannotManageRootRole` if `Role` is the root role. + +Aborts with `EUnauthorized` if caller does not hold the admin role of `Role`. + + + +Lets the caller renounce their own non-root `Role`. No-op if caller does not hold `Role`. + +Aborts with `ECannotRenounceForOtherAccount` if `account` is not `ctx.sender()`. + +Aborts with `EForeignRole` if `Role` is not defined in the same module as `RootRole`. + +Aborts with `ECannotManageRootRole` if `Role` is the root role. Root renounce must use `begin_default_admin_renounce` and `accept_default_admin_renounce`. + + + +Changes the admin role for `Role` to `AdminRole`. Caller must hold the current admin role of `Role`. + +Aborts with `EForeignRole` if either `Role` or `AdminRole` is not defined in the same module as `RootRole`. + +Aborts with `ECannotManageRootRole` if `Role` is the root role. + +Aborts with `EUnauthorized` if caller lacks the current admin role of `Role`. + + + +Schedules a root-role transfer to `new_admin` at `clock.timestamp_ms() + default_admin_delay_ms`. + +Caller must hold the root role. An existing pending transfer or renounce is overwritten. + +Aborts with `EUnauthorized` if caller does not hold the root role. + +Aborts with `EZeroAddress` if `new_admin` is `@0x0`. Use `begin_default_admin_renounce` to permanently lock the registry. + + + +Accepts a pending root-role transfer after the scheduled delay. Caller must be the pending new admin. + +The call clears the pending action, revokes the old root holder, and grants the root role to the caller. + +Aborts with `ENoPendingAdminTransfer` if there is no pending transfer or renounce. + +Aborts with `ENotPendingTransfer` if the pending action is a renounce. + +Aborts with `ENotPendingAdmin` if caller is not the scheduled new admin. + +Aborts with `EDelayNotElapsed` if the timelock has not elapsed. + + + +Schedules a root-role renounce at `clock.timestamp_ms() + default_admin_delay_ms`. + +Caller must hold the root role. An existing pending transfer or renounce is overwritten. + +Aborts with `EUnauthorized` if caller does not hold the root role. + + + +Finalizes a pending root-role renounce after the scheduled delay. Caller must still hold the root role. + +After this call, no account holds the root role and root administration is permanently unavailable unless another protocol-specific recovery path exists. + +Aborts with `ENoPendingAdminTransfer` if there is no pending transfer or renounce. + +Aborts with `ENotPendingRenounce` if the pending action is a transfer. + +Aborts with `EUnauthorized` if caller does not hold the root role. + +Aborts with `EDelayNotElapsed` if the timelock has not elapsed. + + + +Cancels a pending root-role transfer or pending root-role renounce. Caller must hold the root role. + +Aborts with `ENoPendingAdminTransfer` if there is no pending transfer or renounce. + +Aborts with `EUnauthorized` if caller does not hold the root role. + + + +Schedules a change to `default_admin_delay_ms`. Caller must hold the root role, and `new_delay_ms` must not exceed `max_default_admin_delay_ms()`. + +Increases wait for `min(new_delay_ms, max_delay_increase_wait_ms())`; decreases wait for `current_delay_ms - new_delay_ms`. An existing pending delay change is overwritten. + +Aborts with `EUnauthorized` if caller does not hold the root role. + +Aborts with `EDelayTooLarge` if `new_delay_ms` exceeds `max_default_admin_delay_ms()`. + + + +Applies a pending delay change after `schedule_after_ms`. + +No authorization is required at accept time; the root holder authorized the change when scheduling it. + +Aborts with `ENoPendingDelayChange` if no delay change is pending. + +Aborts with `EDelayNotElapsed` if `schedule_after_ms` has not been reached. + + + +Cancels a pending delay change. Caller must hold the root role. + +Aborts with `ENoPendingDelayChange` if no delay change is pending. + +Aborts with `EUnauthorized` if caller does not hold the root role. + + + +Mints a temporary `Auth` proof for `ctx.sender()`. Pass it by reference to gated functions in the same PTB. + +Aborts with `EForeignRole` if `Role` is not defined in the same module as `RootRole`. + +Aborts with `EUnauthorized` if sender does not hold `Role`. + + + +Returns the address that held `Role` when `auth` was minted. + + + +Returns the `TypeName` of the protected root role captured at construction time. + + + +Returns the maximum allowed root-operation delay: 60 days in milliseconds. + + + +Returns the current delay, in milliseconds, for root transfers and root renounces. + + + +Returns whether there is any pending root-role action: either a transfer or a renounce. + + + +Returns whether the pending root-role action is specifically a renounce. Returns `false` when there is no pending action or when the pending action is a transfer. + + + +Returns `some(address)` for a pending transfer. Returns `none` when there is no pending action or when the pending action is a renounce. + +Use `is_pending_default_admin_renounce` to distinguish no pending action from pending renounce. + + + +Returns `some(timestamp_ms)` with the timestamp after which the pending root-role action can be accepted. Returns `none` when no root action is pending. + + + +Returns the maximum wait before a scheduled delay increase can take effect: 48 hours in milliseconds. + + + +Returns whether a default-admin delay change is pending. + + + +Returns `some(new_delay_ms)` with the proposed delay if a change is pending. Returns `none` otherwise. + + + +Returns `some(timestamp_ms)` with the timestamp after which a pending delay change can be applied. Returns `none` otherwise. + + +#### Events [!toc] [#access_control-Events] + + +Emitted when a role is granted to an account. + +Fired by `grant_role` when `account` was not already a member, by `new` for the initial root holder, and by `accept_default_admin_transfer` for the incoming root holder. + +The `role` field is a `TypeName` that identifies the defining package address and module of the role struct. + + + +Emitted when a role is removed from an account. + +Fired by `revoke_role` when `account` held the role, by `renounce_role`, by `accept_default_admin_transfer` for the previous root holder, and by `accept_default_admin_renounce` for the renouncing root holder. + +The `role` field is a `TypeName` that identifies the defining package address and module of the role struct. + + + +Emitted when a role's admin role is reconfigured by `set_role_admin`. + +The `role`, `previous_admin_role`, and `new_admin_role` fields are `TypeName`s. + + + +Emitted when a root-role transfer is scheduled by `begin_default_admin_transfer`. + +`execute_after_ms` is the earliest timestamp at which `accept_default_admin_transfer` can be called. + + + +Emitted when a root-role renounce is scheduled by `begin_default_admin_renounce`. + +`execute_after_ms` is the earliest timestamp at which `accept_default_admin_renounce` can be called. + + + +Emitted by `cancel_default_admin_transfer` when a pending root-role transfer or renounce is cancelled. + +Indexers can correlate this event with the prior `DefaultAdminTransferScheduled` or `DefaultAdminRenounceScheduled` event to identify which pending action was cancelled. + + + +Emitted when a default-admin delay change is scheduled by `begin_default_admin_delay_change`. + +`new_delay_ms` is the proposed new delay. `schedule_after_ms` is the earliest timestamp at which `accept_default_admin_delay_change` can apply it. + + + +Emitted by `cancel_default_admin_delay_change` when a pending default-admin delay change is cancelled. + + +#### Errors [!toc] [#access_control-Errors] + + +Raised when the caller or checked account does not hold the required role. + + + +Raised when grant, revoke, renounce, or admin-change logic is used on the root role instead of the delayed root flows. + + + +Raised when `accept_default_admin_transfer`, `accept_default_admin_renounce`, or `cancel_default_admin_transfer` expects a pending root transfer or renounce but none exists. + + + +Raised when a caller other than the scheduled new root holder tries to accept a root transfer. + + + +Raised when `accept_default_admin_transfer`, `accept_default_admin_renounce`, or `accept_default_admin_delay_change` is called before the pending action's timelock has elapsed. + + + +Raised when `renounce_role` is called for an account other than `ctx.sender()`. + + + +Raised when the initial or scheduled default-admin delay exceeds `max_default_admin_delay_ms()`. + + + +Raised when `new` receives a value that is not a genuine OTW. + + + +Raised when a write path or `new_auth` uses a role type from a module other than the root role's home module. + + + +Raised when `accept_default_admin_transfer` is called while the pending root action is a renounce. + + + +Raised when `accept_default_admin_renounce` is called while the pending root action is a transfer or there is no pending root action. + + + +Raised when accepting or cancelling a default-admin delay change but no delay change is pending. + + + +Raised when `grant_role` or `begin_default_admin_transfer` is called with `@0x0` as the target address. + +The zero address has no signing key, so a role granted to it can never be exercised and a root transfer scheduled to it can never be accepted. + + ### `two_step_transfer` [toc] [#two_step_transfer] +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `delayed_transfer` module provides an ownership-transfer wrapper that enforces a configurable minimum delay before a privileged object can transfer or unwrap. + +## Use cases + +Use `delayed_transfer` when: + +- Your protocol requires on-chain lead time before authority changes. +- Users, DAOs, or monitoring systems need a window to detect and respond. +- The delay should be a reliable, inspectable commitment visible to anyone. + +## Import + +```move +use openzeppelin_access::delayed_transfer; +``` + +## Step 1: Wrap with a delay + +```move +module my_sui_app::treasury; + +use openzeppelin_access::delayed_transfer; + +public struct TreasuryCap has key, store { id: UID } + +const MIN_DELAY_MS: u64 = 86_400_000; // 24 hours + +/// Creates the wrapper and transfers it to ctx.sender() internally. +public fun wrap_treasury_cap(cap: TreasuryCap, ctx: &mut TxContext) { + delayed_transfer::wrap(cap, MIN_DELAY_MS, ctx.sender(), ctx); +} +``` + +`wrap` creates a `DelayedTransferWrapper`, stores the capability inside it as a dynamic object field, and transfers the wrapper to the specified `recipient`. Unlike `two_step_transfer::wrap`, which returns the wrapper, `delayed_transfer::wrap` handles the transfer internally and has no return value. + +## Step 2: Schedule a transfer + +```move +/// Called by the current wrapper owner. +wrapper.schedule_transfer(new_owner_address, &clock, ctx); +/// Emits TransferScheduled with execute_after_ms = clock.timestamp_ms() + min_delay_ms +``` + +The `Clock` object is Sui's shared on-chain clock. The deadline is computed as `clock.timestamp_ms() + min_delay_ms` and stored in the wrapper. Only one action can be pending at a time; scheduling a second without canceling the first aborts with `ETransferAlreadyScheduled`. + +During the delay window, the `TransferScheduled` event is visible on-chain. Monitoring systems, governance dashboards, or individual users watching the chain can detect the pending transfer and take action before it executes. + + +The `recipient` in `schedule_transfer` must be a wallet address, not an object ID. If the wrapper is transferred to an object via transfer-to-object (TTO), both the wrapper and the capability inside it become permanently locked. The `delayed_transfer` module does not implement a `Receiving`-based retrieval mechanism, so there is no way to borrow, unwrap, or further transfer a wrapper that has been sent to an object. + + +## Step 3: Wait, then execute + +```move +/// Callable after the delay window has passed. +wrapper.execute_transfer(&clock, ctx); +/// Emits OwnershipTransferred. Consumes the wrapper and delivers it to the recipient. +``` + +`execute_transfer` consumes the wrapper by value. After this call, the wrapper has been transferred to the scheduled recipient and no longer exists in the caller's scope. Calling it before `execute_after_ms` aborts with `EDelayNotElapsed`. + +## Scheduling an unwrap + +The same delay enforcement applies to recovering the raw capability: + +```move +/// Schedule the unwrap. +wrapper.schedule_unwrap(&clock, ctx); +/// Emits UnwrapScheduled. + +/// After the delay has elapsed, execute the unwrap. +let treasury_cap = wrapper.unwrap(&clock, ctx); +/// Emits UnwrapExecuted. +``` + +## Canceling + +The owner can cancel a pending action at any time before execution: + +```move +wrapper.cancel_schedule(); +``` + +This clears the pending slot immediately, allowing a new action to be scheduled. + +## Borrowing without unwrapping + +The module provides three ways to use the wrapped capability without changing ownership: + +```move +let cap_ref = wrapper.borrow(); +let cap_mut = wrapper.borrow_mut(); +let (cap, borrow_token) = wrapper.borrow_val(); +wrapper.return_val(cap, borrow_token); +``` + +`borrow_val` uses a hot-potato guard, so the value must be returned to the same wrapper before the transaction ends. + +## API Reference + +For function-level signatures and error codes, see the [Access API reference](/contracts-sui/1.x/api/access#delayed_transfer). diff --git a/content/contracts-sui/1.x/fixed-point.mdx b/content/contracts-sui/1.x/fixed-point.mdx index 6316cbdc..b60b3eb7 100644 --- a/content/contracts-sui/1.x/fixed-point.mdx +++ b/content/contracts-sui/1.x/fixed-point.mdx @@ -6,9 +6,11 @@ title: Fixed-Point Math The example code snippets used in this guide are experimental and have not been audited. They are meant to illustrate usage of the OpenZeppelin Sui Package. -The `openzeppelin_fp_math` package adds two decimal fixed-point types with 9 decimals of precision, matching Sui's native coin precision (`10^9`). It is the right tool whenever you need real-valued arithmetic such as prices, fees, rates, ratios, and balance deltas, without giving up determinism or onchain auditability. +The `openzeppelin_fp_math` package adds two decimal fixed-point types with 9 decimals of precision, matching Sui's native coin precision (`10^9`). It +is the right tool whenever you need real-valued arithmetic such as prices, fees, rates, ratios, and balance deltas, without giving up determinism or onchain auditability. -The package complements `openzeppelin_math`, which covers integer arithmetic. Use `openzeppelin_math` when your values are whole numbers; reach for `openzeppelin_fp_math` when fractional values are part of the protocol. +The package complements `openzeppelin_math`, which covers integer arithmetic. Use `openzeppelin_math` when your values are whole numbers; reach +for `openzeppelin_fp_math` when fractional values are part of the protocol. ## Why a 9-decimal scale diff --git a/content/contracts-sui/1.x/guides/access-control.mdx b/content/contracts-sui/1.x/guides/access-control.mdx new file mode 100644 index 00000000..d5fbca0b --- /dev/null +++ b/content/contracts-sui/1.x/guides/access-control.mdx @@ -0,0 +1,689 @@ +--- +title: Role Based Access Control +--- + + +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `access_control` module provides role-based authorization for Sui Move packages. It is designed for protocols that need more than one privileged actor: admins, operators, guardians, treasurers, keepers, or governance executors. + +At a high level, your package defines role marker types, publishes an `AccessControl` registry for those roles, and uses `Auth` values minted by that registry to authorize privileged actions. + +```mermaid +flowchart LR + Root["Root role
module OTW"] --> Registry["AccessControl<Root>"] + Registry --> Admin["Admin roles"] + Admin --> Members["Role members"] + Members --> Auth["Auth<Role>"] + Auth --> Action["Protected module function"] +``` + +The module has two important constraints: + +- The root role must be the module's One-Time Witness (OTW). This pins the registry to the package publish flow. +- Managed roles must be defined in the same module as the root role. Roles added to that same module in later package upgrades still work because the check uses the package's original ID. + +These constraints make `Auth` useful as a typed proof. If a function receives `&Auth`, it can trust that the transaction sender held `TreasurerRole` when the auth value was minted in that PTB. + +## Prerequisites + +Before following this guide, it helps to be familiar with Sui Move One-Time Witnesses (OTWs), shared objects, and programmable transaction blocks (PTBs). The examples reference those concepts throughout. + +## Add the dependency + +Add the access package to `Move.toml`: + +```toml +[dependencies] +openzeppelin_access = { r.mvr = "@openzeppelin-move/access" } +``` + +Then import the module from your Move code: + +```move +use openzeppelin_access::access_control::{Self, AccessControl, Auth}; +``` + +## Default Pattern: Standalone Access Registry + +For new packages, publish `AccessControl` as its own shared object and pass it directly in PTBs. This keeps role management in the OpenZeppelin module, while protected functions only need an `&Auth` parameter. + +The protected object does not store the registry. PTBs mint an auth value from the registry, then pass that auth value to the protected function. + +```mermaid +flowchart LR + Registry["Standalone AccessControl<ROLES>"] + Treasury["Protected Treasury"] + Market["Protected Market"] + User["Signer"] + Auth["Auth<Role>"] + + User -->|"new_auth"| Registry + Registry -->|"mints"| Auth + Auth -->|"authorizes"| Treasury + Auth -->|"authorizes"| Market +``` + +### Define roles + +Put the root role and all managed role marker types in one module. Here, `ROLES` is the OTW and root role type. It is not a collection object; it anchors the singleton `AccessControl` registry. + +```move +module my_protocol::roles; + +use openzeppelin_access::access_control; + +public struct ROLES has drop {} + +public struct TreasuryAdminRole {} +public struct TreasurerRole {} +public struct PauserRole {} +public struct OperatorRole {} + +const DEFAULT_ADMIN_DELAY_MS: u64 = 24 * 60 * 60 * 1_000; + +fun init(otw: ROLES, ctx: &mut TxContext) { + let mut access = access_control::new(otw, DEFAULT_ADMIN_DELAY_MS, ctx); + + access.grant_role<_, TreasuryAdminRole>(ctx.sender(), ctx); + access.set_role_admin<_, TreasurerRole, TreasuryAdminRole>(ctx); + access.set_role_admin<_, PauserRole, TreasuryAdminRole>(ctx); + access.set_role_admin<_, OperatorRole, TreasuryAdminRole>(ctx); + + transfer::public_share_object(access); +} +``` + +### Protect protocol objects + +Protected modules import role types and accept `&Auth` in privileged functions. They do not store the registry, expose role-management wrappers, or check registry object IDs. `AccessControl` is unique for the `ROLES` OTW, and `Auth` can only be minted by that registry. + +```move +module my_protocol::treasury; + +use my_protocol::roles::{PauserRole, TreasurerRole, TreasuryAdminRole}; +use openzeppelin_access::access_control::Auth; + +#[error(code = 0)] +const EPaused: vector = "Treasury operations are paused"; + +#[error(code = 1)] +const EInsufficientBalance: vector = "Treasury balance is too low"; + +public struct Treasury has key, store { + id: UID, + balance: u64, + fee_bps: u64, + paused: bool, +} + +fun init(ctx: &mut TxContext) { + transfer::share_object(Treasury { + id: object::new(ctx), + balance: 0, + fee_bps: 0, + paused: false, + }); +} + +public fun deposit(treasury: &mut Treasury, amount: u64) { + treasury.balance = treasury.balance + amount; +} + +public fun spend(treasury: &mut Treasury, amount: u64, _: &Auth) { + assert!(!treasury.paused, EPaused); + assert!(treasury.balance >= amount, EInsufficientBalance); + treasury.balance = treasury.balance - amount; +} + +public fun pause(treasury: &mut Treasury, _: &Auth) { + treasury.paused = true; +} + +public fun unpause(treasury: &mut Treasury, _: &Auth) { + treasury.paused = false; +} + +public fun set_fee_bps(treasury: &mut Treasury, fee_bps: u64, _: &Auth) { + treasury.fee_bps = fee_bps; +} + +public fun balance(treasury: &Treasury): u64 { treasury.balance } + +public fun is_paused(treasury: &Treasury): bool { treasury.paused } +``` + +The same registry can protect other modules too: + +```move +module my_protocol::market; + +use my_protocol::roles::OperatorRole; +use openzeppelin_access::access_control::Auth; + +public struct Market has key, store { + id: UID, + settled_rounds: u64, +} + +fun init(ctx: &mut TxContext) { + transfer::share_object(Market { + id: object::new(ctx), + settled_rounds: 0, + }); +} + +public fun settle(market: &mut Market, _: &Auth) { + market.settled_rounds = market.settled_rounds + 1; +} +``` + +Define a separate root role type and registry only when a group of objects needs independent administration. + +## Publish and Bootstrap + +Publishing the package runs each module initializer. In this walkthrough, that creates the shared `AccessControl`, the shared `Treasury`, and the shared `Market`. The publisher receives the root role and `TreasuryAdminRole`. + +```mermaid +sequenceDiagram + participant Publisher + participant Publish as Package publish + participant Roles as roles::init + participant Treasury as treasury::init + participant Market as market::init + Publisher->>Publish: sui client publish + Publish->>Roles: init(ROLES) + Roles->>Roles: create AccessControl + Roles->>Roles: grant root role and TreasuryAdminRole + Publish->>Treasury: init() + Publish->>Market: init() + Roles-->>Publish: shared AccessControl object + Treasury-->>Publish: shared Treasury object + Market-->>Publish: shared Market object + Publish-->>Publisher: package and object IDs +``` + +Publish the package: + +```bash +sui client publish +``` + +Record the package and object IDs from the publish output. Set `$ACCESS` to the published OpenZeppelin access package address on your target network: + +```bash +export PACKAGE=0x... +export ACCESS=0x... +export ACCESS_REGISTRY=0x... +export TREASURY=0x... +export MARKET=0x... +export PUBLISHER=0x... +export OPS_ADMIN=0x... +export TREASURER=0x... +export GUARDIAN=0x... +export OPERATOR=0x... +``` + +After publishing, grant an operational admin and optionally renounce the publisher's day-to-day admin role. The publisher keeps the root role until a delayed root transfer is completed. + +Run this PTB from the publisher account, because `renounce_role` can only renounce the sender's own role. + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::grant_role \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::TreasuryAdminRole>" \ + @$ACCESS_REGISTRY \ + @$OPS_ADMIN \ + --move-call $ACCESS::access_control::renounce_role \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::TreasuryAdminRole>" \ + @$ACCESS_REGISTRY \ + @$PUBLISHER +``` + +Run the next PTB from `$OPS_ADMIN` to grant the roles used by the protected functions: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::grant_role \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::TreasurerRole>" \ + @$ACCESS_REGISTRY \ + @$TREASURER \ + --move-call $ACCESS::access_control::grant_role \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::PauserRole>" \ + @$ACCESS_REGISTRY \ + @$GUARDIAN \ + --move-call $ACCESS::access_control::grant_role \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::OperatorRole>" \ + @$ACCESS_REGISTRY \ + @$OPERATOR +``` + +## Interact with Protected Functions + +For functions that take `&Auth`, mint the auth value and use it in the same PTB. The auth value has `drop` only, so it is a temporary proof for the current transaction, not an object that can be stored or transferred. + +```mermaid +sequenceDiagram + participant User + participant Registry as AccessControl + participant Treasury + User->>Registry: new_auth(registry) + Registry-->>User: Auth + User->>Treasury: spend(treasury, amount, auth) +``` + +Treasurer spending from `$TREASURER`: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::new_auth \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::TreasurerRole>" \ + @$ACCESS_REGISTRY \ + --assign treasurer_auth \ + --move-call $PACKAGE::treasury::spend \ + @$TREASURY \ + 4000000000 \ + treasurer_auth +``` + +Guardian pausing from `$GUARDIAN`: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::new_auth \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::PauserRole>" \ + @$ACCESS_REGISTRY \ + --assign pauser_auth \ + --move-call $PACKAGE::treasury::pause @$TREASURY pauser_auth +``` + +Operator settling a market from `$OPERATOR`: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::new_auth \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::OperatorRole>" \ + @$ACCESS_REGISTRY \ + --assign operator_auth \ + --move-call $PACKAGE::market::settle @$MARKET operator_auth +``` + +One auth value can be reused across multiple calls in the same PTB when those calls require the same role: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::new_auth \ + "<$PACKAGE::roles::ROLES,$PACKAGE::roles::PauserRole>" \ + @$ACCESS_REGISTRY \ + --assign pauser_auth \ + --move-call $PACKAGE::treasury::pause @$TREASURY pauser_auth \ + --move-call $PACKAGE::treasury::unpause @$TREASURY pauser_auth +``` + +For new functions, prefer `Auth`. It makes authorization explicit in the function type and lets one proof authorize multiple calls in the same PTB. + +Use `assert_has_role` when authorization must stay inside the function body. This is useful for signature-preserving upgrades, or when a function accepts either of two roles, requires two roles, or chooses the required role from function state before it checks `ctx.sender()`. + +## When Wrappers Are Needed + +The standalone registry pattern assumes the registry is passed directly to PTBs. Wrappers are needed when the registry is stored inside another object and cannot be passed as its own PTB input. + +The main case is an upgrade to an already-published package. Public function signatures cannot be changed, but existing functions may already receive a `Config`, `State`, or similar object. In that layout, store `AccessControl` under that object, borrow it internally, and use `assert_has_role` without changing the public API. + +Only add wrappers in new packages when you intentionally want a domain-specific access API. Otherwise, keep PTBs calling OpenZeppelin directly. + +When the registry is embedded, PTBs cannot access it directly: + +- PTBs cannot project a private field such as `treasury.access`. +- A Move call that returns `&AccessControl<...>` cannot be assigned as a PTB command result and reused by the next command. + +Expose wrapper functions from the module that owns the field. The upgrade section below uses a dynamic object field under an existing `Config`. + +## Upgrading an Existing Package + +Access control can be integrated with an already-published package, but the role root must come from a newly published package. Do not add a new roles module to the existing package and expect its `init` to create the registry during an upgrade. Sui does not run `init` for modules added by package upgrades, so that module will not receive its OTW and cannot initialize `AccessControl`. + +For existing packages, the usual path uses two packages: + +- A new roles package that defines `ROLES` and role marker types, runs `init` on first publish, and creates `AccessControl`. +- An upgrade to the existing protocol package that imports the roles package, stores the registry as a dynamic object field under an existing protocol object, and exposes wrappers where PTBs need a public boundary. + +This works when the function already receives a protocol object that can act as the access anchor, usually an existing `Config` or `State` object. Existing public functions keep their signatures and borrow the registry internally. + +If an existing public function does not receive a suitable object parameter, upgraded code has no place to discover the registry from. In that case, keep the old function behavior, add a new role-gated function with a new signature, and migrate callers to the new entrypoint. + +First publish a new package that owns the root role and role marker types. Its initializer receives the OTW on first publish, creates the registry, and transfers that registry to the publisher for installation. In this example, publish from the address that owns the existing `AdminCap`, or transfer the registry to the `AdminCap` holder before installation. + +```move +module my_protocol_roles::roles; + +use openzeppelin_access::access_control; + +public struct ROLES has drop {} + +public struct ProtocolAdminRole {} +public struct OperatorRole {} +public struct GuardianRole {} + +const DEFAULT_ADMIN_DELAY_MS: u64 = 24 * 60 * 60 * 1_000; + +fun init(otw: ROLES, ctx: &mut TxContext) { + let mut access = access_control::new(otw, DEFAULT_ADMIN_DELAY_MS, ctx); + + access.grant_role<_, ProtocolAdminRole>(ctx.sender(), ctx); + access.set_role_admin<_, OperatorRole, ProtocolAdminRole>(ctx); + access.set_role_admin<_, GuardianRole, ProtocolAdminRole>(ctx); + + transfer::public_transfer(access, ctx.sender()); +} +``` + +Then upgrade the existing protocol package that already owns the `Config` type. Add a dependency on the new roles package, store the registry under `Config`, and expose wrappers for PTBs. The example assumes `Config` and `AdminCap` already existed before the upgrade, with `AdminCap` used for privileged operations. + +```move +module my_protocol::vault; + +use my_protocol_roles::roles::{ROLES, GuardianRole, ProtocolAdminRole}; +use openzeppelin_access::access_control::{Self, AccessControl, Auth}; +use sui::dynamic_object_field as dof; + +public struct Config has key, store { + id: UID, + fee_bps: u64, +} + +public struct AdminCap has key, store { + id: UID, +} + +public struct Vault has key, store { + id: UID, + paused: bool, +} + +public struct AccessKey has copy, drop, store {} + +/// One-time migration function. It preserves the `Config` layout by storing +/// the registry as a dynamic object field instead of adding a struct field. +public fun install_access( + config: &mut Config, + _: &AdminCap, + access: AccessControl, +) { + dof::add(&mut config.id, AccessKey {}, access); +} + +fun access(config: &Config): &AccessControl { + dof::borrow>(&config.id, AccessKey {}) +} + +fun access_mut(config: &mut Config): &mut AccessControl { + dof::borrow_mut>(&mut config.id, AccessKey {}) +} + +public fun new_auth(config: &Config, ctx: &mut TxContext): Auth { + access_control::new_auth(access(config), ctx) +} + +public fun grant_role(config: &mut Config, account: address, ctx: &mut TxContext) { + access_control::grant_role(access_mut(config), account, ctx); +} + +public fun has_role(config: &Config, account: address): bool { + access_control::has_role(access(config), account) +} + +// Existing public function: same signature as v1. +public fun set_fee_bps(config: &mut Config, fee_bps: u64, ctx: &mut TxContext) { + access_control::assert_has_role(access(config), ctx.sender()); + config.fee_bps = fee_bps; +} + +// New public function: new signatures can make authorization explicit. +public fun emergency_pause(vault: &mut Vault, _: &Auth) { + vault.paused = true; +} +``` + +The upgrade flow looks like this: + +```mermaid +flowchart TB + RolesPublish["Publish roles package"] --> OwnedRegistry["Owned AccessControl<ROLES>"] + ProtocolUpgrade["Upgrade protocol package"] --> Vault["vault module
wrappers + internal checks"] + Config["Existing Config"] --> Install["install_access(config, admin_cap, registry)"] + AdminCap["Existing AdminCap"] --> Install + OwnedRegistry --> Install + Install --> ConfigField["AccessControl<ROLES>
stored under Config"] + Vault --> ConfigField + ConfigField --> Existing["Existing functions
assert_has_role"] + ConfigField --> PTBs["PTBs call wrappers
grant_role / new_auth"] +``` + +Publish the roles package first: + +```bash +sui client publish +``` + +Record the roles package ID and the owned `AccessControl` object ID transferred to the publisher. Then upgrade the existing protocol package: + +```bash +sui client upgrade \ + --upgrade-capability $UPGRADE_CAP +``` + +Record the upgraded protocol package ID, the existing config object ID, and the existing admin-cap object ID. `$ACCESS` is the published OpenZeppelin access package address on your target network: + +```bash +export PROTOCOL=0x... +export ROLES_PACKAGE=0x... +export ACCESS=0x... +export CONFIG=0x... +export ADMIN_CAP=0x... +export ACCESS_REGISTRY=0x... +export VAULT=0x... +export OPERATOR=0x... +export GUARDIAN=0x... +``` + +Install the registry under the existing config object. This PTB must be signed by the address that owns both `ACCESS_REGISTRY` and `ADMIN_CAP`. + +```bash +sui client ptb \ + --move-call $PROTOCOL::vault::install_access @$CONFIG @$ADMIN_CAP @$ACCESS_REGISTRY +``` + +Bootstrap roles through the upgraded protocol wrapper. Run this from an address that holds `ProtocolAdminRole`, which is the roles-package publisher in this example. + +```bash +sui client ptb \ + --move-call $PROTOCOL::vault::grant_role \ + "<$ROLES_PACKAGE::roles::OperatorRole>" \ + @$CONFIG \ + @$OPERATOR \ + --move-call $PROTOCOL::vault::grant_role \ + "<$ROLES_PACKAGE::roles::GuardianRole>" \ + @$CONFIG \ + @$GUARDIAN +``` + +Call a retrofitted function that kept its original signature and now uses `assert_has_role` internally. Run this from a `ProtocolAdminRole` holder: + +```bash +sui client ptb \ + --move-call $PROTOCOL::vault::set_fee_bps @$CONFIG 30 +``` + +Call a new function that uses `Auth`. Run this from `$GUARDIAN`, because `new_auth` checks that the transaction sender holds `GuardianRole`: + +```bash +sui client ptb \ + --move-call $PROTOCOL::vault::new_auth \ + "<$ROLES_PACKAGE::roles::GuardianRole>" \ + @$CONFIG \ + --assign guardian_auth \ + --move-call $PROTOCOL::vault::emergency_pause @$VAULT guardian_auth +``` + +## Root Role Operations + +The root role is intentionally harder to change than ordinary roles. It cannot be granted, revoked, or renounced with `grant_role`, `revoke_role`, or `renounce_role`. Use the delayed root-role flows (`begin_default_admin_*`, `accept_default_admin_*`, and `cancel_default_admin_*`) for root transfer, root renounce, and changes to the root-operation delay. + +Scheduling a root transfer or a root renounce uses the same pending slot. Starting one overwrites the other, and `cancel_default_admin_transfer` cancels either pending action. + +Root transfers cannot target `@0x0`, and ordinary role grants also reject `@0x0`. If the goal is to intentionally lock the registry, use the delayed root-renounce flow instead of transferring the root role to the zero address. + +The PTBs below use the standalone `AccessControl` object from the default pattern. For an upgraded roles package, replace `$PACKAGE::roles::ROLES` with `$ROLES_PACKAGE::roles::ROLES`. If your registry is embedded in another object or stored as a dynamic object field, expose wrappers that call the same `access_control` functions internally. + +```mermaid +sequenceDiagram + participant Root as Current root holder + participant Registry as AccessControl + participant NewRoot as Pending root holder + Root->>Registry: begin_default_admin_transfer(newRoot, clock) + Registry-->>Registry: records execute_after_ms + NewRoot->>Registry: accept_default_admin_transfer(clock) + Registry-->>Registry: revokes old root and grants new root +``` + +### Transfer the root role + +Schedule the transfer from the current root holder: + +```bash +export CLOCK=0x6 +export NEW_ROOT=0x... + +sui client ptb \ + --move-call $ACCESS::access_control::begin_default_admin_transfer \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY \ + @$NEW_ROOT \ + @$CLOCK +``` + +After the configured delay elapses, the pending root holder accepts: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::accept_default_admin_transfer \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY \ + @$CLOCK +``` + +The current root holder can cancel before acceptance: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::cancel_default_admin_transfer \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY +``` + +### Renounce the root role + +Root renounce is a delayed two-step flow. Use it only when the protocol is intentionally being locked, because after acceptance no account holds the root role and the registry can no longer recover role administration through root actions. + +```mermaid +sequenceDiagram + participant Root as Current root holder + participant Registry as AccessControl + Root->>Registry: begin_default_admin_renounce(clock) + Registry-->>Registry: records execute_after_ms + Root->>Registry: accept_default_admin_renounce(clock) + Registry-->>Registry: revokes root from caller +``` + +Schedule the renounce: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::begin_default_admin_renounce \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY \ + @$CLOCK +``` + +After the configured delay elapses, the current root holder finalizes it: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::accept_default_admin_renounce \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY \ + @$CLOCK +``` + +The current root holder can cancel a pending transfer or renounce with the same cancellation call: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::cancel_default_admin_transfer \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY +``` + +### Change the root-operation delay + +`default_admin_delay_ms` controls how long root transfers and root renounces must wait before acceptance. It can also be changed through a delayed flow. The maximum value is `max_default_admin_delay_ms()`, currently 60 days. + +Delay increases take effect after `min(new_delay_ms, max_delay_increase_wait_ms())`; `max_delay_increase_wait_ms()` is 48 hours. Delay decreases take effect after `current_delay_ms - new_delay_ms`, so the root holder cannot immediately shorten the protection window and schedule a faster root change. + +```mermaid +sequenceDiagram + participant Root as Root holder + participant Registry as AccessControl + participant Any as Any signer + Root->>Registry: begin_default_admin_delay_change(newDelay, clock) + Registry-->>Registry: records schedule_after_ms + Any->>Registry: accept_default_admin_delay_change(clock) + Registry-->>Registry: updates default_admin_delay_ms +``` + +Schedule a delay change: + +```bash +export NEW_DEFAULT_ADMIN_DELAY_MS=172800000 + +sui client ptb \ + --move-call $ACCESS::access_control::begin_default_admin_delay_change \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY \ + $NEW_DEFAULT_ADMIN_DELAY_MS \ + @$CLOCK +``` + +After `schedule_after_ms`, any signer can apply the scheduled delay change: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::accept_default_admin_delay_change \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY \ + @$CLOCK +``` + +The root holder can cancel a pending delay change before it is applied: + +```bash +sui client ptb \ + --move-call $ACCESS::access_control::cancel_default_admin_delay_change \ + "<$PACKAGE::roles::ROLES>" \ + @$ACCESS_REGISTRY +``` + +## Operational Checklist + +- Define role marker types in the same module as the OTW that roots the registry. +- Use a standalone `AccessControl` object by default. +- Use `Auth` in new protected functions so access checks stay out of the function body. +- Use embedded or dynamic-field registries mainly for upgrades where existing public function signatures must stay stable. +- Grant an operational admin role during `init`; avoid using the root role for daily operations. +- Use `set_role_admin` to make day-to-day roles administered by an operational admin role. +- Use `assert_has_role` when upgrading existing functions or when authorization must be computed inside the function body. +- Transfer the root role to governance or a multisig through the delayed transfer flow. +- Renounce the root role only through the delayed renounce flow and only when the protocol is intentionally being locked. +- Treat delay changes as governance-sensitive operations; existing pending root transfers or renounces keep the delay they were scheduled under. diff --git a/content/contracts-sui/1.x/index.mdx b/content/contracts-sui/1.x/index.mdx index 7b58f54c..2e752da1 100644 --- a/content/contracts-sui/1.x/index.mdx +++ b/content/contracts-sui/1.x/index.mdx @@ -6,7 +6,7 @@ title: Contracts for Sui 1.x - `openzeppelin_math` for deterministic integer arithmetic, configurable rounding, and decimal scaling. - `openzeppelin_fp_math` for 9-decimal fixed-point arithmetic (`UD30x9`, `SD29x9`) backed by `u128`. -- `openzeppelin_access` for ownership-transfer wrappers around privileged `key + store` objects. +- `openzeppelin_access` for role-based authorization and ownership-transfer wrappers around privileged `key + store` objects. ## Quickstart @@ -76,13 +76,15 @@ sui move test ## Picking a package -- Need integer arithmetic with safe overflow and explicit rounding? Use [Integer Math](/contracts-sui/1.x/math). +- Need role-based authorization for privileged functions or controlled transfer of admin/treasury/upgrade capabilities? Use [Access](/contracts-sui/1.x/access). - Need fractional values like prices, fees, rates, or signed deltas? Use [Fixed-Point Math](/contracts-sui/1.x/fixed-point). -- Need controlled transfer of admin/treasury/upgrade capabilities? Use [Access](/contracts-sui/1.x/access). +- Need integer arithmetic with safe overflow and explicit rounding? Use [Integer Math](/contracts-sui/1.x/math). The packages compose. A typical protocol module imports `openzeppelin_math` for share math, `openzeppelin_fp_math` for rate and fee math, and `openzeppelin_access` for the admin capability that governs both. -## Next Steps +## Next steps - Package guides: [Integer Math](/contracts-sui/1.x/math), [Fixed-Point Math](/contracts-sui/1.x/fixed-point), [Access](/contracts-sui/1.x/access). +- Access modules: [RBAC](/contracts-sui/1.x/access-control), [Two-Step Transfer](/contracts-sui/1.x/two-step-transfer), [Delayed Transfer](/contracts-sui/1.x/delayed-transfer). +- Learn: [Role Based Access Control](/contracts-sui/1.x/guides/access-control). - API reference: [Integer Math](/contracts-sui/1.x/api/math), [Fixed-Point Math](/contracts-sui/1.x/api/fixed-point), [Access](/contracts-sui/1.x/api/access). diff --git a/content/contracts-sui/1.x/math.mdx b/content/contracts-sui/1.x/math.mdx index 85094d69..f573d217 100644 --- a/content/contracts-sui/1.x/math.mdx +++ b/content/contracts-sui/1.x/math.mdx @@ -8,7 +8,16 @@ The example code snippets used in this guide are experimental and have not been The `openzeppelin_math` package is the numeric foundation for OpenZeppelin Contracts for Sui. It provides deterministic arithmetic across unsigned integer widths, explicit rounding controls, and helpers for decimal normalization. -Use this package when your app needs arithmetic behavior that is predictable, auditable, and safe around overflow and precision boundaries. Instead of hiding rounding and truncation inside implementation details, `openzeppelin_math` makes those decisions explicit so they can be part of your protocol rules. +## Use cases + +Use `openzeppelin_math` when your app needs: + +- Integer arithmetic with explicit rounding behavior. +- Overflow-aware calculations that return `Option` at risky boundaries. +- Decimal normalization between systems with different precision. +- Wide intermediate arithmetic for high-precision integer paths. + +Instead of hiding rounding and truncation inside implementation details, `openzeppelin_math` makes those decisions explicit so they can be part of your protocol rules. ## Usage diff --git a/content/contracts-sui/1.x/two-step-transfer.mdx b/content/contracts-sui/1.x/two-step-transfer.mdx new file mode 100644 index 00000000..aa71100d --- /dev/null +++ b/content/contracts-sui/1.x/two-step-transfer.mdx @@ -0,0 +1,113 @@ +--- +title: Two-Step Transfer +--- + + +The example code snippets used in this guide are experimental and have not been audited. They simply help exemplify usage of the OpenZeppelin Sui Package. + + +The `two_step_transfer` module provides an ownership-transfer wrapper for single-owned privileged objects. A transfer only completes when the designated recipient explicitly accepts it. + +## Use cases + +Use `two_step_transfer` when: + +- The transfer can execute immediately once confirmed. +- The principal initiating the transfer is a known, controlled key. +- The risk you are guarding against is human error, such as sending a capability to a wrong or non-existent address. + + +`initiate_transfer` records `ctx.sender()` as the cancel authority. Avoid using this policy directly in shared-object executor flows unless your design explicitly maps signer identity to cancel authority. + + +## Import + +```move +use openzeppelin_access::two_step_transfer; +``` + +## Step 1: Wrap the capability + +```move +module my_sui_app::admin; + +use openzeppelin_access::two_step_transfer; + +public struct AdminCap has key, store { id: UID } + +/// Wrap and immediately initiate a transfer to `new_admin`. +/// The wrapper is consumed by `initiate_transfer` and held +/// inside the shared `PendingOwnershipTransfer` until the +/// recipient accepts or the initiator cancels. +public fun wrap_and_transfer(cap: AdminCap, new_admin: address, ctx: &mut TxContext) { + let wrapper = two_step_transfer::wrap(cap, ctx); + // Emits WrapExecuted + + wrapper.initiate_transfer(new_admin, ctx); + // Emits TransferInitiated +} +``` + +`wrap` stores the `AdminCap` inside a `TwoStepTransferWrapper` and emits a `WrapExecuted` event. Because the wrapper lacks the `store` ability, it cannot be sent via `transfer::public_transfer`. The intended next step is to call `initiate_transfer`, which consumes the wrapper and creates a shared `PendingOwnershipTransfer` object that both parties can interact with. + +## Step 2: Initiate a transfer + +`initiate_transfer` consumes the wrapper by value and creates a shared `PendingOwnershipTransfer` object. The sender's address is recorded as `from` (the cancel authority), and the recipient's address is recorded as `to`. + +```move +/// Called by the current wrapper owner. Consumes the wrapper. +wrapper.initiate_transfer(new_admin_address, ctx); +/// Emits TransferInitiated { wrapper_id, from, to } +``` + +After this call, the wrapper is held inside the pending request via transfer-to-object. The original owner no longer has it in their scope, but they retain the ability to cancel because their address is recorded as `from`. + +The `TransferInitiated` event contains the pending request's ID and the wrapper ID, allowing off-chain indexers to discover the shared `PendingOwnershipTransfer` object for the next step. + +## Step 3: Recipient accepts or initiator cancels + +The designated recipient calls `accept_transfer` to complete the handoff. This step uses Sui's [transfer-to-object (TTO)](https://docs.sui.io/guides/developer/objects/transfers/transfer-to-object) pattern: the wrapper was transferred to the `PendingOwnershipTransfer` object in Step 2, so the recipient must provide a `Receiving>` ticket to claim it. + +```move +/// Called by the address recorded as `to` (new_admin_address). +/// `request` is the shared PendingOwnershipTransfer object. +/// `wrapper_ticket` is the Receiving> for the wrapper +/// that was transferred to the request object. +two_step_transfer::accept_transfer(request, wrapper_ticket, ctx); +// Emits TransferAccepted { wrapper_id, from, to } +``` + +If the initiator changes their mind before the recipient accepts, they can cancel. The cancel call also requires the `Receiving` ticket for the wrapper: + +```move +/// Called by the address recorded as `from` (the original initiator). +two_step_transfer::cancel_transfer(request, wrapper_ticket, ctx); +/// Wrapper is returned to the `from` address. +``` + +## Borrowing without unwrapping + +The module provides three ways to use the wrapped capability without changing ownership: + +```move +let cap_ref = wrapper.borrow(); +let cap_mut = wrapper.borrow_mut(); +let (cap, borrow_token) = wrapper.borrow_val(); +wrapper.return_val(cap, borrow_token); +``` + +`borrow_val` uses a hot-potato guard, so the value must be returned to the same wrapper before the transaction ends. + +## Unwrapping + +To permanently reclaim the raw capability and destroy the wrapper: + +```move +let admin_cap = wrapper.unwrap(ctx); +``` + +This bypasses the transfer flow entirely. Only the current wrapper owner can call it. + +## API Reference + +For function-level signatures and error codes, see the [Access API reference](/contracts-sui/1.x/api/access#two_step_transfer). diff --git a/content/contracts-sui/index.mdx b/content/contracts-sui/index.mdx index a045cc1f..d947ab8b 100644 --- a/content/contracts-sui/index.mdx +++ b/content/contracts-sui/index.mdx @@ -24,7 +24,7 @@ import { latestStable } from "./latest-versions.js"; 9-decimal fixed-point types (`UD30x9`, `SD29x9`) for prices, fees, rates, and signed balance deltas. - Ownership-transfer policies (`two_step_transfer`, `delayed_transfer`) for privileged capabilities. + Role-based authorization and ownership-transfer policies for privileged protocol operations. diff --git a/skills/docs-sync/SKILL.md b/skills/docs-sync/SKILL.md index b38855cd..47589c35 100644 --- a/skills/docs-sync/SKILL.md +++ b/skills/docs-sync/SKILL.md @@ -187,6 +187,23 @@ All `docs.*` paths in config are **relative to ``**. Never hard-code absolute filesystem paths in config, prompts, reports, or generated docs. +## Tone and prose + +Generated or touched docs must follow `docs.tone` from config. For the +contracts-sui slice this means direct, concise, precise, and +security-conscious. + +When editing prose: + +- Prefer short sentences and concrete nouns. +- Remove filler, marketing language, repetition, and obvious narration. +- State behavior directly before caveats or rationale. +- Keep security warnings specific: exact risk, exact condition, exact + mitigation. +- Do not rewrite untouched working sections just for tone. When a + section is already being edited, make the affected prose match the + tone. + ## Reference Files Read only what the run needs: diff --git a/skills/docs-sync/config/libraries/contracts-sui.yml b/skills/docs-sync/config/libraries/contracts-sui.yml index bd12f44f..3d22fee3 100644 --- a/skills/docs-sync/config/libraries/contracts-sui.yml +++ b/skills/docs-sync/config/libraries/contracts-sui.yml @@ -17,7 +17,7 @@ docs: version_index_path: "content/contracts-sui/1.x/index.mdx" local_conventions_path: "content/contracts-sui/AGENTS.md" nav_config_path: "src/navigation/sui/current.json" - tone: "clear, precise, security-conscious" + tone: "direct, concise, precise, security-conscious" target_audience: "smart contract developers integrating OpenZeppelin libraries" navigation: diff --git a/skills/docs-sync/process.md b/skills/docs-sync/process.md index 72c4a08a..49e71161 100644 --- a/skills/docs-sync/process.md +++ b/skills/docs-sync/process.md @@ -399,8 +399,9 @@ overall set was approved at G3). Follow any) under ``. 2. Regenerate the structured sections (Types, Functions, Events, Errors) from the source. -3. Preserve existing prose that is still accurate; replace prose that no - longer matches the source. +3. Preserve existing prose that is accurate and tone-compliant. Replace + prose that no longer matches the source, and trim affected prose that + is verbose, repetitive, or indirect. 4. Honor the entry order from `` (for Sui: description, then Aborts, then Emits, then NOTE/INFO/WARNING). 5. If a public item has weak or missing source comments, generate only @@ -444,6 +445,9 @@ For each rewrite (targeted or broad): 5. Add or update security warnings according to `security.warning_style` and `security.require_security_sections_for` from config. +6. Keep touched prose aligned with `docs.tone`: direct, concise, + precise, and security-conscious. Remove filler and obvious narration + in the sections already being edited. --- diff --git a/skills/docs-sync/references/rules/api-reference-rules.md b/skills/docs-sync/references/rules/api-reference-rules.md index 2f25fbd3..7a486d09 100644 --- a/skills/docs-sync/references/rules/api-reference-rules.md +++ b/skills/docs-sync/references/rules/api-reference-rules.md @@ -208,8 +208,10 @@ To keep API reference comparable run-to-run: 1. **Sort sections in source order** when the source order is meaningful (Sui modules use source order); sort alphabetically only when the language has no inherent order. -2. **Preserve prose carried over** verbatim if it is still accurate. - Do not rewrite for style. +2. **Preserve prose carried over** if it is accurate and + tone-compliant. Do not churn untouched sections. When an entry is + already being edited, trim verbose or indirect prose so it matches + `docs.tone`. 3. **Use exact identifier strings** from the source. Do not pretty-print signatures. 4. **Pin source links to a release tag** when one exists; otherwise diff --git a/skills/docs-sync/references/rules/docs-pr-checklist.md b/skills/docs-sync/references/rules/docs-pr-checklist.md index 58521a58..d9f6297c 100644 --- a/skills/docs-sync/references/rules/docs-pr-checklist.md +++ b/skills/docs-sync/references/rules/docs-pr-checklist.md @@ -65,6 +65,13 @@ within configured rules) or surface it explicitly. - [ ] No `#TODO`, ``, or template `` tokens remain unless `automation.fail_on_unresolved_placeholders: false`. +## 6a. Tone + +- [ ] Touched docs match `docs.tone`: direct, concise, precise, and + security-conscious. +- [ ] Touched prose avoids filler, repetition, marketing language, and + obvious narration. + ## 7. Navigation - [ ] `` lists every page reachable in the slice. diff --git a/skills/docs-sync/references/templates/api-reference-template.md b/skills/docs-sync/references/templates/api-reference-template.md index 79689d10..81b5e6c0 100644 --- a/skills/docs-sync/references/templates/api-reference-template.md +++ b/skills/docs-sync/references/templates/api-reference-template.md @@ -168,8 +168,9 @@ matching explanation page when one exists.> - **Source order, not alphabetical**, for sections within a module. Source order is meaningful in Move and matches the contracts repo. -- **Carry over working prose verbatim.** Do not rewrite for style. - Replace prose only when it has become wrong. +- **Carry over working prose** if it is accurate and tone-compliant. Do + not churn untouched entries. When an entry is already being edited, + trim verbose or indirect prose so it matches `docs.tone`. - **Match canonical entry order** (description → Aborts → Emits → NOTE/INFO/WARNING) per ``. For Sui this order is mandatory. diff --git a/skills/docs-sync/references/templates/explanation-template.md b/skills/docs-sync/references/templates/explanation-template.md index 99248c5a..7d129396 100644 --- a/skills/docs-sync/references/templates/explanation-template.md +++ b/skills/docs-sync/references/templates/explanation-template.md @@ -114,9 +114,9 @@ PTBs, with off-chain indexers, with upgrade paths?> you mention a function, link to it instead of redocumenting it. - **State opinions clearly.** Explanations are the one place where the docs may say "we recommend X over Y, because Z". -- **Match `docs.tone` from config.** Default: *clear, precise, - security-conscious*. In explanations this means: willing to discuss - what could go wrong and willing to compare to alternatives. +- **Match `docs.tone` from config.** Default: *direct, concise, + precise, security-conscious*. In explanations this means clear + tradeoffs, specific risks, and no extra setup prose. ## Where to put the file diff --git a/skills/docs-sync/references/templates/guide-template.md b/skills/docs-sync/references/templates/guide-template.md index 9d2f2c57..2bbc2930 100644 --- a/skills/docs-sync/references/templates/guide-template.md +++ b/skills/docs-sync/references/templates/guide-template.md @@ -144,4 +144,5 @@ module my_app::full_example; - Do not include source-code links in guides. Link to the related API reference instead; API reference pages own source links. - Match `docs.tone` from config. Default for the contracts-sui slice: - *clear, precise, security-conscious*. + *direct, concise, precise, security-conscious*. Remove filler, + repetition, and obvious narration in touched sections. diff --git a/skills/docs-sync/references/templates/tutorial-template.md b/skills/docs-sync/references/templates/tutorial-template.md index 8caf4451..312c4222 100644 --- a/skills/docs-sync/references/templates/tutorial-template.md +++ b/skills/docs-sync/references/templates/tutorial-template.md @@ -135,9 +135,10 @@ PASS [ 0 ] my_first_oz::my_first_oz_tests::happy_path not ready to evaluate tradeoffs yet. - **Checkpoints earn trust.** Every 1–3 steps, give the reader a way to confirm they are still on the rails. -- **Match `docs.tone` from config.** Default: *clear, precise, - security-conscious*. In tutorials this means: encouraging, but with - honest security warnings inline rather than postponed. +- **Match `docs.tone` from config.** Default: *direct, concise, + precise, security-conscious*. In tutorials this means short, + action-first steps with honest security warnings inline rather than + postponed. ## Where to put the file diff --git a/src/navigation/sui/current.json b/src/navigation/sui/current.json index 2f363c23..4d17cca1 100644 --- a/src/navigation/sui/current.json +++ b/src/navigation/sui/current.json @@ -13,6 +13,17 @@ "name": "Overview", "url": "/contracts-sui/1.x" }, + { + "type": "folder", + "name": "Learn", + "children": [ + { + "type": "page", + "name": "Role Based Access Control", + "url": "/contracts-sui/1.x/guides/access-control" + } + ] + }, { "type": "folder", "name": "Packages", @@ -29,9 +40,30 @@ "url": "/contracts-sui/1.x/fixed-point" }, { - "type": "page", + "type": "folder", "name": "Access", - "url": "/contracts-sui/1.x/access" + "index": { + "type": "page", + "name": "Overview", + "url": "/contracts-sui/1.x/access" + }, + "children": [ + { + "type": "page", + "name": "RBAC", + "url": "/contracts-sui/1.x/access-control" + }, + { + "type": "page", + "name": "Two-Step Transfer", + "url": "/contracts-sui/1.x/two-step-transfer" + }, + { + "type": "page", + "name": "Delayed Transfer", + "url": "/contracts-sui/1.x/delayed-transfer" + } + ] } ] },