Skip to content

Feature: SubnameCap delegation capability for subdomain creation #363

@arbuthnot-eth

Description

@arbuthnot-eth

Problem

Currently, creating subdomains under a SuiNS domain requires possession of the parent SuinsRegistration NFT. This presents challenges for delegation scenarios:

  1. No delegation without transfer: Domain owners cannot grant subdomain creation rights without transferring the entire NFT
  2. All-or-nothing permissions: There's no way to grant limited permissions (e.g., only leaf subdomains, or only up to N subdomains)
  3. No revocation mechanism: Once the NFT is transferred, the original owner loses all control
  4. Re-registration invalidation: Delegated permissions should automatically invalidate when a domain expires and is re-registered by someone else

Proposal

Introduce SubnameCap, a native capability object that allows holders to create subdomains under a specific parent domain without possessing the parent SuinsRegistration NFT.

Key Design: NFT ID Binding + Active Caps List

The SubnameCap stores both parent_domain AND parent_nft_id. Validation requires the cap to be present in an active caps list stored as a dynamic field on the main SuiNS shared object (the protocol singleton), following the same pattern used by Registry and Config:

Scenario NFT ID In Active List Cap Valid?
Domain transfer (same NFT → new wallet) Unchanged Yes ✅ Yes (persists)
Domain expires + re-registered New NFT ID Orphaned ❌ No (auto-invalidated)
Explicit revocation by NFT holder Unchanged Removed ❌ No (revoked)
Cap surrendered by holder N/A Removed ❌ No (destroyed)
Batch clear by NFT holder Unchanged All removed ❌ No (all revoked)

SubnameCap Structure

public struct SubnameCap has key, store {
    id: UID,
    parent_domain: Domain,
    parent_nft_id: ID,
    // Permissions
    allow_leaf_creation: bool,
    allow_node_creation: bool,
    default_node_allow_creation: bool,
    default_node_allow_extension: bool,
    // Programmable Limits
    max_uses: Option<u64>,           // None = unlimited
    uses_count: u64,
    max_duration_ms: Option<u64>,    // Duration limit for created subdomains
    cap_expiration_ms: Option<u64>,  // Absolute timestamp when cap expires
}

Active Caps Storage

Active caps are stored as a dynamic field on the main SuiNS shared object (the protocol singleton), keyed by domain. Access is gated through the existing app authorization system:

// Only authorized apps can access the UID for dynamic fields
let suins_uid = suins::app_uid_mut(SubDomains {}, suins);
df::add(suins_uid, ActiveCapsKey { domain }, ActiveSubnameCaps { caps: vector[] });
/// Key for the dynamic field (defined in subdomains module, so only it can access)
public struct ActiveCapsKey has copy, drop, store {
    domain: Domain,
}

/// Metadata stored for each active cap (enables enumeration without cap object)
public struct CapEntry has copy, drop, store {
    cap_id: ID,
    created_at_ms: u64,
    allow_leaf: bool,
    allow_node: bool,
    max_uses: Option<u64>,
    max_duration_ms: Option<u64>,
    cap_expiration_ms: Option<u64>,
}

/// Container stored as dynamic field value
public struct ActiveSubnameCaps has store {
    caps: vector<CapEntry>,
}

Storage hierarchy:

SuiNS (shared object - protocol singleton)
├── RegistryKey<Registry> → Registry (existing)
├── ConfigKey<Config> → Config (existing)
├── AppKey<App> → bool (existing)
└── ActiveCapsKey { domain } → ActiveSubnameCaps (new, via app_uid_mut)
    ├── { domain: "example.sui" } → vector<CapEntry>
    ├── { domain: "foo.sui" } → vector<CapEntry>
    └── ...

This approach:

  • Follows established SuiNS patterns (Registry, Config stored the same way)
  • Uses existing app authorization (app_uid / app_uid_mut require authorized app witness)
  • Enables enumeration of all active caps for a domain via get_active_caps(suins, domain)
  • No additional shared object contention (subdomain ops already require &mut SuiNS)
  • Key type isolation ensures only the subdomains module can access its data
  • Automatically orphans old caps when domain is re-registered (NFT ID check fails)

Core Functions

Function Requires Description
create_subname_cap &mut SuiNS, parent NFT Creates cap and adds to active list
revoke_subname_cap &mut SuiNS, parent NFT, cap_id Removes cap from active list (one-sided)
surrender_subname_cap &mut SuiNS, cap object Cap holder removes and destroys their cap
clear_active_caps &mut SuiNS, parent NFT Batch revoke ALL caps for a domain
new_leaf_with_cap &mut SuiNS, &mut SubnameCap Create leaf subdomain with cap
new_with_cap &mut SuiNS, &mut SubnameCap Create node subdomain with cap

Note: Functions require &mut SuiNS because the active caps list is stored as a dynamic field on the SuiNS object. This is consistent with existing subdomain operations which already require &mut SuiNS for registry access.

Validation Flow

When a cap is used (new_leaf_with_cap or new_with_cap):

  1. Check allow_leaf_creation or allow_node_creation permission
  2. Verify parent domain exists in registry
  3. Verify parent is not expired
  4. Verify record.nft_id() == cap.parent_nft_id (auto-invalidates on re-registration)
  5. Verify cap ID is in the active caps list for that domain
  6. Check cap-specific limits (max_uses, cap_expiration_ms, max_duration_ms)

Events

For indexing and transparency:

  • SubnameCapCreated - Emitted when a cap is created
  • SubnameCapRevoked - Emitted when a cap is revoked by parent holder
  • SubnameCapSurrendered - Emitted when a cap is surrendered by cap holder
  • SubnameCapUsed - Emitted when a cap is used to create a subdomain
  • ActiveCapsCleared - Emitted when all caps are batch cleared for a domain

Enumeration & Query Functions

// Active caps enumeration
get_active_caps(suins, domain): vector<CapEntry>
get_active_caps_count(suins, domain): u64
is_cap_active(suins, cap): bool
is_cap_revoked(suins, cap): bool

// CapEntry getters (for reading from active list without cap object)
cap_entry_id(entry): ID
cap_entry_created_at_ms(entry): u64
cap_entry_allow_leaf(entry): bool
cap_entry_allow_node(entry): bool
cap_entry_max_uses(entry): Option<u64>
cap_entry_max_duration_ms(entry): Option<u64>
cap_entry_expiration_ms(entry): Option<u64>

// SubnameCap getters
cap_parent_domain(cap): Domain
cap_parent_nft_id(cap): ID
cap_allow_leaf_creation(cap): bool
cap_allow_node_creation(cap): bool
cap_max_uses(cap): Option<u64>
cap_uses_count(cap): u64
cap_remaining_uses(cap): Option<u64>
cap_max_duration_ms(cap): Option<u64>
cap_expiration_ms(cap): Option<u64>
is_cap_expired(cap, clock): bool
is_cap_usage_exhausted(cap): bool

Files Modified

  1. packages/suins/sources/suins.move

    • Add app_uid<App: drop>(_: App, self: &SuiNS): &UID - read-only UID access for authorized apps
    • Add app_uid_mut<App: drop>(_: App, self: &mut SuiNS): &mut UID - mutable UID access for authorized apps
    • These functions use assert_app_is_authorized<App>() to ensure only authorized apps can access
  2. packages/subdomains/sources/subdomains.move

    • Add SubnameCap struct and related types
    • Add all cap management functions
    • Add events for cap lifecycle
  3. packages/subdomains/tests/subdomain_tests.move

    • 57 comprehensive tests including all cap functionality

Acceptance Criteria

  • SubnameCap struct with key, store abilities (transferable)
  • NFT ID binding for automatic invalidation on domain re-registration
  • Active caps list stored as dynamic field on SuiNS shared object (via authorized app access)
  • Configurable permissions (leaf vs node creation)
  • Programmable limits (max_uses, max_duration_ms, cap_expiration_ms)
  • One-sided revocation by parent NFT holder
  • Batch clear all caps for a domain
  • Voluntary surrender by cap holder
  • Enumeration support (get_active_caps, get_active_caps_count)
  • Events for cap lifecycle (creation, revocation, surrender, usage, clear)
  • Comprehensive test coverage (57 tests)
  • Consistent timestamp semantics with existing SuiNS code (absolute ms since epoch)

Use Cases

  1. Community subdomain programs: Projects can delegate subdomain creation to community managers
  2. Subdomain marketplaces: Sellers can create caps with specific limits for buyers
  3. Organization hierarchies: Departments can manage their own subdomains
  4. Time-limited campaigns: Create caps that expire after an event
  5. Quota-based delegation: Limit delegatees to creating N subdomains
  6. Domain transfers: Use clear_active_caps before transferring to revoke all delegations

Security Considerations

  1. NFT ID binding: Auto-invalidates caps when domain is re-registered
  2. Active list validation: Caps must be in active list to be used
  3. App authorization: app_uid / app_uid_mut require authorized app witness, preventing unauthorized dynamic field access
  4. Key type isolation: ActiveCapsKey is defined in subdomains module, so only it can access the data
  5. One-sided revocation: NFT holder can revoke without needing the cap object
  6. Batch clear: NFT holder can revoke all caps at once before domain transfer
  7. Surrender option: Cap holders can cleanly remove themselves from the active list
  8. Expiration inheritance: Subdomain expiration cannot exceed parent expiration
  9. Denylist compliance: Cap-created subdomains still respect the blocklist
  10. Limit enforcement: Uses count tracked on-chain via mutable cap reference
  11. Orphaned lists: On re-registration, old active caps list is orphaned but harmless (NFT ID check fails)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions