Skip to content

refactor(be): one write path for account reference lists - #4232

Open
sea-snake wants to merge 2 commits into
mainfrom
refactor/account-reference-write-path
Open

refactor(be): one write path for account reference lists#4232
sea-snake wants to merge 2 commits into
mainfrom
refactor/account-reference-write-path

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Design: #4222. Overview: #4230.

The account reference list is written at six sites, and five counters plus a global gauge are derived from it at each one. Keeping them correct is a convention rather than a guarantee, and the PRs above this add two more write sites and a reverse index.

  • write_reference_list becomes the only place a reference list is stored. It reads the previous row, diffs it against the new one, and derives every counter from that diff. update_counters and AccountType are gone.
  • The deltas are defined over the list itself, exactly as rebuild_identity_account_counters defines them, so a write cannot disagree with a rebuild.
  • The application row is resolved before anything is written. A canister keeps what a rejected call already wrote, so checking it from inside the counter update left the row stored and the counters half applied.
  • Writing an empty list is rejected. Absent and empty are opposites: absent means nothing ever happened here, empty means everything was given away and that seed must never be derived again.
  • with_account_mut and set_account_last_used return Result, so a bookkeeping fault is not collapsed into "account not found".

The derived counters equal what the six call sites computed, site by site; those two error paths and the empty-list refusal are the behaviour that does change.

Tests: reference_list_write_path_tests (7), including written counters equalling a rebuild over a mixed workload, and a write against an unknown application writing nothing at all even on a zero-delta write.

Copilot AI lite review requested due to automatic review settings August 19, 2026 00:05
@sea-snake
sea-snake requested a review from a team as a code owner August 19, 2026 00:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zeropath-ai

zeropath-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 22f5c00.

Security Overview
Detected Code Changes
Change Type Relevant files
Bug Fix ► src/internet_identity/src/account_management.rs
    Guard last used timestamp update with proper result handling (store.set_account_last_used)
Refactor ► src/internet_identity/src/storage.rs
    Change with_account_mut return type to Result<Option, StorageError> and adapt call sites
► src/internet_identity/src/storage.rs
    Replace direct writes to stable_account_reference_list_memory with write_reference_list and introduce additional error handling
► src/internet_identity/src/storage.rs
    Introduce write_reference_list helper and refactor related logic to apply deltas and update counters accordingly
► src/internet_identity/src/storage.rs
    Introduce ReferenceListDeltas struct to compute and apply changes to account references counters
► src/internet_identity/src/storage.rs
    Introduce AccountsCounterOverflow error case and EmptyAccountReferenceList error case
► src/internet_identity/src/storage.rs
    Adjust allocate_account_number to handle overflow without trapping
Enhancement ► src/internet_identity/src/storage.rs
    Materialize reference list writes through new write_reference_list path and ensure application/anchor counters updated via deltas
► src/internet_identity/src/storage/storable/accounts_counter.rs
    Remove AccountType-based increment logic and related code (likely consolidated elsewhere)

@sea-snake
sea-snake force-pushed the refactor/account-reference-write-path branch 2 times, most recently from 78329c3 to 28029ff Compare August 20, 2026 10:57
@sea-snake sea-snake added the feature:tracked-default-accounts Design: tracked default accounts, application removal, principal lookup label Aug 22, 2026
Every counter derived from a reference list is now diffed inside
`write_reference_list` instead of being incremented at the six call sites that
store one. `update_counters` and `AccountType` are gone; the anchor, application
and global counters follow from the list itself, the way
`rebuild_identity_account_counters` already defines them.

The application row is resolved before anything is written, so a write against a
reaped or missing application writes nothing at all rather than storing the row
and leaving the counters half applied. Writing an empty list is rejected.
`with_account_mut` and `set_account_last_used` return `Result` so those faults are
not collapsed into "account not found".

Implements docs/ongoing/tracked-default-accounts.md §4 (D3, D4) and §8.1 (D19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake force-pushed the refactor/account-reference-write-path branch from 28029ff to 9d290c1 Compare August 22, 2026 18:42
stored_accounts: self
.stored_accounts
.checked_add(1)
.expect("overflow in stored_accounts"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be easy to making the code panic-free in this PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field stored_accounts is a u64 value, it actually overflowing should be practically unreachable and cannot be considered a user error.

Plumbing this unreachable error through to the candid response would introduce an error that the frontend can't handle and should never expect. Similar to the salt not being set.

There's no handling of the error either in the canister, a genuine overflow would not be resolvable since it would lead to an invalid count e.g. stop counting or resetting the count.

I'm open to suggestions though :D

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update, seems like I can plumb it through to the generic StorageError which is already intended for these edge cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, both writes to the counter return now instead of trapping.

The overflow check moved into allocate_account_number, which already returns Result<_, StorageError>, with a new AccountsCounterOverflow variant. That let increment_accounts go away entirely, so StorableAccountsCounter is a plain storable type again like everything else under storable/, and the check sits on the line that produces the account number instead of one indirection away.

Worth being explicit about why it refuses rather than saturating, since saturating looks like the tidier way to be panic-free: allocate_account_number takes the account number from the counter, so at the ceiling saturating would hand out the same number forever, and since a named account's principal is calculate_account_seed(account_number, origin), two accounts at one origin would collide on a principal. Refusing is the only safe option there, and there's a comment saying so.

One more I'd missed, which matters more than the overflow: apply_reference_counter_deltas wrote the global counter with an expect. That's a regression in this PR rather than existing behaviour, main maps the same write to StorageError::ErrorUpdatingAccountCounter in update_counters, so it does that again and the function returns Result.

There's now a test that sets stored_accounts to u64::MAX and asserts the error, so the "unreachable in practice" claim above is at least pinned by something. I couldn't find a way to make the stable cell's set() fail from a unit test, so that second path is unverified beyond compiling.

Two writes to the accounts counter could trap. Neither can now.

`allocate_account_number` takes the account number from the counter, so the
increment must not wrap or saturate: either re-issues a number already in
use, and two accounts at one origin would then derive the same principal.
The check moves to the allocator, where `StorageError` already is, which
lets `StorableAccountsCounter` go back to being a plain storable type with
no logic on it, and puts the check on the line that makes the number.
`AccountsCounterOverflow` is the refusal.

`apply_reference_counter_deltas` wrote the global counter with an `expect`.
It returns `Result` and maps that write to `ErrorUpdatingAccountCounter`,
which is what the write path this replaced already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake requested a review from aterga August 25, 2026 19:14
// Update last used timestamp
storage_borrow_mut(|storage| {
storage.set_account_last_used(anchor_number, origin.clone(), account_number, time());
let _ =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we propagate this error instead of discarding it? If updating last_used fails, the sign-in still succeeds but II may not record the account use correctly. What would this cause downstream?


let deltas = ReferenceListDeltas::between(&previous, &current);

self.stable_account_reference_list_memory

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reference list is saved before the counters are updated. If the counter update fails, the list is saved but the counters are not. Can we make sure these changes succeed or fail together?

let value = StorableAccountReferenceList::from_vec(account_references);

self.stable_account_reference_list_memory.insert(key, value);
self.write_reference_list(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.write_reference_list(
let Some(result) = result else {
return Ok(None);
};
self.write_reference_list(

If no matching reference was found, account_references is unchanged here. Can we return Ok(None) instead of passing the unchanged list to write_reference_list?

self.accounts == 0 && self.references == 0
}

fn apply(&self, accounts: u64, references: u64) -> (u64, u64) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn apply(&self, accounts: u64, references: u64) -> (u64, u64) {
fn apply(
&self,
accounts: u64,
references: u64,
) -> Result<(u64, u64), StorageError> {
let accounts = accounts
.checked_add_signed(self.accounts)
.ok_or(StorageError::AccountCounterOutOfBounds)?;
let references = references
.checked_add_signed(self.references)
.ok_or(StorageError::AccountCounterOutOfBounds)?;
Ok((accounts, references))
}
// and on the call sites something like
let (stored_accounts, stored_account_references) =
deltas.apply(existing_accounts, existing_references)?;

Maybe it would be better to return an error here instead of clamping the count to zero. If eviction removes a reference while the counter is already at zero, clamping hides the fact that the counter and stored references no longer match

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:tracked-default-accounts Design: tracked default accounts, application removal, principal lookup

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants