diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dfbca9a..ebdda3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.15.9 (TBA) + +### Fixes + +* [FIX][web] `miden-idxdb-store` now persists an account's code whenever it writes a full account state, not only at account creation. Previously a sync/update could write a header whose code root had no matching code row, making `getAccount()` fail with `invalid type: unit value, expected struct AccountCodeIdxdbObject`. Full-state writes now persist both atomically, including transaction batches, and a missing row surfaces as a specific store error. + ## 0.15.8 (2026-07-22) ### Enhancements diff --git a/crates/idxdb-store/src/account/mod.rs b/crates/idxdb-store/src/account/mod.rs index 37b74518..4c1b446c 100644 --- a/crates/idxdb-store/src/account/mod.rs +++ b/crates/idxdb-store/src/account/mod.rs @@ -259,8 +259,13 @@ impl IdxdbStore { let root_serialized = root.to_string(); let promise = idxdb_get_account_code(self.db_id(), root_serialized); - let account_code_idxdb: AccountCodeIdxdbObject = + // A missing row deserializes to `None` rather than surfacing as a serde + // "invalid type: unit value" error, so a dangling header code root turns + // into a clear, diagnosable store error instead of a cryptic crash. + let account_code_idxdb: Option = await_js(promise, "failed to fetch account code").await?; + let account_code_idxdb = + account_code_idxdb.ok_or(StoreError::AccountCodeDataNotFound(root))?; let code = AccountCode::read_from_bytes(&account_code_idxdb.code)?; diff --git a/crates/idxdb-store/src/js/accounts.js b/crates/idxdb-store/src/js/accounts.js index e1037978..c4aebb65 100644 --- a/crates/idxdb-store/src/js/accounts.js +++ b/crates/idxdb-store/src/js/accounts.js @@ -571,8 +571,9 @@ async function restoreAssetsFromHistorical(db, accountId, nonce) { export async function applyFullAccountState(dbId, accountState) { try { const db = getDatabase(dbId); - const { accountId, nonce, storageSlots, storageMapEntries, assets, codeRoot, storageRoot, vaultRoot, committed, accountCommitment, accountSeed, } = accountState; + const { accountId, nonce, storageSlots, storageMapEntries, assets, codeRoot, code, storageRoot, vaultRoot, committed, accountCommitment, accountSeed, } = accountState; await db.dexie.transaction("rw", [ + db.accountCodes, db.latestAccountStorages, db.historicalAccountStorages, db.latestStorageMapEntries, @@ -582,6 +583,12 @@ export async function applyFullAccountState(dbId, accountState) { db.latestAccountHeaders, db.historicalAccountHeaders, ], async () => { + // Persist the account code so the header's `codeRoot` always resolves. + // `put` is idempotent: it safely replaces an existing row and fills the + // gap for an unseen root (e.g. an account observed only through sync, + // never locally inserted) that would otherwise leave getAccountCode + // unable to resolve the header. + await db.accountCodes.put({ root: codeRoot, code }); // Archive: save current latest values to historical (so they can be // restored on undo), then replace latest with the new state. await archiveAndReplaceStorageSlots(db, accountId, nonce, storageSlots); diff --git a/crates/idxdb-store/src/js/sync.js b/crates/idxdb-store/src/js/sync.js index 338573cc..2dc0154a 100644 --- a/crates/idxdb-store/src/js/sync.js +++ b/crates/idxdb-store/src/js/sync.js @@ -94,6 +94,9 @@ export async function applyStateSync(dbId, stateUpdate) { db.blockHeaders, db.partialBlockchainNodes, db.tags, + // applyFullAccountState (called per account update below) opens a nested + // transaction that writes accountCodes; Dexie requires it in the parent scope. + db.accountCodes, db.latestAccountHeaders, db.historicalAccountHeaders, db.latestAccountStorages, @@ -127,6 +130,7 @@ export async function applyStateSync(dbId, stateUpdate) { storageMapEntries: accountUpdate.storageMapEntries, assets: accountUpdate.assets, codeRoot: accountUpdate.codeRoot, + code: accountUpdate.code, storageRoot: accountUpdate.storageRoot, vaultRoot: accountUpdate.vaultRoot, committed: accountUpdate.committed, diff --git a/crates/idxdb-store/src/js/transactions.js b/crates/idxdb-store/src/js/transactions.js index 4955b27b..99f19a2d 100644 --- a/crates/idxdb-store/src/js/transactions.js +++ b/crates/idxdb-store/src/js/transactions.js @@ -138,6 +138,9 @@ export async function applyTransactionBatch(dbId, payloads) { db.historicalStorageMapEntries, db.latestAccountAssets, db.historicalAccountAssets, + // Full account updates persist code in a nested transaction, so the + // parent batch must include accountCodes in its scope as well. + db.accountCodes, db.latestAccountHeaders, db.historicalAccountHeaders, db.inputNotes, diff --git a/crates/idxdb-store/src/sync/js_bindings.rs b/crates/idxdb-store/src/sync/js_bindings.rs index 5852c8f9..c2f955e4 100644 --- a/crates/idxdb-store/src/sync/js_bindings.rs +++ b/crates/idxdb-store/src/sync/js_bindings.rs @@ -163,6 +163,12 @@ pub struct JsAccountUpdate { #[wasm_bindgen(js_name = "codeRoot")] pub code_root: String, + /// The account's serialized executable code. Persisted alongside the header + /// so the header's `codeRoot` always resolves to a stored `accountCodes` + /// row — even when this is the first time the store sees this code root. + #[wasm_bindgen(js_name = "code")] + pub code: Vec, + /// Whether this account update has been committed. #[wasm_bindgen(js_name = "committed")] pub committed: bool, @@ -203,6 +209,7 @@ impl JsAccountUpdate { assets: asset_vault.assets().map(|asset| JsVaultAsset::from_asset(&asset)).collect(), account_id: account.id().to_string(), code_root: account.code().commitment().to_string(), + code: account.code().to_bytes(), committed: account.is_public(), nonce: account.nonce().to_string(), account_commitment: account.to_commitment().to_string(), diff --git a/crates/idxdb-store/src/transaction/mod.rs b/crates/idxdb-store/src/transaction/mod.rs index 8eb84778..0bd981a7 100644 --- a/crates/idxdb-store/src/transaction/mod.rs +++ b/crates/idxdb-store/src/transaction/mod.rs @@ -71,6 +71,8 @@ struct BatchFullAccountState { storage_map_entries: Vec, assets: Vec, code_root: String, + #[serde(with = "serde_bytes")] + code: Vec, storage_root: String, vault_root: String, committed: bool, @@ -460,6 +462,7 @@ impl IdxdbStore { storage_map_entries, assets, code_root: account.code().commitment().to_string(), + code: account.code().to_bytes(), storage_root: account.storage().to_commitment().to_string(), vault_root: account.vault().root().to_string(), committed: account.is_public(), diff --git a/crates/idxdb-store/src/ts/accounts.test.ts b/crates/idxdb-store/src/ts/accounts.test.ts index f865551d..ffea773d 100644 --- a/crates/idxdb-store/src/ts/accounts.test.ts +++ b/crates/idxdb-store/src/ts/accounts.test.ts @@ -51,6 +51,7 @@ async function openTestDb(version = "0.1.0"): Promise { // ============================================================ const ACC = "0xacc1"; const CODE_ROOT = "0xcode1"; +const CODE_BYTES = new Uint8Array([1, 2, 3, 4]); const STORAGE_ROOT = "0xsroot1"; const VAULT_ROOT = "0xvroot1"; const COMMITMENT = "0xcommit1"; @@ -605,6 +606,7 @@ describe("applyFullAccountState", () => { storageMapEntries: [{ slotName: "map1", key: "k1", value: "vnew" }], assets: [{ vaultKey: "vk1", asset: "0xnewasset" }], codeRoot: CODE_ROOT, + code: CODE_BYTES, storageRoot: "0xsroot2", vaultRoot: "0xvroot2", committed: true, @@ -650,6 +652,7 @@ describe("applyFullAccountState", () => { storageMapEntries: [], assets: [], codeRoot: "0xcodeNew", + code: CODE_BYTES, storageRoot: "0xsrootNew", vaultRoot: "0xvrootNew", committed: false, @@ -680,6 +683,7 @@ describe("applyFullAccountState", () => { storageMapEntries: [{ slotName: "brand-new-map", key: "k", value: "v" }], assets: [{ vaultKey: "brand-new-key", asset: "0xa" }], codeRoot: CODE_ROOT, + code: CODE_BYTES, storageRoot: STORAGE_ROOT, vaultRoot: VAULT_ROOT, committed: false, @@ -711,6 +715,115 @@ describe("applyFullAccountState", () => { }); }); +// ============================================================ +// applyFullAccountState — account-code persistence (regression) +// +// Regression for the "invalid type: unit value, expected struct +// AccountCodeIdxdbObject" crash: applyFullAccountState (the update_account / +// sync path) used to write the header's codeRoot without ensuring the +// accountCodes row existed, leaving getAccountCode unable to resolve it. It +// now persists the code in the same transaction. +// ============================================================ +describe("applyFullAccountState — account-code persistence", () => { + it("writes the account code so getAccountCode resolves the header's codeRoot", async () => { + const dbId = await openTestDb(); + + await seedAccount(dbId, { codeRoot: CODE_ROOT }); + await applyFullAccountState(dbId, { + accountId: ACC, + nonce: "2", + storageSlots: [], + storageMapEntries: [], + assets: [], + codeRoot: CODE_ROOT, + code: CODE_BYTES, + storageRoot: STORAGE_ROOT, + vaultRoot: VAULT_ROOT, + committed: true, + accountCommitment: "0xcommit2", + accountSeed: undefined, + }); + + const code = await getAccountCode(dbId, CODE_ROOT); + expect(code).not.toBeNull(); + expect(code?.root).toBe(CODE_ROOT); + expect(code?.code).toBe("AQIDBA=="); + }); + + it("fills a code root the store has never seen (sync-only account)", async () => { + // The production gap: an account observed only through sync, whose code was + // never locally inserted via insert_account/upsertAccountCode. Before the + // fix, getAccountCode(newRoot) returned null -> the Rust crash. + const dbId = await openTestDb(); + const NEW_ROOT = "0xcode-never-seen"; + + expect(await getAccountCode(dbId, NEW_ROOT)).toBeNull(); + + await applyFullAccountState(dbId, { + accountId: "0xsync-only", + nonce: "1", + storageSlots: [], + storageMapEntries: [], + assets: [], + codeRoot: NEW_ROOT, + code: CODE_BYTES, + storageRoot: STORAGE_ROOT, + vaultRoot: VAULT_ROOT, + committed: true, + accountCommitment: "0xcommit-sync", + accountSeed: undefined, + }); + + const code = await getAccountCode(dbId, NEW_ROOT); + expect(code).not.toBeNull(); + expect(code?.root).toBe(NEW_ROOT); + expect(code?.code).toBe("AQIDBA=="); + }); + + it("preserves and repairs account code across a 0.15.x patch reopen", async () => { + // End-to-end shape of the reported incident: an account created under one + // patch survives the patch bump, but its code row is missing when a later + // full-state sync refreshes the account. + const name = uniqueDbName(); + await openDatabase(name, "0.15.5"); + openDbIds.push(name); + await seedAccount(name, { codeRoot: CODE_ROOT }); + await upsertAccountCode(name, CODE_ROOT, CODE_BYTES); + + // Patch bump preserves the DB (same major.minor -> no nuke). + getDatabase(name).dexie.close(); + await openDatabase(name, "0.15.6"); + + expect((await getAccountHeader(name, ACC))?.codeRoot).toBe(CODE_ROOT); + expect((await getAccountCode(name, CODE_ROOT))?.code).toBe("AQIDBA=="); + + // Reproduce the dangling relationship from the failure report, then apply + // the same full-state refresh that previously left it unresolved. + await getDatabase(name).accountCodes.delete(CODE_ROOT); + expect(await getAccountCode(name, CODE_ROOT)).toBeNull(); + + await applyFullAccountState(name, { + accountId: ACC, + nonce: "2", + storageSlots: [], + storageMapEntries: [], + assets: [], + codeRoot: CODE_ROOT, + code: CODE_BYTES, + storageRoot: STORAGE_ROOT, + vaultRoot: VAULT_ROOT, + committed: true, + accountCommitment: "0xcommit-upgraded", + accountSeed: undefined, + }); + + // The refresh restores the invariant: the header's code root resolves. + const header = await getAccountHeader(name, ACC); + expect(header?.codeRoot).toBe(CODE_ROOT); + expect((await getAccountCode(name, CODE_ROOT))?.code).toBe("AQIDBA=="); + }); +}); + // ============================================================ // upsertForeignAccountCode / getForeignAccountCode // ============================================================ @@ -1260,6 +1373,7 @@ describe("error paths: unregistered dbId re-throws", () => { storageMapEntries: [], assets: [], codeRoot: "0xcode", + code: CODE_BYTES, storageRoot: "0xsr", vaultRoot: "0xvr", committed: false, diff --git a/crates/idxdb-store/src/ts/accounts.ts b/crates/idxdb-store/src/ts/accounts.ts index 7af0f6fa..d59f3e2e 100644 --- a/crates/idxdb-store/src/ts/accounts.ts +++ b/crates/idxdb-store/src/ts/accounts.ts @@ -728,6 +728,7 @@ export async function applyFullAccountState( storageMapEntries: JsStorageMapEntry[]; assets: JsVaultAsset[]; codeRoot: string; + code: Uint8Array; storageRoot: string; vaultRoot: string; committed: boolean; @@ -744,6 +745,7 @@ export async function applyFullAccountState( storageMapEntries, assets, codeRoot, + code, storageRoot, vaultRoot, committed, @@ -754,6 +756,7 @@ export async function applyFullAccountState( await db.dexie.transaction( "rw", [ + db.accountCodes, db.latestAccountStorages, db.historicalAccountStorages, db.latestStorageMapEntries, @@ -764,6 +767,13 @@ export async function applyFullAccountState( db.historicalAccountHeaders, ], async () => { + // Persist the account code so the header's `codeRoot` always resolves. + // `put` is idempotent: it safely replaces an existing row and fills the + // gap for an unseen root (e.g. an account observed only through sync, + // never locally inserted) that would otherwise leave getAccountCode + // unable to resolve the header. + await db.accountCodes.put({ root: codeRoot, code }); + // Archive: save current latest values to historical (so they can be // restored on undo), then replace latest with the new state. await archiveAndReplaceStorageSlots(db, accountId, nonce, storageSlots); diff --git a/crates/idxdb-store/src/ts/sync.test.ts b/crates/idxdb-store/src/ts/sync.test.ts index 4093686a..5bdd0c6f 100644 --- a/crates/idxdb-store/src/ts/sync.test.ts +++ b/crates/idxdb-store/src/ts/sync.test.ts @@ -942,6 +942,7 @@ describe("sync", () => { describe("applyStateSync — account updates", () => { it("applies a full account state during sync", async () => { const dbId = await openTestDb(); + const code = new Uint8Array([0x01, 0x02, 0x03]); await applyStateSync( dbId, @@ -957,6 +958,7 @@ describe("sync", () => { vaultRoot: "vault-root-1", assets: [], codeRoot: "code-root-1", + code, committed: true, accountCommitment: "commitment-1", accountSeed: undefined, @@ -974,10 +976,15 @@ describe("sync", () => { expect(account!.nonce).toBe("1"); expect(account!.committed).toBe(true); expect(account!.codeRoot).toBe("code-root-1"); + + const storedCode = await db.accountCodes.get(account!.codeRoot); + expect(storedCode?.code).toEqual(code); }); it("applies multiple account updates in one sync call", async () => { const dbId = await openTestDb(); + const codeA = new Uint8Array([0x0a]); + const codeB = new Uint8Array([0x0b]); await applyStateSync( dbId, @@ -993,6 +1000,7 @@ describe("sync", () => { vaultRoot: "vr-A", assets: [], codeRoot: "cr-A", + code: codeA, committed: true, accountCommitment: "com-A", accountSeed: undefined, @@ -1006,6 +1014,7 @@ describe("sync", () => { vaultRoot: "vr-B", assets: [], codeRoot: "cr-B", + code: codeB, committed: false, accountCommitment: "com-B", accountSeed: new Uint8Array([0xca, 0xfe]), @@ -1019,6 +1028,9 @@ describe("sync", () => { const ids = all.map((a) => a.id); expect(ids).toContain("acct-sync-A"); expect(ids).toContain("acct-sync-B"); + + expect((await db.accountCodes.get("cr-A"))?.code).toEqual(codeA); + expect((await db.accountCodes.get("cr-B"))?.code).toEqual(codeB); }); }); }); diff --git a/crates/idxdb-store/src/ts/sync.ts b/crates/idxdb-store/src/ts/sync.ts index 201a6476..d893558a 100644 --- a/crates/idxdb-store/src/ts/sync.ts +++ b/crates/idxdb-store/src/ts/sync.ts @@ -164,6 +164,7 @@ interface JsAccountUpdate { assets: JsVaultAsset[]; accountId: string; codeRoot: string; + code: Uint8Array; committed: boolean; nonce: string; accountCommitment: string; @@ -219,6 +220,9 @@ export async function applyStateSync( db.blockHeaders, db.partialBlockchainNodes, db.tags, + // applyFullAccountState (called per account update below) opens a nested + // transaction that writes accountCodes; Dexie requires it in the parent scope. + db.accountCodes, db.latestAccountHeaders, db.historicalAccountHeaders, db.latestAccountStorages, @@ -306,6 +310,7 @@ export async function applyStateSync( storageMapEntries: accountUpdate.storageMapEntries, assets: accountUpdate.assets, codeRoot: accountUpdate.codeRoot, + code: accountUpdate.code, storageRoot: accountUpdate.storageRoot, vaultRoot: accountUpdate.vaultRoot, committed: accountUpdate.committed, diff --git a/crates/idxdb-store/src/ts/transactions-batch.test.ts b/crates/idxdb-store/src/ts/transactions-batch.test.ts index 66fe3904..06327f69 100644 --- a/crates/idxdb-store/src/ts/transactions-batch.test.ts +++ b/crates/idxdb-store/src/ts/transactions-batch.test.ts @@ -74,6 +74,31 @@ function buildPayload(suffix: string) { }; } +function buildFullPayload(suffix: string) { + const payload = buildPayload(suffix); + const code = new Uint8Array([4, 5, 6]); + return { + ...payload, + accountState: { + kind: "full" as const, + account: { + accountId: `0xacc-${suffix}`, + nonce: "1", + storageSlots: [], + storageMapEntries: [], + assets: [], + codeRoot: `0xcode-${suffix}`, + code, + storageRoot: `0xsroot-${suffix}`, + vaultRoot: `0xvroot-${suffix}`, + committed: false, + accountCommitment: `0xcommit-${suffix}`, + accountSeed: undefined, + }, + }, + }; +} + describe("applyTransactionBatch atomicity", () => { it("commits all writes from a valid 2-payload batch (positive control)", async () => { const dbId = await openTestDb(); @@ -86,6 +111,17 @@ describe("applyTransactionBatch atomicity", () => { expect(await db.latestAccountHeaders.count()).toBe(2); }); + it("persists account code with a full-state update", async () => { + const dbId = await openTestDb(); + const db = getDatabase(dbId); + + await applyTransactionBatch(dbId, [buildFullPayload("full")]); + + expect((await db.accountCodes.get("0xcode-full"))?.code).toEqual( + new Uint8Array([4, 5, 6]) + ); + }); + it("rolls back all writes when a mid-batch write fails", async () => { const dbId = await openTestDb(); const db = getDatabase(dbId); @@ -104,12 +140,16 @@ describe("applyTransactionBatch atomicity", () => { }); await expect( - applyTransactionBatch(dbId, [buildPayload("a"), buildPayload("b")]) + applyTransactionBatch(dbId, [ + buildFullPayload("a"), + buildFullPayload("b"), + ]) ).rejects.toThrow(); expect(await db.transactions.count()).toBe(0); expect(await db.inputNotes.count()).toBe(0); expect(await db.latestAccountHeaders.count()).toBe(0); + expect(await db.accountCodes.count()).toBe(0); expect(await db.notesScripts.count()).toBe(0); }); }); diff --git a/crates/idxdb-store/src/ts/transactions.ts b/crates/idxdb-store/src/ts/transactions.ts index 98bdcf2e..4886992c 100644 --- a/crates/idxdb-store/src/ts/transactions.ts +++ b/crates/idxdb-store/src/ts/transactions.ts @@ -195,6 +195,7 @@ interface JsFullAccountState { storageMapEntries: JsStorageMapEntry[]; assets: JsVaultAsset[]; codeRoot: string; + code: Uint8Array; storageRoot: string; vaultRoot: string; committed: boolean; @@ -289,6 +290,9 @@ export async function applyTransactionBatch( db.historicalStorageMapEntries, db.latestAccountAssets, db.historicalAccountAssets, + // Full account updates persist code in a nested transaction, so the + // parent batch must include accountCodes in its scope as well. + db.accountCodes, db.latestAccountHeaders, db.historicalAccountHeaders, db.inputNotes, diff --git a/crates/web-client/playwright.config.ts b/crates/web-client/playwright.config.ts index 66753b1e..c0382278 100644 --- a/crates/web-client/playwright.config.ts +++ b/crates/web-client/playwright.config.ts @@ -83,6 +83,7 @@ const ciShardProjects = process.env.CI use: { ...devices["Desktop Chrome"] }, testMatch: [ "test/account.test.ts", + "test/account_code_dangling.test.ts", "test/account_component.test.ts", "test/account_file.test.ts", "test/account_reader.test.ts", @@ -190,6 +191,7 @@ export default defineConfig({ // Skip browser-only and WASM-specific tests testIgnore: [ "test/store_isolation*", + "test/account_code_dangling.test.ts", // IndexedDB corruption regression "test/sync_lock*", "test/import_export*", "test/remote_keystore*", diff --git a/crates/web-client/test/account_code_dangling.test.ts b/crates/web-client/test/account_code_dangling.test.ts new file mode 100644 index 00000000..0d8e44e3 --- /dev/null +++ b/crates/web-client/test/account_code_dangling.test.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +import { test, expect } from "./test-setup"; + +// Regression for the "invalid type: unit value, expected struct +// AccountCodeIdxdbObject" crash. This runs against the real WASM IndexedDB +// store and deliberately removes the code row referenced by an account header. +test.describe("account code dangling regression", () => { + test("getAccount reports the missing code row instead of a serde error", async ({ + run, + }) => { + const result = await run(async ({ client, sdk, helpers }) => { + const wallet = await client.newWallet( + sdk.AccountStorageMode.public(), + sdk.AuthScheme.AuthRpoFalcon512 + ); + const walletId = wallet.id(); + + const codeStoreState = await new Promise<{ + countBefore: number; + countAfter: number; + }>((resolve, reject) => { + const request = indexedDB.open("mock_client_db"); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction("accountCode", "readwrite"); + const store = tx.objectStore("accountCode"); + const countRequest = store.count(); + let countBefore = 0; + + countRequest.onsuccess = () => { + countBefore = countRequest.result; + store.clear(); + }; + tx.oncomplete = () => { + db.close(); + resolve({ countBefore, countAfter: 0 }); + }; + tx.onerror = () => reject(tx.error); + }; + request.onerror = () => reject(request.error); + }); + + // Use a fresh client so the read cannot be satisfied by in-memory state. + const client2 = await helpers.createFreshMockClient(); + let errorMessage: string | null = null; + let hasAccount = false; + try { + const account = await client2.getAccount(walletId); + hasAccount = account !== undefined && account !== null; + } catch (error) { + errorMessage = String(error?.message ?? error); + } + + return { codeStoreState, errorMessage, hasAccount }; + }); + + expect(result.codeStoreState.countBefore).toBeGreaterThan(0); + expect(result.codeStoreState.countAfter).toBe(0); + expect(result.hasAccount).toBe(false); + expect(result.errorMessage).toMatch(/account code.*not found/i); + expect(result.errorMessage).not.toContain("unit value"); + }); +});