ID Pointer

ID Pointer is a technique that separates the main data (an object) and its accessors / capabilities by linking the latter to the original. There's a few different directions in which this pattern can be used:

  • issuing transferable capabilities for shared objects (for example, a TransferCap that changes 'owner' field of a shared object)
  • splitting dynamic data and static (for example, an NFT and its Collection information)
  • avoiding unnecessary type linking (and witness requirement) in generic applications (LP token for a LiquidityPool)
/// This example implements a simple `Lock` and `Key` mechanics
/// on Sui where `Lock<T>` is a shared object that can contain any object,
/// and `Key` is an owned object which is required to get access to the
/// contents of the lock.
///
/// `Key` is linked to its `Lock` using an `ID` field. This check allows
/// off-chain discovery of the target as well as splits the dynamic
/// transferable capability and the 'static' contents. Another benefit of
/// this approach is that the target asset is always discoverable while its
/// `Key` can be wrapped into another object (eg a marketplace listing).
module examples::lock_and_key {
    /// Lock is empty, nothing to take.
    const ELockIsEmpty: u64 = 0;

    /// Key does not match the Lock.
    const EKeyMismatch: u64 = 1;

    /// Lock already contains something.
    const ELockIsFull: u64 = 2;

    /// Lock that stores any content inside it.
    public struct Lock<T: store + key> has key {
        id: UID,
        locked: Option<T>
    }

    /// A key that is created with a Lock; is transferable
    /// and contains all the needed information to open the Lock.
    public struct Key<phantom T: store + key> has key, store {
        id: UID,
        lock_id: ID,
    }

    /// Returns an ID of a Lock for a given Key.
    public fun key_for<T: store + key>(key: &Key<T>): ID {
        key.lock_id
    }

    /// Lock some content inside a shared object. A Key is created and is
    /// sent to the transaction sender. For example, we could turn the
    /// lock into a treasure chest by locking some `Coin<SUI>` inside.
    ///
    /// Return the Key to the caller so they decide what to do with it.
    public fun create<T: store + key>(obj: T, ctx: &mut TxContext): Key<T> {
        let id = object::new(ctx);
        let lock_id = id.to_inner();

        transfer::share_object(Lock<T> {
            id,
            locked: option::some(obj),
        });

        Key<T> { id: object::new(ctx), lock_id }
    }

    /// Lock something inside a shared object using a Key. Aborts if
    /// lock is not empty or if key doesn't match the lock.
    public fun lock<T: store + key>(
        obj: T,
        lock: &mut Lock<T>,
        key: &Key<T>,
    ) {
        assert!(lock.locked.is_none(), ELockIsFull);
        assert!(&key.lock_id == object::borrow_id(lock), EKeyMismatch);

        lock.locked.fill(obj);
    }

    /// Unlock the Lock with a Key and access its contents.
    /// Can only be called if both conditions are met:
    /// - key matches the lock
    /// - lock is not empty
    public fun unlock<T: store + key>(
        lock: &mut Lock<T>,
        key: &Key<T>,
    ): T {
        assert!(lock.locked.is_some(), ELockIsEmpty);
        assert!(&key.lock_id == object::borrow_id(lock), EKeyMismatch);

        lock.locked.extract()
    }
}

This pattern is used in these examples: