From bf666952c8050ca323901b5eb4503b2149ee0d7f Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 18:12:10 +0700 Subject: [PATCH 01/11] =?UTF-8?q?feat(WALM-264):=20permanent=20V1=20memory?= =?UTF-8?q?=20deletion=20=E2=80=94=20UI=20+=20backend,=20flag-gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing delete for old V1 memories, off by default behind ENABLE_MEMORY_DELETION (server) + VITE_ENABLE_MEMORY_DELETION (app). Backend: POST /api/delete-memories (protected_routes) accepts the user-signed sponsored delete PTB {digest, signature, blobIds}, submits it via the sidecar sponsor-execute path, and only after on-chain success deletes the matching vector_entries rows (delete_by_blob_id, owner-scoped from verified auth — never the body). sponsor.rs's sidecar call is extracted into call_sidecar_sponsor_execute, shared with the existing /sponsor/execute proxy. cleanup_expired_blob remains the self-heal backstop if row-delete fails after a landed tx. Frontend: /cleanup page enumerates the wallet's on-chain Walrus Blob objects (walrusClient.getBlobType — no hardcoded package), converts blob_id u256→base64url to match the relayer DB, builds the delete PTB (deleteBlob + transferObjects of reclaimed Storage) in batches of 950 per Sui PTB caps, sponsors via POST /sponsor, and stops after the wallet signature — the backend owns submit + DB cleanup. Confirm dialog and copy state plainly that deletion is permanent, never recoverable, never migrated. Plus the dismissible old-memories banner and a user guide (docs/fundamentals/deleting-old-memories). Verified: cargo tests 296 pass (5 pre-existing failures on clean dev), app tsc + vite build + asset verification pass. --- apps/app/.env.local.testnet.bak | 16 + apps/app/src/App.tsx | 4 + apps/app/src/components/CleanupSection.tsx | 401 ++++++++++++++++++ apps/app/src/components/OldMemoriesBanner.tsx | 74 ++++ apps/app/src/config.ts | 4 + apps/app/src/index.css | 63 +++ apps/app/src/pages/Dashboard.tsx | 3 + apps/app/src/utils/walrusBlobs.ts | 128 ++++++ docs/docs.json | 3 +- docs/fundamentals/deleting-old-memories.md | 55 +++ services/server/src/main.rs | 4 + services/server/src/routes/admin.rs | 125 ++++++ services/server/src/routes/mod.rs | 4 +- services/server/src/routes/remember.rs | 1 + services/server/src/routes/sponsor.rs | 120 ++++-- services/server/src/storage/db.rs | 21 + services/server/src/types.rs | 42 ++ 17 files changed, 1024 insertions(+), 44 deletions(-) create mode 100644 apps/app/.env.local.testnet.bak create mode 100644 apps/app/src/components/CleanupSection.tsx create mode 100644 apps/app/src/components/OldMemoriesBanner.tsx create mode 100644 apps/app/src/utils/walrusBlobs.ts create mode 100644 docs/fundamentals/deleting-old-memories.md diff --git a/apps/app/.env.local.testnet.bak b/apps/app/.env.local.testnet.bak new file mode 100644 index 00000000..7045f572 --- /dev/null +++ b/apps/app/.env.local.testnet.bak @@ -0,0 +1,16 @@ +# ── WALM-264 local testing — Railway dev envs (testnet) ── +# Backend: local memwal-server on :8000 (railway dev env + local DB/Redis, +# ENABLE_MEMORY_DELETION=true). + +VITE_MEMWAL_SERVER_URL=http://localhost:8000 +VITE_SUI_NETWORK=testnet +VITE_MEMWAL_PACKAGE_ID=0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6 +VITE_MEMWAL_REGISTRY_ID=0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437 +VITE_ENOKI_API_KEY=enoki_public_7a7422ea8969ca8d66f82302563036a9 +VITE_GOOGLE_CLIENT_ID=603573168998-1en4kolflalfkffklhejndaqncer0j3d.apps.googleusercontent.com + +# WALM-264 feature flag — shows the Dashboard cleanup section + banner +VITE_ENABLE_MEMORY_DELETION=true + +# PR #355 merged into dev — gRPC keeps testnet working (JSON-RPC is dead): +VITE_SUI_GRPC_URL=https://fullnode.testnet.sui.io diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 57ca2941..3b6830be 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -27,6 +27,7 @@ import Dashboard from './pages/Dashboard' import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' import ConnectMcp from './pages/ConnectMcp' +import OldMemoriesBanner from './components/OldMemoriesBanner' import { useRouteAnalytics } from './hooks/useRouteAnalytics' @@ -265,6 +266,8 @@ function AppContent() { } return ( + <> + : @@ -280,6 +283,7 @@ function AppContent() { } /> } /> + ) } diff --git a/apps/app/src/components/CleanupSection.tsx b/apps/app/src/components/CleanupSection.tsx new file mode 100644 index 00000000..f988bf11 --- /dev/null +++ b/apps/app/src/components/CleanupSection.tsx @@ -0,0 +1,401 @@ +/** + * CleanupSection — permanent V1 memory deletion (WALM-264), rendered as a + * Dashboard card (id="cleanup", the old-memories banner links here). Styled + * to match the Delegate keys card (same Card shell + table classes). + * + * The user's ONLY action is signing: the card builds the + * `system::delete_blob` PTB (batched), gets it Enoki-sponsored via + * `POST /sponsor`, collects the wallet signature, then hands + * `{ digest, signature, blobIds }` to `POST /api/delete-memories` — the + * backend submits the transaction and deletes the matching DB rows in the + * same call, so chain and DB never drift. + * + * Deletion is PERMANENT. A deleted memory is gone from Walrus, from the + * relayer index, and can never be recovered or migrated. The confirm + * dialog states this in plain words — per WALM-264 that messaging is the + * one thing that must be right. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { useCurrentAccount, useSignTransaction, useSuiClient } from '@mysten/dapp-kit' +import { Transaction } from '@mysten/sui/transactions' +import { RefreshCw, Trash2 } from 'lucide-react' +import { config } from '../config' +import { useDelegateKey } from '../App' +import { apiCall } from '../utils/api' +import { Card } from './Card' +import { getWalrusClient, listOwnedWalrusBlobs, type OwnedWalrusBlob } from '../utils/walrusBlobs' +import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' + +/** + * Sui PTB caps: 1024 commands / 2048 inputs / 128 KB. Each delete is one + * command + one owned input, plus one final transferObjects — so ~1023 is + * the hard ceiling; 950 leaves size + gas headroom. + */ +const DELETE_BATCH_SIZE = 950 + +type Phase = + | { kind: 'loading' } + | { kind: 'ready' } + | { kind: 'deleting'; batch: number; totalBatches: number } + | { kind: 'done'; deletedBlobs: number; deletedRows: number } + +export default function CleanupSection() { + const currentAccount = useCurrentAccount() + const suiClient = useSuiClient() + const { mutateAsync: signTransaction } = useSignTransaction() + const { delegateKey, accountObjectId } = useDelegateKey() + + const address = currentAccount?.address || '' + + const [blobs, setBlobs] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [scanned, setScanned] = useState(0) + const [phase, setPhase] = useState({ kind: 'loading' }) + const [error, setError] = useState('') + const [confirming, setConfirming] = useState(false) + + const deletableBlobs = useMemo(() => blobs.filter((b) => b.deletable), [blobs]) + const permanentCount = blobs.length - deletableBlobs.length + + const loadBlobs = useCallback(async () => { + if (!address) return + setPhase({ kind: 'loading' }) + setError('') + try { + const owned = await listOwnedWalrusBlobs(suiClient, address, setScanned) + + // §5.2 — the relayer DB is the authoritative V1 memory list: only + // offer blobs whose id is indexed there, so unrelated/V2 blobs the + // wallet owns are never deletable from this UI. Requires the + // delegate-key session (the endpoint is authenticated); without + // one the list stays unscoped but the delete button is disabled + // anyway. + let scoped = owned + if (delegateKey) { + const res = await apiCall( + delegateKey, + config.memwalServerUrl, + '/api/memory-blob-ids', + {}, + accountObjectId ?? undefined, + ) + const known = new Set(res.blobIds ?? []) + scoped = owned.filter((b) => known.has(b.blobId)) + } + + setBlobs(scoped) + setSelected(new Set(scoped.filter((b) => b.deletable).map((b) => b.objectId))) + setPhase({ kind: 'ready' }) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to list blobs') + setPhase({ kind: 'ready' }) + } + }, [address, suiClient, delegateKey, accountObjectId]) + + useEffect(() => { + void loadBlobs() + }, [loadBlobs]) + + // The old-memories banner links to /dashboard#cleanup — react-router + // doesn't scroll to hashes, so do it once the section exists. + useEffect(() => { + if (window.location.hash === '#cleanup') { + document.getElementById('cleanup')?.scrollIntoView({ behavior: 'smooth' }) + } + }, []) + + const toggle = useCallback((objectId: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(objectId)) next.delete(objectId) + else next.add(objectId) + return next + }) + }, []) + + const executeDelete = useCallback(async () => { + if (!address || !delegateKey) return + setConfirming(false) + setError('') + + const targets = deletableBlobs.filter((b) => selected.has(b.objectId)) + if (targets.length === 0) return + + trackEvent('memory_delete_start', { blobs: targets.length }) + + const batches: OwnedWalrusBlob[][] = [] + for (let i = 0; i < targets.length; i += DELETE_BATCH_SIZE) { + batches.push(targets.slice(i, i + DELETE_BATCH_SIZE)) + } + + let deletedBlobs = 0 + let deletedRows = 0 + try { + const walrusClient = getWalrusClient(suiClient) + for (let i = 0; i < batches.length; i++) { + const batch = batches[i] + setPhase({ kind: 'deleting', batch: i + 1, totalBatches: batches.length }) + + // Build the delete PTB: each delete frees a Storage object, + // returned to the user in one final transfer. + const tx = new Transaction() + tx.setSender(address) + const reclaimed = batch.map((blob) => + tx.add(walrusClient.deleteBlob({ blobObjectId: blob.objectId })), + ) + tx.transferObjects(reclaimed, address) + + const kindBytes = await tx.build({ client: suiClient, onlyTransactionKind: true }) + + // Sponsor (Enoki) — gas is covered; the user only signs. + const sponsorRes = await fetch(`${config.memwalServerUrl}/sponsor`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transactionBlockKindBytes: uint8ToBase64(kindBytes), + sender: address, + }), + }) + if (!sponsorRes.ok) { + throw new Error(`Sponsor failed (${sponsorRes.status}): ${await sponsorRes.text()}`) + } + const sponsored = await sponsorRes.json() + + const { signature } = await signTransaction({ + transaction: Transaction.from(sponsored.bytes), + }) + + // The backend submits and deletes the DB rows together. + const result = await apiCall( + delegateKey, + config.memwalServerUrl, + '/api/delete-memories', + { + digest: sponsored.digest, + signature, + blobIds: batch.map((b) => b.blobId), + }, + accountObjectId ?? undefined, + ) + + deletedBlobs += batch.length + deletedRows += result.deletedRows ?? 0 + // Remove the finished batch so a mid-run failure resumes + // with only what's left. + const done = new Set(batch.map((b) => b.objectId)) + setBlobs((prev) => prev.filter((b) => !done.has(b.objectId))) + setSelected((prev) => { + const next = new Set(prev) + for (const id of done) next.delete(id) + return next + }) + } + setPhase({ kind: 'done', deletedBlobs, deletedRows }) + trackEvent('memory_delete_complete', { blobs: deletedBlobs }) + } catch (err) { + setError(err instanceof Error ? err.message : 'delete failed') + setPhase({ kind: 'ready' }) + trackEvent('memory_delete_failed', { error_type: getAnalyticsErrorType(err) }) + } + }, [address, delegateKey, accountObjectId, deletableBlobs, selected, suiClient, signTransaction]) + + const selectedCount = selected.size + const busy = phase.kind === 'deleting' || phase.kind === 'loading' + const allSelected = deletableBlobs.length > 0 && selectedCount === deletableBlobs.length + + return ( + + + + + } + > +
+ Deleting is permanent: a deleted memory is erased from Walrus and + from your memory index. It can never be recovered, and it will never be migrated + anywhere. Only delete memories you are sure you no longer want. +
+ + {error && ( +
+ {error} +
+ )} + + {phase.kind === 'loading' && ( +

+ Scanning your wallet for memory blobs...{scanned > 0 ? ` ${scanned} found` : ''} +

+ )} + + {phase.kind === 'deleting' && ( +

+ Deleting batch {phase.batch} of {phase.totalBatches}... check your wallet for a + signature request. +

+ )} + + {phase.kind === 'done' && ( +

+ Deleted {phase.deletedBlobs} memor{phase.deletedBlobs === 1 ? 'y' : 'ies'} on-chain + ({phase.deletedRows} index rows removed). This cannot be undone. +

+ )} + + {phase.kind !== 'loading' && deletableBlobs.length === 0 ? ( + phase.kind !== 'done' && ( +
+ No deletable memories found for this wallet. +
+ ) + ) : phase.kind !== 'loading' && ( + <> +

+ {deletableBlobs.length} deletable memor{deletableBlobs.length === 1 ? 'y' : 'ies'} found + {permanentCount > 0 ? ` (${permanentCount} non-deletable blobs not shown)` : ''}. + {' '}Deletion runs in batches of {DELETE_BATCH_SIZE} — one signature per batch. +

+ +
+ + + + + + + + + + {deletableBlobs.map((blob) => { + const isSelected = selected.has(blob.objectId) + return ( + + + + + + ) + })} + +
+ + Blob IDObject ID
+ + + + {blob.blobId.slice(0, 10)}...{blob.blobId.slice(-6)} + + + + {blob.objectId.slice(0, 8)}...{blob.objectId.slice(-6)} + +
+
+ + {!delegateKey && ( +

+ A delegate key session is required to delete — set one up first. +

+ )} + + )} + + {confirming && ( +
{ + if (event.target === event.currentTarget) setConfirming(false) + }} + > +
+
+

+ Permanently delete {selectedCount} memor{selectedCount === 1 ? 'y' : 'ies'}? +

+

+ This erases the selected memories from Walrus and from your memory index, + forever. They cannot be recovered, restored, or migrated afterwards — by + anyone. There is no undo. +

+
+
+ + +
+
+
+ )} +
+ ) +} + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + return btoa(binary) +} diff --git a/apps/app/src/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx new file mode 100644 index 00000000..4978c26e --- /dev/null +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -0,0 +1,74 @@ +/** + * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". + * + * Shown once per connected wallet (dismissal persisted in localStorage) + * when the wallet owns deletable V1 Walrus blobs. Links to the Dashboard cleanup section. + * Mounted in AppContent; renders nothing while the feature flag is off, + * on the dashboard (the section is already visible there), or before the count is known. + */ + +import { useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { useCurrentAccount, useSuiClient } from '@mysten/dapp-kit' +import { config } from '../config' +import { listOwnedWalrusBlobs } from '../utils/walrusBlobs' + +const DISMISS_KEY = 'memwal_cleanup_banner_dismissed' + +export default function OldMemoriesBanner() { + const currentAccount = useCurrentAccount() + const suiClient = useSuiClient() + const location = useLocation() + const address = currentAccount?.address || '' + + const [count, setCount] = useState(null) + const [dismissed, setDismissed] = useState( + () => localStorage.getItem(DISMISS_KEY) === 'true', + ) + + useEffect(() => { + if (!config.enableMemoryDeletion || !address || dismissed) return + let cancelled = false + listOwnedWalrusBlobs(suiClient, address) + .then((blobs) => { + if (!cancelled) setCount(blobs.filter((b) => b.deletable).length) + }) + .catch(() => { + // Banner is best-effort — a failed count just means no banner. + }) + return () => { + cancelled = true + } + }, [address, suiClient, dismissed]) + + if ( + !config.enableMemoryDeletion || + !address || + dismissed || + !count || + location.pathname === '/dashboard' + ) { + return null + } + + return ( +
+

+ You have {count} old memor{count === 1 ? 'y' : 'ies'} stored on Walrus.{' '} + Delete them if you no longer want them — deletion is + permanent. +

+ +
+ ) +} diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index d4b3f210..e4e95521 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -44,6 +44,10 @@ export const config = { import.meta.env.VITE_ANALYTICS_ALLOWED_HOSTS as string | undefined, DEFAULT_ANALYTICS_ALLOWED_HOSTS, ), + // WALM-264: permanent V1 memory deletion UI. Off by default so nothing + // is user-visible until the feature is tested and rollout is agreed. + // Must be enabled together with the relayer's ENABLE_MEMORY_DELETION. + enableMemoryDeletion: (import.meta.env.VITE_ENABLE_MEMORY_DELETION as string || '') === 'true', demoUrls: (import.meta.env.VITE_DEMO_URLS as string || '') .split(',').map(s => s.trim()).filter(Boolean) .map(entry => { diff --git a/apps/app/src/index.css b/apps/app/src/index.css index 6cd13e1c..1f0e1dd1 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -11269,3 +11269,66 @@ h1, h2, h3 { height: 16px; } } + +/* ============================================================ + Delete old memories card (WALM-264) — mirrors the Delegate + keys card; order places it last in the dash-shell flex flow. + ============================================================ */ + +/* The install card zeroes its bottom margin (it used to be the last card + on the page), so the cleanup card supplies its own top gap to keep the + 56px card rhythm — and, as the new last card, zeroes its own bottom. */ +.dash-page .dashboard-cleanup-card { + order: 7; + margin-top: 56px; + margin-bottom: 0 !important; +} + +/* Header delete button matches the card-header pill spec (52px, radius 26, + px-30, 18px label — same as Refresh/Add key) in danger colors. */ +.dash-page .dashboard-cleanup-delete { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 52px; + height: 52px; + padding: 10px 30px; + border-radius: 26px; + font-size: 1.125rem; + letter-spacing: 0.72px; +} + +/* Text scale mirrors the final card-subtitle scale (0.82rem / 1.35). */ +.dash-page .dashboard-cleanup-warning { + background: rgba(232, 255, 117, 0.08); + border: 1px solid rgba(232, 255, 117, 0.35); + border-radius: var(--radius-md); + padding: 10px 14px; + margin: 0 0 14px; + color: #b8bbbe; + font-size: 0.82rem; + line-height: 1.35; +} + +.dash-page .dashboard-cleanup-warning strong { + color: #e8ff75; +} + +.dash-page .dashboard-cleanup-error { + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.2); + border-radius: var(--radius-md); + padding: 10px 14px; + margin: 0 0 14px; + color: var(--danger); + font-size: 0.82rem; + line-height: 1.35; + word-break: break-word; +} + +.dash-page .dashboard-cleanup-status { + margin: 0 0 14px; + color: #b8bbbe; + font-size: 0.82rem; + line-height: 1.35; +} diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index 191aa173..5064301c 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -23,6 +23,7 @@ SyntaxHighlighter.registerLanguage('javascript', js) SyntaxHighlighter.registerLanguage('python', python) import { useDelegateKey } from '../App' import { Card } from '../components/Card' +import CleanupSection from '../components/CleanupSection' import { SecretValueInput } from '../components/SecretValueInput' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' @@ -1344,6 +1345,8 @@ const result = await generateText({ + {config.enableMemoryDeletion && } + {removeKeysConfirm && (
+ +export interface OwnedWalrusBlob { + /** Sui object id — what `system::delete_blob` consumes */ + objectId: string + /** Walrus blob id, base64url (matches relayer DB `blob_id`) */ + blobId: string + /** Only deletable blobs can be deleted; the rest are permanent */ + deletable: boolean +} + +let walrusClient: WalrusClient | null = null + +export function getWalrusClient(suiClient: SuiClient): WalrusClient { + if (!walrusClient) { + walrusClient = new WalrusClient({ + network: config.suiNetwork, + suiClient: suiClient as never, + }) + } + return walrusClient +} + +/** + * blob_id on chain is a u256 decimal string; aggregators and the relayer DB + * use base64url of its little-endian bytes. Mirrors the sidecar's + * `blobIdFromRaw`. + */ +export function blobIdFromRaw(rawBlobId: string | number | null | undefined): string | null { + if (rawBlobId === null || rawBlobId === undefined) return null + const blobIdStr = String(rawBlobId) + if (!/^\d+$/.test(blobIdStr) || blobIdStr.length <= 20) return blobIdStr + try { + const hex = BigInt(blobIdStr).toString(16).padStart(64, '0') + const bytesBE = hex.match(/.{2}/g)!.map((b) => parseInt(b, 16)) + const bytesLE = new Uint8Array(bytesBE.reverse()) + let binary = '' + for (const b of bytesLE) binary += String.fromCharCode(b) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } catch { + return blobIdStr + } +} + +/** + * Enumerate every Walrus `blob::Blob` object the wallet owns. The Walrus + * package id comes from the WalrusClient (per network), so nothing is + * hardcoded. Paginates until exhaustion — a heavy V1 user can own tens of + * thousands of blobs, so callers should show progress while this runs. + */ +export async function listOwnedWalrusBlobs( + suiClient: SuiClient, + owner: string, + onProgress?: (count: number) => void, +): Promise { + const blobType = await getWalrusClient(suiClient).getBlobType() + + const blobs: OwnedWalrusBlob[] = [] + let cursor: string | null | undefined + let hasMore = true + + // gRPC exposes listOwnedObjects (server-side type filter + flat json), + // JSON-RPC exposes getOwnedObjects (StructType filter + nested fields) — + // same duck-typed branching as suiClientCompat. + const grpc = isGrpcClient(suiClient) + + while (hasMore) { + if (grpc) { + const page = await (suiClient as any).listOwnedObjects({ + owner, + type: blobType, + include: { json: true }, + cursor: cursor ?? undefined, + limit: 50, + }) + for (const obj of page?.objects ?? []) { + if (typeof obj?.objectId !== 'string') continue + const blobId = blobIdFromRaw(obj.json?.blob_id) + if (!blobId) continue + blobs.push({ + objectId: obj.objectId, + blobId, + deletable: obj.json?.deletable === true, + }) + } + hasMore = Boolean(page?.hasNextPage && page?.cursor) + cursor = page?.cursor + } else { + const page = await (suiClient as any).getOwnedObjects({ + owner, + filter: { StructType: blobType }, + options: { showContent: true }, + cursor: cursor ?? undefined, + limit: 50, + }) + for (const obj of page.data) { + const content = obj.data?.content + if (!obj.data?.objectId || !content || content.dataType !== 'moveObject') continue + const fields = content.fields as Record + const blobId = blobIdFromRaw(fields.blob_id as string) + if (!blobId) continue + blobs.push({ + objectId: obj.data.objectId, + blobId, + deletable: fields.deletable === true, + }) + } + hasMore = page.hasNextPage + cursor = page.nextCursor + } + + onProgress?.(blobs.length) + } + + return blobs +} diff --git a/docs/docs.json b/docs/docs.json index 32cc46d5..61569a63 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -56,7 +56,8 @@ "group": "Concepts", "pages": [ "fundamentals/concepts/memory-space", - "fundamentals/concepts/ownership-and-access" + "fundamentals/concepts/ownership-and-access", + "fundamentals/deleting-old-memories" ] }, { diff --git a/docs/fundamentals/deleting-old-memories.md b/docs/fundamentals/deleting-old-memories.md new file mode 100644 index 00000000..efef30e2 --- /dev/null +++ b/docs/fundamentals/deleting-old-memories.md @@ -0,0 +1,55 @@ +--- +title: "Deleting Old Memories" +--- + +Walrus Memory lets you permanently delete memories you no longer want. This +page explains what deletion does, what it can never undo, and how the flow +works. + +## ⚠️ Deletion is permanent + +Read this before deleting anything: + +- A deleted memory is **erased from Walrus storage and from your memory + index, forever**. It cannot be recovered, restored, or re-indexed — by + you, by the Walrus Memory team, or by anyone else. +- A deleted memory will **never be migrated** to any future version of + Walrus Memory. If you delete before a migration, that memory simply never + arrives on the other side. +- There is **no undo, no trash bin, and no grace period**. The moment the + transaction lands on-chain, the data is gone. + +Only delete memories you are certain you no longer want. + +## How it works + +Your memories live on Walrus as `Blob` objects owned by your wallet. The +delete flow: + +1. **Open the cleanup page** — from the "old memories" banner or the "Delete old + memories" section at the bottom of your dashboard. The app scans your wallet on-chain and lists every deletable + memory blob it owns. +2. **Choose what to delete** — everything is selected by default; untick + anything you want to keep. +3. **Confirm** — a dialog restates that deletion is permanent. +4. **Sign** — the app builds the delete transaction for you and sponsors the + gas, so signing is the only thing you do. Deletion runs in batches of + ~950 memories per transaction: a wallet with 20,000 memories signs about + 22 times, a wallet with a few hundred signs once. +5. **Done** — the server submits each signed transaction, waits for it to + land on-chain, and removes the matching rows from your memory index in + the same call. Reclaimed Walrus storage objects are returned to your + wallet. + +If the flow is interrupted partway (a rejected signature, a network drop), +nothing is lost or half-deleted: already-processed batches are fully deleted +on-chain and in the index, and re-opening the page picks up exactly the +remaining blobs. + +## Notes + +- Gas is sponsored — deleting costs you nothing. +- Only blobs marked `deletable` on-chain can be deleted; permanent blobs are + not shown. +- You need an active delegate-key session (the same one the dashboard and + playground use) so the server can verify the request is really yours. diff --git a/services/server/src/main.rs b/services/server/src/main.rs index cebd0c38..b60ba9f6 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -775,6 +775,10 @@ async fn main() { // admin/harness endpoints — namespace delete + stats. // Mode-blind; owner-scoped via AuthInfo. .route("/api/forget", post(routes::forget)) + // WALM-264: permanent V1 memory deletion (submit signed PTB + + // delete DB rows). Gated by ENABLE_MEMORY_DELETION (404 when off). + .route("/api/delete-memories", post(routes::delete_memories)) + .route("/api/memory-blob-ids", post(routes::memory_blob_ids)) .route("/api/stats", post(routes::stats)) // Router::layer runs middleware bottom-to-top (last added runs first). // Keep auth outer so AuthInfo is in request extensions before rate limiting reads it. diff --git a/services/server/src/routes/admin.rs b/services/server/src/routes/admin.rs index d5b55ac8..69521a31 100644 --- a/services/server/src/routes/admin.rs +++ b/services/server/src/routes/admin.rs @@ -74,6 +74,108 @@ pub async fn forget( })) } +/// Walrus blob IDs are base64url of a 32-byte id (43 chars, unpadded). +/// Accept a small range around that so we don't hard-fail on encoding +/// variants, while still rejecting garbage before it reaches the DB. +fn validate_blob_id(s: &str) -> bool { + (40..=48).contains(&s.len()) + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=') +} + +/// Upper bound on blob ids per call — one PTB holds ~950 deletes (Sui's +/// 1024-command cap minus headroom), so anything above that is a client bug. +const MAX_DELETE_BLOB_IDS: usize = 1000; + +/// POST /api/memory-blob-ids (WALM-264) +/// +/// Return every Walrus blob id indexed for the verified owner. The +/// delete-memories UI intersects the wallet's on-chain Blob objects with +/// this set so only real V1 memories are offered for deletion (a wallet +/// can also own unrelated or V2 blobs). Same flag gate as delete. +pub async fn memory_blob_ids( + State(state): State>, + Extension(auth): Extension, +) -> Result, AppError> { + if !state.config.enable_memory_deletion { + return Err(AppError::BlobNotFound("Not found".into())); + } + let blob_ids = state.db.list_blob_ids_by_owner(&auth.owner).await?; + Ok(Json(MemoryBlobIdsResponse { blob_ids })) +} + +/// POST /api/delete-memories (WALM-264) +/// +/// Permanently delete V1 memories: submit the user-signed sponsored +/// `system::delete_blob` PTB, and — only after it lands on-chain — delete +/// the matching `vector_entries` rows. The backend owns both side-effects +/// so chain and DB can't drift (a frontend that submits but never cleans +/// the DB, or vice versa). Owner comes from verified auth, never the body, +/// so a caller can only ever remove their own rows. If the row-delete step +/// fails after a successful submit, `cleanup_expired_blob` self-heals the +/// rows on the next recall. +/// +/// Gated by `ENABLE_MEMORY_DELETION` (off by default) until rollout is +/// agreed — the route answers 404 when disabled, indistinguishable from +/// the route not existing. +pub async fn delete_memories( + State(state): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, AppError> { + if !state.config.enable_memory_deletion { + return Err(AppError::BlobNotFound("Not found".into())); + } + + if !super::sponsor::validate_digest(&body.digest) { + return Err(AppError::BadRequest("Invalid digest".into())); + } + let sig_bytes = super::sponsor::decode_base64(&body.signature) + .ok_or_else(|| AppError::BadRequest("signature must be valid base64".into()))?; + if !super::sponsor::validate_sponsored_signature_len(sig_bytes.len()) { + return Err(AppError::BadRequest( + "signature has unexpected length".into(), + )); + } + if body.blob_ids.is_empty() { + return Err(AppError::BadRequest("blobIds cannot be empty".into())); + } + if body.blob_ids.len() > MAX_DELETE_BLOB_IDS { + return Err(AppError::BadRequest("too many blobIds".into())); + } + if !body.blob_ids.iter().all(|id| validate_blob_id(id)) { + return Err(AppError::BadRequest("Invalid blob id".into())); + } + + let owner = &auth.owner; + tracing::info!( + "delete-memories: owner={} blobs={} submitting signed PTB", + owner, + body.blob_ids.len() + ); + + // Submit first; abort the whole call if the transaction doesn't land — + // never delete rows for a delete that didn't happen on-chain. + super::sponsor::execute_sponsored_tx(&state, &body.digest, &body.signature).await?; + + let mut deleted_rows: u64 = 0; + for blob_id in &body.blob_ids { + deleted_rows += state.db.delete_by_blob_id(blob_id, owner).await?; + } + + tracing::info!( + "delete-memories complete: owner={} blobs={} deleted_rows={}", + owner, + body.blob_ids.len(), + deleted_rows + ); + + Ok(Json(DeleteMemoriesResponse { + digest: body.digest, + deleted_rows, + })) +} + /// POST /api/stats /// /// Return memory count + stored bytes for `owner`'s `namespace`. Used by @@ -690,6 +792,29 @@ pub async fn restore( mod tests { use crate::types::RecallResult; + // ── delete-memories blob id validation ─────────────────────── + + #[test] + fn blob_id_accepts_base64url_43_chars() { + // Canonical Walrus blob id: base64url of 32 bytes, unpadded. + assert!(super::validate_blob_id( + "J9fUNQpo-J6bl1WbyLtw-xX_KTteIa6hsN_XwVIALiM" + )); + } + + #[test] + fn blob_id_rejects_garbage() { + assert!(!super::validate_blob_id("")); // empty + assert!(!super::validate_blob_id("short")); // too short + assert!(!super::validate_blob_id(&"a".repeat(49))); // too long + assert!(!super::validate_blob_id( + "J9fUNQpo-J6bl1WbyLtw-xX_KTteIa6hsN_XwVIA%iM" // bad char + )); + assert!(!super::validate_blob_id( + "'; DROP TABLE vector_entries; -- padding padd" // injection shape + )); + } + // ── Memory context wraps in XML tags ───────────────────────── #[test] diff --git a/services/server/src/routes/mod.rs b/services/server/src/routes/mod.rs index 9cfc330d..6724426c 100644 --- a/services/server/src/routes/mod.rs +++ b/services/server/src/routes/mod.rs @@ -22,7 +22,9 @@ mod sponsor; // Re-export every handler so `main.rs` keeps using `routes::` // without having to know which submodule each handler lives in. -pub use admin::{ask, forget, get_config, health, restore, stats, version}; +pub use admin::{ + ask, delete_memories, forget, get_config, health, memory_blob_ids, restore, stats, version, +}; pub use analyze::analyze; pub use recall::{recall, recall_manual}; pub use remember::{ diff --git a/services/server/src/routes/remember.rs b/services/server/src/routes/remember.rs index 2a5477f1..67d314f0 100644 --- a/services/server/src/routes/remember.rs +++ b/services/server/src/routes/remember.rs @@ -1078,6 +1078,7 @@ mod tests { sponsor_rate_limit: crate::types::SponsorRateLimitConfig::default(), allowed_origins: String::new(), benchmark_mode: false, + enable_memory_deletion: false, } } diff --git a/services/server/src/routes/sponsor.rs b/services/server/src/routes/sponsor.rs index dc37eb85..f561c7ab 100644 --- a/services/server/src/routes/sponsor.rs +++ b/services/server/src/routes/sponsor.rs @@ -52,12 +52,12 @@ fn validate_sui_address(s: &str) -> bool { } /// Validate base64 and return decoded bytes, or None on failure. -fn decode_base64(s: &str) -> Option> { +pub(super) fn decode_base64(s: &str) -> Option> { base64::engine::general_purpose::STANDARD.decode(s).ok() } /// Validate a Sui transaction digest: base58 alphabet, 43 or 44 characters. -fn validate_digest(s: &str) -> bool { +pub(super) fn validate_digest(s: &str) -> bool { let len = s.len(); if len != 43 && len != 44 { return false; @@ -72,10 +72,83 @@ fn validate_digest(s: &str) -> bool { /// Sui transaction signatures are serialized as base64 bytes. Native schemes are /// 65/97 bytes, while zkLogin signatures are variable-size serialized payloads. -fn validate_sponsored_signature_len(len: usize) -> bool { +pub(super) fn validate_sponsored_signature_len(len: usize) -> bool { (65..=MAX_SPONSORED_SIGNATURE_BYTES).contains(&len) } +/// Forward a signed sponsored transaction to the sidecar's +/// `/sponsor/execute` and return the upstream status + body. Shared by the +/// `/sponsor/execute` proxy and `/api/delete-memories`. +async fn call_sidecar_sponsor_execute( + state: &AppState, + digest: &str, + signature: &str, +) -> Result<(reqwest::StatusCode, axum::body::Bytes), AppError> { + let forwarded = serde_json::json!({ + "digest": digest, + "signature": signature, + }); + + let url = format!("{}/sponsor/execute", state.config.sidecar_url); + let mut req = state + .http_client + .post(&url) + .header("Content-Type", "application/json") + .json(&forwarded); + if let Some(secret) = state.config.sidecar_secret.as_deref() { + req = req.header("authorization", format!("Bearer {}", secret)); + } + let req = crate::observability::apply_request_id_header(req); + let started = std::time::Instant::now(); + let resp = req.send().await.map_err(|e| { + crate::observability::observe_external( + "sidecar", + "sponsor_execute", + "transport_error", + started.elapsed(), + ); + crate::observability::record_sidecar_failure("sponsor_execute", "transport_error"); + AppError::Internal(format!("Sponsor execute proxy failed: {}", e)) + })?; + let status_label = resp.status().as_u16().to_string(); + crate::observability::observe_external( + "sidecar", + "sponsor_execute", + &status_label, + started.elapsed(), + ); + + let upstream_status = resp.status(); + let resp_body = resp + .bytes() + .await + .map_err(|e| AppError::Internal(format!("Sponsor execute proxy read failed: {}", e)))?; + Ok((upstream_status, resp_body)) +} + +/// Execute a user-signed sponsored transaction via the sidecar and wait for +/// the result. Used by `/api/delete-memories`, which must confirm on-chain +/// success before touching the DB. Returns `Ok(())` only on success. +pub(super) async fn execute_sponsored_tx( + state: &AppState, + digest: &str, + signature: &str, +) -> Result<(), AppError> { + let (upstream_status, resp_body) = call_sidecar_sponsor_execute(state, digest, signature).await?; + if upstream_status.is_success() { + Ok(()) + } else { + crate::observability::record_sidecar_failure("sponsor_execute", "http_error"); + tracing::error!( + "sponsored execute upstream error {}: {}", + upstream_status, + String::from_utf8_lossy(&resp_body) + ); + let (_, masked_msg) = mask_upstream(upstream_status.as_u16()); + Err(AppError::Internal(masked_msg.to_string())) + } +} + /// POST /sponsor — proxy to sidecar POST /sponsor pub async fn sponsor_proxy( State(state): State>, @@ -265,45 +338,8 @@ pub async fn sponsor_execute_proxy( } } - let forwarded = serde_json::json!({ - "digest": req.digest, - "signature": req.signature, - }); - - let url = format!("{}/sponsor/execute", state.config.sidecar_url); - let mut req = state - .http_client - .post(&url) - .header("Content-Type", "application/json") - .json(&forwarded); - if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let req = crate::observability::apply_request_id_header(req); - let started = std::time::Instant::now(); - let resp = req.send().await.map_err(|e| { - crate::observability::observe_external( - "sidecar", - "sponsor_execute", - "transport_error", - started.elapsed(), - ); - crate::observability::record_sidecar_failure("sponsor_execute", "transport_error"); - AppError::Internal(format!("Sponsor execute proxy failed: {}", e)) - })?; - let status_label = resp.status().as_u16().to_string(); - crate::observability::observe_external( - "sidecar", - "sponsor_execute", - &status_label, - started.elapsed(), - ); - - let upstream_status = resp.status(); - let resp_body = resp - .bytes() - .await - .map_err(|e| AppError::Internal(format!("Sponsor execute proxy read failed: {}", e)))?; + let (upstream_status, resp_body) = + call_sidecar_sponsor_execute(&state, &req.digest, &req.signature).await?; if upstream_status.is_success() { Ok(Response::builder() diff --git a/services/server/src/storage/db.rs b/services/server/src/storage/db.rs index 968539d5..adce5ef0 100644 --- a/services/server/src/storage/db.rs +++ b/services/server/src/storage/db.rs @@ -391,6 +391,27 @@ impl VectorDb { /// Delete a vector entry by blob_id (used for expired blob cleanup). /// Called reactively when Walrus returns 404 during blob download. /// Requires owner to prevent cross-user blob deletion. + /// All distinct blob ids indexed for `owner`, across every namespace. + /// WALM-264: lets the delete-memories UI scope the on-chain blob list to + /// actual V1 memories (the DB is the authoritative V1 list), so unrelated + /// or V2 blobs the wallet owns are never offered for deletion. + pub async fn list_blob_ids_by_owner(&self, owner: &str) -> Result, AppError> { + let started = std::time::Instant::now(); + let result: Result, AppError> = sqlx::query_as( + "SELECT DISTINCT blob_id FROM vector_entries WHERE owner = $1", + ) + .bind(owner) + .fetch_all(&self.pool) + .await + .map_err(|e| AppError::Internal(format!("Failed to list blob ids by owner: {}", e))); + crate::observability::observe_db( + "vector.list_blob_ids_by_owner", + db_status(&result), + started.elapsed(), + ); + Ok(result?.into_iter().map(|(blob_id,)| blob_id).collect()) + } + pub async fn delete_by_blob_id(&self, blob_id: &str, owner: &str) -> Result { let started = std::time::Instant::now(); let result = sqlx::query("DELETE FROM vector_entries WHERE blob_id = $1 AND owner = $2") diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 5e5e017b..e9556b16 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -242,6 +242,11 @@ pub struct Config { /// bypassing SEAL + Walrus. **Not for production.** Off by default; /// set `BENCHMARK_MODE=true` to enable. Surfaced via `GET /health`. pub benchmark_mode: bool, + /// WALM-264: gate for `POST /api/delete-memories` (permanent V1 memory + /// deletion). Off by default so nothing is user-reachable until the + /// feature is tested and a rollout is agreed; set + /// `ENABLE_MEMORY_DELETION=true` to enable. + pub enable_memory_deletion: bool, } impl Config { @@ -316,6 +321,7 @@ impl Config { benchmark_mode: std::env::var("BENCHMARK_MODE") .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) .unwrap_or(false), + enable_memory_deletion: env_bool("ENABLE_MEMORY_DELETION"), } } } @@ -876,6 +882,42 @@ pub struct ForgetResponse { pub owner: String, } +/// POST /api/delete-memories — permanently delete V1 memories (WALM-264). +/// +/// The frontend builds the `system::delete_blob` PTB over the user's own +/// Walrus Blob objects, gets it sponsored (`POST /sponsor`), and has the +/// user sign it. This endpoint then owns the whole side-effect: it submits +/// the signed transaction and, only after on-chain success, deletes the +/// matching `vector_entries` rows — so chain and DB can't drift apart on +/// a frontend that submits but never cleans up. +#[derive(Debug, Deserialize)] +pub struct DeleteMemoriesRequest { + /// Digest of the sponsored transaction (from `POST /sponsor`). + pub digest: String, + /// User signature over the sponsored transaction bytes (base64). + pub signature: String, + /// Walrus blob IDs (base64url) whose DB rows to delete after the + /// transaction lands. Must be the same blobs the PTB deletes. + #[serde(rename = "blobIds")] + pub blob_ids: Vec, +} + +/// POST /api/memory-blob-ids — the owner's indexed Walrus blob ids +/// (WALM-264). Used by the delete-memories UI to scope the on-chain blob +/// list to actual V1 memories. +#[derive(Debug, Serialize)] +pub struct MemoryBlobIdsResponse { + #[serde(rename = "blobIds")] + pub blob_ids: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DeleteMemoriesResponse { + pub digest: String, + #[serde(rename = "deletedRows")] + pub deleted_rows: u64, +} + /// POST /api/stats — count + stored bytes for a namespace. /// Used by the benchmark harness for verification. Mode-blind. #[derive(Debug, Deserialize)] From dcd15b6aa53cac03ca99531d75612496816c9277 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 19:20:34 +0700 Subject: [PATCH 02/11] fix(app): accept JSON-RPC's nested UID shape in registry table lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchAccountIdForOwner read the registry Table id as registryJson.accounts.id, which is a plain string on gRPC json but Move's nested UID shape ({ id: { id } }) on JSON-RPC even after field unwrapping. The JSON-RPC path then passed an object as getDynamicFieldObject's parentId and every account lookup failed with 'Invalid params' — SetupWizard treated real accounts as missing and tried create_account, which aborts on-chain with EAccountAlreadyExists. Regression from the PR #355 compat layer, hidden until now because testnet runs the gRPC path. --- apps/app/src/utils/suiClientCompat.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/app/src/utils/suiClientCompat.ts b/apps/app/src/utils/suiClientCompat.ts index 9166a669..5b119831 100644 --- a/apps/app/src/utils/suiClientCompat.ts +++ b/apps/app/src/utils/suiClientCompat.ts @@ -62,7 +62,12 @@ export async function fetchAccountIdForOwner( let tableId = registryTableIdCache.get(registryId) if (!tableId) { const registryJson = await fetchObjectJson(suiClient, registryId) - tableId = (registryJson?.accounts as { id?: string } | undefined)?.id + // gRPC json flattens the Table's UID to a plain string; JSON-RPC keeps + // Move's nested UID shape ({ id: { id: "0x…" } }) even after field + // unwrapping. Accept both, or the JSON-RPC path passes an object as + // parentId and every lookup dies with "Invalid params". + const rawId = (registryJson?.accounts as { id?: string | { id?: string } } | undefined)?.id + tableId = typeof rawId === 'string' ? rawId : rawId?.id if (!tableId) return null registryTableIdCache.set(registryId, tableId) } From e19c226fa0221355e263e93d4a26e8b8d9658074 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 19:25:59 +0700 Subject: [PATCH 03/11] fix(app): upgrade apiCall signing to the nonce-based auth scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils/api.ts still signed the legacy '{ts}.{method}.{path}.{bodySha}' message with no x-nonce header, so the relayer's auth middleware rejected every call with 426 (unsupported legacy SDK). Sign the current '{ts}.{method}.{path}.{bodySha}.{nonce}.{account_id}' message and send x-nonce, matching SDK v0.4+ — unblocks the delete-memories UI's calls to /api/memory-blob-ids and /api/delete-memories. --- apps/app/src/utils/api.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/app/src/utils/api.ts b/apps/app/src/utils/api.ts index d8bcbc41..ed0d1d3d 100644 --- a/apps/app/src/utils/api.ts +++ b/apps/app/src/utils/api.ts @@ -9,16 +9,21 @@ /** * Sign a request using Ed25519 delegate key. * - * Message format: "{timestamp}.{method}.{path}.{sha256(body)}" + * Message format (must match the relayer auth middleware and SDK v0.4+): + * "{timestamp}.{method}.{path}.{sha256(body)}.{nonce}.{account_id}" + * The nonce is a per-request UUID v4 (replay protection) — requests without + * an x-nonce header are rejected with 426 as unsupported legacy clients. */ export async function signRequest( privateKeyHex: string, method: string, path: string, body: string, + accountId = '', ) { const ed = await import('@noble/ed25519') const timestamp = Math.floor(Date.now() / 1000).toString() + const nonce = crypto.randomUUID() const bodyBytes = new TextEncoder().encode(body) const hashBuf = await crypto.subtle.digest('SHA-256', bodyBytes) @@ -26,7 +31,7 @@ export async function signRequest( .map((b) => b.toString(16).padStart(2, '0')) .join('') - const message = `${timestamp}.${method}.${path}.${bodySha}` + const message = `${timestamp}.${method}.${path}.${bodySha}.${nonce}.${accountId}` const msgBytes = new TextEncoder().encode(message) const privKey = Uint8Array.from( @@ -38,6 +43,7 @@ export async function signRequest( return { timestamp, + nonce, publicKey: Array.from(pubKey) .map((b) => b.toString(16).padStart(2, '0')) .join(''), @@ -61,11 +67,12 @@ export async function apiCall( accountId?: string, ) { const bodyStr = JSON.stringify(body) - const { timestamp, publicKey, signature } = await signRequest( + const { timestamp, nonce, publicKey, signature } = await signRequest( privateKeyHex, 'POST', path, bodyStr, + accountId ?? '', ) const headers: Record = { @@ -73,6 +80,7 @@ export async function apiCall( 'x-public-key': publicKey, 'x-signature': signature, 'x-timestamp': timestamp, + 'x-nonce': nonce, 'x-delegate-key': privateKeyHex, } if (accountId) { From f3e796520e7502909ba3b6dde80aca3f9b11318b Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 19:28:31 +0700 Subject: [PATCH 04/11] refactor(app): render the old-memories banner inside the Dashboard shell Move the banner from AppContent into the Dashboard page (below the navbar, above the header) so it shows where the cleanup section lives and inherits the dash-shell layout, with the standard alert icon. --- apps/app/src/App.tsx | 4 -- apps/app/src/components/OldMemoriesBanner.tsx | 38 ++++++++++--------- apps/app/src/index.css | 12 ++++++ apps/app/src/pages/Dashboard.tsx | 3 ++ 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 3b6830be..57ca2941 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -27,7 +27,6 @@ import Dashboard from './pages/Dashboard' import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' import ConnectMcp from './pages/ConnectMcp' -import OldMemoriesBanner from './components/OldMemoriesBanner' import { useRouteAnalytics } from './hooks/useRouteAnalytics' @@ -266,8 +265,6 @@ function AppContent() { } return ( - <> - : @@ -283,7 +280,6 @@ function AppContent() { } /> } /> - ) } diff --git a/apps/app/src/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx index 4978c26e..5040977e 100644 --- a/apps/app/src/components/OldMemoriesBanner.tsx +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -1,15 +1,17 @@ /** * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". * - * Shown once per connected wallet (dismissal persisted in localStorage) - * when the wallet owns deletable V1 Walrus blobs. Links to the Dashboard cleanup section. - * Mounted in AppContent; renders nothing while the feature flag is off, - * on the dashboard (the section is already visible there), or before the count is known. + * Rendered at the top of the Dashboard shell (below the navbar, above the + * page header) so it's the first thing a user sees; links down to the + * cleanup section on the same page. Shown once per browser (dismissal + * persisted in localStorage) when the wallet owns deletable V1 Walrus + * blobs; renders nothing while the feature flag is off or before the + * count is known. */ import { useEffect, useState } from 'react' -import { Link, useLocation } from 'react-router-dom' import { useCurrentAccount, useSuiClient } from '@mysten/dapp-kit' +import { TriangleAlert } from 'lucide-react' import { config } from '../config' import { listOwnedWalrusBlobs } from '../utils/walrusBlobs' @@ -18,7 +20,6 @@ const DISMISS_KEY = 'memwal_cleanup_banner_dismissed' export default function OldMemoriesBanner() { const currentAccount = useCurrentAccount() const suiClient = useSuiClient() - const location = useLocation() const address = currentAccount?.address || '' const [count, setCount] = useState(null) @@ -41,26 +42,29 @@ export default function OldMemoriesBanner() { } }, [address, suiClient, dismissed]) - if ( - !config.enableMemoryDeletion || - !address || - dismissed || - !count || - location.pathname === '/dashboard' - ) { + if (!config.enableMemoryDeletion || !address || dismissed || !count) { return null } return ( -
+
+
} @@ -234,7 +221,7 @@ export default function CleanupSection() {
Deleting is permanent: a deleted memory is erased from Walrus and from your memory index. It can never be recovered, and it will never be migrated - anywhere. Only delete memories you are sure you no longer want. + anywhere. Only delete your old memories if you are sure you no longer want them.
{error && ( @@ -263,87 +250,30 @@ export default function CleanupSection() {

)} - {phase.kind !== 'loading' && deletableBlobs.length === 0 ? ( - phase.kind !== 'done' && ( + {phase.kind === 'ready' && ( + !delegateKey ? ( + // Without the session the list can't be scoped to real + // memories, so a count here would be wrong — prompt for + // setup instead. +

+ A delegate key session is required to delete — set one up first. +

+ ) : count === 0 ? (
No deletable memories found for this wallet.
- ) - ) : phase.kind !== 'loading' && ( - <> + ) : (

- {deletableBlobs.length} deletable memor{deletableBlobs.length === 1 ? 'y' : 'ies'} found - {permanentCount > 0 ? ` (${permanentCount} non-deletable blobs not shown)` : ''}. - {' '}Deletion runs in batches of {DELETE_BATCH_SIZE} — one signature per batch. + You have {count} old memor{count === 1 ? 'y' : 'ies'} that can + be deleted + {permanentCount > 0 ? ` (${permanentCount} non-deletable blobs are not included)` : ''}. + {count > DELETE_BATCH_SIZE + ? ` Deletion runs in batches of ${DELETE_BATCH_SIZE} — you will sign ${Math.ceil(count / DELETE_BATCH_SIZE)} times.` + : count === 1 + ? ' Deleting it takes one signature.' + : ' Deleting them all takes one signature.'}

- -
- - - - - - - - - - {deletableBlobs.map((blob) => { - const isSelected = selected.has(blob.objectId) - return ( - - - - - - ) - })} - -
- - Blob IDObject ID
- - - - {blob.blobId.slice(0, 10)}...{blob.blobId.slice(-6)} - - - - {blob.objectId.slice(0, 8)}...{blob.objectId.slice(-6)} - -
-
- - {!delegateKey && ( -

- A delegate key session is required to delete — set one up first. -

- )} - + ) )} {confirming && ( @@ -362,10 +292,10 @@ export default function CleanupSection() { >

- Permanently delete {selectedCount} memor{selectedCount === 1 ? 'y' : 'ies'}? + Permanently delete all {count} old memor{count === 1 ? 'y' : 'ies'}?

- This erases the selected memories from Walrus and from your memory index, + This erases your old memories from Walrus and from your memory index, forever. They cannot be recovered, restored, or migrated afterwards — by anyone. There is no undo.

@@ -393,9 +323,3 @@ export default function CleanupSection() { ) } - -function uint8ToBase64(bytes: Uint8Array): string { - let binary = '' - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) - return btoa(binary) -} diff --git a/apps/app/src/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx index 5040977e..15c98885 100644 --- a/apps/app/src/components/OldMemoriesBanner.tsx +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -1,37 +1,58 @@ /** * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". * - * Rendered at the top of the Dashboard shell (below the navbar, above the - * page header) so it's the first thing a user sees; links down to the - * cleanup section on the same page. Shown once per browser (dismissal - * persisted in localStorage) when the wallet owns deletable V1 Walrus - * blobs; renders nothing while the feature flag is off or before the - * count is known. + * Mounted twice, exactly one instance visible at a time: + * - AppContent (per the T3 spec: once, inside `.app`) — every page EXCEPT + * the dashboard, where the mount site skips it; + * - Dashboard's shell — below the navbar, above the "Welcome" header + * (`.dash-alert--cleanup` order), linking down to the cleanup section. + * + * The count is the relayer-scoped deletable set (shared cached scan with + * CleanupSection — no duplicate chain walk), so it never inflates with V2 + * or unrelated blobs; without a delegate-key session the count can't be + * scoped, so the banner stays hidden. Dismissal persists per wallet in + * localStorage. "Delete them" navigates to /dashboard#cleanup + * (CleanupSection scrolls to the hash on mount) or smooth-scrolls when + * already on the dashboard. */ import { useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' import { useCurrentAccount, useSuiClient } from '@mysten/dapp-kit' import { TriangleAlert } from 'lucide-react' import { config } from '../config' -import { listOwnedWalrusBlobs } from '../utils/walrusBlobs' +import { useDelegateKey } from '../App' +import { listScopedDeletableBlobs } from '../utils/walrusBlobs' + +const DISMISS_KEY_PREFIX = 'memwal_cleanup_banner_dismissed' -const DISMISS_KEY = 'memwal_cleanup_banner_dismissed' +function isDismissed(address: string): boolean { + return ( + localStorage.getItem(`${DISMISS_KEY_PREFIX}:${address}`) === 'true' || + // Legacy global key from the first release of the banner. + localStorage.getItem(DISMISS_KEY_PREFIX) === 'true' + ) +} export default function OldMemoriesBanner() { const currentAccount = useCurrentAccount() const suiClient = useSuiClient() + const location = useLocation() + const { delegateKey, accountObjectId } = useDelegateKey() const address = currentAccount?.address || '' const [count, setCount] = useState(null) - const [dismissed, setDismissed] = useState( - () => localStorage.getItem(DISMISS_KEY) === 'true', - ) + const [dismissed, setDismissed] = useState(() => (address ? isDismissed(address) : false)) + + useEffect(() => { + setDismissed(address ? isDismissed(address) : false) + }, [address]) useEffect(() => { - if (!config.enableMemoryDeletion || !address || dismissed) return + if (!config.enableMemoryDeletion || !address || !delegateKey || dismissed) return let cancelled = false - listOwnedWalrusBlobs(suiClient, address) - .then((blobs) => { + listScopedDeletableBlobs(suiClient, address, { delegateKey, accountObjectId }) + .then(({ blobs }) => { if (!cancelled) setCount(blobs.filter((b) => b.deletable).length) }) .catch(() => { @@ -40,9 +61,9 @@ export default function OldMemoriesBanner() { return () => { cancelled = true } - }, [address, suiClient, dismissed]) + }, [address, suiClient, delegateKey, accountObjectId, dismissed]) - if (!config.enableMemoryDeletion || !address || dismissed || !count) { + if (!config.enableMemoryDeletion || !address || !delegateKey || dismissed || !count) { return null } @@ -51,22 +72,24 @@ export default function OldMemoriesBanner() {