Migrate from per-note vaults

trustset seals each note in its own Mera vault, so reading six notes is six passkey prompts. Moving that one file to a satchel namespace makes it one prompt per session, and the contract, the ABI and the on-chain bytes layout around it stay.

What changes for the user

ActionPer-note Mera vaultOne satchel session
Open six notessix promptsone prompt
Seal a noteone prompt eachno prompt after the session opens
Error textthree hand-written stringsSatchelError carries message and fix

The diff

One file, web/src/lib/sealed.ts in Silk-Nodes/trustset. The Notes contract, NOTES_ABI and the Kind type are untouched.

web/src/lib/sealed.ts
--- a/web/src/lib/sealed.ts
+++ b/web/src/lib/sealed.ts
@@
-import {
-  createSecretVaultWithExistingPasskey, decryptSecretVaultWithPasskey, isMeraError, parseSecretVault,
-  type PasskeySecretVault,
-} from "@category-labs/mera";
+import { open, ns, parseSealed, SatchelError, type Sealed, type Satchel } from "satchel-sdk";
@@
-export type Sealed = { kind: Kind; by: string; at: number; vault: PasskeySecretVault | null; index: number };
+export type Note = { kind: Kind; by: string; at: number; vault: Sealed | null; index: number };
+const NOTES = ns("trustset/notes");
+let session: Promise<Satchel> | undefined;
+const satchel = () => (session ??= open({ rpId: location.hostname }));   // one prompt per session
@@ readNotes
-    try { vault = parseSecretVault(JSON.parse(dec.decode(ethers.getBytes(r.vault)))); } catch { /* unreadable, shown as such */ }
+    try { vault = parseSealed(ethers.getBytes(r.vault)); } catch { /* unreadable, shown as such */ }
@@ sealNote
-  const secret = enc.encode(text);
-  const vault = await createSecretVaultWithExistingPasskey({ rpId: location.hostname, secret });
-  const bytes = enc.encode(JSON.stringify(vault));
+  const bytes = (await (await satchel()).seal(NOTES, text)).toBytes();
@@ openNote
-export async function openNote(vault: PasskeySecretVault): Promise<string> {
-  const out = await decryptSecretVaultWithPasskey({ rpId: location.hostname, vault });
-  const text = dec.decode(out as Uint8Array);
-  (out as Uint8Array).fill(0);
-  return text;
-}
+export async function openNote(vault: Sealed): Promise<string> {
+  return dec.decode(await (await satchel()).unseal(NOTES, vault));
+}
@@ explainSeal
-  if (isMeraError(e)) {
-    if (e.code === "PRF_UNAVAILABLE") return "This passkey cannot derive keys. iCloud Keychain and Google Password Manager passkeys can; some security keys cannot.";
-    if (e.code === "DECRYPT_FAILED") return "That passkey is not the one this note was sealed with.";
-    if (e.code === "PASSKEY_OPERATION_FAILED") return "The passkey prompt was closed, or there is no trustset passkey on this device yet.";
-    return e.message;
-  }
+  if (e instanceof SatchelError) return `${e.message} ${e.fix}`;

What stays and what goes

Notes already sealed as Mera vaults

Notes sealed before the change stay readable through the old call path until they are re-sealed. resealLegacy does it in one pass: each legacy vault is opened once, then sealed under the namespace.

resealLegacy.ts
import { decryptSecretVaultWithPasskey, parseSecretVault } from "@category-labs/mera";
import { ns, type Satchel } from "satchel-sdk";

const NOTES = ns("trustset/notes");

/** Reads every legacy Mera vault once, then seals the plaintext under the namespace. */
export async function resealLegacy(s: Satchel, legacy: Uint8Array[]): Promise<Uint8Array[]> {
  const out: Uint8Array[] = [];
  for (const raw of legacy) {
    const vault = parseSecretVault(JSON.parse(new TextDecoder().decode(raw)));
    const plain = await decryptSecretVaultWithPasskey({ rpId: location.hostname, vault });
    out.push((await s.seal(NOTES, plain)).toBytes());
    plain.fill(0);
  }
  return out;
}

Each legacy vault still costs one prompt to open, once. After that the note is a satchel blob and never costs another.

The applied change

The patch applied to a trustset checkout, with its build log, is in integrations/trustset.