From b75c0476030955a0ac59d1265d6bce9f52da9755 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:14:53 +0700 Subject: [PATCH 01/15] fix(relayer): recover Walrus register destroy_zero from stale WAL price (#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). --- .../__tests__/walrus-wal-destroy-zero.test.ts | 62 +++++++++++++++++++ services/server/scripts/sidecar/config.ts | 9 ++- services/server/scripts/sidecar/enoki.ts | 16 +++++ .../scripts/sidecar/routes/walrus-upload.ts | 11 +++- services/server/src/jobs.rs | 44 +++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 services/server/scripts/__tests__/walrus-wal-destroy-zero.test.ts 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 26e3d796..7a5a5c7c 100644 --- a/services/server/scripts/sidecar/config.ts +++ b/services/server/scripts/sidecar/config.ts @@ -123,9 +123,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 0d8380c0..0c44ea39 100644 --- a/services/server/scripts/sidecar/enoki.ts +++ b/services/server/scripts/sidecar/enoki.ts @@ -53,6 +53,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/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); From 542071f357497b46c268321c6121afcd77d4a7cd Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:06:15 +0700 Subject: [PATCH 02/15] fix(relayer): honor SUI_RPC_URL env to move off degraded public fullnode 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. --- services/server/scripts/sidecar/config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/server/scripts/sidecar/config.ts b/services/server/scripts/sidecar/config.ts index 7a5a5c7c..2fcea868 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); export const SUI_TYPE = "0x2::sui::SUI"; // ============================================================ From 612c3a29b2aa83a95ed0ee45e85001dd553acc0e Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 18:12:10 +0700 Subject: [PATCH 03/15] =?UTF-8?q?feat(WALM-264):=20permanent=20V1=20memory?= =?UTF-8?q?=20deletion=20=E2=80=94=20UI=20+=20backend,=20flag-gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/app/.env.local.testnet.bak | 16 + apps/app/src/App.tsx | 4 + apps/app/src/components/CleanupSection.tsx | 401 ++++++++++++++++++ apps/app/src/components/OldMemoriesBanner.tsx | 74 ++++ apps/app/src/config.ts | 4 + apps/app/src/index.css | 63 +++ apps/app/src/pages/Dashboard.tsx | 3 + apps/app/src/utils/walrusBlobs.ts | 128 ++++++ docs/docs.json | 3 +- docs/fundamentals/deleting-old-memories.md | 55 +++ services/server/src/main.rs | 4 + services/server/src/routes/admin.rs | 125 ++++++ services/server/src/routes/mod.rs | 4 +- services/server/src/routes/remember.rs | 1 + services/server/src/routes/sponsor.rs | 120 ++++-- services/server/src/storage/db.rs | 21 + services/server/src/types.rs | 42 ++ 17 files changed, 1024 insertions(+), 44 deletions(-) create mode 100644 apps/app/.env.local.testnet.bak create mode 100644 apps/app/src/components/CleanupSection.tsx create mode 100644 apps/app/src/components/OldMemoriesBanner.tsx create mode 100644 apps/app/src/utils/walrusBlobs.ts create mode 100644 docs/fundamentals/deleting-old-memories.md diff --git a/apps/app/.env.local.testnet.bak b/apps/app/.env.local.testnet.bak new file mode 100644 index 00000000..7045f572 --- /dev/null +++ b/apps/app/.env.local.testnet.bak @@ -0,0 +1,16 @@ +# ── WALM-264 local testing — Railway dev envs (testnet) ── +# Backend: local memwal-server on :8000 (railway dev env + local DB/Redis, +# ENABLE_MEMORY_DELETION=true). + +VITE_MEMWAL_SERVER_URL=http://localhost:8000 +VITE_SUI_NETWORK=testnet +VITE_MEMWAL_PACKAGE_ID=0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6 +VITE_MEMWAL_REGISTRY_ID=0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437 +VITE_ENOKI_API_KEY=enoki_public_7a7422ea8969ca8d66f82302563036a9 +VITE_GOOGLE_CLIENT_ID=603573168998-1en4kolflalfkffklhejndaqncer0j3d.apps.googleusercontent.com + +# WALM-264 feature flag — shows the Dashboard cleanup section + banner +VITE_ENABLE_MEMORY_DELETION=true + +# PR #355 merged into dev — gRPC keeps testnet working (JSON-RPC is dead): +VITE_SUI_GRPC_URL=https://fullnode.testnet.sui.io diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 5e0e0586..92d1ddc2 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard' import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' import ConnectMcp from './pages/ConnectMcp' +import OldMemoriesBanner from './components/OldMemoriesBanner' import { useRouteAnalytics } from './hooks/useRouteAnalytics' @@ -250,6 +251,8 @@ function AppContent() { } return ( + <> + : @@ -265,6 +268,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..f988bf11 --- /dev/null +++ b/apps/app/src/components/CleanupSection.tsx @@ -0,0 +1,401 @@ +/** + * CleanupSection — permanent V1 memory deletion (WALM-264), rendered as a + * Dashboard card (id="cleanup", the old-memories banner links here). Styled + * to match the Delegate keys card (same Card shell + table classes). + * + * The user's ONLY action is signing: the card builds the + * `system::delete_blob` PTB (batched), gets it Enoki-sponsored via + * `POST /sponsor`, collects the wallet signature, then hands + * `{ digest, signature, blobIds }` to `POST /api/delete-memories` — the + * backend submits the transaction and deletes the matching DB rows in the + * same call, so chain and DB never drift. + * + * Deletion is PERMANENT. A deleted memory is gone from Walrus, from the + * relayer index, and can never be recovered or migrated. The confirm + * dialog states this in plain words — per WALM-264 that messaging is the + * one thing that must be right. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +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 { useDelegateKey } from '../App' +import { apiCall } from '../utils/api' +import { Card } from './Card' +import { getWalrusClient, listOwnedWalrusBlobs, type OwnedWalrusBlob } from '../utils/walrusBlobs' +import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' + +/** + * Sui PTB caps: 1024 commands / 2048 inputs / 128 KB. Each delete is one + * command + one owned input, plus one final transferObjects — so ~1023 is + * the hard ceiling; 950 leaves size + gas headroom. + */ +const DELETE_BATCH_SIZE = 950 + +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 { delegateKey, accountObjectId } = useDelegateKey() + + const address = currentAccount?.address || '' + + const [blobs, setBlobs] = useState([]) + const [selected, setSelected] = useState>(new Set()) + 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 () => { + if (!address) return + setPhase({ kind: 'loading' }) + setError('') + try { + const owned = await listOwnedWalrusBlobs(suiClient, address, setScanned) + + // §5.2 — the relayer DB is the authoritative V1 memory list: only + // offer blobs whose id is indexed there, so unrelated/V2 blobs the + // wallet owns are never deletable from this UI. Requires the + // delegate-key session (the endpoint is authenticated); without + // one the list stays unscoped but the delete button is disabled + // anyway. + let scoped = owned + if (delegateKey) { + const res = await apiCall( + delegateKey, + config.memwalServerUrl, + '/api/memory-blob-ids', + {}, + accountObjectId ?? undefined, + ) + const known = new Set(res.blobIds ?? []) + scoped = owned.filter((b) => known.has(b.blobId)) + } + + setBlobs(scoped) + setSelected(new Set(scoped.filter((b) => b.deletable).map((b) => b.objectId))) + setPhase({ kind: 'ready' }) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to list blobs') + setPhase({ kind: 'ready' }) + } + }, [address, suiClient, delegateKey, accountObjectId]) + + 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 toggle = useCallback((objectId: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(objectId)) next.delete(objectId) + else next.add(objectId) + return next + }) + }, []) + + const executeDelete = useCallback(async () => { + if (!address || !delegateKey) return + setConfirming(false) + setError('') + + const targets = deletableBlobs.filter((b) => selected.has(b.objectId)) + if (targets.length === 0) return + + trackEvent('memory_delete_start', { blobs: targets.length }) + + const batches: OwnedWalrusBlob[][] = [] + for (let i = 0; i < targets.length; i += DELETE_BATCH_SIZE) { + batches.push(targets.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 }) + + // Build the delete PTB: each delete frees a Storage object, + // returned 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 sponsorRes = await fetch(`${config.memwalServerUrl}/sponsor`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transactionBlockKindBytes: uint8ToBase64(kindBytes), + sender: address, + }), + }) + if (!sponsorRes.ok) { + throw new Error(`Sponsor failed (${sponsorRes.status}): ${await sponsorRes.text()}`) + } + const sponsored = await sponsorRes.json() + + const { signature } = await signTransaction({ + transaction: Transaction.from(sponsored.bytes), + }) + + // The backend submits and deletes the DB rows together. + const result = await apiCall( + delegateKey, + config.memwalServerUrl, + '/api/delete-memories', + { + digest: sponsored.digest, + signature, + blobIds: batch.map((b) => b.blobId), + }, + accountObjectId ?? undefined, + ) + + deletedBlobs += batch.length + deletedRows += result.deletedRows ?? 0 + // Remove 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))) + setSelected((prev) => { + const next = new Set(prev) + for (const id of done) next.delete(id) + return next + }) + } + setPhase({ kind: 'done', deletedBlobs, deletedRows }) + trackEvent('memory_delete_complete', { blobs: deletedBlobs }) + } catch (err) { + setError(err instanceof Error ? err.message : 'delete failed') + setPhase({ kind: 'ready' }) + trackEvent('memory_delete_failed', { error_type: getAnalyticsErrorType(err) }) + } + }, [address, delegateKey, accountObjectId, deletableBlobs, selected, suiClient, signTransaction]) + + const selectedCount = selected.size + const busy = phase.kind === 'deleting' || phase.kind === 'loading' + const allSelected = deletableBlobs.length > 0 && selectedCount === deletableBlobs.length + + 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 memories you are sure you no longer want. +
+ + {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 !== 'loading' && deletableBlobs.length === 0 ? ( + phase.kind !== 'done' && ( +
+ No deletable memories found for this wallet. +
+ ) + ) : phase.kind !== 'loading' && ( + <> +

+ {deletableBlobs.length} deletable memor{deletableBlobs.length === 1 ? 'y' : 'ies'} found + {permanentCount > 0 ? ` (${permanentCount} non-deletable blobs not shown)` : ''}. + {' '}Deletion runs in batches of {DELETE_BATCH_SIZE} — one signature per batch. +

+ +
+ + + + + + + + + + {deletableBlobs.map((blob) => { + const isSelected = selected.has(blob.objectId) + return ( + + + + + + ) + })} + +
+ + Blob IDObject ID
+ + + + {blob.blobId.slice(0, 10)}...{blob.blobId.slice(-6)} + + + + {blob.objectId.slice(0, 8)}...{blob.objectId.slice(-6)} + +
+
+ + {!delegateKey && ( +

+ A delegate key session is required to delete — set one up first. +

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

+ Permanently delete {selectedCount} memor{selectedCount === 1 ? 'y' : 'ies'}? +

+

+ This erases the selected memories from Walrus and from your memory index, + forever. They cannot be recovered, restored, or migrated afterwards — by + anyone. There is no undo. +

+
+
+ + +
+
+
+ )} +
+ ) +} + +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/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx new file mode 100644 index 00000000..4978c26e --- /dev/null +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -0,0 +1,74 @@ +/** + * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". + * + * Shown once per connected wallet (dismissal persisted in localStorage) + * when the wallet owns deletable V1 Walrus blobs. Links to the Dashboard cleanup section. + * Mounted in AppContent; renders nothing while the feature flag is off, + * on the dashboard (the section is already visible there), or before the count is known. + */ + +import { useEffect, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { useCurrentAccount, useSuiClient } from '@mysten/dapp-kit' +import { config } from '../config' +import { listOwnedWalrusBlobs } from '../utils/walrusBlobs' + +const DISMISS_KEY = 'memwal_cleanup_banner_dismissed' + +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( + () => localStorage.getItem(DISMISS_KEY) === 'true', + ) + + useEffect(() => { + if (!config.enableMemoryDeletion || !address || dismissed) return + let cancelled = false + listOwnedWalrusBlobs(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 || + location.pathname === '/dashboard' + ) { + return null + } + + return ( +
+

+ You have {count} old memor{count === 1 ? 'y' : 'ies'} stored on Walrus.{' '} + Delete them if you no longer want them — deletion is + permanent. +

+ +
+ ) +} diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index 52429f52..d2ae8489 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -40,6 +40,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/index.css b/apps/app/src/index.css index 6cd13e1c..1f0e1dd1 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -11269,3 +11269,66 @@ 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; +} + +/* 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 f9750a66..f8eb5a3e 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -23,6 +23,7 @@ SyntaxHighlighter.registerLanguage('javascript', js) SyntaxHighlighter.registerLanguage('python', python) import { useDelegateKey } from '../App' import { Card } from '../components/Card' +import CleanupSection from '../components/CleanupSection' import { SecretValueInput } from '../components/SecretValueInput' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' @@ -1371,6 +1372,8 @@ const result = await generateText({ + {config.enableMemoryDeletion && } + {removeKeysConfirm && (
+ +export interface OwnedWalrusBlob { + /** Sui object id — what `system::delete_blob` consumes */ + objectId: string + /** Walrus blob id, base64url (matches relayer DB `blob_id`) */ + blobId: string + /** Only deletable blobs can be deleted; the rest are permanent */ + deletable: boolean +} + +let walrusClient: WalrusClient | null = null + +export function getWalrusClient(suiClient: SuiClient): WalrusClient { + if (!walrusClient) { + walrusClient = new WalrusClient({ + network: config.suiNetwork, + suiClient: suiClient as never, + }) + } + return walrusClient +} + +/** + * 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 + } +} + +/** + * Enumerate every Walrus `blob::Blob` object the wallet owns. The Walrus + * package id comes 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 while this runs. + */ +export async function listOwnedWalrusBlobs( + suiClient: SuiClient, + owner: string, + onProgress?: (count: number) => void, +): Promise { + const blobType = await getWalrusClient(suiClient).getBlobType() + + 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: obj.json?.deletable === true, + }) + } + 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: fields.deletable === true, + }) + } + hasMore = page.hasNextPage + cursor = page.nextCursor + } + + onProgress?.(blobs.length) + } + + return blobs +} 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..efef30e2 --- /dev/null +++ b/docs/fundamentals/deleting-old-memories.md @@ -0,0 +1,55 @@ +--- +title: "Deleting Old Memories" +--- + +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**. It cannot be recovered, restored, or re-indexed — by + you, by the Walrus Memory team, or by anyone else. +- A deleted memory will **never be migrated** to any future version of + Walrus Memory. If you delete before a migration, that memory simply never + arrives on the other side. +- There is **no undo, no trash bin, and no grace period**. The moment the + transaction lands on-chain, 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 page** — from the "old memories" banner or the "Delete old + memories" section at the bottom of your dashboard. The app scans your wallet on-chain and lists every deletable + memory blob it owns. +2. **Choose what to delete** — everything is selected by default; untick + anything you want to keep. +3. **Confirm** — a dialog restates that deletion is permanent. +4. **Sign** — the app builds the delete transaction for you and sponsors the + gas, so signing is the only thing you do. Deletion runs in batches of + ~950 memories per transaction: a wallet with 20,000 memories signs about + 22 times, a wallet with a few hundred signs once. +5. **Done** — the server submits each signed transaction, waits for it to + land on-chain, and removes the matching rows from your memory index in + the same call. Reclaimed Walrus storage objects are returned to your + wallet. + +If the flow is interrupted partway (a rejected signature, a network drop), +nothing is lost or half-deleted: already-processed batches are fully deleted +on-chain and in the index, and re-opening the page picks up exactly the +remaining blobs. + +## Notes + +- Gas is sponsored — deleting costs you nothing. +- Only blobs marked `deletable` on-chain can be deleted; permanent blobs are + not shown. +- You need an active delegate-key session (the same one the dashboard and + playground use) so the server can verify the request is really yours. diff --git a/services/server/src/main.rs b/services/server/src/main.rs index be16ba41..1351810a 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -763,6 +763,10 @@ async fn main() { // admin/harness endpoints — namespace delete + stats. // Mode-blind; owner-scoped via AuthInfo. .route("/api/forget", post(routes::forget)) + // WALM-264: permanent V1 memory deletion (submit signed PTB + + // delete DB rows). Gated by ENABLE_MEMORY_DELETION (404 when off). + .route("/api/delete-memories", post(routes::delete_memories)) + .route("/api/memory-blob-ids", post(routes::memory_blob_ids)) .route("/api/stats", post(routes::stats)) // Router::layer runs middleware bottom-to-top (last added runs first). // Keep auth outer so AuthInfo is in request extensions before rate limiting reads it. diff --git a/services/server/src/routes/admin.rs b/services/server/src/routes/admin.rs index d5b55ac8..69521a31 100644 --- a/services/server/src/routes/admin.rs +++ b/services/server/src/routes/admin.rs @@ -74,6 +74,108 @@ 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 — one PTB holds ~950 deletes (Sui's +/// 1024-command cap minus headroom), so anything above that 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 the verified 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). Same flag gate as delete. +pub async fn memory_blob_ids( + State(state): State>, + Extension(auth): Extension, +) -> Result, AppError> { + if !state.config.enable_memory_deletion { + return Err(AppError::BlobNotFound("Not found".into())); + } + let blob_ids = state.db.list_blob_ids_by_owner(&auth.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 lands on-chain — delete +/// the matching `vector_entries` rows. The backend owns both side-effects +/// so chain and DB can't drift (a frontend that submits but never cleans +/// the DB, or vice versa). Owner comes from verified auth, never the body, +/// so a caller can only ever remove their own rows. If the row-delete step +/// fails after a successful submit, `cleanup_expired_blob` self-heals the +/// rows on the next recall. +/// +/// Gated by `ENABLE_MEMORY_DELETION` (off by default) until rollout is +/// agreed — the route answers 404 when disabled, indistinguishable from +/// the route not existing. +pub async fn delete_memories( + State(state): State>, + Extension(auth): Extension, + 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())); + } + + let owner = &auth.owner; + tracing::info!( + "delete-memories: owner={} blobs={} submitting signed PTB", + owner, + 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. + super::sponsor::execute_sponsored_tx(&state, &body.digest, &body.signature).await?; + + let mut deleted_rows: u64 = 0; + for blob_id in &body.blob_ids { + deleted_rows += state.db.delete_by_blob_id(blob_id, 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 +792,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 d39e1b0b..904edc3a 100644 --- a/services/server/src/routes/remember.rs +++ b/services/server/src/routes/remember.rs @@ -1077,6 +1077,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..f561c7ab 100644 --- a/services/server/src/routes/sponsor.rs +++ b/services/server/src/routes/sponsor.rs @@ -52,12 +52,12 @@ fn validate_sui_address(s: &str) -> bool { } /// 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 { let len = s.len(); if len != 43 && len != 44 { return false; @@ -72,10 +72,83 @@ 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, +) -> Result<(reqwest::StatusCode, axum::body::Bytes), AppError> { + let forwarded = serde_json::json!({ + "digest": digest, + "signature": 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)))?; + Ok((upstream_status, resp_body)) +} + +/// Execute a user-signed sponsored transaction via the sidecar and wait for +/// the result. Used by `/api/delete-memories`, which must confirm on-chain +/// success before touching the DB. Returns `Ok(())` only on success. +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).await?; + if upstream_status.is_success() { + Ok(()) + } 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 +338,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).await?; if upstream_status.is_success() { Ok(Response::builder() diff --git a/services/server/src/storage/db.rs b/services/server/src/storage/db.rs index 968539d5..adce5ef0 100644 --- a/services/server/src/storage/db.rs +++ b/services/server/src/storage/db.rs @@ -391,6 +391,27 @@ impl VectorDb { /// 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. + /// 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()) + } + pub async fn delete_by_blob_id(&self, blob_id: &str, owner: &str) -> Result { let started = std::time::Instant::now(); let result = sqlx::query("DELETE FROM vector_entries WHERE blob_id = $1 AND owner = $2") diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 246b4005..a1702158 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -229,6 +229,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 { @@ -299,6 +304,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"), } } } @@ -859,6 +865,42 @@ 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. +#[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)] From d8004faa493f33e7609e5f145e60a7488c0c12c6 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 19:20:34 +0700 Subject: [PATCH 04/15] fix(app): accept JSON-RPC's nested UID shape in registry table lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/app/src/utils/suiClientCompat.ts | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 apps/app/src/utils/suiClientCompat.ts diff --git a/apps/app/src/utils/suiClientCompat.ts b/apps/app/src/utils/suiClientCompat.ts new file mode 100644 index 00000000..5b119831 --- /dev/null +++ b/apps/app/src/utils/suiClientCompat.ts @@ -0,0 +1,127 @@ +/** + * Sui client compatibility layer — App.tsx's SuiClientProvider hands out a + * SuiGrpcClient when VITE_SUI_GRPC_URL is set for the active network (opt-in, + * mirroring the sidecar's SUI_GRPC_URL migration for the JSON-RPC sunset) and + * a SuiJsonRpcClient otherwise. The two have different getObject / + * dynamic-field / execute method shapes — these helpers branch on which one + * is actually present so callers work correctly under either, instead of + * assuming one shape unconditionally. + */ + +import { fromBase64, fromHex, normalizeSuiAddress, toHex } from '@mysten/sui/utils' + +/** + * Single transport discriminator shared by every cross-transport helper — + * gRPC clients expose getDynamicField, JSON-RPC clients don't. + */ +export function isGrpcClient(suiClient: unknown): boolean { + return typeof (suiClient as { getDynamicField?: unknown })?.getDynamicField === 'function' +} + +// JSON-RPC's showContent wraps every nested Move struct in its own +// {type, fields, hasPublicTransfer} envelope (e.g. accounts.fields.id.id); +// gRPC's .json is fully flat (accounts.id). Strip every such wrapper +// recursively so both transports produce the same flat shape. +function unwrapJsonRpcFields(value: unknown): unknown { + if (Array.isArray(value)) return value.map(unwrapJsonRpcFields) + if (value && typeof value === 'object') { + const obj = value as Record + const inner = 'fields' in obj && typeof obj.fields === 'object' && obj.fields !== null ? obj.fields : obj + const out: Record = {} + for (const [k, v] of Object.entries(inner as Record)) { + out[k] = unwrapJsonRpcFields(v) + } + return out + } + return value +} + +/** Fetch a Move object's fields as a flat JS object, regardless of client transport. */ +export async function fetchObjectJson(suiClient: unknown, objectId: string): Promise | null> { + if (isGrpcClient(suiClient)) { + const res = await (suiClient as any).getObject({ objectId, include: { json: true } }) + return (res?.object?.json as Record) ?? null + } + + const res = await (suiClient as any).getObject({ id: objectId, options: { showContent: true } }) + const content = res?.data?.content + if (!content || !('fields' in content)) return null + return unwrapJsonRpcFields(content.fields) as Record +} + +// The registry's inner Table object ID is an immutable on-chain constant — +// cache it so repeat account lookups skip the registry round trip. +const registryTableIdCache = new Map() + +/** Resolve a MemWalAccount object ID for `ownerAddress` via the registry's Table. */ +export async function fetchAccountIdForOwner( + suiClient: unknown, + registryId: string, + ownerAddress: string, +): Promise { + let tableId = registryTableIdCache.get(registryId) + if (!tableId) { + const registryJson = await fetchObjectJson(suiClient, registryId) + // 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) + } + + if (isGrpcClient(suiClient)) { + const dynFieldRes = await (suiClient as any).getDynamicField({ + parentId: tableId, + name: { type: 'address', bcs: fromHex(normalizeSuiAddress(ownerAddress)) }, + }) + const valueBytes = dynFieldRes?.dynamicField?.value?.bcs + if (!valueBytes || valueBytes.length !== 32) return null + return '0x' + toHex(valueBytes) + } + + const dynField = await (suiClient as any).getDynamicFieldObject({ + parentId: tableId, + name: { type: 'address', value: ownerAddress }, + }) + const content = dynField?.data?.content + if (!content || !('fields' in content)) return null + const value = (content.fields as Record)?.value + return typeof value === 'string' ? value : null +} + +/** Normalize a delegate key's public_key field to hex — gRPC encodes it as base64, JSON-RPC as number[]. */ +export function publicKeyToHex(publicKey: unknown): string { + if (typeof publicKey === 'string') return toHex(fromBase64(publicKey)) + if (Array.isArray(publicKey)) return toHex(new Uint8Array(publicKey as number[])) + console.warn('[suiClientCompat] unrecognized public_key encoding', typeof publicKey) + return '' +} + +/** + * Execute a signed transaction, regardless of client transport. dapp-kit's + * default useSignAndExecuteTransaction execute step calls the JSON-RPC-only + * client.executeTransactionBlock(); gRPC's core API only has + * executeTransaction(), with a {Transaction:{digest}} | {FailedTransaction: + * {digest}} union response instead of a flat one. + */ +export async function executeTransactionCompat( + client: unknown, + { bytes, signature }: { bytes: string; signature: string }, +): Promise<{ digest: string; rawEffects?: number[] }> { + if (!isGrpcClient(client)) { + const res = await (client as any).executeTransactionBlock({ + transactionBlock: bytes, + signature, + options: { showRawEffects: true }, + }) + return { digest: res.digest, rawEffects: res.rawEffects } + } + + const res = await (client as any).executeTransaction({ transaction: fromBase64(bytes), signatures: [signature] }) + const digest = res?.Transaction?.digest ?? res?.FailedTransaction?.digest + if (!digest) throw new Error('executeTransaction: could not resolve digest from result') + return { digest } +} From ed7cc2330c80391ce7a91b30d2e8f831b3f501cb Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 19:25:59 +0700 Subject: [PATCH 05/15] fix(app): upgrade apiCall signing to the nonce-based auth scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/app/src/utils/api.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/app/src/utils/api.ts b/apps/app/src/utils/api.ts index d8bcbc41..ed0d1d3d 100644 --- a/apps/app/src/utils/api.ts +++ b/apps/app/src/utils/api.ts @@ -9,16 +9,21 @@ /** * Sign a request using Ed25519 delegate key. * - * Message format: "{timestamp}.{method}.{path}.{sha256(body)}" + * Message format (must match the relayer auth middleware and SDK v0.4+): + * "{timestamp}.{method}.{path}.{sha256(body)}.{nonce}.{account_id}" + * The nonce is a per-request UUID v4 (replay protection) — requests without + * an x-nonce header are rejected with 426 as unsupported legacy clients. */ export async function signRequest( privateKeyHex: string, method: string, path: string, body: string, + accountId = '', ) { const ed = await import('@noble/ed25519') const timestamp = Math.floor(Date.now() / 1000).toString() + const nonce = crypto.randomUUID() const bodyBytes = new TextEncoder().encode(body) const hashBuf = await crypto.subtle.digest('SHA-256', bodyBytes) @@ -26,7 +31,7 @@ export async function signRequest( .map((b) => 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) { From 9a2be4ff85130e274aa82fee7a8f714313935dd6 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 19:28:31 +0700 Subject: [PATCH 06/15] refactor(app): render the old-memories banner inside the Dashboard shell 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. --- apps/app/src/App.tsx | 4 -- apps/app/src/components/OldMemoriesBanner.tsx | 38 ++++++++++--------- apps/app/src/index.css | 12 ++++++ apps/app/src/pages/Dashboard.tsx | 3 ++ 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 92d1ddc2..5e0e0586 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -26,7 +26,6 @@ import Dashboard from './pages/Dashboard' import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' import ConnectMcp from './pages/ConnectMcp' -import OldMemoriesBanner from './components/OldMemoriesBanner' import { useRouteAnalytics } from './hooks/useRouteAnalytics' @@ -251,8 +250,6 @@ function AppContent() { } return ( - <> - : @@ -268,7 +265,6 @@ function AppContent() { } /> } /> - ) } diff --git a/apps/app/src/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx index 4978c26e..5040977e 100644 --- a/apps/app/src/components/OldMemoriesBanner.tsx +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -1,15 +1,17 @@ /** * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". * - * Shown once per connected wallet (dismissal persisted in localStorage) - * when the wallet owns deletable V1 Walrus blobs. Links to the Dashboard cleanup section. - * Mounted in AppContent; renders nothing while the feature flag is off, - * on the dashboard (the section is already visible there), or before the count is known. + * Rendered at the top of the Dashboard shell (below the navbar, above the + * page header) so it's the first thing a user sees; links down to the + * cleanup section on the same page. Shown once per browser (dismissal + * persisted in localStorage) when the wallet owns deletable V1 Walrus + * blobs; renders nothing while the feature flag is off or before the + * count is known. */ 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 { listOwnedWalrusBlobs } from '../utils/walrusBlobs' @@ -18,7 +20,6 @@ const DISMISS_KEY = 'memwal_cleanup_banner_dismissed' export default function OldMemoriesBanner() { const currentAccount = useCurrentAccount() const suiClient = useSuiClient() - const location = useLocation() const address = currentAccount?.address || '' const [count, setCount] = useState(null) @@ -41,26 +42,29 @@ export default function OldMemoriesBanner() { } }, [address, suiClient, dismissed]) - if ( - !config.enableMemoryDeletion || - !address || - dismissed || - !count || - location.pathname === '/dashboard' - ) { + if (!config.enableMemoryDeletion || !address || dismissed || !count) { return null } return ( -
+
+
} @@ -234,7 +221,7 @@ export default function CleanupSection() {
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 memories you are sure you no longer want. + anywhere. Only delete your old memories if you are sure you no longer want them.
{error && ( @@ -263,87 +250,30 @@ export default function CleanupSection() {

)} - {phase.kind !== 'loading' && deletableBlobs.length === 0 ? ( - phase.kind !== 'done' && ( + {phase.kind === 'ready' && ( + !delegateKey ? ( + // Without the session the list can't be scoped to real + // memories, so a count here would be wrong — prompt for + // setup instead. +

+ A delegate key session is required to delete — set one up first. +

+ ) : count === 0 ? (
No deletable memories found for this wallet.
- ) - ) : phase.kind !== 'loading' && ( - <> + ) : (

- {deletableBlobs.length} deletable memor{deletableBlobs.length === 1 ? 'y' : 'ies'} found - {permanentCount > 0 ? ` (${permanentCount} non-deletable blobs not shown)` : ''}. - {' '}Deletion runs in batches of {DELETE_BATCH_SIZE} — one signature per batch. + 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.'}

- -
- - - - - - - - - - {deletableBlobs.map((blob) => { - const isSelected = selected.has(blob.objectId) - return ( - - - - - - ) - })} - -
- - Blob IDObject ID
- - - - {blob.blobId.slice(0, 10)}...{blob.blobId.slice(-6)} - - - - {blob.objectId.slice(0, 8)}...{blob.objectId.slice(-6)} - -
-
- - {!delegateKey && ( -

- A delegate key session is required to delete — set one up first. -

- )} - + ) )} {confirming && ( @@ -362,10 +292,10 @@ export default function CleanupSection() { >

- Permanently delete {selectedCount} memor{selectedCount === 1 ? 'y' : 'ies'}? + Permanently delete all {count} old memor{count === 1 ? 'y' : 'ies'}?

- This erases the selected memories from Walrus and from your memory index, + 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.

@@ -393,9 +323,3 @@ export default function CleanupSection() { ) } - -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/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx index 5040977e..15c98885 100644 --- a/apps/app/src/components/OldMemoriesBanner.tsx +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -1,37 +1,58 @@ /** * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". * - * Rendered at the top of the Dashboard shell (below the navbar, above the - * page header) so it's the first thing a user sees; links down to the - * cleanup section on the same page. Shown once per browser (dismissal - * persisted in localStorage) when the wallet owns deletable V1 Walrus - * blobs; renders nothing while the feature flag is off or before the - * count is known. + * 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; without a delegate-key session the count can't be + * scoped, so the banner stays hidden. 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 { listOwnedWalrusBlobs } from '../utils/walrusBlobs' +import { useDelegateKey } from '../App' +import { listScopedDeletableBlobs } from '../utils/walrusBlobs' + +const DISMISS_KEY_PREFIX = 'memwal_cleanup_banner_dismissed' -const DISMISS_KEY = '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 { delegateKey, accountObjectId } = useDelegateKey() const address = currentAccount?.address || '' const [count, setCount] = useState(null) - const [dismissed, setDismissed] = useState( - () => localStorage.getItem(DISMISS_KEY) === 'true', - ) + const [dismissed, setDismissed] = useState(() => (address ? isDismissed(address) : false)) + + useEffect(() => { + setDismissed(address ? isDismissed(address) : false) + }, [address]) useEffect(() => { - if (!config.enableMemoryDeletion || !address || dismissed) return + if (!config.enableMemoryDeletion || !address || !delegateKey || dismissed) return let cancelled = false - listOwnedWalrusBlobs(suiClient, address) - .then((blobs) => { + listScopedDeletableBlobs(suiClient, address, { delegateKey, accountObjectId }) + .then(({ blobs }) => { if (!cancelled) setCount(blobs.filter((b) => b.deletable).length) }) .catch(() => { @@ -40,9 +61,9 @@ export default function OldMemoriesBanner() { return () => { cancelled = true } - }, [address, suiClient, dismissed]) + }, [address, suiClient, delegateKey, accountObjectId, dismissed]) - if (!config.enableMemoryDeletion || !address || dismissed || !count) { + if (!config.enableMemoryDeletion || !address || !delegateKey || dismissed || !count) { return null } @@ -51,22 +72,24 @@ export default function OldMemoriesBanner() {
} > -
- 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} diff --git a/apps/app/src/index.css b/apps/app/src/index.css index 0aa21bdd..ab64c5c4 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -11277,11 +11277,48 @@ h1, h2, h3 { /* 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. */ + 56px card rhythm — and, as the new last card, zeroes its own bottom. + Shell/type mirror the keys+install cards' final design; the generic + 860px rules (24px padding, 28px title, 44px pills — all !important) + cover mobile the same way they do for those cards. */ .dash-page .dashboard-cleanup-card { order: 7; margin-top: 56px; margin-bottom: 0 !important; + padding: 39px 40px; + border-color: #54575a; + border-radius: 24px; + background: #000000; + overflow: clip; +} + +.dash-page .dashboard-cleanup-card .card-header { + align-items: flex-start; + gap: 24px; + margin-bottom: 34px; +} + +.dash-page .dashboard-cleanup-card .card-title { + color: #faf8f5; + font-family: var(--font-sans); + font-size: 36px; + font-weight: 500; + line-height: 0.88; + letter-spacing: 0; +} + +.dash-page .dashboard-cleanup-card .card-subtitle { + margin-top: 14px; + color: #b8bbbe; + font-family: var(--font-sans); + font-size: 18px; + font-weight: 400; + line-height: 1.3; +} + +.dash-page .dashboard-cleanup-card .card-header-actions { + align-self: flex-start; + gap: 20px; } /* Old-memories banner — first thing in the shell: below the navbar, @@ -11319,37 +11356,36 @@ h1, h2, h3 { 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; + padding: 12px 16px; + margin: 0 0 18px; color: var(--danger); - font-size: 0.82rem; - line-height: 1.35; + font-size: 16px; + line-height: 1.4; word-break: break-word; } +/* Body copy matches the final card-subtitle scale (18px / 1.3). */ .dash-page .dashboard-cleanup-status { - margin: 0 0 14px; + margin: 0; color: #b8bbbe; - font-size: 0.82rem; - line-height: 1.35; + font-size: 18px; + font-weight: 400; + line-height: 1.3; +} + +/* The generic 860px rules shrink header pills to 44px but only match + .btn-secondary — keep the danger pill in step with Refresh. */ +@media (max-width: 860px) { + .dash-page .dashboard-cleanup-delete { + min-height: 44px; + height: 44px; + padding: 0 16px; + border-radius: 22px; + font-size: 15px; + letter-spacing: 0.25px; + } } diff --git a/apps/app/src/utils/sponsor.ts b/apps/app/src/utils/sponsor.ts index a42a644d..f0e879e4 100644 --- a/apps/app/src/utils/sponsor.ts +++ b/apps/app/src/utils/sponsor.ts @@ -15,11 +15,24 @@ export interface SponsoredTx { digest: string } +export interface SponsorOptions { + /** + * Route through POST /sponsor-delete instead of /sponsor. That path has + * no sponsor rate limit — a delete-all run sponsors one transaction per + * batch and would trip the normal per-IP/per-sender caps mid-run — and + * in exchange the sidecar only sponsors transactions it verifies to be + * pure walrus `system::delete_blob` cleanups. + */ + deleteFlow?: boolean +} + export async function sponsorTransactionKind( kindBytes: Uint8Array, sender: string, + { deleteFlow }: SponsorOptions = {}, ): Promise { - const res = await fetch(`${config.memwalServerUrl}/sponsor`, { + const endpoint = deleteFlow ? '/sponsor-delete' : '/sponsor' + const res = await fetch(`${config.memwalServerUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/services/server/scripts/__tests__/sponsor-delete-verify.test.ts b/services/server/scripts/__tests__/sponsor-delete-verify.test.ts new file mode 100644 index 00000000..4e3813d2 --- /dev/null +++ b/services/server/scripts/__tests__/sponsor-delete-verify.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Transaction } from "@mysten/sui/transactions"; +import { toBase64 } from "@mysten/sui/utils"; + +import { verifyDeleteOnlyKind } from "../sidecar/sponsor-delete-verify.js"; + +// The deleteOnly sponsor path (WALM-264) skips both sponsor rate-limit axes, +// so this verifier is the only thing standing between the exemption and a +// free bypass for sponsoring arbitrary transactions. These tests build real +// TransactionKind bytes offline (objectRef inputs need no chain resolution) +// and assert that ONLY pure delete-blob cleanups pass. + +const WALRUS_PKG = "0x" + "ab".repeat(32); +const OTHER_PKG = "0x" + "cd".repeat(32); +const SENDER = "0x" + "12".repeat(32); +const OTHER_ADDR = "0x" + "34".repeat(32); + +// base58 of 32 zero bytes — any well-formed digest works for an objectRef. +const DIGEST = "11111111111111111111111111111111"; + +let nextObjectId = 0; +function objectRef(tx: Transaction) { + nextObjectId++; + return tx.objectRef({ + objectId: "0x" + nextObjectId.toString(16).padStart(64, "0"), + version: "1", + digest: DIGEST, + }); +} + +function deleteBlobCall(tx: Transaction, pkg: string = WALRUS_PKG) { + return tx.moveCall({ + package: pkg, + module: "system", + function: "delete_blob", + arguments: [objectRef(tx), objectRef(tx)], + }); +} + +async function kindBase64(build: (tx: Transaction) => void): Promise { + const tx = new Transaction(); + build(tx); + return toBase64(await tx.build({ onlyTransactionKind: true })); +} + +test("accepts deletes plus transfer of reclaimed storage to the sender", async () => { + const kind = await kindBase64((tx) => { + const a = deleteBlobCall(tx); + const b = deleteBlobCall(tx); + tx.transferObjects([a, b], SENDER); + }); + assert.equal(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG), null); +}); + +test("accepts deletes without a transfer", async () => { + const kind = await kindBase64((tx) => { + deleteBlobCall(tx); + }); + assert.equal(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG), null); +}); + +test("normalizes addresses before comparing", async () => { + const kind = await kindBase64((tx) => { + const a = deleteBlobCall(tx); + tx.transferObjects([a], SENDER); + }); + assert.equal( + verifyDeleteOnlyKind(kind, SENDER.toUpperCase().replace("0X", "0x"), WALRUS_PKG.toUpperCase().replace("0X", "0x")), + null, + ); +}); + +test("rejects a move call against a foreign package", async () => { + const kind = await kindBase64((tx) => { + deleteBlobCall(tx, OTHER_PKG); + }); + assert.match(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG) ?? "", /delete_blob calls are allowed/); +}); + +test("rejects a different function in the walrus system module", async () => { + const kind = await kindBase64((tx) => { + tx.moveCall({ + package: WALRUS_PKG, + module: "system", + function: "extend_blob", + arguments: [objectRef(tx), objectRef(tx)], + }); + }); + assert.match(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG) ?? "", /delete_blob calls are allowed/); +}); + +test("rejects a transfer to anyone but the sender", async () => { + const kind = await kindBase64((tx) => { + const a = deleteBlobCall(tx); + tx.transferObjects([a], OTHER_ADDR); + }); + assert.match(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG) ?? "", /recipient must be the sender/); +}); + +test("rejects transferring pre-existing owned objects (inputs, not results)", async () => { + const kind = await kindBase64((tx) => { + deleteBlobCall(tx); + tx.transferObjects([objectRef(tx)], SENDER); + }); + assert.match(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG) ?? "", /results of delete_blob/); +}); + +test("rejects other command kinds", async () => { + const kind = await kindBase64((tx) => { + deleteBlobCall(tx); + tx.splitCoins(tx.gas, [1n]); + }); + assert.match(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG) ?? "", /SplitCoins is not allowed/); +}); + +test("rejects a transaction with no delete_blob calls", async () => { + const kind = await kindBase64((tx) => { + tx.transferObjects([objectRef(tx)], SENDER); + }); + assert.notEqual(verifyDeleteOnlyKind(kind, SENDER, WALRUS_PKG), null); +}); + +test("rejects bytes that are not a TransactionKind", () => { + assert.match( + verifyDeleteOnlyKind(toBase64(new Uint8Array([1, 2, 3])), SENDER, WALRUS_PKG) ?? "", + /not a valid TransactionKind/, + ); +}); diff --git a/services/server/scripts/sidecar/routes/sponsor.ts b/services/server/scripts/sidecar/routes/sponsor.ts index 9cf51b20..e6eaf0f7 100644 --- a/services/server/scripts/sidecar/routes/sponsor.ts +++ b/services/server/scripts/sidecar/routes/sponsor.ts @@ -6,10 +6,11 @@ */ import express, { type Express } from "express"; -import { suiClient } from "../clients.js"; +import { getWalrusClient, 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 { verifyDeleteOnlyKind } from "../sponsor-delete-verify.js"; import { errorMessage } from "../util.js"; /** @@ -54,7 +55,7 @@ async function verifyExecutedTransaction( export function registerSponsorRoutes(app: Express): void { app.post("/sponsor", express.json({ limit: JSON_LIMIT_METADATA }), async (req, res) => { try { - const { transactionBlockKindBytes, sender } = req.body; + const { transactionBlockKindBytes, sender, deleteOnly } = req.body; if (!transactionBlockKindBytes || !sender) { return res.status(400).json({ error: "Missing required fields: transactionBlockKindBytes, sender" }); } @@ -62,6 +63,18 @@ export function registerSponsorRoutes(app: Express): void { return res.status(503).json({ error: "Enoki sponsorship is not configured (ENOKI_API_KEY missing)" }); } + // WALM-264: deleteOnly marks a request from the Rust server's + // rate-limit-exempt /sponsor-delete route — refuse to sponsor + // anything that isn't strictly a cleanup transaction, otherwise + // the exemption would be a free bypass for arbitrary sponsoring. + if (deleteOnly === true) { + const { package_id } = await getWalrusClient().systemObject(); + const rejection = verifyDeleteOnlyKind(transactionBlockKindBytes, sender, package_id); + if (rejection !== null) { + return res.status(400).json({ error: `deleteOnly verification failed: ${rejection}` }); + } + } + // Redact full sender address (PII / deanonymisation) — log only // a short prefix for correlation. Never log the full digest here either. const senderPrefix = typeof sender === "string" ? sender.slice(0, 10) : "unknown"; diff --git a/services/server/scripts/sidecar/sponsor-delete-verify.ts b/services/server/scripts/sidecar/sponsor-delete-verify.ts new file mode 100644 index 00000000..8bd5cfd1 --- /dev/null +++ b/services/server/scripts/sidecar/sponsor-delete-verify.ts @@ -0,0 +1,87 @@ +/** + * Delete-only PTB verification for the rate-limit-exempt sponsor path + * (WALM-264). POST /sponsor with `deleteOnly: true` comes from the Rust + * server's /sponsor-delete route, which skips BOTH sponsor rate-limit axes + * (per-IP middleware and per-sender bucket) so a delete-all run over + * hundreds of blobs isn't throttled mid-way. That exemption is only safe + * because this check rejects anything that isn't strictly a cleanup + * transaction — otherwise the flag would be a free bypass for sponsoring + * arbitrary transactions. + * + * A kind passes iff every command is one of: + * - MoveCall `::system::delete_blob` against the live Walrus + * package id (read from the on-chain System object — the same source + * the app's SDK resolves its target from), or + * - TransferObjects sending ONLY results of earlier commands (the + * reclaimed Storage objects) back to the sponsored sender itself. + * Inputs are rejected so the exempt path can't move pre-existing + * owned objects gaslessly, and any other recipient is rejected. + * and at least one delete_blob is present. + */ + +import { Transaction } from "@mysten/sui/transactions"; +import { fromBase64, normalizeSuiAddress, toHex } from "@mysten/sui/utils"; + +/** Returns null when the kind is a valid delete-only PTB, else a reason. */ +export function verifyDeleteOnlyKind( + kindBytesBase64: string, + sender: string, + walrusPackageId: string, +): string | null { + let data: ReturnType; + try { + data = Transaction.fromKind(fromBase64(kindBytesBase64)).getData(); + } catch { + return "transactionBlockKindBytes is not a valid TransactionKind"; + } + + const commands = data.commands ?? []; + if (commands.length === 0) return "transaction has no commands"; + + const expectedPackage = normalizeSuiAddress(walrusPackageId); + const expectedSender = normalizeSuiAddress(sender); + let deleteCount = 0; + + for (const command of commands) { + if (command.$kind === "MoveCall") { + const call = command.MoveCall; + if ( + normalizeSuiAddress(call.package) !== expectedPackage || + call.module !== "system" || + call.function !== "delete_blob" + ) { + return `only ${expectedPackage}::system::delete_blob calls are allowed`; + } + deleteCount++; + continue; + } + + if (command.$kind === "TransferObjects") { + const transfer = command.TransferObjects; + // Only results of the delete calls (reclaimed Storage) may move. + for (const obj of transfer.objects) { + if (obj.$kind !== "Result" && obj.$kind !== "NestedResult") { + return "TransferObjects may only move results of delete_blob calls"; + } + } + const address = transfer.address; + if (address.$kind !== "Input") { + return "TransferObjects recipient must be a pure input"; + } + const input = data.inputs[address.Input]; + if (input?.$kind !== "Pure") { + return "TransferObjects recipient must be a pure input"; + } + const bytes = fromBase64(input.Pure.bytes); + if (bytes.length !== 32 || `0x${toHex(bytes)}` !== expectedSender) { + return "TransferObjects recipient must be the sender"; + } + continue; + } + + return `command kind ${command.$kind} is not allowed in a delete-only transaction`; + } + + if (deleteCount === 0) return "transaction contains no delete_blob calls"; + return null; +} diff --git a/services/server/src/main.rs b/services/server/src/main.rs index c92de598..4a5dc030 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -786,15 +786,6 @@ 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)), @@ -804,6 +795,28 @@ async fn main() { rate_limit::sponsor_rate_limit_middleware, )); + // WALM-264: permanent V1 memory deletion — deliberately OUTSIDE the + // sponsor rate-limit middleware. A delete-all run over hundreds of + // blobs makes ~2 calls per 40-blob batch (sponsor + submit), which + // trips the 10/min-per-IP bucket after a handful of batches and + // strands the run. Exempting is safe because both routes are gated by + // ENABLE_MEMORY_DELETION (404 when off) and are individually + // unspoofable: /sponsor-delete only sponsors PTBs the sidecar verifies + // to be pure walrus system::delete_blob cleanups, and + // /api/delete-memories only acts when the user-signed transaction + // SUCCEEDS on-chain (sponsored digests are single-use). No delegate-key + // gate (plan T1): ownership is the wallet signature on the sponsored + // transaction. + let deletion_routes = Router::new() + .route( + "/sponsor-delete", + post(routes::sponsor_delete_proxy).layer(DefaultBodyLimit::max(10 * 1024)), + ) + .route( + "/api/delete-memories", + post(routes::delete_memories).layer(DefaultBodyLimit::max(64 * 1024)), + ); + // MCP proxy routes — reverse-proxy to the Node sidecar's `/mcp/*` routes. // No signed-request auth here: MCP clients ship a single Bearer at SSE // open and the sidecar parses it as the Ed25519 delegate key. Body limit @@ -853,6 +866,7 @@ async fn main() { get(observability::metrics).layer(DefaultBodyLimit::max(16 * 1024)), ) .merge(sponsor_routes) + .merge(deletion_routes) .merge(mcp_routes); // CORS — restrict to configured origins. diff --git a/services/server/src/routes/mod.rs b/services/server/src/routes/mod.rs index 6724426c..a15b32b6 100644 --- a/services/server/src/routes/mod.rs +++ b/services/server/src/routes/mod.rs @@ -30,7 +30,7 @@ pub use recall::{recall, recall_manual}; pub use remember::{ remember, remember_bulk, remember_bulk_status, remember_manual, remember_status, }; -pub use sponsor::{sponsor_execute_proxy, sponsor_proxy}; +pub use sponsor::{sponsor_delete_proxy, sponsor_execute_proxy, sponsor_proxy}; use futures::stream::{self, StreamExt}; diff --git a/services/server/src/routes/sponsor.rs b/services/server/src/routes/sponsor.rs index 0b42fd1b..05422cfd 100644 --- a/services/server/src/routes/sponsor.rs +++ b/services/server/src/routes/sponsor.rs @@ -161,13 +161,10 @@ pub(super) async fn execute_sponsored_tx( } } -/// POST /sponsor — proxy to sidecar POST /sponsor -pub async fn sponsor_proxy( - State(state): State>, - body: axum::body::Bytes, -) -> Result, AppError> { - // Parse and validate — never echo back client-supplied values in errors. - let req: SponsorRequest = serde_json::from_slice(&body) +/// Parse and validate a /sponsor-shaped body (shared by `/sponsor` and +/// `/sponsor-delete`) — never echo back client-supplied values in errors. +fn parse_sponsor_request(body: &[u8]) -> Result { + let req: SponsorRequest = serde_json::from_slice(body) .map_err(|_| AppError::BadRequest("Invalid request body".into()))?; if !validate_sui_address(&req.sender) { @@ -183,57 +180,26 @@ pub async fn sponsor_proxy( )); } - // Per-sender rate limit — second axis that a distributed IP attack cannot bypass. - // Runs after validation so we only count well-formed requests against the sender. - { - let config = &state.config.sponsor_rate_limit; - match rate_limit::check_sender_rate_limit( - &state, - &req.sender, - config.per_minute, - config.per_hour, - ) - .await - { - Ok(rate_limit::SponsorRlResult::MinuteLimitExceeded) => { - tracing::warn!( - "sponsor rate limit [sender/min]: sender={}...", - &req.sender[..16] - ); - return Ok(json_error_response( - axum::http::StatusCode::TOO_MANY_REQUESTS, - "Rate limit exceeded", - )); - } - Ok(rate_limit::SponsorRlResult::HourLimitExceeded) => { - tracing::warn!( - "sponsor rate limit [sender/hr]: sender={}...", - &req.sender[..16] - ); - return Ok(json_error_response( - axum::http::StatusCode::TOO_MANY_REQUESTS, - "Rate limit exceeded", - )); - } - Ok(rate_limit::SponsorRlResult::Allowed) => {} - Err(_) => { - // Redis and in-memory fallback both unavailable — deny to fail-closed. - tracing::error!( - "sponsor sender rate limit unavailable for sponsor_proxy, denying request" - ); - return Ok(json_error_response( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - "Rate limiter temporarily unavailable", - )); - } - } - } + Ok(req) +} +/// Forward a validated sponsor request to the sidecar's POST /sponsor. +/// `delete_only: true` (the `/sponsor-delete` path) tells the sidecar to +/// verify the kind is strictly a WALM-264 delete-blob cleanup before +/// sponsoring — the check that makes that path's rate-limit exemption safe. +async fn forward_sponsor( + state: &AppState, + req: &SponsorRequest, + delete_only: bool, +) -> Result, AppError> { // Re-serialise only validated fields before forwarding. - let forwarded = serde_json::json!({ + let mut forwarded = serde_json::json!({ "sender": req.sender, "transactionBlockKindBytes": req.transaction_block_kind_bytes, }); + if delete_only { + forwarded["deleteOnly"] = serde_json::json!(true); + } let url = format!("{}/sponsor", state.config.sidecar_url); let mut req = state @@ -283,6 +249,87 @@ pub async fn sponsor_proxy( } } +/// POST /sponsor — proxy to sidecar POST /sponsor +pub async fn sponsor_proxy( + State(state): State>, + body: axum::body::Bytes, +) -> Result, AppError> { + let req = parse_sponsor_request(&body)?; + + // Per-sender rate limit — second axis that a distributed IP attack cannot bypass. + // Runs after validation so we only count well-formed requests against the sender. + { + let config = &state.config.sponsor_rate_limit; + match rate_limit::check_sender_rate_limit( + &state, + &req.sender, + config.per_minute, + config.per_hour, + ) + .await + { + Ok(rate_limit::SponsorRlResult::MinuteLimitExceeded) => { + tracing::warn!( + "sponsor rate limit [sender/min]: sender={}...", + &req.sender[..16] + ); + return Ok(json_error_response( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded", + )); + } + Ok(rate_limit::SponsorRlResult::HourLimitExceeded) => { + tracing::warn!( + "sponsor rate limit [sender/hr]: sender={}...", + &req.sender[..16] + ); + return Ok(json_error_response( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded", + )); + } + Ok(rate_limit::SponsorRlResult::Allowed) => {} + Err(_) => { + // Redis and in-memory fallback both unavailable — deny to fail-closed. + tracing::error!( + "sponsor sender rate limit unavailable for sponsor_proxy, denying request" + ); + return Ok(json_error_response( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "Rate limiter temporarily unavailable", + )); + } + } + } + + forward_sponsor(&state, &req, false).await +} + +/// POST /sponsor-delete — sponsor a WALM-264 delete-blob cleanup transaction. +/// +/// Deliberately NO sponsor rate limit on this path (neither the per-IP +/// middleware — see the router split in main.rs — nor the per-sender +/// bucket): a delete-all run sponsors one transaction per 40-blob batch, +/// so a wallet with hundreds of old memories would trip the 10/min caps +/// mid-run and strand the cleanup. The exemption cannot be abused for +/// arbitrary sponsoring: the sidecar refuses to sponsor a `deleteOnly` +/// request whose kind is anything but walrus `system::delete_blob` calls +/// plus a transfer of the reclaimed Storage back to the sender. +/// +/// Gated by `ENABLE_MEMORY_DELETION` (404 when off), same as the other +/// WALM-264 routes. +pub async fn sponsor_delete_proxy( + State(state): State>, + body: axum::body::Bytes, +) -> Result, AppError> { + if !state.config.enable_memory_deletion { + return Err(AppError::BlobNotFound("Not found".into())); + } + + let req = parse_sponsor_request(&body)?; + forward_sponsor(&state, &req, true).await +} + /// POST /sponsor/execute — proxy to sidecar POST /sponsor/execute pub async fn sponsor_execute_proxy( State(state): State>, From f0ff538ffe4ec742c8fe6e5735c6bbc152d0b88d Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:17:55 +0700 Subject: [PATCH 13/15] fix(app,relayer): default mainnet to the dedicated suiscan JSON-RPC endpoint The public fullnode.mainnet.sui.io pool serves stale/slow reads under load (read-after-write lag measured at 40-70s), which breaks flows that read state their own transaction just wrote. Point the relayer's SUI_RPC_URL at rpc-mainnet.suiscan.xyz and give the app a matching VITE_SUI_RPC_URL override (active network only; other networks keep the public default). --- apps/app/.env.example | 3 +++ apps/app/Dockerfile | 2 ++ apps/app/src/App.tsx | 14 ++++++++++++-- apps/app/src/config.ts | 5 +++++ services/server/.env.example | 4 +++- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/app/.env.example b/apps/app/.env.example index c7030e66..36042bd0 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -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 diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile index d8272ff2..cd37de25 100644 --- a/apps/app/Dockerfile +++ b/apps/app/Dockerfile @@ -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_ENOKI_API_KEY ARG VITE_GOOGLE_CLIENT_ID ARG VITE_DOCS_URL @@ -51,6 +52,7 @@ 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_ENOKI_API_KEY=$VITE_ENOKI_API_KEY ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID ENV VITE_DOCS_URL=$VITE_DOCS_URL diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 51092706..0fa42e19 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -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' }, }) const queryClient = new QueryClient() diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index d2ae8489..8f1b2b63 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -19,6 +19,11 @@ export const config = { '0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437', memwalServerUrl: import.meta.env.VITE_MEMWAL_SERVER_URL as string || 'http://localhost:8000', suiNetwork: (import.meta.env.VITE_SUI_NETWORK as string || 'testnet') as 'testnet' | 'mainnet', + // JSON-RPC endpoint override for the app's Sui client, mirroring the + // relayer's SUI_RPC_URL. Empty falls back to the public fullnode for the + // network — which serves stale/slow reads under load on mainnet, so + // production should point this at a dedicated RPC (e.g. suiscan). + suiRpcUrl: import.meta.env.VITE_SUI_RPC_URL as string || '', sealKeyServers: (import.meta.env.VITE_SEAL_KEY_SERVERS as string || '') .split(',').map(s => s.trim()).filter(Boolean) as string[], sidecarUrl: import.meta.env.VITE_SIDECAR_URL as string || 'http://localhost:9000', diff --git a/services/server/.env.example b/services/server/.env.example index bd0e0c8c..fb188b82 100644 --- a/services/server/.env.example +++ b/services/server/.env.example @@ -27,7 +27,9 @@ OPENAI_API_BASE=https://api.openai.com/v1 # Sui Network (for onchain verification) # Controls all network-dependent defaults (RPC, Walrus, SEAL) SUI_NETWORK=mainnet -SUI_RPC_URL=https://fullnode.mainnet.sui.io:443 +# Use a dedicated RPC endpoint (production uses rpc-mainnet.suiscan.xyz); +# the public fullnode.mainnet.sui.io pool serves stale reads under load. +SUI_RPC_URL=https://rpc-mainnet.suiscan.xyz # Walrus Storage (publisher + aggregator) WALRUS_PUBLISHER_URL=https://publisher.walrus-mainnet.walrus.space From ef99a079f883ed8f470c84925dc1b0bacb04ce40 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:51:06 +0700 Subject: [PATCH 14/15] =?UTF-8?q?chore(app):=20unmount=20the=20old-memorie?= =?UTF-8?q?s=20banner=20=E2=80=94=20rollout=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product wants the delete feature live without the top-of-page nudge for now. Both mount sites (AppContent app-wide + Dashboard shell) are removed; the component, its shared cached scan, and its CSS stay so re-enabling is just re-adding the two mounts (documented in the component header). The cleanup card on the dashboard is unaffected. --- apps/app/src/App.tsx | 7 +------ apps/app/src/components/OldMemoriesBanner.tsx | 6 ++++-- apps/app/src/pages/Dashboard.tsx | 3 --- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index f84177d3..ab202209 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -19,10 +19,9 @@ 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, useLocation } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } 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' @@ -269,7 +268,6 @@ function AppContent() { const currentAccount = useCurrentAccount() const autoConnectStatus = useAutoConnectWallet() const { delegateKey } = useDelegateKey() - const { pathname } = useLocation() const authPending = autoConnectStatus === 'idle' const requireAccount = (element: React.ReactNode) => { @@ -279,9 +277,6 @@ 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' && } : diff --git a/apps/app/src/components/OldMemoriesBanner.tsx b/apps/app/src/components/OldMemoriesBanner.tsx index 94ce676e..c4517cea 100644 --- a/apps/app/src/components/OldMemoriesBanner.tsx +++ b/apps/app/src/components/OldMemoriesBanner.tsx @@ -1,9 +1,11 @@ /** * OldMemoriesBanner (WALM-264 T3) — "You have N old memories — delete them". * - * Mounted twice, exactly one instance visible at a time: + * Currently NOT mounted anywhere — rollout deferred by product until further + * notice. To re-enable, mount twice with 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; + * the dashboard: `{pathname.replace(/\/+$/, '') !== '/dashboard' && }`; * - Dashboard's shell — below the navbar, above the "Welcome" header * (`.dash-alert--cleanup` order), linking down to the cleanup section. * diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index 6031d971..5064301c 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -24,7 +24,6 @@ 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' @@ -749,8 +748,6 @@ const result = await generateText({
- - {/* Header */}

Welcome to your Dashboard

From 3d26bf541a31228c5816de2847e1f1201cdef35a Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:29:07 +0700 Subject: [PATCH 15/15] fix(WALM-264): make /api/delete-memories digests single-use (replay guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #389 flagged that the DB row-delete was bound to a client-supplied blob-id list that is not part of the signed transaction, with replay protection outsourced entirely to Enoki. A sponsored digest and the user signature are both public on-chain (txSignatures), so a replay of a victim's past delete with an attacker-chosen blob-id list could desync that victim's index if the upstream execute ever returned success for an already-consumed digest. Claim the digest single-use in Postgres (migration 010, PK + ON CONFLICT DO NOTHING) after execute confirms on-chain success but before any row-delete: a first-time caller is never blocked by a transient execute failure, and a replay is rejected here regardless of upstream behaviour. Corrects the handler doc comment (the self-heal claim only holds for a signer's own blobs, which the tx really deleted → Walrus 404 → cleanup_expired_blob; it does not cover a replay). Also drops the now-pointless <> fragment left in App.tsx after the banner unmount (review nit). --- apps/app/src/App.tsx | 2 -- .../010_processed_delete_digests.sql | 16 +++++++++ services/server/src/routes/admin.rs | 33 ++++++++++++++++--- services/server/src/storage/db.rs | 31 +++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 services/server/migrations/010_processed_delete_digests.sql diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index ab202209..ab3fd2b2 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -276,7 +276,6 @@ function AppContent() { } return ( - <> : @@ -292,7 +291,6 @@ function AppContent() { } /> } /> - ) } diff --git a/services/server/migrations/010_processed_delete_digests.sql b/services/server/migrations/010_processed_delete_digests.sql new file mode 100644 index 00000000..c954e584 --- /dev/null +++ b/services/server/migrations/010_processed_delete_digests.sql @@ -0,0 +1,16 @@ +-- WALM-264: single-use ledger for /api/delete-memories. +-- +-- The delete handler binds an irreversible DB row-delete to a client-supplied +-- blob-id list that is NOT part of the signed transaction, trusting only that +-- the sponsored digest is single-use. A sponsored digest + the user signature +-- are both public on-chain (txSignatures), so without a server-side guard a +-- replay of a victim's past delete — with an attacker-chosen blob-id list — +-- could desync that victim's index if the upstream execute ever returned +-- success for an already-consumed digest. Claiming the digest here (PK, atomic +-- ON CONFLICT) makes the row-delete fire at most once per digest regardless of +-- upstream behaviour. +CREATE TABLE IF NOT EXISTS processed_delete_digests ( + digest TEXT PRIMARY KEY, + owner TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/services/server/src/routes/admin.rs b/services/server/src/routes/admin.rs index e7c98785..5452e977 100644 --- a/services/server/src/routes/admin.rs +++ b/services/server/src/routes/admin.rs @@ -121,11 +121,19 @@ pub async fn memory_blob_ids( /// 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. +/// transaction's deleted objects, so a dishonest caller could otherwise +/// pass blob ids the transaction never touched. Two things bound that: +/// 1. the owner is the executed tx's on-chain sender, so the row-delete +/// can only ever hit the wallet that signed; and +/// 2. the digest is claimed single-use in the DB (`claim_delete_digest`) +/// before any row-delete, so a REPLAY of another wallet's public +/// {digest, signature} with an attacker-chosen blob-id list is +/// rejected here even if the upstream execute returns success for an +/// already-consumed digest. +/// A signer passing extra blob ids of their OWN is the only remaining +/// desync, bounded to self-harm; those rows point at blobs the tx really +/// deleted, so recall hits a Walrus 404 and `cleanup_expired_blob` heals +/// the drift. /// /// Gated by `ENABLE_MEMORY_DELETION` (off by default) until rollout is /// agreed — the route answers 404 when disabled. @@ -173,6 +181,21 @@ pub async fn delete_memories( AppError::Internal("sponsored execute did not report a sender".into()) })?; + // Replay guard: claim the digest single-use before deleting any rows. + // Runs AFTER execute (so a first-time caller is never blocked by a + // transient execute failure) but BEFORE the row-delete, so a replayed + // {digest, signature} — both public on-chain — cannot desync the + // signer's index with an attacker-chosen blob-id list. + if !state.db.claim_delete_digest(&body.digest, &owner).await? { + tracing::warn!( + "delete-memories: digest already processed for owner={}, refusing replay", + owner + ); + return Err(AppError::BadRequest( + "This deletion has already been processed".into(), + )); + } + let deleted_rows = state.db.delete_by_blob_ids(&body.blob_ids, &owner).await?; tracing::info!( diff --git a/services/server/src/storage/db.rs b/services/server/src/storage/db.rs index ec0a0469..c5003e72 100644 --- a/services/server/src/storage/db.rs +++ b/services/server/src/storage/db.rs @@ -90,6 +90,13 @@ impl VectorDb { .await .map_err(|e| AppError::Internal(format!("Failed to run migration 009: {}", e)))?; + // single-use ledger for /api/delete-memories (replay guard). + let migration_010 = include_str!("../../migrations/010_processed_delete_digests.sql"); + sqlx::raw_sql(migration_010) + .execute(&pool) + .await + .map_err(|e| AppError::Internal(format!("Failed to run migration 010: {}", e)))?; + tracing::info!("database connected and migrations applied"); Ok(Self { pool }) @@ -466,6 +473,30 @@ impl VectorDb { Ok(result?.rows_affected()) } + /// Claim a delete digest exactly once (WALM-264 replay guard). + /// Returns `true` if this call inserted the digest (first use), `false` + /// if it was already claimed — the caller must NOT delete rows on `false`. + /// The PRIMARY KEY + `ON CONFLICT DO NOTHING` makes the check-and-claim + /// atomic across concurrent callers, so exactly one wins the race. + pub async fn claim_delete_digest(&self, digest: &str, owner: &str) -> Result { + let started = std::time::Instant::now(); + let result = sqlx::query( + "INSERT INTO processed_delete_digests (digest, owner) VALUES ($1, $2) \ + ON CONFLICT (digest) DO NOTHING", + ) + .bind(digest) + .bind(owner) + .execute(&self.pool) + .await + .map_err(|e| AppError::Internal(format!("Failed to claim delete digest: {}", e))); + crate::observability::observe_db( + "delete_digest.claim", + db_status(&result), + started.elapsed(), + ); + Ok(result?.rows_affected() == 1) + } + // ============================================================ // Delegate Key Cache // ============================================================