diff --git a/.gitignore b/.gitignore
index d252afbc..27454300 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,8 @@ pnpm-debug.log*
# Environment variables
.env
.env*.local
+.env.local.*
+.env*.bak
.env.production
.env.test
*/.env.test
diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile
index 1d382967..99163f67 100644
--- a/apps/app/Dockerfile
+++ b/apps/app/Dockerfile
@@ -44,6 +44,9 @@ ARG VITE_POSTHOG_PROJECT_API_KEY
ARG VITE_POSTHOG_KEY
ARG VITE_POSTHOG_HOST
ARG VITE_POSTHOG_UI_HOST
+# WALM-264 — flag baked at build time; without this ARG the Railway
+# service variable can never reach the bundle and the delete UI stays off.
+ARG VITE_ENABLE_MEMORY_DELETION
ENV VITE_MEMWAL_SERVER_URL=$VITE_MEMWAL_SERVER_URL
ENV VITE_MEMWAL_PACKAGE_ID=$VITE_MEMWAL_PACKAGE_ID
@@ -61,6 +64,7 @@ ENV VITE_POSTHOG_PROJECT_API_KEY=$VITE_POSTHOG_PROJECT_API_KEY
ENV VITE_POSTHOG_KEY=$VITE_POSTHOG_KEY
ENV VITE_POSTHOG_HOST=$VITE_POSTHOG_HOST
ENV VITE_POSTHOG_UI_HOST=$VITE_POSTHOG_UI_HOST
+ENV VITE_ENABLE_MEMORY_DELETION=$VITE_ENABLE_MEMORY_DELETION
RUN pnpm --filter @memwal/app build
diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index 57ca2941..d0010f2d 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -19,9 +19,10 @@ import { isEnokiNetwork, registerEnokiWallets } from '@mysten/enoki'
import { getJsonRpcFullnodeUrl, SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
import { SuiGrpcClient } from '@mysten/sui/grpc'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { config } from './config'
+import OldMemoriesBanner from './components/OldMemoriesBanner'
import LandingPage from './pages/LandingPage'
import Dashboard from './pages/Dashboard'
import SetupWizard from './pages/SetupWizard'
@@ -257,6 +258,7 @@ function AppContent() {
const currentAccount = useCurrentAccount()
const autoConnectStatus = useAutoConnectWallet()
const { delegateKey } = useDelegateKey()
+ const { pathname } = useLocation()
const authPending = autoConnectStatus === 'idle'
const requireAccount = (element: React.ReactNode) => {
@@ -265,6 +267,10 @@ function AppContent() {
}
return (
+ <>
+ {/* WALM-264 T3 — one app-wide mount; the dashboard renders its own
+ instance below the navbar instead (see Dashboard.tsx). */}
+ {pathname.replace(/\/+$/, '') !== '/dashboard' && }
:
@@ -280,6 +286,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..243f8476
--- /dev/null
+++ b/apps/app/src/components/CleanupSection.tsx
@@ -0,0 +1,311 @@
+/**
+ * CleanupSection — permanent V1 memory deletion (WALM-264). Dashboard card
+ * (id="cleanup"; the old-memories banner links here).
+ *
+ * One button deletes ALL deletable blobs — no per-blob picking, and no
+ * delegate-key session (plan T1: the wallet signature over the sponsored
+ * transaction IS the ownership proof). The user only signs: the app builds
+ * each PTB (batched), sponsors it via POST /sponsor (Enoki), then hands
+ * {digest, signature, blobIds} to POST /api/delete-memories — the backend
+ * submits on-chain, verifies success and the sender, and deletes that
+ * sender's matching DB rows in the same call, so chain and index never
+ * drift.
+ *
+ * Deletion is permanent: never recoverable, never migrated. The warning
+ * and confirm dialog say so in plain words — per WALM-264 that messaging
+ * is the one thing that must be right.
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+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 { sponsorTransactionKind } from '../utils/sponsor'
+import { Card } from './Card'
+import {
+ getWalrusClient,
+ invalidateWalrusBlobScan,
+ listScopedDeletableBlobs,
+ type OwnedWalrusBlob,
+} from '../utils/walrusBlobs'
+import { getAnalyticsErrorType, trackEvent } from '../utils/analytics'
+
+/**
+ * The binding limit is NOT Sui's 1024-command PTB cap but the relayer's
+ * /sponsor gates: 7000 bytes of TransactionKind (sponsor.rs) under a 10 KiB
+ * JSON body (main.rs). Each delete costs ~135 bytes of kind BCS, so ~50 is
+ * the ceiling — 40 leaves headroom. (To test the multi-batch flow with a
+ * small wallet, temporarily set this to 1.)
+ */
+const DELETE_BATCH_SIZE = 40
+
+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 address = currentAccount?.address || ''
+
+ const [blobs, setBlobs] = useState([])
+ 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 (force = false) => {
+ if (!address) return
+ setPhase({ kind: 'loading' })
+ setScanned(0)
+ setError('')
+ try {
+ // Scoped to the relayer DB's authoritative V1 memory list, so
+ // unrelated/V2 blobs the wallet owns are never deleted from here.
+ const scoped = await listScopedDeletableBlobs(suiClient, address, {
+ onProgress: setScanned,
+ force,
+ })
+ setBlobs(scoped)
+ setPhase({ kind: 'ready' })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'failed to list blobs')
+ setPhase({ kind: 'ready' })
+ }
+ }, [address, suiClient])
+
+ 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 deleteInFlight = useRef(false)
+
+ const executeDelete = useCallback(async () => {
+ if (!address || deletableBlobs.length === 0) return
+ // Double-click guard: a second concurrent run would sponsor the same
+ // objects twice and paint a spurious error over a successful delete.
+ if (deleteInFlight.current) return
+ deleteInFlight.current = true
+ setConfirming(false)
+ setError('')
+
+ trackEvent('memory_delete_start', { blobs: deletableBlobs.length })
+
+ const batches: OwnedWalrusBlob[][] = []
+ for (let i = 0; i < deletableBlobs.length; i += DELETE_BATCH_SIZE) {
+ batches.push(deletableBlobs.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 })
+
+ // Each delete frees a Storage object; return them all 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 sponsored = await sponsorTransactionKind(kindBytes, address)
+
+ const { signature } = await signTransaction({
+ transaction: Transaction.from(sponsored.bytes),
+ })
+
+ // The backend submits, verifies on-chain success + sender,
+ // and deletes that sender's DB rows together. The wallet
+ // signature is the ownership proof — no session needed.
+ const deleteRes = await fetch(`${config.memwalServerUrl}/api/delete-memories`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ digest: sponsored.digest,
+ signature,
+ blobIds: batch.map((b) => b.blobId),
+ }),
+ })
+ if (!deleteRes.ok) {
+ throw new Error(`Delete failed (${deleteRes.status}): ${await deleteRes.text()}`)
+ }
+ const result = await deleteRes.json()
+
+ deletedBlobs += batch.length
+ deletedRows += result.deletedRows ?? 0
+ // Drop 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)))
+ }
+ setPhase({ kind: 'done', deletedBlobs, deletedRows })
+ trackEvent('memory_delete_complete', { blobs: deletedBlobs })
+ invalidateWalrusBlobScan(address)
+ } catch (err) {
+ // A failure can land AFTER the on-chain delete succeeded (e.g.
+ // the row-delete call dropped) — re-scan so retry only sees
+ // blobs that still exist, instead of rebuilding a PTB over
+ // deleted objects and failing forever. Error is set after the
+ // reload (loadBlobs clears it on start).
+ trackEvent('memory_delete_failed', { error_type: getAnalyticsErrorType(err) })
+ invalidateWalrusBlobScan(address)
+ await loadBlobs(true)
+ setError(
+ `${err instanceof Error ? err.message : 'delete failed'} — the list was refreshed; retrying deletes only what remains.`,
+ )
+ } finally {
+ deleteInFlight.current = false
+ }
+ }, [address, deletableBlobs, suiClient, signTransaction, loadBlobs])
+
+ const count = deletableBlobs.length
+ const busy = phase.kind === 'deleting' || phase.kind === 'loading'
+
+ 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 your old memories if you are sure you no longer want them.
+
+
+ {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.
+
+ 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.'}
+
+ Permanently delete all {count} old memor{count === 1 ? 'y' : 'ies'}?
+
+
+ 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.
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/app/src/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx
new file mode 100644
index 00000000..94ce676e
--- /dev/null
+++ b/apps/app/src/components/OldMemoriesBanner.tsx
@@ -0,0 +1,98 @@
+/**
+ * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them".
+ *
+ * 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. No delegate-key session needed (plan T1). 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 { listScopedDeletableBlobs } from '../utils/walrusBlobs'
+
+const DISMISS_KEY_PREFIX = '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 address = currentAccount?.address || ''
+
+ const [count, setCount] = useState(null)
+ const [dismissed, setDismissed] = useState(() => (address ? isDismissed(address) : false))
+
+ useEffect(() => {
+ setDismissed(address ? isDismissed(address) : false)
+ }, [address])
+
+ useEffect(() => {
+ if (!config.enableMemoryDeletion || !address || dismissed) return
+ let cancelled = false
+ listScopedDeletableBlobs(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) {
+ return null
+ }
+
+ return (
+
+
+
+ You have {count} old memor{count === 1 ? 'y' : 'ies'} stored on Walrus.{' '}
+ {
+ if (location.pathname.replace(/\/+$/, '') === '/dashboard') {
+ event.preventDefault()
+ document.getElementById('cleanup')?.scrollIntoView({ behavior: 'smooth' })
+ }
+ }}
+ >
+ 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/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts
index cdf88372..a50d820b 100644
--- a/apps/app/src/hooks/useSponsoredTransaction.ts
+++ b/apps/app/src/hooks/useSponsoredTransaction.ts
@@ -16,6 +16,7 @@
import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit'
import { Transaction } from '@mysten/sui/transactions'
import { config } from '../config'
+import { sponsorTransactionKind } from '../utils/sponsor'
import { executeTransactionCompat } from '../utils/suiClientCompat'
export function useSponsoredTransaction() {
@@ -47,25 +48,9 @@ export function useSponsoredTransaction() {
client: suiClient,
onlyTransactionKind: true,
})
- const kindBase64 = uint8ArrayToBase64(kindBytes)
// 2. Sponsor via server (proxied to sidecar)
- const sponsorRes = await fetch(`${config.memwalServerUrl}/sponsor`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- transactionBlockKindBytes: kindBase64,
- sender,
- }),
- })
-
- if (!sponsorRes.ok) {
- const errText = await sponsorRes.text()
- throw new Error(`Sponsor failed (${sponsorRes.status}): ${errText}`)
- }
-
- const sponsored = await sponsorRes.json()
- // sponsored = { bytes: base64, digest: string }
+ const sponsored = await sponsorTransactionKind(kindBytes, sender)
// 3. Sign sponsored bytes with user wallet
const sponsoredTx = Transaction.from(sponsored.bytes)
@@ -105,12 +90,3 @@ export function useSponsoredTransaction() {
return { mutateAsync }
}
-
-// Helper: Uint8Array → base64
-function uint8ArrayToBase64(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/index.css b/apps/app/src/index.css
index 6cd13e1c..0aa21bdd 100644
--- a/apps/app/src/index.css
+++ b/apps/app/src/index.css
@@ -11269,3 +11269,87 @@ 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;
+}
+
+/* Old-memories banner — first thing in the shell: below the navbar,
+ above the "Welcome" header (all other dash-shell orders start at 0). */
+.dash-page .dash-alert--cleanup {
+ order: -1;
+ margin-bottom: 40px !important;
+}
+
+/* App-wide mount (T3: once in AppContent, direct child of .app) — pages
+ render their own navbars below it, so keep the page column width and a
+ slim gap instead of the in-shell 48px alert margin. */
+.app > .dash-alert--cleanup {
+ width: min(1240px, calc(100% - 48px));
+ margin: 16px auto;
+ margin-bottom: 16px !important; /* base .dash-alert forces 48px */
+}
+
+.dash-alert--cleanup .dash-alert-dismiss {
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+/* 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..6031d971 100644
--- a/apps/app/src/pages/Dashboard.tsx
+++ b/apps/app/src/pages/Dashboard.tsx
@@ -23,6 +23,8 @@ SyntaxHighlighter.registerLanguage('javascript', js)
SyntaxHighlighter.registerLanguage('python', python)
import { useDelegateKey } from '../App'
import { Card } from '../components/Card'
+import CleanupSection from '../components/CleanupSection'
+import OldMemoriesBanner from '../components/OldMemoriesBanner'
import { SecretValueInput } from '../components/SecretValueInput'
import { config } from '../config'
import { getAnalyticsErrorType, trackEvent } from '../utils/analytics'
@@ -747,6 +749,8 @@ const result = await generateText({
+
+
{/* Header */}
Welcome to your Dashboard
@@ -1344,6 +1348,8 @@ const result = await generateText({