-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Description
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:
- Type check: the caller's type argument
Tmatches the gate'srequired_type(defense-in-depth against type confusion — e.g., passingClockinstead of the intended NFT) - 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
- Freeze vs share. The implementation uses
freeze_object. Any reason to prefershare_objectfor this pattern? - 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?
- Module name. Proposing
patterns::token_gated. Open topatterns::nft_gatedif preferred.
Happy to iterate based on feedback.