diff --git a/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts b/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts index 39b51519..c24494fe 100644 --- a/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts +++ b/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { Server } from "node:http"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; // Minimal env for booting the default-mode sidecar app. The find-blob-by-job // validation path returns 400 before touching the chain, so no client mocking @@ -14,6 +17,7 @@ process.env.WALRUS_PACKAGE_ID = `0x${"a".repeat(64)}`; const { createSidecarApp } = await import("../sidecar/app.js"); const { sanitizeRequestId } = await import("../sidecar/log.js"); +const { MEMWAL_JOB_TAG_KEY } = await import("../sidecar/util.js"); // The reconcile correctness hinges on the register-side tag value // (sanitizeRequestId(jobId)) equaling the query-side value (also @@ -30,6 +34,46 @@ test("a remember-job UUID survives sanitizeRequestId unchanged (tag == query)", assert.equal(sanitizeRequestId("x".repeat(129)), null); }); +// Regression: the durable upload path (walrus-upload-journal.ts) once wrote +// the crash-recovery job tag under a different key (`memwal_migration_job`, +// an unrelated dead-code constant from the V1->V2 migration feature) than +// the one scanOwnerForJobBlob() actually searches for (`memwal_job_id`) — so +// the tag written by every real /api/remember call could never be found by +// reconcile, and a crash right after mint-but-before-journal would mint a +// second paid blob on retry, silently. Both the write sites (durable + +// legacy) and the read site now import the same MEMWAL_JOB_TAG_KEY constant +// from util.ts instead of each hardcoding the string, so this can't drift +// again without deliberately un-importing the shared constant. This test +// pins the constant's value and confirms no file in sidecar/ hardcodes a +// competing literal for job-tag purposes. +test("register (write) and reconcile (read) use the identical job-tag key — no hardcoded drift", () => { + assert.equal(MEMWAL_JOB_TAG_KEY, "memwal_job_id"); + + const here = path.dirname(fileURLToPath(import.meta.url)); + const sidecarDir = path.join(here, "..", "sidecar"); + const filesToCheck = [ + path.join(sidecarDir, "routes", "walrus-upload-journal.ts"), + path.join(sidecarDir, "routes", "walrus-upload.ts"), + path.join(sidecarDir, "routes", "walrus-query.ts"), + ]; + + for (const file of filesToCheck) { + const src = readFileSync(file, "utf8"); + assert.ok( + src.includes("MEMWAL_JOB_TAG_KEY"), + `${path.basename(file)} must reference the shared MEMWAL_JOB_TAG_KEY constant, not a hardcoded literal` + ); + // The only other job-shaped tag key in this codebase is the distinct, + // separately-purposed `memwal_migration_job` (findOwnedBlobObjects in + // walrus-query.ts) — a hardcoded `"memwal_job_id"` string literal + // anywhere in these files would mean someone reintroduced the drift. + assert.ok( + !src.includes('"memwal_job_id"') && !src.includes("'memwal_job_id'"), + `${path.basename(file)} must not hardcode the "memwal_job_id" string literal — import MEMWAL_JOB_TAG_KEY instead` + ); + } +}); + async function listen(): Promise<{ server: Server; baseUrl: string }> { return await new Promise((resolve) => { const server = createSidecarApp().listen(0, "127.0.0.1", () => { diff --git a/services/server/scripts/sidecar/routes/walrus-query.ts b/services/server/scripts/sidecar/routes/walrus-query.ts index 4f9c3063..b2189a95 100644 --- a/services/server/scripts/sidecar/routes/walrus-query.ts +++ b/services/server/scripts/sidecar/routes/walrus-query.ts @@ -17,7 +17,7 @@ import { import { getWalrusClient, refreshWalrusClientIfStale, suiClient, suiGraphqlClient } from "../clients.js"; import { requestIdFor, sanitizeRequestId, sidecarLog } from "../log.js"; import { withRpcRetry } from "../retry/rpc.js"; -import { errorMessage, mapConcurrent } from "../util.js"; +import { MEMWAL_JOB_TAG_KEY, errorMessage, mapConcurrent } from "../util.js"; /** * blob_id from chain is a big integer (U256); convert to base64url @@ -455,7 +455,7 @@ async function scanOwnerForJobBlob( const blobs = await listBlobObjectsGrpc(normalizeSuiAddress(scanOwner), blobType, Infinity); for (const blob of blobs) { const entries = await fetchBlobMetadataEntries(blob.objectId); - const jobMatches = entries.some(({ key, value }) => key === "memwal_job_id" && value === jobId); + const jobMatches = entries.some(({ key, value }) => key === MEMWAL_JOB_TAG_KEY && value === jobId); // Normalize the tagged owner before comparing — the register stores the // raw `owner` the relayer sent, which may be mixed-case / unpadded hex. const ownerMatches = entries.some( diff --git a/services/server/scripts/sidecar/routes/walrus-upload-journal.ts b/services/server/scripts/sidecar/routes/walrus-upload-journal.ts index 8534dd69..2e7819cb 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload-journal.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload-journal.ts @@ -42,7 +42,7 @@ import { NoSideEffectError, withRpcRetry, } from "../retry/rpc.js"; -import { delayInjectedResponseOnce, errorMessage, parseWalrusKeySlot } from "../util.js"; +import { MEMWAL_JOB_TAG_KEY, delayInjectedResponseOnce, errorMessage, parseWalrusKeySlot } from "../util.js"; import { DURABLE_WALLET_FALLBACK_POLICY, assertFinalizedTransactionSuccess, @@ -552,7 +552,7 @@ export function registerWalrusUploadJournalRoute(app: Express): void { ...(namespace ? { memwal_namespace: namespace } : {}), ...(targetOwner ? { memwal_owner: targetOwner } : {}), ...(packageId ? { memwal_package_id: packageId } : {}), - memwal_migration_job: jobId, + [MEMWAL_JOB_TAG_KEY]: jobId, }, }); enforceAddressBalanceCoinIntents(registerTx); diff --git a/services/server/scripts/sidecar/routes/walrus-upload.ts b/services/server/scripts/sidecar/routes/walrus-upload.ts index c51edd4d..3ad677ea 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload.ts @@ -35,7 +35,7 @@ import { import { acquireWalrusUploadSlots, walrusUploadLimitSnapshot, WalrusUploadLimitError } from "../concurrency.js"; import { requestIdFor, sanitizeRequestId, sidecarLog } from "../log.js"; import { sidecarStartedAtMs, sidecarStateSnapshot } from "../state.js"; -import { dedupeAddresses, errorMessage, parseWalrusKeySlot, shortAddress, sleep, truncateForLog } from "../util.js"; +import { MEMWAL_JOB_TAG_KEY, dedupeAddresses, errorMessage, parseWalrusKeySlot, shortAddress, sleep, truncateForLog } from "../util.js"; import { isMoveAbortBalanceSplit, isMoveAbortWalDestroyZero } from "../enoki.js"; import { ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, @@ -240,7 +240,7 @@ export function registerWalrusUploadRoute(app: Express): void { // Correlate the minted blob back to its remember job so a // crashed write (mint landed, response/DB-record lost) can be // reconciled — found and adopted — instead of re-minting. - ...(jobIdForLog ? { memwal_job_id: jobIdForLog } : {}), + ...(jobIdForLog ? { [MEMWAL_JOB_TAG_KEY]: jobIdForLog } : {}), }, }); diff --git a/services/server/scripts/sidecar/util.ts b/services/server/scripts/sidecar/util.ts index 17095869..133f0e47 100644 --- a/services/server/scripts/sidecar/util.ts +++ b/services/server/scripts/sidecar/util.ts @@ -8,6 +8,23 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * On-chain Blob attribute key used to tag a mint with the remember-job id + * that requested it, so a crash-recovery reconcile (after a mint lands but + * before its journal write) can find and adopt the orphaned blob instead of + * minting a second paid one on retry. + * + * Both the write side (walrus-upload-journal.ts's durable register step, + * walrus-upload.ts's legacy register step) and the read side + * (walrus-query.ts's scanOwnerForJobBlob) must import this same constant + * rather than each hardcoding the string literal — they previously didn't, + * and drifted: the durable path wrote `memwal_migration_job` (an unrelated + * constant from the V1->V2 migration feature) while the reconcile scan + * searched for `memwal_job_id`, so the two could never match and the + * durable path's crash-recovery guarantee silently did nothing. + */ +export const MEMWAL_JOB_TAG_KEY = "memwal_job_id"; + /** * Test-only lost-response window after a durable side effect has completed. *