Cycle

The Scriptorium

Smart Assembly code templates and tools for on-chain development in Eve Frontier.

Tribe-Gated Storage
IntermediateChapter 2 of 520 min read

The Config Module

The config.move module is the foundation of the tribe storage extension. It defines the shared configuration object, the admin capability, the authorization witness, and generic helpers for managing dynamic fields. Every other module in the package depends on it.

Full Source

Here is the complete config.move from the tribe_storage_access package:

move
/// Builder extension shared configuration for tribe-gated Smart Storage Units.
///
/// Publishes a shared ExtensionConfig at package init and transfers an AdminCap
/// to the deployer. Other modules in this package attach typed rules (e.g. TribeConfig)
/// as Sui dynamic fields under the shared config object.
module tribe_storage_access::config;

use sui::dynamic_field as df;

/// Shared configuration object. Rules are stored as dynamic fields.
public struct ExtensionConfig has key {
    id: UID,
}

/// Capability proving ownership of this extension deployment.
/// Only the holder can configure rules.
public struct AdminCap has key, store {
    id: UID,
}

/// Zero-sized typed witness for SSU extension authorization.
/// The SSU owner calls storage_unit::authorize_extension<XAuth>() to register this
/// extension, then our functions pass XAuth {} to gated entry points.
public struct XAuth has drop {}

/// Create an XAuth witness value. Only callable within this package.
public(package) fun x_auth(): XAuth {
    XAuth {}
}

// === Init ===

fun init(ctx: &mut TxContext) {
    let admin_cap = AdminCap { id: object::new(ctx) };
    transfer::transfer(admin_cap, ctx.sender());

    let config = ExtensionConfig { id: object::new(ctx) };
    transfer::share_object(config);
}

// === Dynamic field helpers ===

public fun has_rule<K: copy + drop + store>(config: &ExtensionConfig, key: K): bool {
    df::exists_(&config.id, key)
}

public fun borrow_rule<K: copy + drop + store, V: store>(
    config: &ExtensionConfig, key: K,
): &V {
    df::borrow(&config.id, key)
}

public fun borrow_rule_mut<K: copy + drop + store, V: store>(
    config: &mut ExtensionConfig,
    _: &AdminCap,
    key: K,
): &mut V {
    df::borrow_mut(&mut config.id, key)
}

public fun add_rule<K: copy + drop + store, V: store>(
    config: &mut ExtensionConfig,
    _: &AdminCap,
    key: K,
    value: V,
) {
    df::add(&mut config.id, key, value);
}

public fun set_rule<K: copy + drop + store, V: store + drop>(
    config: &mut ExtensionConfig,
    _: &AdminCap,
    key: K,
    value: V,
) {
    if (df::exists_(&config.id, copy key)) {
        let _old: V = df::remove(&mut config.id, copy key);
    };
    df::add(&mut config.id, key, value);
}

public fun remove_rule<K: copy + drop + store, V: store>(
    config: &mut ExtensionConfig,
    _: &AdminCap,
    key: K,
): V {
    df::remove(&mut config.id, key)
}

Dissecting Each Section

ExtensionConfig

move
public struct ExtensionConfig has key {
    id: UID,
}
ExtensionConfig is intentionally minimal -- just a UID. All actual configuration data is stored as dynamic fields on this object. This design lets you add new rule types without modifying the struct definition or publishing a new package version.

The key ability makes it a SUI object. It does not have store, which means it cannot be wrapped inside another object or transferred after creation. Since it is shared via transfer::share_object, this is exactly what we want -- it should remain a top-level shared object forever.

AdminCap

move
public struct AdminCap has key, store {
    id: UID,
}
AdminCap has both key and store. The store ability is important because it enables:
  • Transferring the cap to another address (transfer::transfer)
  • Wrapping it inside a time-lock or multisig wrapper if desired
  • Using it with the public transfer function (objects without store can only be transferred by their defining module)

XAuth Witness

move
public struct XAuth has drop {}
XAuth has only drop. It has no key (not an object), no store (cannot be stored), and no copy (cannot be duplicated). It is a pure authorization token that exists only for the duration of a function call.

The drop ability is required because the SSU functions consume and drop the witness value after verifying its type.

The x_auth() Factory

move
public(package) fun x_auth(): XAuth {
    XAuth {}
}

This is the only way to create an XAuth value. The public(package) visibility ensures:

  • tribe_storage.move (same package) can call config::x_auth() -- needed for share/withdraw operations.
  • Any external package cannot call it -- they cannot forge authorization.

Initialization

move
fun init(ctx: &mut TxContext) {
    let admin_cap = AdminCap { id: object::new(ctx) };
    transfer::transfer(admin_cap, ctx.sender());

    let config = ExtensionConfig { id: object::new(ctx) };
    transfer::share_object(config);
}

The init function:

  • Creates an AdminCap and sends it to the deployer (ctx.sender()).
  • Creates an ExtensionConfig and makes it shared.
  • After publishing, you need to record both object IDs. The AdminCap ID goes in your wallet. The ExtensionConfig ID goes in your dApp's configuration constants.

    Dynamic Field Helpers

    The config module provides a generic CRUD layer over sui::dynamic_field:

    has_rule -- Existence Check

    move
    public fun has_rule<K: copy + drop + store>(config: &ExtensionConfig, key: K): bool {
        df::exists_(&config.id, key)
    }

    Returns true if a dynamic field with the given key exists. Used in verify_tribe() to check that tribe configuration has been set.

    borrow_rule -- Read-Only Access

    move
    public fun borrow_rule<K: copy + drop + store, V: store>(
        config: &ExtensionConfig, key: K,
    ): &V {
        df::borrow(&config.id, key)
    }

    Returns an immutable reference to the stored value. No AdminCap required -- anyone can read rules. This is intentional: players need to read the tribe config to verify membership.

    borrow_rule_mut -- Read-Write Access (Admin Only)

    move
    public fun borrow_rule_mut<K: copy + drop + store, V: store>(
        config: &mut ExtensionConfig,
        _: &AdminCap,
        key: K,
    ): &mut V {
        df::borrow_mut(&mut config.id, key)
    }

    Returns a mutable reference. Requires AdminCap and a mutable reference to config. Useful for modifying a rule in-place without removing and re-adding it.

    add_rule / set_rule -- Write Operations

    move
    // add_rule aborts if key already exists
    public fun add_rule<K: copy + drop + store, V: store>(
        config: &mut ExtensionConfig,
        _: &AdminCap,
        key: K,
        value: V,
    ) {
        df::add(&mut config.id, key, value);
    }
    
    // set_rule is upsert -- removes old value if present, then adds new
    public fun set_rule<K: copy + drop + store, V: store + drop>(
        config: &mut ExtensionConfig,
        _: &AdminCap,
        key: K,
        value: V,
    ) {
        if (df::exists_(&config.id, copy key)) {
            let _old: V = df::remove(&mut config.id, copy key);
        };
        df::add(&mut config.id, key, value);
    }
    set_rule requires V: store + drop because the old value (if it exists) is removed and implicitly dropped. If your value type does not have drop, use remove_rule to get the old value back and handle it explicitly.

    remove_rule -- Deletion

    move
    public fun remove_rule<K: copy + drop + store, V: store>(
        config: &mut ExtensionConfig,
        _: &AdminCap,
        key: K,
    ): V {
        df::remove(&mut config.id, key)
    }

    Returns the removed value. The caller decides what to do with it -- store it elsewhere, transfer it, or (if it has drop) just let it go out of scope.

    Design Patterns Worth Noting

    Separation of Infrastructure and Business Logic

    The config module knows nothing about tribes, storage units, or inventories. It is purely generic infrastructure. This means you could reuse the same pattern for entirely different extensions (rate limiting, whitelist management, fee collection) without changing config.move.

    Read Access Is Public, Write Access Is Gated

    All borrow_rule calls are public and take &ExtensionConfig (immutable). All mutation functions take &mut ExtensionConfig and require &AdminCap. This asymmetry is deliberate -- it allows any player to verify rules while restricting who can change them.

    The Config Object Is the Namespace

    Dynamic fields are scoped to the object they are attached to. Two different ExtensionConfig objects (from two different deployments) can have the same key types without conflicting. The config object ID acts as a natural namespace.

    Key Takeaways

    • ExtensionConfig is a minimal shared object that uses dynamic fields for all its data.
    • AdminCap gates write access to the config. It is created once at publish time and transferred to the deployer.
    • XAuth is a zero-sized witness type. Its public(package) factory function ensures only same-package modules can produce it.
    • The dynamic field helpers (has_rule, borrow_rule, set_rule, etc.) form a generic CRUD layer. Reading is public; writing requires AdminCap.
    • This separation of concerns makes the config module reusable across different extension types.

    Sign in to track your progress.