PackagesAccess

Delayed 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

use openzeppelin_access::delayed_transfer;

Step 1: Wrap with a delay

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<TreasuryCap>, 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

/// 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

/// 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:

/// 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:

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:

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.