feat(WALM-264): permanent V1 memory deletion — flag-gated UI + backend#378
Closed
harrymove-ctrl wants to merge 11 commits into
Closed
feat(WALM-264): permanent V1 memory deletion — flag-gated UI + backend#378harrymove-ctrl wants to merge 11 commits into
harrymove-ctrl wants to merge 11 commits into
Conversation
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.
ducnmm
approved these changes
Jul 10, 2026
Collaborator
Style Guide AuditAll 1 file(s) pass the style guide audit. |
…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.
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.
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.
…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) |
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WALM-264 — let users permanently delete their old V1 memories
Everything is OFF by default behind two flags (
ENABLE_MEMORY_DELETIONon the relayer,VITE_ENABLE_MEMORY_DELETIONin 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
docs/fundamentals/deleting-old-memories.md(Sui docs style-guide compliant).How it works (simple version)
Blobobjects (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 (newPOST /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.system::delete_blobtransactions itself, 40 deletes per batch (the binding limit is the relayer's/sponsorrequest caps — 7 KB of transaction bytes — not Sui's 1024-command cap), returning reclaimedStorageobjects to the wallet.POST /sponsor) — signing is the only thing the user does.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_blobpath self-heals on the next recall.Verified
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)
/sponsoris 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./api/delete-memoriesis 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)
delete_blobrequires the move-call target<current walrus package>::system::delete_blobon the Enoki app allowlist (already added for mainnet:0x98da433a…::system::delete_blob).Also included (found while testing on mainnet)
fetchAccountIdForOwnerread the Table id as a string; JSON-RPC returns{ id: { id } }, so every lookup on the JSON-RPC path failed and SetupWizard triedcreate_accountfor 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.utils/api.tsstill signed the legacy 4-part message with nox-nonce, so auth middleware rejected it with 426; now signs the current 6-part message like SDK v0.4+.SUI_RPC_URL, recover Walrus register from stale WAL price — small relayer fixes hit while testing.