From sui-dev-agents
Encrypts data with on-chain access policies using Sui's Seal protocol and threshold key servers. Handles token-gated content, pay-to-decrypt, encrypted NFT metadata, and private data sharing.
How this skill is triggered — by the user, by Claude, or both
Slash command
/sui-dev-agents:sui-sealThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**On-chain access policies + threshold encryption + decentralized key servers.**
On-chain access policies + threshold encryption + decentralized key servers.
Targets: @mysten/seal 1.1.3 (^1.1), @mysten/sui 2.17.0 (^2.16). Tested: 2026-05-21.
Compatibility notes: @mysten/sui is a peer dependency of @mysten/seal. The suiClient must be a v2.x SuiGrpcClient (from @mysten/sui/grpc) or SuiJsonRpcClient (from @mysten/sui/jsonRpc) — these satisfy SealCompatibleClient. Do not mix @mysten/[email protected] and @2.x in the same install — run npm ls @mysten/sui before adding seal/walrus/dapp-kit. Seal is NOT a $extend() client extension; always instantiate new SealClient({ ... }) directly.
seal_approve* entry functionsSecurity:
t of n key servers are compromisedt key servers are available| Concept | Description |
|---|---|
| Identity-Based Encryption (IBE) | Data encrypted under an id derived from on-chain policy |
| Threshold Key Servers | Distributed key management — no single point of failure |
| Session Keys | Time-limited decryption credentials signed by the user (personal message) |
| seal_approve PTB | A built (not executed) PTB calling seal_approve* that key servers dry-run to authorize |
| Access Policy (Move) | Move module exposing seal_approve*(id, ...) entry functions |
1. App encrypts data with SealClient.encrypt({ packageId, id, threshold, data })
↓
2. Encrypted blob (Uint8Array) stored on Walrus / IPFS / DB
↓
3. User starts a session: SessionKey.create({ address, packageId, ttlMin, signer, suiClient })
↓
4. App builds a Transaction that calls `${packageId}::policy::seal_approve_*` with the id
↓
5. tx.build({ client: suiClient, onlyTransactionKind: true }) → txBytes
↓
6. sealClient.decrypt({ data, sessionKey, txBytes }) → plaintext
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { SealClient } from '@mysten/seal';
const suiClient = new SuiGrpcClient({
network: 'testnet',
baseUrl: 'https://fullnode.testnet.sui.io:443',
});
// Each entry is a key server *object ID* on-chain — not a URL.
// weight controls how that server counts toward the threshold.
const sealClient = new SealClient({
suiClient,
serverConfigs: [
{ objectId: '0xKEYSERVER_OBJ_1', weight: 1 },
{ objectId: '0xKEYSERVER_OBJ_2', weight: 1 },
{ objectId: '0xKEYSERVER_OBJ_3', weight: 1 },
// optional fields per entry:
// apiKeyName, apiKey, aggregatorUrl (required for committee-mode servers)
],
verifyKeyServers: true,
timeout: 10_000,
});
// @check:skip
import { fromHex } from '@mysten/sui/utils';
const PACKAGE_ID = '0xYOUR_POLICY_PKG';
// `id` is the IBE identity. Convention: bytes that the on-chain
// seal_approve* function will validate (e.g. allowlist object id || nonce).
const id = fromHex('deadbeef'); // hex string of the identity bytes
const { encryptedObject, key } = await sealClient.encrypt({
threshold: 2, // 2-of-3 key servers
packageId: PACKAGE_ID,
id: '0xdeadbeef', // hex-encoded identity string
data: new TextEncoder().encode('secret content'),
// optional: aad, kemType, demType (DemType.AesGcm256 by default)
});
// `encryptedObject` is Uint8Array (BCS-serialized) — store wherever you want.
// `key` is the raw symmetric key — DO NOT share; only useful for backup/escrow.
const blobId = await uploadToWalrus(encryptedObject);
// @check:skip
import { SessionKey } from '@mysten/seal';
import { Transaction } from '@mysten/sui/transactions';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
// 1. Create a session key. The user signs a personal message under the hood
// (signer can be any Signer — Ed25519, EnokiSigner, PasskeyKeypair, etc.).
const keypair = Ed25519Keypair.generate();
const sessionKey = await SessionKey.create({
address: keypair.toSuiAddress(),
packageId: PACKAGE_ID,
ttlMin: 10, // minutes, NOT ms
signer: keypair,
suiClient,
});
// 2. Build (don't execute) the seal_approve PTB. The key servers will
// dry-run this transaction; if it succeeds, they release the share.
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::policy::seal_approve_allowlist`,
arguments: [
tx.pure.vector('u8', Array.from(fromHex('deadbeef'))), // the id
tx.object('0xALLOWLIST_OBJ'),
// ...any other args your seal_approve_* function needs
],
});
const txBytes = await tx.build({
client: suiClient,
onlyTransactionKind: true, // REQUIRED — TransactionKind, not full tx
});
// 3. Fetch the encrypted blob and decrypt.
const encryptedBlob: Uint8Array = await fetchFromWalrus(blobId);
const plaintext = await sealClient.decrypt({
data: encryptedBlob,
sessionKey,
txBytes,
});
console.log(new TextDecoder().decode(plaintext));
// @check:skip
// Pre-fetch keys once, then decrypt many objects cheaply.
await sealClient.fetchKeys({
ids: ['0xdeadbeef', '0xcafebabe'],
txBytes, // a single PTB that calls seal_approve* for all ids
sessionKey,
threshold: 2,
});
for (const blob of blobs) {
const pt = await sealClient.decrypt({ data: blob, sessionKey, txBytes });
// ...
}
// @check:skip
const exported = sessionKey.export(); // ExportedSessionKey (JSON-safe)
localStorage.setItem('seal-sk', JSON.stringify(exported));
// Later:
const restored = SessionKey.import(
JSON.parse(localStorage.getItem('seal-sk')!),
suiClient,
keypair, // optional, only needed if you must re-sign
);
// @check:skip
import { EncryptedObject } from '@mysten/seal';
const meta = EncryptedObject.parse(encryptedBlob);
// → { version, packageId, id, services, threshold, ciphertext, ... }
The on-chain side exposes seal_approve* entry functions. The function name must start with seal_approve. The first argument is always the IBE id as vector<u8>. The body should abort if access is denied — success means "release the key".
module example::token_gate {
use sui::object::{Self, UID, ID};
public struct GatePolicy has key {
id: UID,
required_collection: ID,
}
/// Seal key servers dry-run this. Aborts → no key. Returns → key released.
/// `id` is the IBE identity supplied by the decryptor (must match what was encrypted).
public fun seal_approve_holder(
id: vector<u8>,
policy: &GatePolicy,
nft: &SomeNFT, // caller passes their NFT
_ctx: &TxContext,
) {
assert!(some_nft::collection(nft) == policy.required_collection, 0);
// (optionally bind `id` to `object::id(nft)` so each NFT has its own key)
}
}
module example::time_lock {
use sui::clock::Clock;
public struct TimeLockPolicy has key { id: UID, unlock_ms: u64 }
public fun seal_approve_after(
_id: vector<u8>,
policy: &TimeLockPolicy,
clock: &Clock,
) {
assert!(clock::timestamp_ms(clock) >= policy.unlock_ms, 0);
}
}
module example::paywall {
use sui::coin::{Self, Coin};
use sui::sui::SUI;
public struct Receipt has key, store { id: UID, owner: address, paid_for: vector<u8> }
/// Real transaction: user pays, gets a Receipt object.
public entry fun pay(price: u64, mut payment: Coin<SUI>, paid_for: vector<u8>, ctx: &mut TxContext) {
assert!(coin::value(&payment) >= price, 0);
// ...transfer payment, mint receipt...
}
/// seal_approve runs against the user's owned Receipt.
public fun seal_approve_with_receipt(
id: vector<u8>,
receipt: &Receipt,
ctx: &TxContext,
) {
assert!(receipt.owner == tx_context::sender(ctx), 0);
assert!(receipt.paid_for == id, 0);
}
}
| Use Case | Policy Type |
|---|---|
| Premium content / paywall | Receipt-based seal_approve |
| NFT-gated community content | Holder check |
| Time-release announcements | Clock-based |
| Private DAO votes | Membership check |
| Encrypted NFT metadata | Owner-only |
ttlMin short (5–15 min); rotate session keysthreshold servers reachableid to something on-chain (object ID, content hash) so re-using a key isn't possiblesealClient.seal.encrypt(...) / client.extend(seal()) — neither exists.
SealClient class. There is no .seal namespace and no $extend() factory.new SealClient({ suiClient, serverConfigs }), call sealClient.encrypt(...) / .decrypt(...) directly.Calling decrypt without txBytes.
txBytes is mandatory. It must be a TransactionKind (tx.build({ client, onlyTransactionKind: true })) that calls a seal_approve* Move function for the given id. Key servers dry-run this PTB; if it aborts, the key is denied.KeyServerConfig.url — wrong field.
{ objectId: '0x…', weight: 1 }. There is no url field. (For committee-mode servers, supply aggregatorUrl.)EncryptedObject metadata, or enumerating key servers / public keys (seal ≥1.1.3)npx claudepluginhub first-mover-tw/sui-dev-agents --plugin sui-dev-agentsProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.