Skip to content

Add token-gated access pattern #466

@Danny-Devs

Description

@Danny-Devs

Every existing SEAL pattern gates access on who you are (address) or what's true (time, payment). None gate on what you hold. Token-gated access fills this gap: encrypt data that only holders of a specific on-chain object (NFT, DAO token, game asset) can decrypt — and access travels with the asset automatically.

The Getting Started guide already lists "Token-gated access" as a use case, but no pattern exists in move/patterns/sources/. Here's a proposal for one.

Motivation

Existing patterns vs token-gated:

Pattern Access based on Access transfers with asset?
whitelist.move Admin-managed address list No
account_based.move Caller's address No
token_gated (this) Object ownership Yes

If Alice holds an NFT that grants access and transfers it to Bob, Bob gets access and Alice loses it automatically. No admin update required.

Use cases: NFT-gated content (art reveals, membership perks), DAO governance documents, gaming assets unlocking encrypted content.

Proposed API

/// Create a gate for token type T. Returns admin cap + gate to freeze.
public fun create_token_gate<T: key>(ctx: &mut TxContext): (Cap, TokenGate)

/// Freeze the gate as an immutable object (anyone can reference it).
public fun freeze_token_gate(gate: TokenGate)

/// Seal approval: caller must provide a reference to an object of type T.
entry fun seal_approve<T: key>(id: vector<u8>, _token: &T, gate: &TokenGate)

How it works

The gate stores required_type: TypeName at creation via type_name::with_original_ids<T>() (same function used in key_request.move lines 43, 67) so the check survives package upgrades. At approval time, seal_approve verifies two things:

  1. Type check: the caller's type argument T matches the gate's required_type (defense-in-depth against type confusion — e.g., passing Clock instead of the intended NFT)
  2. Prefix check: the requested key-ID starts with the gate's object ID (same approach as whitelist.move)

Ownership of _token is enforced by the Move VM itself — for owned objects, only the owner can pass &T as a transaction argument. The function doesn't need to check ownership explicitly; if the caller can provide the reference, they have it.

Security assumption

This pattern relies on T being an owned object. If T can be shared or frozen, anyone could pass &T and bypass the gate. This is a weaker guarantee than patterns like subscription.move (which prevents nesting via key only, no store) or whitelist.move (which checks ctx.sender() directly). The tradeoff is intentional — it enables the "access travels with the asset" property that no other pattern provides. For high-value content with token types you don't control, a collection-specific integration with concrete types would be more appropriate.

Design decisions

Frozen gate object. TokenGate is never mutated after creation. Patterns that share objects (whitelist, subscription) use share_object, but those have mutable operations (add/remove, subscribe). Since TokenGate has none, freeze_object is more appropriate — it signals immutability, avoids consensus overhead on reads, and prevents accidental mutation via future upgrades. Any future admin state can live in a separate object referencing the frozen gate's ID.

No versioning. Five of seven existing patterns omit versioning. This pattern's state is immutable after creation.

This would be the first pattern with <T: key> on seal_approve. ValidPtb doesn't inspect type_arguments — it validates function names and package IDs, then forwards the full PTB to dryRunTransactionBlock. The Sui runtime handles generic type resolution natively. No existing pattern exercises this path, but the infrastructure forwards type arguments transparently. Client-side PTB construction passes the type argument like any other generic move call:

tx.moveCall({
    target: `${SEAL_PATTERNS}::token_gated::seal_approve`,
    typeArguments: [myNftType],  // e.g., "0x123::my_nft::MyNFT"
    arguments: [tx.pure.vector('u8', fromHex(id)), tx.object(myNftId), tx.object(gateId)],
});

If that's a concern, a per-collection concrete approach is feasible.

Implementation

Full Move code (~110 lines + tests, click to expand)
// Copyright (c), Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

/// Token-gated access pattern:
/// - Anyone can create a gate for a specific object type T.
/// - Anyone can encrypt to the gate's key-id.
/// - Anyone who owns an object of type T can request the associated key.
///
/// Use cases that can be built on top of this: NFT-gated content, DAO governance
/// documents, gaming assets unlocking encrypted content.
///
/// Security: assumes the token type T is only ever owned, never shared or frozen.
/// If T can be shared or frozen, anyone could pass a reference and bypass the gate.
///
/// This pattern does NOT implement versioning, please see other patterns for
/// examples of versioning.
///
module patterns::token_gated;

use std::type_name::{Self, TypeName};

const ENoAccess: u64 = 1;
const ETypeMismatch: u64 = 2;

public struct TokenGate has key {
    id: UID,
    /// The type of token required for access (defense-in-depth)
    required_type: TypeName,
}

// Cap can also be used to add admin operations in future versions,
// see https://docs.sui.io/concepts/sui-move-concepts/packages/upgrade#versioned-shared-objects
public struct Cap has key, store {
    id: UID,
    gate_id: ID,
}

//////////////////////////////////////////
/////// Token gate with an admin cap (frozen after creation)

/// Create a token gate for a specific token type T.
/// The associated key-ids are [pkg id][gate id][nonce] for any nonce.
public fun create_token_gate<T: key>(ctx: &mut TxContext): (Cap, TokenGate) {
    let gate = TokenGate {
        id: object::new(ctx),
        required_type: type_name::with_original_ids<T>(),
    };
    let cap = Cap {
        id: object::new(ctx),
        gate_id: object::id(&gate),
    };
    (cap, gate)
}

public fun freeze_token_gate(gate: TokenGate) {
    transfer::freeze_object(gate);
}

entry fun create_token_gate_entry<T: key>(ctx: &mut TxContext) {
    let (cap, gate) = create_token_gate<T>(ctx);
    freeze_token_gate(gate);
    transfer::public_transfer(cap, ctx.sender());
}

//////////////////////////////////////////////////////////
/// Access control
/// key format: [pkg id][gate id][random nonce]

/// Verify type match and key-id prefix (same structure as whitelist.move).
fun check_policy<T: key>(id: vector<u8>, _token: &T, gate: &TokenGate): bool {
    // Defense-in-depth: verify T matches the required type.
    // Uses with_original_ids so the check survives NFT package upgrades
    // (same function as key_request.move lines 43, 67).
    assert!(
        type_name::with_original_ids<T>() == gate.required_type,
        ETypeMismatch,
    );
    let prefix = gate.id.to_bytes();
    let mut i = 0;
    if (prefix.length() > id.length()) {
        return false
    };
    while (i < prefix.length()) {
        if (prefix[i] != id[i]) {
            return false
        };
        i = i + 1;
    };
    true
}

/// Approve access if caller owns any object of type T.
/// Ownership enforced by Move VM for owned objects.
entry fun seal_approve<T: key>(id: vector<u8>, _token: &T, gate: &TokenGate) {
    assert!(check_policy<T>(id, _token, gate), ENoAccess);
}

// Tests

#[test_only]
public fun destroy_for_testing(gate: TokenGate, cap: Cap) {
    let TokenGate { id, required_type: _ } = gate;
    object::delete(id);
    let Cap { id, .. } = cap;
    object::delete(id);
}

#[test_only]
public struct TestNFT has key {
    id: UID,
}

#[test_only]
public fun create_test_nft(ctx: &mut TxContext): TestNFT {
    TestNFT { id: object::new(ctx) }
}

#[test_only]
public fun destroy_test_nft(nft: TestNFT) {
    let TestNFT { id } = nft;
    object::delete(id);
}

#[test]
fun test_check_policy() {
    let ctx = &mut tx_context::dummy();
    let nft = create_test_nft(ctx);
    let (cap, gate) = create_token_gate<TestNFT>(ctx);

    // Fail for empty id
    assert!(!check_policy<TestNFT>(b"", &nft, &gate), 1);

    // Fail for invalid id
    assert!(!check_policy<TestNFT>(b"123", &nft, &gate), 1);

    // Work for valid id with gate prefix
    let mut obj_id = object::id(&gate).to_bytes();
    obj_id.push_back(11);
    assert!(check_policy<TestNFT>(obj_id, &nft, &gate), 1);

    destroy_test_nft(nft);
    destroy_for_testing(gate, cap);
}

#[test]
fun test_seal_approve() {
    let ctx = &mut tx_context::dummy();
    let nft = create_test_nft(ctx);
    let (cap, gate) = create_token_gate<TestNFT>(ctx);

    let mut obj_id = object::id(&gate).to_bytes();
    obj_id.push_back(11);

    // Correct type + valid prefix succeeds
    seal_approve<TestNFT>(obj_id, &nft, &gate);

    destroy_test_nft(nft);
    destroy_for_testing(gate, cap);
}

#[test]
#[expected_failure(abort_code = ETypeMismatch)]
fun test_wrong_type_rejected() {
    let ctx = &mut tx_context::dummy();
    let nft = create_test_nft(ctx);
    let (cap, gate) = create_token_gate<TestNFT>(ctx);

    let mut obj_id = object::id(&gate).to_bytes();
    obj_id.push_back(11);

    // Wrong type aborts with ETypeMismatch
    seal_approve<Cap>(obj_id, &cap, &gate);

    destroy_test_nft(nft);
    destroy_for_testing(gate, cap);
}

#[test]
#[expected_failure(abort_code = ENoAccess)]
fun test_wrong_prefix_rejected() {
    let ctx = &mut tx_context::dummy();
    let nft = create_test_nft(ctx);
    let (cap, gate) = create_token_gate<TestNFT>(ctx);

    // Use cap's ID (valid-length but wrong prefix)
    let mut wrong_id = object::id(&cap).to_bytes();
    wrong_id.push_back(11);

    seal_approve<TestNFT>(wrong_id, &nft, &gate);

    destroy_test_nft(nft);
    destroy_for_testing(gate, cap);
}

#[test]
fun test_create_and_destroy() {
    let ctx = &mut tx_context::dummy();
    let (cap, gate) = create_token_gate<TestNFT>(ctx);
    destroy_for_testing(gate, cap);
}

Proposed ExamplePatterns.md entry

## Token-gated access

[Move source](https://github.com/MystenLabs/seal/blob/main/move/patterns/sources/token_gated.move)

Use this pattern to gate encrypted content on ownership of a specific on-chain object type
(NFT, DAO token, game asset). You create a gate for a token type; anyone holding an object of
that type can decrypt content bound to the gate. Access travels with the asset — transfer the
token, transfer the access — with no admin update required. The token type must be owned, never
shared or frozen; see the module comments for the full security model.

Questions

  1. Freeze vs share. The implementation uses freeze_object. Any reason to prefer share_object for this pattern?
  2. Instance-gated variant. A variant gating on a specific object ID (not just any object of type T) would add ~25 lines. Include now or follow-up?
  3. Module name. Proposing patterns::token_gated. Open to patterns::nft_gated if preferred.

Happy to iterate based on feedback.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions