stellar-access 0.7.1

Access Control, Ownable, and Role Transfer utilities for Stellar contracts.
Documentation
use soroban_sdk::{contracttype, panic_with_error, Address, Env, IntoVal, Val};

use crate::role_transfer::RoleTransferError;

/// Stores the pending role holder and the explicit deadline for acceptance.
#[contracttype]
pub struct PendingTransfer {
    pub address: Address,
    pub live_until_ledger: u32,
}

/// Initiates the role transfer. If `live_until_ledger == 0`, cancels the
/// pending transfer.
///
/// Does not emit any events.
///
/// # Arguments
///
/// * `e` - Access to the Soroban environment.
/// * `new` - The proposed new role holder.
/// * `pending_key` - Storage key for the pending role holder.
/// * `live_until_ledger` - Ledger number until which the new role holder can
///   accept. A value of `0` cancels the pending transfer. If the specified
///   ledger is in the past or exceeds the maximum allowed TTL extension for a
///   temporary storage entry, the function will panic.
///
/// # Errors
///
/// * [`RoleTransferError::NoPendingTransfer`] - If trying to cancel a transfer
///   that doesn't exist.
/// * [`RoleTransferError::InvalidLiveUntilLedger`] - If the specified ledger is
///   in the past, or exceeds the maximum allowed TTL extension for a temporary
///   storage entry.
/// * [`RoleTransferError::InvalidPendingAccount`] - If the specified pending
///   account is not the same as the provided `new` address.
///
/// # Notes
///
/// * This function does not enforce authorization. Ensure that authorization is
///   handled at a higher level.
/// * `live_until_ledger` is stored explicitly inside [`PendingTransfer`] and is
///   checked on every [`accept_transfer`] call, regardless of the storage
///   entry's TTL. This means the deadline is always enforced, even if the
///   underlying temporary entry is kept alive longer by the network minimum TTL
///   or by a permissionless `extend_ttl` call.
/// * To extend the acceptance window after a transfer has already been
///   initiated, the current role holder can call this function again with the
///   same `new` address and a later `live_until_ledger`. This overwrites the
///   existing [`PendingTransfer`] in place, updating the deadline.
pub fn transfer_role<T>(e: &Env, new: &Address, pending_key: &T, live_until_ledger: u32)
where
    T: IntoVal<Env, Val>,
{
    if live_until_ledger == 0 {
        let Some(pending) = e.storage().temporary().get::<T, PendingTransfer>(pending_key) else {
            panic_with_error!(e, RoleTransferError::NoPendingTransfer);
        };
        if pending.address != *new {
            panic_with_error!(e, RoleTransferError::InvalidPendingAccount);
        }
        e.storage().temporary().remove(pending_key);

        return;
    }

    let current_ledger = e.ledger().sequence();
    if live_until_ledger > e.ledger().max_live_until_ledger() || live_until_ledger < current_ledger
    {
        panic_with_error!(e, RoleTransferError::InvalidLiveUntilLedger);
    }

    let live_for = live_until_ledger - current_ledger;
    let pending = PendingTransfer { address: new.clone(), live_until_ledger };
    e.storage().temporary().set(pending_key, &pending);
    e.storage().temporary().extend_ttl(pending_key, live_for, live_for);
}

/// Returns `true` if the given pending-transfer key holds an **active**
/// (non-expired) [`PendingTransfer`].
///
/// An entry whose `live_until_ledger` is less than the current ledger
/// sequence is treated as absent and removed from storage as cleanup.
///
/// # Arguments
///
/// * `e` - Access to the Soroban environment.
/// * `pending_key` - Storage key for the pending role holder.
pub fn has_active_pending_transfer<T>(e: &Env, pending_key: &T) -> bool
where
    T: IntoVal<Env, Val>,
{
    match e.storage().temporary().get::<T, PendingTransfer>(pending_key) {
        Some(pending) if e.ledger().sequence() <= pending.live_until_ledger => true,
        Some(_) => {
            // Expired entry — clean it up.
            e.storage().temporary().remove(pending_key);
            false
        }
        None => false,
    }
}

/// Completes the role transfer if authorization is provided by the pending role
/// holder. Pending role holder is retrieved from the storage.
///
/// # Arguments
///
/// * `e` - Access to the Soroban environment.
/// * `active_key` - Storage key for the current role holder.
/// * `pending_key` - Storage key for the pending role holder.
///
/// # Errors
///
/// * [`RoleTransferError::NoPendingTransfer`] - If there is no pending transfer
///   to accept.
/// * [`RoleTransferError::TransferExpired`] - If the current ledger is past the
///   `live_until_ledger` stored in [`PendingTransfer`]. The deadline is checked
///   explicitly here, so it is enforced even if the storage entry is still
///   alive due to the network minimum TTL or a permissionless `extend_ttl`
///   call.
pub fn accept_transfer<T, U>(e: &Env, active_key: &T, pending_key: &U) -> Address
where
    T: IntoVal<Env, Val>,
    U: IntoVal<Env, Val>,
{
    let pending = e
        .storage()
        .temporary()
        .get::<U, PendingTransfer>(pending_key)
        .unwrap_or_else(|| panic_with_error!(e, RoleTransferError::NoPendingTransfer));

    if e.ledger().sequence() > pending.live_until_ledger {
        panic_with_error!(e, RoleTransferError::TransferExpired);
    }

    pending.address.require_auth();

    e.storage().temporary().remove(pending_key);
    e.storage().instance().set(active_key, &pending.address);

    pending.address
}