Skip to content

feat(WALM-264): permanent V1 memory deletion — flag-gated UI + backend#378

Closed
harrymove-ctrl wants to merge 11 commits into
devfrom
feat/v1-memory-deletion
Closed

feat(WALM-264): permanent V1 memory deletion — flag-gated UI + backend#378
harrymove-ctrl wants to merge 11 commits into
devfrom
feat/v1-memory-deletion

Conversation

@harrymove-ctrl

@harrymove-ctrl harrymove-ctrl commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

WALM-264 — let users permanently delete their old V1 memories

Everything is OFF by default behind two flags (ENABLE_MEMORY_DELETION on the relayer, VITE_ENABLE_MEMORY_DELETION in the app). With the flags off, users see nothing and the new endpoints answer 404. We flip them only when rollout is agreed.

What users get

  • A "Delete old memories" card on the Dashboard: it counts the old memory blobs the wallet owns and shows one button — "Delete all N forever". No per-memory picking (per Henry's review: don't show users a 1000-row list; only the number).
  • A dismissible banner ("You have N old memories — delete them") on every page, pointing to that card. Dismissed per wallet (localStorage).
  • A user guide: docs/fundamentals/deleting-old-memories.md (Sui docs style-guide compliant).
  • Very explicit copy everywhere: deletion is permanent — never recoverable, never migrated to V2.

How it works (simple version)

  1. The app scans the wallet's on-chain Walrus Blob objects (gRPC or JSON-RPC transport), skips expired blobs (their storage is already gone, and one expired blob would abort a whole delete transaction), and keeps only the ones our relayer DB knows about (new POST /api/memory-blob-ids). Wallets without a delegate-key session see a setup prompt instead of a count, so we never show an unscoped number. The scan is cached and shared between the banner and the card.
  2. Click Delete all → confirm dialog → the app builds the system::delete_blob transactions itself, 40 deletes per batch (the binding limit is the relayer's /sponsor request caps — 7 KB of transaction bytes — not Sui's 1024-command cap), returning reclaimed Storage objects to the wallet.
  3. Enoki sponsors the gas (POST /sponsor) — signing is the only thing the user does.
  4. The signature goes to the new POST /api/delete-memories. The backend submits the transaction, verifies it actually succeeded on-chain (effects status, not just Enoki's 200), checks the executed sender matches the authenticated owner, and only then deletes the matching DB rows in one bulk query — one call owns both side effects, so chain and index can't drift.

Failure handling: finished batches stay deleted; on any mid-run failure the card re-scans the chain so a retry only sees blobs that still exist. A double-click can't start a second concurrent run. If the row-delete ever fails after a landed transaction, the existing cleanup_expired_blob path self-heals on the next recall.

Verified

  • Full flow tested E2E on mainnet with a real wallet: scan → sponsor → sign → backend submit → DB rows deleted.
  • Adversarial code review (correctness/security pass) driven into the design: batch size vs sponsor byte caps, expired-blob batch poisoning, effects-status verification, sender↔owner binding, double-click guard, post-success resume, scoped banner counts.
  • App: tsc, vite build, asset verification pass. Server: cargo test — 298 pass, 5 pre-existing failures (jobs::tests, unrelated, also fail on clean dev). Sidecar module imports verified.

Known limitations (documented, non-blocking)

  • /sponsor is rate-limited per sender (10/min, 30/hr), so wallets with thousands of memories need multiple sessions — the guide says so. Raising the limit (or a bigger per-batch byte allowance) is a possible follow-up.
  • The blob-id list sent to /api/delete-memories is client-supplied and not cross-checked against the transaction's deleted objects; a dishonest caller can only desync their own index rows (same blast radius as /api/forget), and recall self-heals. Documented in the route docs.

Ops notes (before enabling in prod)

  • Enoki allowlist: sponsoring delete_blob requires the move-call target <current walrus package>::system::delete_blob on the Enoki app allowlist (already added for mainnet: 0x98da433a…::system::delete_blob). ⚠️ This id is the latest Walrus package version — it must be re-added whenever Walrus upgrades their package.
  • Enable = set both flags above; nothing else changes.

Also included (found while testing on mainnet)

  • fix: JSON-RPC nested UID shape in registry lookupfetchAccountIdForOwner read the Table id as a string; JSON-RPC returns { id: { id } }, so every lookup on the JSON-RPC path failed and SetupWizard tried create_account for wallets that already have accounts. Regression from the feat(relayer): gRPC write path for the JSON-RPC sunset (2026-07-31) #355 compat layer, hidden until now because testnet runs gRPC. Matters for any mainnet deploy.
  • fix: apiCall nonce-based authutils/api.ts still signed the legacy 4-part message with no x-nonce, so auth middleware rejected it with 426; now signs the current 6-part message like SDK v0.4+.
  • fix(relayer): honor SUI_RPC_URL, recover Walrus register from stale WAL price — small relayer fixes hit while testing.
  • fix: accept 42-char base58 digests — ~0.1% of real 32-byte digests encode shorter than 43 chars and were rejected as invalid.

hien-p and others added 6 commits July 10, 2026 19:03
User-facing delete for old V1 memories, off by default behind
ENABLE_MEMORY_DELETION (server) + VITE_ENABLE_MEMORY_DELETION (app).

Backend: POST /api/delete-memories (protected_routes) accepts the
user-signed sponsored delete PTB {digest, signature, blobIds}, submits
it via the sidecar sponsor-execute path, and only after on-chain
success deletes the matching vector_entries rows (delete_by_blob_id,
owner-scoped from verified auth — never the body). sponsor.rs's sidecar
call is extracted into call_sidecar_sponsor_execute, shared with the
existing /sponsor/execute proxy. cleanup_expired_blob remains the
self-heal backstop if row-delete fails after a landed tx.

Frontend: /cleanup page enumerates the wallet's on-chain Walrus Blob
objects (walrusClient.getBlobType — no hardcoded package), converts
blob_id u256→base64url to match the relayer DB, builds the delete PTB
(deleteBlob + transferObjects of reclaimed Storage) in batches of 950
per Sui PTB caps, sponsors via POST /sponsor, and stops after the
wallet signature — the backend owns submit + DB cleanup. Confirm
dialog and copy state plainly that deletion is permanent, never
recoverable, never migrated. Plus the dismissible old-memories banner
and a user guide (docs/fundamentals/deleting-old-memories).

Verified: cargo tests 296 pass (5 pre-existing failures on clean dev),
app tsc + vite build + asset verification pass.
fetchAccountIdForOwner read the registry Table id as registryJson.accounts.id,
which is a plain string on gRPC json but Move's nested UID shape
({ id: { id } }) on JSON-RPC even after field unwrapping. The JSON-RPC
path then passed an object as getDynamicFieldObject's parentId and every
account lookup failed with 'Invalid params' — SetupWizard treated real
accounts as missing and tried create_account, which aborts on-chain with
EAccountAlreadyExists. Regression from the PR #355 compat layer, hidden
until now because testnet runs the gRPC path.
utils/api.ts still signed the legacy '{ts}.{method}.{path}.{bodySha}'
message with no x-nonce header, so the relayer's auth middleware
rejected every call with 426 (unsupported legacy SDK). Sign the current
'{ts}.{method}.{path}.{bodySha}.{nonce}.{account_id}' message and send
x-nonce, matching SDK v0.4+ — unblocks the delete-memories UI's calls
to /api/memory-blob-ids and /api/delete-memories.
Move the banner from AppContent into the Dashboard page (below the
navbar, above the header) so it shows where the cleanup section lives
and inherits the dash-shell layout, with the standard alert icon.
…ce (#351)

Mainnet writes were failing 100% at the register_sponsor phase with an Enoki
dry_run_failed -> MoveAbort in 0x2::coin::destroy_zero (ENonZero). Root cause is
not Enoki: @mysten/walrus '#withWal' pre-funds an *exact* WAL payment computed
from the client's cached systemState price, then asserts the coin is empty via
coin::destroy_zero. When mainnet storage/write price drifts down between the
cached read and execution, the contract deducts less WAL than we split off and
the leftover trips destroy_zero. Verified on prod: on-chain price moved
71464->70922 within ~15 min, and a fresh relayer boot served 6 writes before the
same client flipped to 100% destroy_zero at the next price move.

Two gaps let this fail hard instead of self-healing:
- The sidecar's auto-refresh only fired on isMoveAbortBalanceSplit ('balance'
  + 'split'); this message says destroy_zero, so refreshWalrusClient never ran.
- The Rust worker swept it into the MoveAbort -> Permanent catch, so Apalis
  Dead-marked every write with no retry.

Fix:
- enoki.ts: add isMoveAbortWalDestroyZero detector.
- walrus-upload.ts: refreshWalrusClient() on the destroy_zero abort so the retry
  rebuilds against the live price.
- jobs.rs: classify the register destroy_zero abort as Transient (retryable),
  disjoint from the balance::split gas-budget path.
- config.ts: drop WALRUS_CLIENT_MAX_AGE_MS default 30m -> 60s so the cached
  price tracks a live-drifting mainnet price (still env-overridable).

Tests: new TS detector suite (7) + Rust classification test using the verbatim
prod error. Full suites green (85 TS, 40 jobs).
The sidecar hardcoded getJsonRpcFullnodeUrl(mainnet) and ignored the SUI_RPC_URL
env, so it could not be pointed at a dedicated RPC when the public fullnode pool
degrades. Observed today: the public pool served stale reads (a blob certified
at 00:49:14 read back as 'does not exist' at 00:49:49, 35s later), failing
uploads at get_blob/certify/metadata. Honor an explicit SUI_RPC_URL override so
ops can switch to a healthy RPC via env; fall back to the network default.
@jessiemongeon1

jessiemongeon1 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Style Guide Audit

All 1 file(s) pass the style guide audit.

@railway-app railway-app Bot temporarily deployed to Walrus Memory / dev July 10, 2026 17:31 Inactive
…verification

UI (per Henry's review): no per-blob table — the cleanup card shows only
the deletable count and one "Delete all N forever" button; the banner is
mounted once in AppContent (dashboard renders its own instance below the
navbar), counts the relayer-scoped set only, hides without a delegate-key
session, and dismisses per wallet.

Correctness/hardening from adversarial review:
- Batch size 950 → 40: the binding limit is /sponsor's 7000-byte
  TransactionKind cap under a 10 KiB body (~135 B per delete ≈ 50 max),
  not Sui's 1024-command cap. 950 could never sponsor. Override via
  VITE_DELETE_BATCH_SIZE (clamped) to test the multi-batch flow.
- Expired blobs are excluded from the delete set (storage.end_epoch vs
  walrus systemState epoch, both transports) — one expired blob would
  abort the whole PTB and its data is gone anyway.
- /api/delete-memories no longer trusts "Enoki 200" as success: the
  sidecar verifies effects status via core.getTransaction (verifyEffects
  opt-in flag; plain proxy unchanged) and reports the executed sender,
  which the server binds to the authenticated owner before touching rows.
- Double-click guard on executeDelete; mid-run failure re-scans the chain
  so retry only sees blobs that still exist (a failure after a landed tx
  no longer bricks retries).
- Chain scans are cached per wallet (5 min TTL, shared in-flight) so the
  banner and card don't each walk tens of thousands of objects; delete
  invalidates the cache.
- Bulk row delete (blob_id = ANY($1) AND owner = $2) instead of one query
  per blob; accept 42-char base58 digests (leading-zero encodings);
  honest docstrings for the flag gate and the client-supplied blob-id
  trust model; sponsor first-half extracted to utils/sponsor.ts (shared
  with useSponsoredTransaction).

Docs: deleting-old-memories.md rewritten for the one-button flow and the
Sui docs style guide (all 19 audit violations: em dashes, onchain,
present tense, frontmatter description/keywords, no emoji headings,
backticks, active voice).

Verified: app tsc + vite build + asset check; cargo test 298 pass
(5 pre-existing jobs::tests failures on clean dev); sponsor → Enoki
mainnet returns 200 for a real delete PTB after the allowlist add.
@railway-app railway-app Bot temporarily deployed to Walrus Memory / dev July 10, 2026 17:32 Inactive
Comment thread apps/app/src/utils/walrusBlobs.ts Fixed
Vite bakes env at build time — without the ARG/ENV pair the Railway
service variable can never reach the bundle, so the WALM-264 delete UI
could not be enabled on any deployed environment even after rollout is
agreed. Also passes VITE_DELETE_BATCH_SIZE (test knob, clamped in code).
Plumbing only: no environment sets these today, so nothing turns on.
@railway-app railway-app Bot temporarily deployed to Walrus Memory / dev July 10, 2026 17:45 Inactive
Not part of the plan — the batch size is a fixed constant (40, bounded by
/sponsor's request caps). Multi-batch testing = temporarily edit the
constant. The plan-required off-by-default flag
(VITE_ENABLE_MEMORY_DELETION) keeps its Dockerfile build-arg plumbing.
@railway-app railway-app Bot temporarily deployed to Walrus Memory / dev July 10, 2026 17:50 Inactive
…wnership proof

Plan T1 says the cleanup flow has NO delegate-key gate, but both endpoints
were built in protected_routes (T2's authenticated-route wording), so the
UI dead-ended at 'set up a delegate key first' — backwards for the exact
audience of this feature (users deleting their data on the way out).

/api/delete-memories and /api/memory-blob-ids move to the sponsor route
group (public, IP rate-limited, flag-gated 404 as before):
- delete-memories: owner is now the executed transaction's on-chain
  sender (sidecar already verifies effects + sender) — cryptographically
  stronger than a session, never from the request. Sponsored digests are
  single-use upstream, so replays die at execute.
- memory-blob-ids: takes an owner address (validated); blob ownership is
  public on-chain data, this only reveals which blobs the relayer indexes.

Frontend drops useDelegateKey/apiCall from the cleanup card and banner:
connect a wallet, see the count, sign, done. Docs note replaced.

Verified: tsc + vite build; cargo test 298 pass (5 pre-existing
jobs::tests); live probes — memory-blob-ids 200 unauthenticated,
validation 400/422 paths intact.
return await promise
} catch (err) {
// Don't cache failures.
if (scanCache.get(owner)?.promise === promise) scanCache.delete(owner)
@ducnmm

ducnmm commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Superseded by #389, which contains this feature plus the current dev merged in (conflicts with #353/#355 already resolved), the /sponsor-delete rate-limit fix, and the suiscan JSON-RPC defaults — matching what production runs.

@ducnmm ducnmm closed this Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants