Waldrop SDK · Guide

Sharing access to encrypted blobs

Two granularities — store-level (the viewer can decrypt every blob in your BlobStore) and per-blob (only one specific blob keyed by SEAL marker). Both gates run inside the Move predicate seal_approve that the SEAL key servers dry-run before releasing the decryption key.

The two ACLs

01
Store-level (viewers)

Single allowlist on the BlobStore. Members can decrypt every blob you'll ever store — past + future. Best for trusted partners.

02
Per-blob (per_blob_shares)

Table keyed by SEAL marker. One marker → list of allowed viewers. Granting access to one blob doesn't leak the rest of the store.

Both ACLs are owner-managed. Only the BlobStore owner can call add_* / remove_* Move functions.

Reading the ACL

The SDK exposes the store-level read side:

// Returns allowlisted addresses (the owner is implicit and excluded).
const viewers = await client.blob.listViewers({ owner: "0x…" });

// True when `address` is the owner OR in the store-level allowlist —
// mirrors `seal_approve`.
const ok = await client.blob.canView({
  owner: "0x…",
  address: viewerAddress,
});

The per-blob ACL isn't surfaced via a dedicated method yet. Read the BlobStore object's per_blob_shares field directly via Sui RPC if you need it.

Writing the ACL

The SDK doesn't build the write-side PTBs for you — but it exports the PACKAGE_ID and SHARED_OBJECTS so building them is short:

Add a store-level viewer

import { Transaction } from "@mysten/sui/transactions";
import { PACKAGE_ID, SHARED_OBJECTS } from "@waldrop/sdk";

const tx = new Transaction();
tx.setSender(owner);
tx.moveCall({
  target: `${PACKAGE_ID}::storage::add_viewer`,
  arguments: [
    tx.object(blobStoreId),
    tx.object(SHARED_OBJECTS.globalConfig),
    tx.pure.address(viewerAddress),
  ],
});

await signer.signAndExecuteTransaction({ transaction: tx });

Add a per-blob share

import { bcs } from "@mysten/sui/bcs";

function hexToBytes(hex: string): Uint8Array {
  const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
  return new Uint8Array(
    clean.match(/.{2}/g)!.map((b) => parseInt(b, 16)),
  );
}

const tx = new Transaction();
tx.setSender(owner);
tx.moveCall({
  target: `${PACKAGE_ID}::storage::add_blob_share`,
  arguments: [
    tx.object(blobStoreId),
    tx.object(SHARED_OBJECTS.globalConfig),
    tx.pure(bcs.vector(bcs.u8()).serialize(Array.from(hexToBytes(sealMarker)))),
    tx.pure.address(viewerAddress),
  ],
});

await signer.signAndExecuteTransaction({ transaction: tx });

sealMarker is the 16-byte hex from UploadBlobResult.sealMarker (returned when you uploaded the blob). For older blobs, it's also stored on the BlobRef returned by client.blob.list.

Bundle viewers into the upload tx

For the most common pattern (grant initial access at upload time), use the initialShareViewers array — the SDK bundles add_blob_share calls into the same PTB as register_blob:

await client.blob.upload({
  data,
  // …
  encrypted: true,
  blobStoreId,
  initialShareViewers: ["0xabc…", "0xdef…"],
});

Atomic: either registration + all shares land, or nothing does. Saves a second signature.

Removing access

Symmetric remove_* calls:

tx.moveCall({
  target: `${PACKAGE_ID}::storage::remove_viewer`,
  arguments: [tx.object(blobStoreId), tx.pure.address(viewerAddress)],
});

tx.moveCall({
  target: `${PACKAGE_ID}::storage::remove_blob_share`,
  arguments: [
    tx.object(blobStoreId),
    tx.pure(bcs.vector(bcs.u8()).serialize(Array.from(markerBytes))),
    tx.pure.address(viewerAddress),
  ],
});
Revocation isn't forward-secure

SEAL gates access to the key, not the bytes after. A viewer who already pulled the key from the key servers can keep decrypting any blob whose ciphertext they've kept. For true revocation, re-encrypt under a fresh identity and upload a new blob — old ciphertext is then orphaned.

Self-share is rejected

The contract aborts when an address tries to add itself to its own viewer list (ECannotAddSelf). The SDK's initialShareViewers filters out the sender's own address client-side to keep the rest of the share calls successful even if you accidentally include yourself.

The Move predicate

For reference — this is what SEAL's key servers dry-run before releasing a key:

public fun seal_approve(
    id: vector<u8>,            // the 53-byte SEAL identity
    store: &BlobStore,
    ctx: &TxContext,
): bool {
    let caller = tx_context::sender(ctx);

    // 1. Owner is always allowed.
    if (caller == store.owner) return true;

    // 2. Store-level viewer ACL.
    if (vec_set::contains(&store.viewers, &caller)) return true;

    // 3. Per-blob share check — extract marker from identity.
    let marker = extract_marker(&id);          // bytes 32..48
    if (table::contains(&store.per_blob_shares, marker)) {
        let allowed = table::borrow(&store.per_blob_shares, marker);
        if (vec_set::contains(allowed, &caller)) return true;
    }

    false
}

The TypeScript client.blob.canView mirrors the first two checks. Per-blob lookups aren't in canView because the SDK doesn't load the per_blob_shares table by default (it's expensive on a store with many shares).

A simple sharing UX

For a sharing UI:

  1. Render the store-level viewers list (client.blob.listViewers)
  2. Form input for a new Sui address
  3. On submit, build the add_viewer PTB and sign
  4. Optimistically prepend the new address to the list, reconcile on tx success

For per-blob:

  1. List the user's encrypted blobs (client.blob.list, filter by encrypted === true)
  2. For each, show a "Share" button → modal with address input
  3. Read the BlobRef's sealMarker, build add_blob_share, sign
Edit this page on GitHub ↗
Waldrop · 2026cryptokarigar