SEAL-encrypt before the publisher PUT. Only the BlobStore owner (and
allowlisted viewers) can later decrypt — the ciphertext on Walrus is
public-readable but unreadable.
One signature, not two
Encryption uses public key-server data only — no wallet prompt. The single
wallet signature in the upload pipeline is the on-chain register tx at
the end. Decryption later does require a signature (SEAL session key).
Prerequisites
npm install @waldrop/sdk @mysten/sui @mysten/seal
You also need an existing BlobStore — SEAL identities are scoped to a
specific store id, so encrypted blobs can't be uploaded into a fresh PTB-
created store in the same call.
// One-time: do a plaintext upload first to create your BlobStore.const first = await client.blob.upload({ /* plaintext */ });const blobStoreId = first.blobStoreId!; // reuse this for all future uploads
Recipe
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";import { SuiGrpcClient } from "@mysten/sui/grpc";import type { Transaction } from "@mysten/sui/transactions";import { WaldropClient, type TransactionSigner } from "@waldrop/sdk";const keypair = Ed25519Keypair.fromSecretKey(process.env.WALDROP_PRIVATE_KEY!);const suiClient = new SuiGrpcClient({ network: "testnet", baseUrl: "https://fullnode.testnet.sui.io:443",});const signer: TransactionSigner = { async signAndExecuteTransaction({ transaction }) { const r = await (suiClient as any).signAndExecuteTransaction({ transaction: transaction as Transaction, signer: keypair, include: { effects: true }, }); return { digest: r?.digest, effects: r?.effects }; },};const client = new WaldropClient({ network: "testnet", suiClient });const plaintext = new TextEncoder().encode( "internal-only · do not log this · @waldrop/sdk demo",);const result = await client.blob.upload({ data: plaintext, fileName: "secret.txt", contentType: "text/plain", epochs: 26, senderAddress: keypair.toSuiAddress(), subscriptionId: process.env.WALDROP_SUBSCRIPTION_ID!, signer, blobStoreId: process.env.WALDROP_BLOB_STORE_ID!, encrypted: true, // ← the only difference onProgress: (e) => console.log(`[${e.stage}] ${e.percent}%`),});console.log(result.blobId);console.log(result.sealMarker); // 32-char hex marker
What changes vs plaintext
[SHA-256] ← of original bytes (always)[SEAL encrypt] ← NEW — 53-byte identity = store_id || marker || nonce[publisher PUT] ← uploads CIPHERTEXT (larger than plaintext)[register_blob] ← BlobRef.encrypted = true, BlobRef.seal_marker recorded
The marker is recorded on-chain so per-blob shares (add_blob_share) can
key off it without needing to know the full SEAL identity.
import { DecryptionError } from "@waldrop/sdk";const { bytes } = await client.blob.fetch({ blobId: result.blobId });// bytes is still SEAL ciphertext at this point.try { const plaintext = await client.crypto.decrypt({ bytes, blobStoreId: result.blobStoreId!, signer: keypair, // signs the SEAL session-key message }); console.log(new TextDecoder().decode(plaintext));} catch (e) { if (e instanceof DecryptionError) { // Wrong store id, not on viewer ACL, or malformed ciphertext. } throw e;}
Session key caching
SEAL caches the session key in memory after the first prompt — subsequent
decrypt() calls within ~10 minutes won't re-prompt. In a browser, also
cached in sessionStorage so it survives page reloads but not new tabs.