Skip to content

Add "Ownable 2 step" module#2292

Open
afa7789 wants to merge 9 commits into0xMiden:nextfrom
afa7789:openzeppelin/ownable_2_step
Open

Add "Ownable 2 step" module#2292
afa7789 wants to merge 9 commits into0xMiden:nextfrom
afa7789:openzeppelin/ownable_2_step

Conversation

@afa7789
Copy link
Contributor

@afa7789 afa7789 commented Jan 16, 2026

We want Ownable2Step to override Ownable, as the former is the more secure version.

The core issue with Ownable is that the transferOwnership function immediately sets the new owner in a single transaction. This creates a critical vulnerability:

If you make a typo or pass a wrong address, ownership is permanently lost. There's no recovery mechanism — the contract is effectively locked forever with no owner able to call privileged functions.

How Ownable2Step fixes this
Ownable2Step introduces a two-phase handoff:

Step 1 — Nominate: The current owner calls transferOwnership(newAddress), which only sets a pendingOwner. The current owner remains in control.
Step 2 — Accept: The pendingOwner must call acceptOwnership() from their own address to complete the transfer.

See here:
#2488
#2486

@afa7789 afa7789 changed the title Creation of module "Ownable 2 step" in masm. Add "Ownable 2 step" module Jan 16, 2026
@afa7789 afa7789 force-pushed the openzeppelin/ownable_2_step branch 2 times, most recently from 650e63c to 5754cfa Compare January 19, 2026 16:02
@mmagician mmagician added the pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority label Feb 9, 2026
- Renamed `OWNER_CONFIG_SLOT_NAME` to `OWNERSHIP_SLOT_NAME` for clarity.
- Updated storage layout documentation to reflect changes in ownership data structure.
- Modified methods to retrieve owner and pending owner information from the new ownership slot.
- Adjusted minting logic to accommodate the new ownership structure.
- Removed outdated tests related to ownership transfer and added new tests for the updated ownership functionality.
- Introduced a new `ownable` module to encapsulate ownership transfer logic and related tests.
@afa7789 afa7789 force-pushed the openzeppelin/ownable_2_step branch from 7d2ac3e to 397aa5c Compare February 23, 2026 13:18
@afa7789 afa7789 marked this pull request as ready for review February 23, 2026 21:23
@afa7789 afa7789 requested a review from onurinanc February 23, 2026 21:23
Copy link
Contributor

@onurinanc onurinanc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @afa7789! Looks good! I left a few comments, mostly about locals usage, and a couple of suggestions to improve the comments.

@onurinanc
Copy link
Contributor

onurinanc commented Feb 24, 2026

It might be something like this:

# miden::standards::access::ownable
#
# Provides two-step ownership management functionality for account components.
# This module can be imported and used by any component that needs owner controls.
#
# Unlike a single-step ownership transfer, this module requires the new owner to explicitly
# accept the transfer before it takes effect. This prevents accidental transfers to incorrect
# addresses, which would permanently lock the component.
#
# The transfer flow is:
#   1. The current owner calls `transfer_ownership` to nominate a new owner.
#   2. The nominated account calls `accept_ownership` to complete the transfer.
#   3. Optionally, the current owner can call `transfer_ownership` with their own address
#      to cancel the pending transfer.
#
# Storage layout (single slot):
#   Rust Word:  [pending_owner_suffix, pending_owner_prefix, owner_suffix, owner_prefix]
#                word[0]          word[1]         word[2]       word[3]
#
#   After get_item (big-endian load), the stack is:
#               [owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix]
#                (word[3])     (word[2])      (word[1])       (word[0])

use miden::protocol::active_account
use miden::protocol::account_id
use miden::protocol::active_note
use miden::protocol::native_account

# CONSTANTS
# ================================================================================================

# ZERO_ADDRESS word (all zeros) used to represent no owner.
const ZERO_ADDRESS = [0, 0, 0, 0]

# The slot in this component's storage layout where the owner configuration is stored.
# Contains both the current owner and the pending owner in a single word.
const OWNER_CONFIG_SLOT = word("miden::standards::access::ownable::owner_config")

# ERRORS
# ================================================================================================

const ERR_SENDER_NOT_OWNER = "note sender is not the owner"
const ERR_SENDER_NOT_PENDING_OWNER = "note sender is not the pending owner"
const ERR_NO_PENDING_OWNER = "no pending ownership transfer exists"

# LOCAL MEMORY ADDRESSES
# ================================================================================================

# transfer_ownership locals
const NEW_OWNER_PREFIX_LOC = 0
const NEW_OWNER_SUFFIX_LOC = 1
const OWNER_PREFIX_LOC = 2
const OWNER_SUFFIX_LOC = 3

# accept_ownership locals
const PENDING_OWNER_PREFIX_LOC = 0
const PENDING_OWNER_SUFFIX_LOC = 1
const SENDER_PREFIX_LOC = 2
const SENDER_SUFFIX_LOC = 3

# INTERNAL PROCEDURES
# ================================================================================================

#! Returns the full ownership word from storage.
#!
#! Inputs:  []
#! Outputs: [owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix]
#!
#! Where:
#! - owner_prefix and owner_suffix are the prefix and suffix felts of the current owner AccountId.
#! - pending_owner_prefix and pending_owner_suffix are the prefix and suffix felts of the pending owner AccountId.
proc _ownership_word
    push.OWNER_CONFIG_SLOT[0..2] exec.active_account::get_item
    # => [owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix]
end

#! Returns the owner AccountId from storage.
#!
#! Inputs:  []
#! Outputs: [owner_prefix, owner_suffix]
#!
#! Where:
#! - owner_prefix and owner_suffix are the prefix and suffix felts of the owner AccountId.
proc _owner
    exec._ownership_word
    # => [owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix]

    # Drop the pending owner fields
    movup.2 drop
    # => [owner_prefix, owner_suffix, pending_owner_suffix]

    movup.2 drop
    # => [owner_prefix, owner_suffix]
end

#! Returns the pending owner AccountId from storage.
#!
#! Inputs:  []
#! Outputs: [pending_owner_prefix, pending_owner_suffix]
#!
#! Where:
#! - pending_owner_prefix and pending_owner_suffix are the prefix and suffix felts of the pending owner AccountId.
proc _pending_owner
    exec._ownership_word
    # => [owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix]

    drop drop
    # => [pending_owner_prefix, pending_owner_suffix]
end

#! Checks if the given account ID is the owner of this component.
#!
#! Inputs:  [account_id_prefix, account_id_suffix]
#! Outputs: [is_owner]
#!
#! Where:
#! - is_owner is 1 if the account is the owner, 0 otherwise.
proc _is_owner
    exec._owner
    # => [owner_prefix, owner_suffix, account_id_prefix, account_id_suffix]

    exec.account_id::is_equal
    # => [is_owner]
end

#! Checks if the given account ID is the pending owner of this component.
#!
#! Inputs:  [account_id_prefix, account_id_suffix]
#! Outputs: [is_pending_owner]
#!
#! Where:
#! - account_id_prefix and account_id_suffix are the prefix and suffix felts of the AccountId to check.
#! - is_pending_owner is 1 if the account is the pending owner, 0 otherwise.
proc _is_pending_owner
    exec._pending_owner
    # => [pending_owner_prefix, pending_owner_suffix, account_id_prefix, account_id_suffix]

    exec.account_id::is_equal
    # => [is_pending_owner]
end

# PUBLIC INTERFACE
# ================================================================================================

#! Checks if the note sender is the owner and panics if not.
#!
#! Inputs:  []
#! Outputs: []
#!
#! Panics if:
#! - the note sender is not the owner.
pub proc verify_owner
    exec.active_note::get_sender
    # => [sender_prefix, sender_suffix]

    exec._is_owner
    # => [is_owner]

    assert.err=ERR_SENDER_NOT_OWNER
    # => []
end

#! Returns the owner AccountId.
#!
#! Inputs:  [pad(16)]
#! Outputs: [owner_prefix, owner_suffix, pad(14)]
#!
#! Where:
#! - owner_prefix and owner_suffix are the prefix and suffix felts of the owner AccountId.
#!
#! Invocation: call
pub proc owner
    exec._owner
    # => [owner_prefix, owner_suffix, pad(16)]

    # _owner introduces a net +2 stack delta, for call-friendly ABI shape we keep 16 outputs.
    movup.2 drop movup.2 drop
    # => [owner_prefix, owner_suffix, pad(14)]
end

#! Returns the pending owner AccountId.
#!
#! Inputs:  [pad(16)]
#! Outputs: [pending_owner_prefix, pending_owner_suffix, pad(14)]
#!
#! Where:
#! - pending_owner_prefix and pending_owner_suffix are the prefix and suffix felts of the pending owner AccountId.
#!   Both are zero if no transfer is pending.
#!
#! Invocation: call
pub proc pending_owner
    exec._pending_owner
    # => [pending_owner_prefix, pending_owner_suffix, pad(16)]

    # _pending_owner introduces a net +2 stack delta, for call-friendly ABI shape we keep 16 outputs.
    movup.2 drop movup.2 drop
    # => [pending_owner_prefix, pending_owner_suffix, pad(14)]
end

#! Initiates a two-step ownership transfer by setting the pending owner.
#!
#! The current owner remains in control until the pending owner calls `accept_ownership`.
#! Can only be called by the current owner.
#!
#! If the new owner is the current owner, any pending transfer is cancelled and the
#! pending owner field is cleared.
#!
#! Inputs:  [new_owner_prefix, new_owner_suffix, pad(14)]
#! Outputs: [pad(16)]
#!
#! Panics if:
#! - the note sender is not the owner.
#!
#! Locals:
#! 0: new_owner_prefix
#! 1: new_owner_suffix
#! 2: owner_prefix
#! 3: owner_suffix
#!
#! Invocation: call
@locals(4)
pub proc transfer_ownership
    exec.verify_owner
    # => [new_owner_prefix, new_owner_suffix, pad(14)]

    # Persist new owner to avoid deep-stack duplication.
    loc_store.NEW_OWNER_PREFIX_LOC
    # => [new_owner_suffix, pad(14)]

    loc_store.NEW_OWNER_SUFFIX_LOC
    # => [pad(14)]

    # Retrieve and persist the current owner.
    exec._owner
    # => [owner_prefix, owner_suffix, pad(14)]

    loc_store.OWNER_PREFIX_LOC
    # => [owner_suffix, pad(14)]

    loc_store.OWNER_SUFFIX_LOC
    # => [pad(14)]

    # Check if new_owner == owner (cancel case).
    loc_load.NEW_OWNER_SUFFIX_LOC loc_load.NEW_OWNER_PREFIX_LOC
    # => [new_owner_prefix, new_owner_suffix, pad(14)]

    loc_load.OWNER_SUFFIX_LOC loc_load.OWNER_PREFIX_LOC
    # => [owner_prefix, owner_suffix, new_owner_prefix, new_owner_suffix, pad(14)]

    exec.account_id::is_equal
    # => [is_self_transfer, pad(14)]

    if.true
        # Cancel ownership transfer and clear pending owner.
        loc_load.OWNER_SUFFIX_LOC loc_load.OWNER_PREFIX_LOC
        # => [owner_prefix, owner_suffix, pad(14)]

        push.0.0
        # => [0, 0, owner_prefix, owner_suffix, pad(14)]

        movup.3 movup.3
        # => [owner_prefix, owner_suffix, 0, 0, pad(14)]
    else
        # Transfer ownership by setting pending = new_owner.
        loc_load.NEW_OWNER_SUFFIX_LOC loc_load.NEW_OWNER_PREFIX_LOC
        # => [new_owner_prefix, new_owner_suffix, pad(14)]

        loc_load.OWNER_SUFFIX_LOC loc_load.OWNER_PREFIX_LOC
        # => [owner_prefix, owner_suffix, new_owner_prefix, new_owner_suffix, pad(14)]
    end

    push.OWNER_CONFIG_SLOT[0..2]
    # => [slot_prefix, slot_suffix, owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix, pad(14)]

    exec.native_account::set_item
    # => [OLD_OWNERSHIP_WORD, pad(14)]

    dropw
    # => [pad(16)]
end

#! Accepts a pending ownership transfer. The pending owner becomes the new owner
#! and the pending owner field is cleared.
#!
#! Can only be called by the pending owner.
#!
#! Inputs:  [pad(16)]
#! Outputs: [pad(16)]
#!
#! Panics if:
#! - there is no pending ownership transfer (pending owner is zero).
#! - the note sender is not the pending owner.
#!
#! Locals:
#! 0: pending_owner_prefix
#! 1: pending_owner_suffix
#! 2: sender_prefix
#! 3: sender_suffix
#!
#! Invocation: call
@locals(4)
pub proc accept_ownership
    exec.active_note::get_sender
    # => [sender_prefix, sender_suffix, pad(16)]

    loc_store.SENDER_PREFIX_LOC
    # => [sender_suffix, pad(16)]

    loc_store.SENDER_SUFFIX_LOC
    # => [pad(16)]

    exec._ownership_word
    # => [owner_prefix, owner_suffix, pending_owner_prefix, pending_owner_suffix, pad(16)]

    # Drop owner and persist pending owner.
    drop drop
    # => [pending_owner_prefix, pending_owner_suffix, pad(16)]

    loc_store.PENDING_OWNER_PREFIX_LOC
    # => [pending_owner_suffix, pad(16)]

    loc_store.PENDING_OWNER_SUFFIX_LOC
    # => [pad(16)]

    # Check that a pending transfer exists (pending owner is not zero).
    loc_load.PENDING_OWNER_SUFFIX_LOC
    loc_load.PENDING_OWNER_PREFIX_LOC
    # => [pending_owner_prefix, pending_owner_suffix, pad(16)]

    push.0.0
    # => [0, 0, pending_owner_prefix, pending_owner_suffix, pad(16)]

    exec.account_id::is_equal
    # => [is_zero, pad(16)]

    assertz.err=ERR_NO_PENDING_OWNER
    # => [pad(16)]

    # Verify sender == pending owner without another storage read.
    loc_load.SENDER_SUFFIX_LOC
    loc_load.SENDER_PREFIX_LOC
    loc_load.PENDING_OWNER_SUFFIX_LOC
    loc_load.PENDING_OWNER_PREFIX_LOC
    # => [pending_owner_prefix, pending_owner_suffix, sender_prefix, sender_suffix, pad(16)]

    exec.account_id::is_equal
    # => [is_pending, pad(16)]

    assert.err=ERR_SENDER_NOT_PENDING_OWNER
    # => [pad(16)]

    # Build new ownership word: pending becomes owner, clear pending.
    # Target: [new_owner_prefix, new_owner_suffix, 0, 0] where new_owner = pending.
    loc_load.PENDING_OWNER_SUFFIX_LOC
    loc_load.PENDING_OWNER_PREFIX_LOC
    # => [pending_owner_prefix, pending_owner_suffix, pad(16)]

    push.0.0
    # => [0, 0, pending_owner_prefix, pending_owner_suffix, pad(16)]

    movup.3 movup.3
    # => [pending_owner_prefix, pending_owner_suffix, 0, 0, pad(16)]

    push.OWNER_CONFIG_SLOT[0..2]
    # => [slot_prefix, slot_suffix, pending_owner_prefix, pending_owner_suffix, 0, 0, pad(16)]

    exec.native_account::set_item
    # => [OLD_OWNERSHIP_WORD, pad(16)]

    dropw
    # => [pad(16)]
end

#! Renounces ownership, leaving the component without an owner.
#!
#! Can only be called by the current owner. Clears both the owner and any pending owner.
#!
#! Inputs:  [pad(16)]
#! Outputs: [pad(16)]
#!
#! Panics if:
#! - the note sender is not the owner.
#!
#! Invocation: call
#!
#! Important Note!
#! This feature allows the owner to relinquish administrative privileges, a common pattern
#! after an initial stage with centralized administration is over. Once ownership is renounced,
#! the component becomes permanently ownerless and cannot be managed by any account.
pub proc renounce_ownership
    exec.verify_owner
    # => [pad(16)]

    # Set ownership word to all zeros, clearing both owner and pending.
    push.ZERO_ADDRESS
    # => [0, 0, 0, 0, pad(16)]

    push.OWNER_CONFIG_SLOT[0..2]
    # => [slot_prefix, slot_suffix, 0, 0, 0, 0, pad(16)]

    exec.native_account::set_item
    # => [OLD_OWNERSHIP_WORD, pad(16)]

    dropw
    # => [pad(16)]
end

Thanks for adding this!

@onurinanc
Copy link
Contributor

@afa7789 Could you rename ownable.masm to ownable2step.masm as Ownable2Step is a naming convention for the well-known access control mechanism.

@onurinanc
Copy link
Contributor

Hi @PhilippGackstatter, @bobbinth, and @mmagician Ownable2Step related to this issue is ready for review! #2497

@onurinanc
Copy link
Contributor

Let's consider this comment by @PhilippGackstatter #2486 (comment) and add an ID validation using account_id::validate

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-from-maintainers PRs that come from internal contributors or integration partners. They should be given priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants