Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 6 additions & 1 deletion crates/idxdb-store/src/account/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountCodeIdxdbObject> =
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)?;

Expand Down
9 changes: 8 additions & 1 deletion crates/idxdb-store/src/js/accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions crates/idxdb-store/src/js/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/idxdb-store/src/js/transactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions crates/idxdb-store/src/sync/js_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,

/// Whether this account update has been committed.
#[wasm_bindgen(js_name = "committed")]
pub committed: bool,
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions crates/idxdb-store/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ struct BatchFullAccountState {
storage_map_entries: Vec<JsStorageMapEntry>,
assets: Vec<JsVaultAsset>,
code_root: String,
#[serde(with = "serde_bytes")]
code: Vec<u8>,
storage_root: String,
vault_root: String,
committed: bool,
Expand Down Expand Up @@ -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(),
Expand Down
114 changes: 114 additions & 0 deletions crates/idxdb-store/src/ts/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ async function openTestDb(version = "0.1.0"): Promise<string> {
// ============================================================
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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -650,6 +652,7 @@ describe("applyFullAccountState", () => {
storageMapEntries: [],
assets: [],
codeRoot: "0xcodeNew",
code: CODE_BYTES,
storageRoot: "0xsrootNew",
vaultRoot: "0xvrootNew",
committed: false,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
// ============================================================
Expand Down Expand Up @@ -1260,6 +1373,7 @@ describe("error paths: unregistered dbId re-throws", () => {
storageMapEntries: [],
assets: [],
codeRoot: "0xcode",
code: CODE_BYTES,
storageRoot: "0xsr",
vaultRoot: "0xvr",
committed: false,
Expand Down
10 changes: 10 additions & 0 deletions crates/idxdb-store/src/ts/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ export async function applyFullAccountState(
storageMapEntries: JsStorageMapEntry[];
assets: JsVaultAsset[];
codeRoot: string;
code: Uint8Array;
storageRoot: string;
vaultRoot: string;
committed: boolean;
Expand All @@ -744,6 +745,7 @@ export async function applyFullAccountState(
storageMapEntries,
assets,
codeRoot,
code,
storageRoot,
vaultRoot,
committed,
Expand All @@ -754,6 +756,7 @@ export async function applyFullAccountState(
await db.dexie.transaction(
"rw",
[
db.accountCodes,
db.latestAccountStorages,
db.historicalAccountStorages,
db.latestStorageMapEntries,
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions crates/idxdb-store/src/ts/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -957,6 +958,7 @@ describe("sync", () => {
vaultRoot: "vault-root-1",
assets: [],
codeRoot: "code-root-1",
code,
committed: true,
accountCommitment: "commitment-1",
accountSeed: undefined,
Expand All @@ -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,
Expand All @@ -993,6 +1000,7 @@ describe("sync", () => {
vaultRoot: "vr-A",
assets: [],
codeRoot: "cr-A",
code: codeA,
committed: true,
accountCommitment: "com-A",
accountSeed: undefined,
Expand All @@ -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]),
Expand All @@ -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);
});
});
});
5 changes: 5 additions & 0 deletions crates/idxdb-store/src/ts/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ interface JsAccountUpdate {
assets: JsVaultAsset[];
accountId: string;
codeRoot: string;
code: Uint8Array;
committed: boolean;
nonce: string;
accountCommitment: string;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading