Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b75c047
fix(relayer): recover Walrus register destroy_zero from stale WAL pri…
ducnmm Jul 2, 2026
542071f
fix(relayer): honor SUI_RPC_URL env to move off degraded public fullnode
ducnmm Jul 3, 2026
612c3a2
feat(WALM-264): permanent V1 memory deletion — UI + backend, flag-gated
hien-p Jul 10, 2026
d8004fa
fix(app): accept JSON-RPC's nested UID shape in registry table lookup
hien-p Jul 10, 2026
ed7cc23
fix(app): upgrade apiCall signing to the nonce-based auth scheme
hien-p Jul 10, 2026
9a2be4f
refactor(app): render the old-memories banner inside the Dashboard shell
hien-p Jul 10, 2026
5557e87
chore: drop committed .env backup; ignore .env backups and variants
hien-p Jul 10, 2026
73361c1
fix(WALM-264): one-button delete-all, sponsor-cap batching, on-chain …
hien-p Jul 10, 2026
c2cb216
fix(app): pass VITE_ENABLE_MEMORY_DELETION through as a Docker build arg
hien-p Jul 10, 2026
461222e
chore(WALM-264): drop the VITE_DELETE_BATCH_SIZE test knob
hien-p Jul 10, 2026
c02c1c7
fix(WALM-264): drop the delegate-key gate — wallet signature is the o…
hien-p Jul 10, 2026
fe18eb6
fix(WALM-264): exempt delete-all from sponsor rate limits via verifie…
ducnmm Jul 11, 2026
f0ff538
fix(app,relayer): default mainnet to the dedicated suiscan JSON-RPC e…
ducnmm Jul 11, 2026
b6d35ca
Merge remote-tracking branch 'origin/dev' into feat/v1-memory-deletio…
ducnmm Jul 11, 2026
ef99a07
chore(app): unmount the old-memories banner — rollout deferred
ducnmm Jul 11, 2026
3d26bf5
fix(WALM-264): make /api/delete-memories digests single-use (replay g…
ducnmm Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pnpm-debug.log*
# Environment variables
.env
.env*.local
.env.local.*
.env*.bak
.env.production
.env.test
*/.env.test
Expand Down
3 changes: 3 additions & 0 deletions apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ VITE_POSTHOG_UI_HOST=https://us.posthog.com
# ▷ INACTIVE: MAINNET (uncomment below, comment out TESTNET above)
# ══════════════════════════════════════════════════════════════
# VITE_SUI_NETWORK=mainnet
# Dedicated JSON-RPC for the app's Sui client (mirrors relayer SUI_RPC_URL);
# empty falls back to the public fullnode, which is slow/stale on mainnet.
# VITE_SUI_RPC_URL=https://rpc-mainnet.suiscan.xyz
# VITE_MEMWAL_PACKAGE_ID=0xcee7a6fd8de52ce645c38332bde23d4a30fd9426bc4681409733dd50958a24c6
# VITE_MEMWAL_REGISTRY_ID=0x0da982cefa26864ae834a8a0504b904233d49e20fcc17c373c8bed99c75a7edd

Expand Down
6 changes: 6 additions & 0 deletions apps/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ ARG VITE_MEMWAL_SERVER_URL
ARG VITE_MEMWAL_PACKAGE_ID=0xcee7a6fd8de52ce645c38332bde23d4a30fd9426bc4681409733dd50958a24c6
ARG VITE_MEMWAL_REGISTRY_ID=0x0da982cefa26864ae834a8a0504b904233d49e20fcc17c373c8bed99c75a7edd
ARG VITE_SUI_NETWORK=mainnet
ARG VITE_SUI_RPC_URL
ARG VITE_SUI_GRPC_URL
ARG VITE_ENOKI_API_KEY
ARG VITE_GOOGLE_CLIENT_ID
Expand All @@ -44,11 +45,15 @@ 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
ENV VITE_MEMWAL_REGISTRY_ID=$VITE_MEMWAL_REGISTRY_ID
ENV VITE_SUI_NETWORK=$VITE_SUI_NETWORK
ENV VITE_SUI_RPC_URL=$VITE_SUI_RPC_URL
ENV VITE_SUI_GRPC_URL=$VITE_SUI_GRPC_URL
ENV VITE_ENOKI_API_KEY=$VITE_ENOKI_API_KEY
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
Expand All @@ -61,6 +66,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

Expand Down
14 changes: 12 additions & 2 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,19 @@ import '@mysten/dapp-kit/dist/index.css'
// Network config
// ============================================================

// VITE_SUI_RPC_URL overrides the public fullnode for the active network only
// (mirrors the relayer's SUI_RPC_URL — the public mainnet pool serves
// stale/slow reads under load). Other networks keep the public default.
function jsonRpcUrlFor(network: 'testnet' | 'mainnet'): string {
if (network === config.suiNetwork && config.suiRpcUrl) {
return config.suiRpcUrl
}
return getJsonRpcFullnodeUrl(network)
}

const { networkConfig } = createNetworkConfig({
testnet: { url: getJsonRpcFullnodeUrl('testnet'), network: 'testnet' },
mainnet: { url: getJsonRpcFullnodeUrl('mainnet'), network: 'mainnet' },
testnet: { url: jsonRpcUrlFor('testnet'), network: 'testnet' },
mainnet: { url: jsonRpcUrlFor('mainnet'), network: 'mainnet' },
})

// Opt-in gRPC client for the active network (VITE_SUI_GRPC_URL), mirroring the
Expand Down
309 changes: 309 additions & 0 deletions apps/app/src/components/CleanupSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
/**
* 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-delete (Enoki; the
* rate-limit-exempt delete-only path), 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 confirm
* dialog says 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-delete 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<OwnedWalrusBlob[]>([])
const [scanned, setScanned] = useState(0)
const [phase, setPhase] = useState<Phase>({ 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.
// deleteFlow routes via /sponsor-delete: no sponsor rate
// limit (a big run's batches would trip the normal caps),
// in exchange the sidecar verifies the PTB is delete-only.
const sponsored = await sponsorTransactionKind(kindBytes, address, { deleteFlow: true })

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 (
<Card
id="cleanup"
className="dashboard-cleanup-card"
title="Delete old memories"
subtitle="Old memory blobs stored on Walrus by this wallet"
action={
<div className="card-header-actions">
<button
className="btn btn-secondary dashboard-keys-refresh"
onClick={() => void loadBlobs(true)}
disabled={busy}
aria-busy={phase.kind === 'loading'}
>
<RefreshCw size={16} /> Refresh
</button>
<button
className="btn btn-danger dashboard-cleanup-delete"
onClick={() => setConfirming(true)}
disabled={busy || count === 0}
>
<Trash2 size={16} /> Delete all{count > 0 ? ` ${count}` : ''} forever
</button>
</div>
}
>
{error && (
<div className="dashboard-cleanup-error" role="alert">
{error}
</div>
)}

{phase.kind === 'loading' && (
<p className="dashboard-cleanup-status">
Scanning your wallet for memory blobs...{scanned > 0 ? ` ${scanned} found` : ''}
</p>
)}

{phase.kind === 'deleting' && (
<p className="dashboard-cleanup-status" aria-live="polite">
Deleting batch {phase.batch} of {phase.totalBatches}... check your wallet for a
signature request.
</p>
)}

{phase.kind === 'done' && (
<p className="dashboard-cleanup-status" aria-live="polite">
Deleted {phase.deletedBlobs} memor{phase.deletedBlobs === 1 ? 'y' : 'ies'} on-chain
({phase.deletedRows} index rows removed). This cannot be undone.
</p>
)}

{phase.kind === 'ready' && (
count === 0 ? (
<div className="dashboard-empty-message">
<span>No deletable memories found for this wallet.</span>
</div>
) : (
<p className="dashboard-cleanup-status">
You have <strong>{count}</strong> 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.'}
</p>
)
)}

{confirming && (
<div
className="dashboard-confirm-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setConfirming(false)
}}
>
<section
className="dashboard-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="cleanup-confirm-title"
aria-describedby="cleanup-confirm-description"
>
<div className="dashboard-confirm-copy">
<h3 id="cleanup-confirm-title">
Permanently delete all {count} old memor{count === 1 ? 'y' : 'ies'}?
</h3>
<p id="cleanup-confirm-description">
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.
</p>
</div>
<div className="dashboard-confirm-actions">
<button
type="button"
className="btn btn-secondary dashboard-confirm-cancel"
onClick={() => setConfirming(false)}
autoFocus
>
Cancel
</button>
<button
type="button"
className="btn btn-danger dashboard-confirm-remove"
onClick={() => void executeDelete()}
>
Delete forever
</button>
</div>
</section>
</div>
)}
</Card>
)
}
Loading
Loading