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. +

+ )} + + {phase.kind === 'ready' && ( + count === 0 ? ( +
+ No deletable memories found for this wallet. +
+ ) : ( +

+ 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.'} +

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

+ 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 ( +
+
+ ) +} 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({
+ {config.enableMemoryDeletion && } + {removeKeysConfirm && (
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) { diff --git a/apps/app/src/utils/sponsor.ts b/apps/app/src/utils/sponsor.ts new file mode 100644 index 00000000..a42a644d --- /dev/null +++ b/apps/app/src/utils/sponsor.ts @@ -0,0 +1,40 @@ +/** + * Enoki sponsorship — the shared "first half" of the sponsored-tx flow: + * TransactionKind bytes → POST /sponsor → sponsored full-tx bytes + digest. + * Used by useSponsoredTransaction (which then executes via /sponsor/execute) + * and by the delete-memories flow (which hands the signature to + * /api/delete-memories instead — the backend owns submit + DB cleanup). + */ + +import { config } from '../config' + +export interface SponsoredTx { + /** base64 full transaction bytes to sign */ + bytes: string + /** digest the sidecar uses to execute the reservation */ + digest: string +} + +export async function sponsorTransactionKind( + kindBytes: Uint8Array, + sender: string, +): Promise { + const res = await fetch(`${config.memwalServerUrl}/sponsor`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transactionBlockKindBytes: uint8ToBase64(kindBytes), + sender, + }), + }) + if (!res.ok) { + throw new Error(`Sponsor failed (${res.status}): ${await res.text()}`) + } + return res.json() +} + +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/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) } diff --git a/apps/app/src/utils/walrusBlobs.ts b/apps/app/src/utils/walrusBlobs.ts new file mode 100644 index 00000000..d51a4e67 --- /dev/null +++ b/apps/app/src/utils/walrusBlobs.ts @@ -0,0 +1,235 @@ +/** + * Walrus blob enumeration for the V1 memory-deletion flow (WALM-264). + * + * Layers, bottom to top: + * - listOwnedWalrusBlobs — paginate the wallet's on-chain `blob::Blob` + * objects (gRPC or JSON-RPC client), converting the raw u256 `blob_id` + * to the base64url form the relayer DB uses. A blob only counts as + * deletable when its on-chain flag is set AND its storage hasn't + * expired (expired blobs would poison a whole delete PTB, and their + * data is already gone). + * - listScopedDeletableBlobs — the same list intersected with the relayer + * DB's authoritative memory set (POST /api/memory-blob-ids, public + + * rate-limited; no delegate-key session per plan T1), so unrelated or + * V2 blobs the wallet owns are never offered for deletion. Results are + * cached per wallet (5 min) and in-flight calls are shared, so the + * banner and the cleanup card don't each rescan the chain. + */ + +import type { useSuiClient } from '@mysten/dapp-kit' +import { WalrusClient } from '@mysten/walrus' +import { config } from '../config' +import { isGrpcClient } from './suiClientCompat' + +type SuiClient = ReturnType + +export interface OwnedWalrusBlob { + /** Sui object id — what `system::delete_blob` consumes */ + objectId: string + /** Walrus blob id, base64url (matches relayer DB `blob_id`) */ + blobId: string + /** On-chain deletable flag AND storage not expired */ + deletable: boolean +} + +const walrusClients = new WeakMap() + +export function getWalrusClient(suiClient: SuiClient): WalrusClient { + let client = walrusClients.get(suiClient as object) + if (!client) { + client = new WalrusClient({ + network: config.suiNetwork, + suiClient: suiClient as never, + }) + walrusClients.set(suiClient as object, client) + } + return client +} + +/** + * 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 + } +} + +/** + * Whether a blob can actually be deleted: the on-chain flag is set and the + * storage hasn't lapsed. `walrus::blob::delete` is only valid for live + * storage, so one expired blob would abort an entire delete batch — and an + * expired blob's data is gone anyway. Unparsable end_epoch (shape drift) + * fails open so the feature doesn't brick; the sponsor dry-run remains the + * backstop. + */ +function isDeletable(deletableFlag: unknown, endEpoch: unknown, currentEpoch: number): boolean { + if (deletableFlag !== true) return false + const end = Number(endEpoch) + return Number.isFinite(end) ? end > currentEpoch : true +} + +/** + * Enumerate every Walrus `blob::Blob` object the wallet owns. The Walrus + * package id and current epoch come 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. + */ +export async function listOwnedWalrusBlobs( + suiClient: SuiClient, + owner: string, + onProgress?: (count: number) => void, +): Promise { + const walrusClient = getWalrusClient(suiClient) + const [blobType, systemState] = await Promise.all([ + walrusClient.getBlobType(), + walrusClient.systemState(), + ]) + const currentEpoch = systemState.committee.epoch + + 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: isDeletable( + obj.json?.deletable, + obj.json?.storage?.end_epoch, + currentEpoch, + ), + }) + } + 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: isDeletable( + fields.deletable, + fields.storage?.fields?.end_epoch, + currentEpoch, + ), + }) + } + hasMore = page.hasNextPage + cursor = page.nextCursor + } + + onProgress?.(blobs.length) + } + + return blobs +} + +// ── Scoped + cached layer ────────────────────────────────────────────── + +interface CachedScan { + at: number + promise: Promise +} + +const SCAN_TTL_MS = 5 * 60 * 1000 +const scanCache = new Map() + +/** Drop cached scans for a wallet (call after a delete changes the chain). */ +export function invalidateWalrusBlobScan(owner?: string): void { + if (!owner) { + scanCache.clear() + return + } + scanCache.delete(owner) +} + +export interface ScopedBlobsOptions { + onProgress?: (count: number) => void + /** Bypass the cache (user pressed Refresh / post-delete reload). */ + force?: boolean +} + +/** + * The wallet's on-chain blobs intersected with the relayer DB's memory set + * (public endpoint — ownership proof happens at delete time via the wallet + * signature, not here). Cached per wallet so simultaneous callers + * (banner + card) share one chain scan. + */ +export async function listScopedDeletableBlobs( + suiClient: SuiClient, + owner: string, + { onProgress, force }: ScopedBlobsOptions = {}, +): Promise { + const cached = scanCache.get(owner) + if (!force && cached && Date.now() - cached.at < SCAN_TTL_MS) { + return cached.promise + } + + const promise = (async () => { + const [owned, res] = await Promise.all([ + listOwnedWalrusBlobs(suiClient, owner, onProgress), + fetch(`${config.memwalServerUrl}/api/memory-blob-ids`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ owner }), + }).then(async (r) => { + if (!r.ok) throw new Error(`memory-blob-ids failed (${r.status})`) + return r.json() + }), + ]) + const known = new Set(res.blobIds ?? []) + return owned.filter((b) => known.has(b.blobId)) + })() + scanCache.set(owner, { at: Date.now(), promise }) + + try { + return await promise + } catch (err) { + // Don't cache failures. + if (scanCache.get(owner)?.promise === promise) scanCache.delete(owner) + throw err + } +} 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..9b1334a5 --- /dev/null +++ b/docs/fundamentals/deleting-old-memories.md @@ -0,0 +1,41 @@ +--- +title: "Deleting Old Memories" +description: "Permanently delete old memories from Walrus Memory: what deletion does, what it can never undo, and how the delete flow works." +keywords: + - delete memories + - deletion + - Walrus blob + - cleanup + - permanent deletion +--- + +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**. Nobody can recover, restore, or re-index it afterwards: not you, not the Walrus Memory team, not anyone else. +- A deleted memory is **never migrated** to any future version of Walrus Memory. If you delete before a migration, that memory never arrives on the other side. +- There is **no undo, no trash bin, and no grace period**. The moment the transaction lands onchain, 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 section** from the `old memories` banner or the `Delete old memories` section at the bottom of your dashboard. The app scans your wallet onchain and counts every deletable memory blob it owns. The scan skips expired blobs because their storage is already gone. +2. **Delete all**. One button deletes all of your old memories together. There is no per-memory picking. +3. **Confirm**. A dialog restates that deletion is permanent. +4. **Sign**. The app builds each delete transaction for you and sponsors the gas, so signing is the only thing you do. Deletion runs in batches of about 40 memories per transaction: a wallet with a few dozen memories signs once or twice, and a wallet with hundreds signs a handful of times. +5. **Done**. The server submits each signed transaction, confirms it succeeded onchain, and removes the matching rows from your memory index in the same call. You receive the reclaimed Walrus storage objects in your wallet. + +If the flow stops partway (a rejected signature, a network drop), nothing is lost or half-deleted: batches that already ran stay deleted onchain and in the index, and the section picks up exactly the remaining blobs after a refresh. + +## Notes + +- Walrus Memory sponsors the gas, so deleting costs you nothing. +- Only blobs marked `deletable` onchain with unexpired storage can be deleted. The app hides the rest. +- Sponsored transactions have a per-wallet rate limit, so deleting many thousands of memories can take more than one session. +- Your wallet signature is the proof of ownership: the server reads the signer of the executed transaction onchain and only removes that wallet's index rows. You do not need a delegate-key session to delete. diff --git a/services/server/scripts/__tests__/walrus-wal-destroy-zero.test.ts b/services/server/scripts/__tests__/walrus-wal-destroy-zero.test.ts new file mode 100644 index 00000000..94f6e76b --- /dev/null +++ b/services/server/scripts/__tests__/walrus-wal-destroy-zero.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isMoveAbortBalanceSplit, isMoveAbortWalDestroyZero } from "../sidecar/enoki.js"; + +// Production format reference (issue #351): the Walrus register PTB pre-funds an +// exact WAL payment from the client's cached storage price, then asserts the +// coin is empty via `0x2::coin::destroy_zero`. When the on-chain price drops +// between the cached read and execution, the contract deducts less WAL and the +// leftover trips `destroy_zero` with ENonZero (abort code 0). Enoki surfaces it +// during its budget dry-run as a 400 dry_run_failed. + +const PROD_DESTROY_ZERO_ERROR = + 'Enoki API error (400): {"errors":[{"code":"dry_run_failed","message":"Dry run failed, ' + + "could not automatically determine a budget: MoveAbort(MoveLocation { module: ModuleId { " + + "address: 0000000000000000000000000000000000000000000000000000000000000002, name: " + + 'Identifier(\\"balance\\") }, function: 9, instruction: 8, function_name: ' + + 'Some(\\"destroy_zero\\") }, 0) in command 2"}]}'; + +test("matches the verbatim prod destroy_zero abort", () => { + assert.equal(isMoveAbortWalDestroyZero(PROD_DESTROY_ZERO_ERROR), true); +}); + +test("matches the compact MoveLocation shape", () => { + assert.equal( + isMoveAbortWalDestroyZero( + "MoveAbort(MoveLocation { module: coin, function_name: Some(\"destroy_zero\") }, 0) in command 9", + ), + true, + ); +}); + +test("is case-insensitive on both anchors", () => { + assert.equal(isMoveAbortWalDestroyZero("moveabort ... destroy_zero"), true); +}); + +test("bare destroy_zero without MoveAbort context is rejected", () => { + // Guards against unrelated log lines that merely mention the function. + assert.equal(isMoveAbortWalDestroyZero("calling coin::destroy_zero"), false); +}); + +test("balance::split abort does not match this detector", () => { + // The stale-price destroy_zero path must stay disjoint from the gas-budget + // balance::split path so the two handlers never double-fire. + const balanceSplit = + "MoveAbort(MoveLocation { module: balance, function_name: Some(\"split\") }, 2) in command 1"; + assert.equal(isMoveAbortWalDestroyZero(balanceSplit), false); + assert.equal(isMoveAbortBalanceSplit(balanceSplit), true); +}); + +test("the destroy_zero abort is NOT matched by the balance-split detector", () => { + // The prod message contains the `balance` module name but no `split`, so the + // existing isMoveAbortBalanceSplit stays false — this is exactly the gap the + // new detector closes. + assert.equal(isMoveAbortBalanceSplit(PROD_DESTROY_ZERO_ERROR), false); +}); + +test("unrelated errors do not match", () => { + assert.equal(isMoveAbortWalDestroyZero("connection refused"), false); + assert.equal(isMoveAbortWalDestroyZero("HTTP 500 from upload relay"), false); + assert.equal(isMoveAbortWalDestroyZero(""), false); +}); diff --git a/services/server/scripts/sidecar/config.ts b/services/server/scripts/sidecar/config.ts index e4577ba8..2005e559 100644 --- a/services/server/scripts/sidecar/config.ts +++ b/services/server/scripts/sidecar/config.ts @@ -30,7 +30,11 @@ export function parsePositiveIntEnv( // ============================================================ export const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; -export const SUI_RPC_URL = getJsonRpcFullnodeUrl(SUI_NETWORK); +// Honor an explicit SUI_RPC_URL override (e.g. a dedicated / premium RPC) so we +// can move off the public fullnode when its pool degrades — the public endpoint +// has served stale reads (a certified blob read back as "does not exist"), +// which fails uploads at get_blob / certify. Falls back to the network default. +export const SUI_RPC_URL = process.env.SUI_RPC_URL?.trim() || getJsonRpcFullnodeUrl(SUI_NETWORK); // gRPC base URL for the core/upload path. Sui sunsets JSON-RPC on 2026-07-31 in // favour of gRPC + GraphQL, so the write path must move off JSON-RPC. When set, @@ -133,9 +137,16 @@ export function clampWalrusEpochs(rawEpochs: unknown): number { return Math.min(Math.floor(parsed), MAX_WALRUS_EPOCHS); } +// The cached WalrusClient snapshots `systemState` (storage/write price, +// committee) at creation and reuses it for its whole lifetime. The register +// PTB pre-funds an *exact* WAL payment from that cached price, so when mainnet +// price drifts the split leaves change and `coin::destroy_zero` aborts. Keep +// the client young so the cached price tracks the live price closely; the +// register-path `destroy_zero` handler also recreates it on demand. Was 30 min, +// which let the cached price fall far behind a live-drifting mainnet price. export const WALRUS_CLIENT_MAX_AGE_MS = (() => { const parsed = Number.parseInt(process.env.WALRUS_CLIENT_MAX_AGE_MS || "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 30 * 60 * 1000; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 60 * 1000; })(); // Mirror of services/server/src/alerts.rs SIDECAR_WALRUS_DEP_VERSION. diff --git a/services/server/scripts/sidecar/enoki.ts b/services/server/scripts/sidecar/enoki.ts index d27ad42d..17e73247 100644 --- a/services/server/scripts/sidecar/enoki.ts +++ b/services/server/scripts/sidecar/enoki.ts @@ -66,6 +66,22 @@ export function isMoveAbortBalanceSplit(message: string): boolean { return /moveabort/i.test(message) && /balance.*split|split.*balance/i.test(message); } +/** + * Detect the `0x2::coin::destroy_zero` abort (ENonZero, abort code 0) that the + * Walrus register PTB raises when the WAL payment coin still holds a non-zero + * remainder. The `@mysten/walrus` `#withWal` helper pre-funds an *exact* WAL + * amount (`storageUnits × price × epochs`) computed from the client's cached + * `systemState`, then asserts the coin is empty via `destroy_zero`. When the + * on-chain storage/write price drops between the cached read and execution, the + * contract deducts less WAL than we split off, leaving change that trips + * `destroy_zero`. It is not input-specific — refreshing the Walrus client so the + * next attempt re-reads the live price clears it, so callers treat it as + * transient rather than a permanent MoveAbort. + */ +export function isMoveAbortWalDestroyZero(message: string): boolean { + return /moveabort/i.test(message) && /destroy_zero/i.test(message); +} + export async function callEnoki(path: string, payload: unknown): Promise { if (!ENOKI_API_KEY) { throw new Error("ENOKI_API_KEY is not configured"); diff --git a/services/server/scripts/sidecar/routes/sponsor.ts b/services/server/scripts/sidecar/routes/sponsor.ts index 4688c6e2..9cf51b20 100644 --- a/services/server/scripts/sidecar/routes/sponsor.ts +++ b/services/server/scripts/sidecar/routes/sponsor.ts @@ -6,11 +6,51 @@ */ import express, { type Express } from "express"; +import { suiClient } from "../clients.js"; import { ENOKI_API_KEY, ENOKI_NETWORK, JSON_LIMIT_METADATA } from "../config.js"; import { callEnoki, type EnokiExecuteResponse, type EnokiSponsorResponse } from "../enoki.js"; import { requestIdFor, sidecarLog } from "../log.js"; import { errorMessage } from "../util.js"; +/** + * WALM-264: confirm an executed sponsored transaction actually SUCCEEDED + * on-chain and report its sender. Enoki's execute endpoint returns 200 once + * the transaction is submitted — effects status is not part of that + * contract — so callers that key irreversible side-effects off "execute + * returned 200" (the delete-memories DB row delete) must verify effects + * themselves. Uses the core getTransaction API, which has the same shape on + * the gRPC and JSON-RPC clients. + */ +async function verifyExecutedTransaction( + digest: string, +): Promise<{ success: boolean; sender: string | null; error: string | null }> { + let lastError: unknown; + for (let attempt = 0; attempt < 10; attempt++) { + try { + const result: any = await (suiClient as any).core.getTransaction({ + digest, + include: { transaction: true }, + }); + const tx = result?.Transaction ?? result?.FailedTransaction; + if (tx) { + const success = tx.status?.success === true; + return { + success, + sender: tx.transaction?.sender ?? null, + error: success ? null : errorMessage(tx.status?.error ?? "transaction failed"), + }; + } + } catch (err) { + // Not indexed yet (or transient) — retry below. + lastError = err; + } + await new Promise((resolve) => setTimeout(resolve, 1_000 + attempt * 500)); + } + throw new Error( + `executed transaction not found on-chain after retries: ${errorMessage(lastError)}`, + ); +} + export function registerSponsorRoutes(app: Express): void { app.post("/sponsor", express.json({ limit: JSON_LIMIT_METADATA }), async (req, res) => { try { @@ -47,7 +87,7 @@ export function registerSponsorRoutes(app: Express): void { app.post("/sponsor/execute", express.json({ limit: JSON_LIMIT_METADATA }), async (req, res) => { try { - const { digest, signature } = req.body; + const { digest, signature, verifyEffects } = req.body; if (!digest || !signature) { return res.status(400).json({ error: "Missing required fields: digest, signature" }); } @@ -71,6 +111,24 @@ export function registerSponsorRoutes(app: Express): void { // value that ties log lines to individual user transactions. Log only // a length indicator for diagnostics. console.log(`[sponsor/execute] executed sponsored tx (digest_len=${digest.length})`); + + // WALM-264: delete-memories keys an irreversible DB delete off this + // response, so it asks us to confirm effects success + sender. The + // plain proxy path skips this (no behavior change). + if (verifyEffects === true) { + const verdict = await verifyExecutedTransaction(executed.digest ?? digest); + if (!verdict.success) { + sidecarLog("error", "sponsor_execute_effects_failed", { + requestId: requestIdFor(req), + error: verdict.error, + }); + return res.status(422).json({ + error: `sponsored transaction did not succeed on-chain: ${verdict.error ?? "unknown"}`, + }); + } + return res.json({ digest: executed.digest, sender: verdict.sender }); + } + res.json(executed); // { digest } } catch (err: any) { const traceId = requestIdFor(req); diff --git a/services/server/scripts/sidecar/routes/walrus-upload.ts b/services/server/scripts/sidecar/routes/walrus-upload.ts index 0c03be97..837dae77 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload.ts @@ -45,7 +45,7 @@ import { sleep, truncateForLog, } from "../util.js"; -import { isMoveAbortBalanceSplit } from "../enoki.js"; +import { isMoveAbortBalanceSplit, isMoveAbortWalDestroyZero } from "../enoki.js"; import { patchGasCoinIntents, submitRebuildableWalletTransaction, @@ -362,6 +362,15 @@ export function registerWalrusUploadRoute(app: Express): void { if (phase === "register_sponsor" && isMoveAbortBalanceSplit(message)) { refreshWalrusClient("register_sponsor_balance_split"); } + // `coin::destroy_zero` (ENonZero) means the WAL payment coin the + // register PTB pre-funded from the cached storage price wasn't fully + // consumed on-chain — the live price dropped between the cached read + // and execution. Recreate the client so the retry re-reads the + // current price and splits the exact amount. The Rust worker + // classifies this abort Transient so Apalis actually retries. + if (isMoveAbortWalDestroyZero(message)) { + refreshWalrusClient("walrus_wal_payment_destroy_zero"); + } if (isWalrusPackageVersionMismatch(message)) { // EWrongVersion is phase-independent: can fire from register / upload / certify // any time the Walrus system package gets upgraded on-chain after this sidecar diff --git a/services/server/src/jobs.rs b/services/server/src/jobs.rs index 7bb2eab0..d7c42889 100644 --- a/services/server/src/jobs.rs +++ b/services/server/src/jobs.rs @@ -1511,6 +1511,9 @@ async fn maybe_alert_walrus_upload_exhausted( /// /// Mapping rules (enforced at the point of error origination): /// - `MoveAbort(_)` → `Permanent` (deterministic Move-level failure) +/// - Walrus register `0x2::coin::destroy_zero` ENonZero → `Transient` (WAL +/// payment over-funded from a stale cached price; the sidecar refreshes the +/// client so the retry re-reads the live price and splits the exact amount) /// - Enoki dry-run `0x2::balance::split` ENotEnough → `GasPoolExhausted` /// (abort: pool SUI gas coins are fragmented/insufficient; retrying rotates /// to the next starved wallet — needs ops gas consolidation/top-up) @@ -1602,6 +1605,20 @@ impl WalletJobError { && lower.contains("split") } + /// True if `msg` is the Walrus register PTB's `0x2::coin::destroy_zero` + /// abort (ENonZero). The `@mysten/walrus` `#withWal` helper splits an exact + /// WAL payment from the client's cached storage/write price and asserts the + /// coin is empty afterwards; when mainnet price drops between the cached read + /// and execution the contract deducts less WAL, leaving change that trips + /// `destroy_zero`. Recoverable: retrying against a client that re-read the + /// live price splits the correct amount, so this is Transient — never a + /// Permanent MoveAbort. + pub fn is_walrus_wal_payment_price_abort(msg: &str) -> bool { + let lower = msg.to_ascii_lowercase(); + (lower.contains("moveabort") || lower.contains("move abort")) + && lower.contains("destroy_zero") + } + /// Heuristic classification from the sidecar's error string. The sidecar /// surfaces Sui execution errors verbatim (Move abort codes, lock errors). /// Until the sidecar emits structured error codes, we match on substrings. @@ -1646,6 +1663,17 @@ impl WalletJobError { { return WalletJobError::Transient(msg.to_string()); } + // Walrus register PTB `0x2::coin::destroy_zero` abort (ENonZero). The + // `@mysten/walrus` `#withWal` helper pre-funds an exact WAL payment from + // the client's cached storage price, then asserts the coin is empty. When + // the on-chain price drops between the cached read and execution the + // contract deducts less WAL, leaving change that trips `destroy_zero`. + // Not input-specific: the sidecar recreates the client on this error, so + // classifying Transient lets Apalis retry against the refreshed (live) + // price instead of Dead-marking a job the next attempt will succeed on. + if Self::is_walrus_wal_payment_price_abort(msg) { + return WalletJobError::Transient(msg.to_string()); + } // Sui owned-object lock / equivocation. The referenced object+version // is locked to a competing transaction and stays locked until the lock // clears (typically the next epoch boundary), so retrying within this @@ -2395,6 +2423,22 @@ different transaction: TransactionDigest(8bjFgRyXRRYwrzQapgEjpHnGhdfNDY7d6xA82Bt )); } + #[test] + fn walrus_register_destroy_zero_is_transient() { + // Verbatim prod error (issue #351): the register PTB over-funds the WAL + // payment from a stale cached price and `coin::destroy_zero` aborts with + // ENonZero. Must be Transient (retry re-reads the live price), NOT swept + // into the MoveAbort→Permanent catch that would Dead-mark every write. + let destroy_zero = "walrus upload failed: Enoki API error (400): {\"errors\":[{\"code\":\"dry_run_failed\",\"message\":\"Dry run failed, could not automatically determine a budget: MoveAbort(MoveLocation { module: ModuleId { address: 0000000000000000000000000000000000000000000000000000000000000002, name: Identifier(\\\"balance\\\") }, function: 9, instruction: 8, function_name: Some(\\\"destroy_zero\\\") }, 0) in command 2\"}]}"; + assert!(WalletJobError::is_walrus_wal_payment_price_abort( + destroy_zero + )); + let classified = WalletJobError::classify_sidecar_error(destroy_zero); + assert!(matches!(classified, WalletJobError::Transient(_))); + assert!(!classified.is_permanent()); + assert!(!classified.aborts_retries()); + } + #[test] fn parse_locked_object_info_from_prod_error() { let info = parse_locked_object_info(PROD_OBJECT_LOCK_ERROR); diff --git a/services/server/src/main.rs b/services/server/src/main.rs index cebd0c38..df55ac01 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -798,6 +798,19 @@ async fn main() { "/sponsor/execute", post(routes::sponsor_execute_proxy).layer(DefaultBodyLimit::max(4 * 1024)), ) + // WALM-264: permanent V1 memory deletion — lives with the sponsor + // flow it belongs to. No delegate-key gate (plan T1): ownership is + // the wallet signature on the sponsored transaction, verified + // on-chain by the sidecar. Gated by ENABLE_MEMORY_DELETION (404 + // when off). + .route( + "/api/delete-memories", + post(routes::delete_memories).layer(DefaultBodyLimit::max(64 * 1024)), + ) + .route( + "/api/memory-blob-ids", + post(routes::memory_blob_ids).layer(DefaultBodyLimit::max(4 * 1024)), + ) .layer(middleware::from_fn_with_state( state.clone(), rate_limit::sponsor_rate_limit_middleware, diff --git a/services/server/src/routes/admin.rs b/services/server/src/routes/admin.rs index d5b55ac8..e7c98785 100644 --- a/services/server/src/routes/admin.rs +++ b/services/server/src/routes/admin.rs @@ -74,6 +74,120 @@ 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 — a server-side sanity bound (the app +/// sends ~40 per batch, capped by /sponsor's request size); anything above +/// this 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 `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). Public + IP-rate-limited (plan T1: no +/// delegate-key gate) — blob ownership is public on-chain data, this only +/// reveals which of those blobs the relayer indexes. Same flag gate as +/// delete. +pub async fn memory_blob_ids( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + if !state.config.enable_memory_deletion { + return Err(AppError::BlobNotFound("Not found".into())); + } + if !super::sponsor::validate_sui_address(&body.owner) { + return Err(AppError::BadRequest("Invalid owner address".into())); + } + let blob_ids = state.db.list_blob_ids_by_owner(&body.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 SUCCEEDS on-chain +/// (sidecar verifies effects status, not just Enoki's 200) — delete the +/// matching `vector_entries` rows for the transaction's verified sender. +/// No delegate-key session (plan T1): the wallet signature over the +/// sponsored transaction IS the ownership proof, and the owner is read +/// from the executed transaction on-chain — never from the request. The +/// blob-id list is client-supplied and NOT cross-checked against the +/// transaction's deleted objects — a dishonest caller can only desync +/// rows of the wallet that signed (same blast radius as deleting your own +/// data), and `cleanup_expired_blob` self-heals any drift on the next +/// recall. Sponsored digests are single-use upstream, so a replayed call +/// fails at execute and never reaches the row delete. +/// +/// Gated by `ENABLE_MEMORY_DELETION` (off by default) until rollout is +/// agreed — the route answers 404 when disabled. +pub async fn delete_memories( + State(state): State>, + 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())); + } + + tracing::info!( + "delete-memories: blobs={} submitting signed PTB", + 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. The + // sidecar verifies effects success and reports the executed sender, + // which becomes the row-delete owner: only the wallet that signed can + // ever have its rows removed. + let tx_sender = super::sponsor::execute_sponsored_tx(&state, &body.digest, &body.signature) + .await?; + let owner = tx_sender.ok_or_else(|| { + AppError::Internal("sponsored execute did not report a sender".into()) + })?; + + let deleted_rows = state.db.delete_by_blob_ids(&body.blob_ids, &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 +804,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..0b42fd1b 100644 --- a/services/server/src/routes/sponsor.rs +++ b/services/server/src/routes/sponsor.rs @@ -47,19 +47,22 @@ fn json_error_response(status: axum::http::StatusCode, msg: &'static str) -> Res } /// Validate a Sui address: `0x` followed by exactly 64 hex characters. -fn validate_sui_address(s: &str) -> bool { +pub(super) fn validate_sui_address(s: &str) -> bool { s.starts_with("0x") && s.len() == 66 && s[2..].chars().all(|c| c.is_ascii_hexdigit()) } /// 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 { + // Base58 of 32 bytes is normally 43-44 chars, but digests with leading + // zero bytes encode shorter (~0.1% of real digests are 42) — accept the + // full range a 32-byte value can produce. let len = s.len(); - if len != 43 && len != 44 { + if !(32..=44).contains(&len) { return false; } // Base58 alphabet excludes: 0, O, I, l @@ -72,10 +75,92 @@ 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, + verify_effects: bool, +) -> Result<(reqwest::StatusCode, axum::body::Bytes), AppError> { + let forwarded = serde_json::json!({ + "digest": digest, + "signature": signature, + "verifyEffects": verify_effects, + }); + + 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 confirm +/// it SUCCEEDED on-chain (the sidecar checks effects status — Enoki's +/// execute alone only means "submitted"). Used by `/api/delete-memories`, +/// which must confirm on-chain success before touching the DB. Returns the +/// executed transaction's sender so the caller can bind it to the +/// authenticated owner. +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, true).await?; + if upstream_status.is_success() { + let sender = serde_json::from_slice::(&resp_body) + .ok() + .and_then(|v| v.get("sender").and_then(|s| s.as_str()).map(String::from)); + Ok(sender) + } 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 +350,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, false).await?; if upstream_status.is_success() { Ok(Response::builder() @@ -392,8 +440,14 @@ mod more_tests { } #[test] - fn test_digest_too_short_42() { - assert!(!validate_digest(&"1".repeat(42))); + fn test_digest_valid_42_chars_leading_zero_encoding() { + // 32-byte digests with leading zero bytes encode shorter than 43. + assert!(validate_digest(&"1".repeat(42))); + } + + #[test] + fn test_digest_too_short_31() { + assert!(!validate_digest(&"1".repeat(31))); } #[test] diff --git a/services/server/src/storage/db.rs b/services/server/src/storage/db.rs index 968539d5..ec0a0469 100644 --- a/services/server/src/storage/db.rs +++ b/services/server/src/storage/db.rs @@ -388,6 +388,28 @@ impl VectorDb { Ok(rows) } + /// 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()) + } + /// 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. @@ -418,6 +440,32 @@ impl VectorDb { Ok(rows) } + /// Delete every vector entry matching one of `blob_ids`, owner-scoped. + /// WALM-264: one round-trip for a whole delete batch (up to ~1000 ids) + /// instead of a query per blob id. + pub async fn delete_by_blob_ids( + &self, + blob_ids: &[String], + owner: &str, + ) -> Result { + let started = std::time::Instant::now(); + let result = + sqlx::query("DELETE FROM vector_entries WHERE blob_id = ANY($1) AND owner = $2") + .bind(blob_ids) + .bind(owner) + .execute(&self.pool) + .await + .map_err(|e| { + AppError::Internal(format!("Failed to delete vectors by blob_ids: {}", e)) + }); + crate::observability::observe_db( + "vector.delete_by_blob_ids", + db_status(&result), + started.elapsed(), + ); + Ok(result?.rows_affected()) + } + // ============================================================ // Delegate Key Cache // ============================================================ diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 5e5e017b..799fa788 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,50 @@ 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. Public + rate-limited (plan T1: no +/// delegate-key gate); blob ownership is on-chain public data, this only +/// reveals which of a wallet's blobs the relayer indexes. +#[derive(Debug, Deserialize)] +pub struct MemoryBlobIdsRequest { + /// Wallet address whose indexed blob ids to return. + pub owner: String, +} + +#[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)]