Skip to main content

verify-client-sync

Model-check how the Rust multisig SDK (crates/miden-multisig-client) and the TypeScript multisig SDK (packages/miden-multisig-client) keep a cosigner device's local view of an account (its local store state and, for TypeScript, the cached signer config) on the chain and on Guardian, with TLA+/TLC. It checks that every device ends up on the chain's state once nothing is pending at the node, including after the node drops a transaction the device already applied locally, and that no TypeScript decision reads a signer config the local state no longer has. Use when a diff touches the SDKs' sync (sync / sync_from_guardian_internal, syncState / isSafeToAdoptGuardianState), how execution writes local state (finalize_transaction, rebuilds_local_state_from_delta, submit_transaction_at_tip, ProverWorkflow.submit), proposal creation or execution (execute_proposal, execute_imported_proposal, prepareProposalExecution, assertExecutesOnPinnedBase), the TS config cache (refreshConfigFromAccount), or when check.sh reports DRI

Informações da origem

Repositório
OpenZeppelin/guardian
Última atividade na origem
23 de outubro de 2026 às 07:34
Idioma detectado do SKILL.md
inglês
Estrelas
11
Forks
21

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Explorador de arquivos
9 arquivos

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
name
verify-client-sync
description
Model-check how the Rust multisig SDK (crates/miden-multisig-client) and the TypeScript multisig SDK (packages/miden-multisig-client) keep a cosigner device's local view of an account (its local store state and, for TypeScript, the cached signer config) on the chain and on Guardian, with TLA+/TLC. It checks that every device ends up on the chain's state once nothing is pending at the node, including after the node drops a transaction the device already applied locally, and that no TypeScript decision reads a signer config the local state no longer has. Use when a diff touches the SDKs' sync (sync / sync_from_guardian_internal, syncState / isSafeToAdoptGuardianState), how execution writes local state (finalize_transaction, rebuilds_local_state_from_delta, submit_transaction_at_tip, ProverWorkflow.submit), proposal creation or execution (execute_proposal, execute_imported_proposal, prepareProposalExecution, assertExecutesOnPinnedBase), the TS config cache (refreshConfigFromAccount), or when check.sh reports DRIFT.
# Verify client sync `model/ClientSync.tla` models one private multisig account with two cosigner devices, one per SDK, against Guardian (candidate mode, queue depth 1) and a node that takes a submitted transaction into its mempool and later includes it or drops it (expired, or evicted with a dropped dependency). TLC explores every interleaving of the devices proposing, executing and syncing (Guardian sync and chain sync), the node including or dropping, and Guardian promoting or retiring its candidate. A state carries a nonce, a parent and a signer config; one step is one SDK call's decision or one server or node step. Guardian's own TLA+ models (separate skills, not on this branch) treat a client as an ideal actor that always knows the truth. This one models what a device believes. ## What is checked | Property | In plain words | Harm if broken | |---|---|---| | `DeviceConverges` | Once nothing is pending at the node, every device's local state ends up the chain's (under fair syncs). | A device stays on a state that never landed: its proposals are built on it and refused, and it cannot execute until the client restarts or its store is reset. | | `NeverRollsBackPending` | A sync never rolls a device back from a transaction the node still has pending. | If it then lands, the device has already built on the older base and forks against it; a private account cannot recover the discarded post-state from chain sync. | | `ConfigFresh` | No TypeScript decision (readiness, the target signer list of a membership proposal, the cosigner list sent at registration) reads a cached signer config the local state no longer has. | A stale signer list is written into a membership proposal (dropping a signer added since), or readiness is judged against the wrong threshold. | ## Findings (both fixed) **C1 (fixed): a dropped Rust membership, threshold or switch transaction left the device on a state that never landed.** `finalize_transaction` (`client/helpers.rs`) writes those transaction types (`rebuilds_local_state_from_delta`) with `add_or_update_account` after `submit_proven_transaction`, not through miden-client's `apply_transaction_update` (miden-client "persists incorrectly for private accounts" for them). miden-client holds no record of the transaction, so its chain sync can never undo it (`undo_account_state` undoes only recorded, discarded transactions), and `sync_from_guardian_internal` kept local whenever Guardian was not ahead. Trace (mutation `m1`): a signer-config proposal executes on the Rust device, the node drops it, Guardian retires the candidate, and the device stays on its local state for the rest of the session (the store is per process, so a restart and a pull recovered it; `pull_account` on the same store cannot, since miden-client refuses a lower nonce). The TypeScript SDK applies through `submission.apply()`, so miden-client undoes it there. Fix: `finalize_transaction` records the submission it wrote without a record (`UntrackedSubmission`: post-state commitment, submission height, expiration block), and `local_state_abandoned` (`client/account.rs`) lets sync roll local back to Guardian's state when Guardian is not ahead and all of these hold: - this client submitted the local state, and miden-client's own discard rule says that transaction is dropped by the store's sync height (`UntrackedSubmission::dropped_at`: expired, or still unconfirmed `TX_DISCARD_DELTA` = 20 blocks after submission, as miden-client `apply_sync_height_update` judges the transactions it does record); - miden-client has no pending record for the account; - the chain holds Guardian's state; - unless the transaction expired (`UntrackedSubmission::expired_at`, only for a proposal created with an approval expiration), Guardian does not hold the local state as a delta that may still land (`delta_may_still_land_as`: a candidate or retained delta recording the local commitment, or one whose commitment does not parse, keeps local). An expired transaction cannot land, so Guardian is not asked. The node-side condition is what makes the rollback safe. Guardian's answer alone is no evidence: it can forget a delta whose transaction is still pending (reconciliation expires a retained row past `retained_ttl_seconds` without looking at the node, retention disabled deletes a give-up candidate, a best-effort switch push can fail), and a first version of this fix trusted Guardian's not-found and discarded answers and rolled back a still-pending transaction (mutation `m3`). The stale half of the rule is a heuristic, not proof: it is the one miden-client already applies to every recorded transaction, so a Rust untracked transaction and a TypeScript or Rust recorded one are judged alike, and it is why a transaction without an expiration still waits for Guardian's veto (in practice until `retained_ttl_seconds`, 24 hours by default, runs out). A failed read keeps local. The rollback builds a fresh miden-client store (both miden-client and the store refuse a lower nonce) on the same node RPC client, puts Guardian's state and the unconsumed input notes (as expected notes) into it, syncs it to the chain tip, and installs it only once all of that succeeded (`roll_back_store`), so a failed rollback keeps the current store, notes included, for the next sync. A note whose tag is unknown keeps local rather than be lost. Output-note records cannot be carried into a fresh store, so the client keeps the replaced store for the session and note export reads an earlier note from it. The replaced store file stays in the account directory and the rollback's warning names it, so a transaction that lands after the stale window can still be recovered from it. The fix does not cover a switch to a new endpoint: `finalize_transaction` registers the switched, not yet landed, state on the new Guardian, whose head then equals local, so sync returns before the rollback check. The model leaves the switch's rebind and registration out (see Scope). **C2 (fixed): the TypeScript signer config cache went stale.** `threshold` and `signerCommitments` were refreshed only from `syncState` and `syncNetworkOnly`, not after `executeProposal` and not when miden-client's chain sync undoes a dropped transaction. Until the next `syncState`, `createAddSignerProposal` / `createRemoveSignerProposal` built the target signer list from the old list (after executing "add X", a following "add Y" targeted the old list plus Y, and executing it removed X), readiness used the old threshold, and `registerOnGuardian` sent the old cosigner list. Trace (mutation `m2`): execute a signer change, it lands, execute again with no sync in between. Refreshing only after execution is not enough, because the undo of a dropped transaction leaves the cache on the dropped config. Fix: every proposal creator (`createProposal` and each `create*Proposal` method), `signProposal`, `signProposalOffline`, `importProposal`, `syncProposals` (`reconcileProposals`), `submitTransaction` and `registerOnGuardian` re-read the store (`refreshConfigFromStore`), and `prepareProposalExecution` and `prepareCustomExecution` refresh the config from the one store read each already makes for the pinned-base check, before any signature or readiness check. Each reads after its own chain sync when it makes one: that sync is what undoes a dropped transaction, and it can change the signer set, not only the threshold. Proposal verification can sync too (note authentication for consume proposals, the bound-block catch-up), so import, signing (online and offline) and listing judge signatures and readiness after verification, and the consume-notes creator reads after authenticating its notes. The note-recovery listing (`syncProposalsIsolatingFailures`) uses proposals only for their notes and does not judge readiness. `ConfigFresh` checks the config a TypeScript decision actually uses against the store. In the fixed model every decision reads the store in the same step it decides, so the property holds by construction and its value is as a regression guard (`m2` restores the cached read and fails it). The model does not represent a second concurrent call on the same `Multisig` whose chain sync lands between a decision's store read and its use. It also sets `staleUse` only in `Execute`: proposal creation, signing, listing, import and `submitTransaction` read the config the same way, and those reads are held by unit tests in `multisig.test.ts` and by fingerprint drift on their functions (`mirrored.txt`), not by the model. ## Rust and TypeScript differences seen while mapping (not all modeled) From reading both SDKs. None is a model finding by itself; each is a place the two SDKs make different decisions on the same inputs (`AGENTS.md` §3, rule 5). - Rust syncs (Guardian and chain) before propose, sign and execute; TS does not. - Listing: Rust drops proposals with `nonce <= local nonce`; TS lists everything Guardian returns and blocks stale ones at execution. - Execution: both check local == the pinned base (Rust `ProposalBaseMismatch`, TS `assertExecutesOnPinnedBase`); TS echoes Guardian's `prev_commitment`, Rust also checks `nonce > local nonce` and pushes its local commitment. - Switch: Rust pushes the switch delta to the old Guardian before submitting, TS after. - Equal-nonce divergence on sync: Rust keeps local quietly, TS throws. - No chain commitment: Rust's overwrite guard always passes, TS refuses for a transacted account (its `syncState` with no local record still adopts unchecked; `load` does not). - `execute_imported_proposal` (Rust) has no nonce check. ## Run it From the repo root: ```bash .agents/skills/verify-client-sync/check.sh # quick tier, seconds .agents/skills/verify-client-sync/check.sh --deep # larger bounds, a few minutes .agents/skills/verify-client-sync/check.sh --mutations .agents/skills/verify-client-sync/check.sh --cell m1 ``` A FAIL verdict names the property TLC reports violated (any temporal property is `FAIL:temporal`), and an expected failure in `expected.txt` must name it too. A cell whose properties include `DeviceConverges` runs `FairSpec`. `--cell` exits 0 on PASS, 1 on FAIL and 2 on ERROR. `fingerprint.py` hashes Rust (`fn`) and TypeScript (function, method or `const` arrow) definitions; `--accept-drift` refuses to record a function or file it cannot find. Set `TLC_WORKERS` to cap TLC's worker threads. It needs `java` and `python3`; `tla2tools.jar` 1.7.4 is cached in `~/.cache/guardian-tla/` and checksum-verified. Exit codes: 0 all match, 1 a verdict changed, 2 setup error, 3 mirrored source drifted. Both findings are fixed and folded into the normal actions; mutations `m1` (no Rust rollback), `m2` (TS reads its cached config) and `m3` (roll back on Guardian's word alone, without the node-side drop; `m3-expiring` runs it with every config proposal expiring) put each back and must fail (`check.sh --mutations`). A new finding goes in the same way: an expected FAIL until the fix lands, then a mutation. ## Reading a result A `DIFF` to FAIL prints the counterexample as model actions (map below); `st` changes print as `new <id>=(nonce, parent, config, kind)`, and every other value is a state id. If every step is something the SDKs, Guardian and the node can do, it is a real bug; if a step is impossible in the code, fix the action and cite the source. After changing the model to match intended code, run `check.sh --accept-drift`. ## Action map Rust paths are under `crates/miden-multisig-client/src/`, TypeScript paths under `packages/miden-multisig-client/src/`. | Model action | Rust | TypeScript | |---|---|---| | `SyncGuardian` | `client/account.rs` `sync_from_guardian_internal` with `is_not_ahead_of`, `local_state_abandoned` (`UntrackedSubmission::dropped_at` / `expired_at`, `delta_may_still_land_as`), `ensure_safe_to_overwrite_local_state`, then `input_notes_to_carry` and `roll_back_store` on a rollback | `multisig.ts` `syncState` with `guardianIsNotAhead`, `state/adopt.ts` `isSafeToAdoptGuardianState` | | `RefreshConfig` | (none: the config is read from the account each time) | `multisig.ts` `refreshConfigFromAccount`, from `syncState` / `syncNetworkOnly` | | `SyncChainUndo` | miden-client `sync_state` (sqlite store `undo_account_state`) for recorded transactions | the same, through the WASM client | | `Propose` | `client/proposals.rs` `propose_transaction_with_options` (label local + 1) | `multisig.ts` `createProposal`, `resolveProposalNonce` | | `Execute` | `client/proposals.rs` `execute_proposal`, `client/helpers.rs` `get_guardian_ack_signature`, `finalize_transaction` (untracked for `rebuilds_local_state_from_delta` types, recorded as an `UntrackedSubmission`) | `multisig.ts` `prepareProposalExecution` / `prepareCustomExecution` (store read, `adoptConfigFromAccount`, `assertExecutesOnPinnedBase`), `executeProposal`, `prover/workflow.ts` `submit` | | `Land`, `Drop` | the node: include a pending transaction built on the chain head, or drop one | | | `Promote`, `Retire` | Guardian's canonicalization: promote the candidate once the chain holds it; retire it as retained (also while its transaction may still be pending, on exhausted retries or a lagging read) | | | `ReconcilePromote`, `Expire` | Guardian's reconcile pass: promote a retained delta the chain head shows landed; drop it after `retained_ttl_seconds` once its transaction is gone | | | `Forget` | Guardian dropping a delta whose transaction is still pending: reconciliation's TTL expiry (`reconcile_expired`) does not look at the node, and with retention disabled a give-up candidate is deleted outright (`Retire` then `Forget`) | | ## Scope and assumptions - Two devices, one per SDK; up to 5 or 6 states, nonces up to 3 or 4, one dropped transaction. A proposal is plain (keeps the signer config) or a signer-config change. - The account is private: chain sync never installs a state, it only undoes recorded discarded transactions. A public account's chain sync can also update the tracked account; not modeled. - A switch that changes the Guardian endpoint is not modeled: after it the device talks to the new Guardian, which `finalize_transaction` registers with the switched state. C1's rollback does not reach that case. - Signing, approval thresholds and the summary binding are folded into `Execute` (a proposal executes only from the state it was built on); offline export and import, the guardian switch's rebind and registration, and note recovery are not modeled. - Guardian is simplified to its stored state, one candidate, and proposals pinned to its stored state; its own invariants belong to the server-side skills. - The `Retire`, `Expire` and `Forget` guards are assumptions about the server, not a description of it. `Spec` (the safety cells) includes `Forget`, so `NeverRollsBackPending` holds without trusting Guardian's absence. `FairSpec` (the convergence cells) leaves it out and keeps `Expire` to transactions the node no longer has: a forgotten transaction that then lands leaves Guardian behind the chain, a server-side gap no client sync can close (with the default 24 hour TTL it needs a transaction pending at the node for a day). - Each state records whether its proposal set an expiration (`e`), and only states without one take Guardian's veto. A cell sets it for every config proposal (the `expiring` column, constant `Expiring`) instead of branching per proposal, which multiplied the safety cell's state space by more than five. Because the model reads the node's mempool, expiry and the stale window are both exact in it: the `-expiring` cells check that skipping Guardian on expiry keeps the properties, and `m3-expiring` that the expiry path still needs the node-side drop, not that the veto backs the heuristic. - "The node no longer has it pending" stands for miden-client's discard rule, which the SDKs act on; a transaction that outlives the stale window in the mempool and then lands is outside the model, for recorded and untracked transactions alike.
Ver no GitHub