Skip to content

refactor(node-ui): centralize the PCA owner-access model + resolve signer once (#1375)#1468

Merged
Jurij89 merged 9 commits into
mainfrom
refactor/pca-owner-access-model
Jul 6, 2026
Merged

refactor(node-ui): centralize the PCA owner-access model + resolve signer once (#1375)#1468
Jurij89 merged 9 commits into
mainfrom
refactor/pca-owner-access-model

Conversation

@Jurij89

@Jurij89 Jurij89 commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the #1375 owner-access cleanup. Items 1, 4, 5 already landed in PR6 (#1348) / PR7 (#1350) — this PR delivers the two remaining core items, behavior-preserving, with the owner/signing invariants preserved byte-for-byte:

  • Item 2 — centralize the PCA owner-access model. One typed model, pca/ownerAccess.ts:resolvePcaOwnerAccess(), replaces the four hand-copied owner-mode classifiers (detailOwnerMode, ownerActions.resolveOwnerActionSubmitterKind, ConvictionOverview.ownerModeFor, PcaAccountCard's fallback). Returns { mode, wrongNetwork, writesEnabled, submitterKind, ownerUnknownCause?, signableOwners } + a usePcaOwnerAccess sync hook. Consumed by overview/cards, detail view + its sections, the approve modal, and the owner-action submitter resolution.
    • inv-17 (owner precedence) byte-exact: daemon check first (daemon wins even when the owner is also the connected wallet) + the redundant && !eqAddress(owner, primaryWallet) guard; settle stays a daemon carve-out.
    • Address-only classification (the wrong-network-fold hazard): mode is derived from addresses only; wrong-network gates writes separately via writesEnabled, so every consumer keeps its effective (classify + gate) behavior. A call-site-parity test pins this against all four legacy sites.
  • Item 3 — resolve the signer once per account/modal. The per-write fetchPca+fetchWalletsBalances in resolveOwnerActionSubmitterForAccount only drove submitter selection; the actual wallet-prompt safety lives inside walletOwnerActionSubmitter and runs per-prompt. useOwnerActionSubmitter({ access }) now selects the submitter once from the already-loaded model and submits directly — killing the N-per-wallet re-resolve on a batch approve. web3/walletOwnerActionSubmitter.ts is byte-untouched — inv-16 (loadContext/assertStillConnected/live eth_accounts/eth_chainId/allowance re-check) intact.
  • Precursor: collapsed 7 duplicate eqAddress copies into one shared pca/address.ts leaf.

Verification: an internal 5-lens adversarial gate (inv-16 liveness / inv-17 precedence / wrong-network-fold parity per consumer / signer-once behavior / completeness) diffing against main. It confirmed inv-16/inv-17 preserved and caught two behavior-relevant edges, both fixed:

  • Mixed-owner self-coveragederegisterSelf operates on a per-wallet-varying prevAccountId, so it stays on the per-account resolving path (not the target's once-resolved submitter); otherwise a differently-owned prevAccount would misroute → NotAccountOwner revert. Pin test added.
  • Self-heal — the access-path now falls back to the resolving (re-fetching) path when access.mode === 'unknown' (a transient snapshot miss or a just-created replacement PCA not yet chain-readable), so writes recover on retry instead of sticking read-only.

Related

Deliberately scoped / offered follow-ups

  • Item 4 (shared useWalletTxProgress) is satisfied for Create + Detail (PR6). The Approve flow keeps its own approveBatch.ts machine — a different shape (sequential N-wallet batch, ≤2 tx/wallet, the Gossip ingestion validation is incomplete (I-002) #9 confirmed≠submitted + "i of N" hardened in PR7). Folding it into the fixed single-tx template would regress exactly what PR7's gate hardened, so it stays separate by design.
  • disabledReason field / one-prop sections. The model centralizes the decision (mode/writesEnabled/submitterKind/signableOwners); the per-consumer disabled copy (which interpolates accountId/pool state) stays consumer-owned, so Funding/PublishingWallets/LifecycleSection take access + ownerTitle (3→2 props) rather than one. Happy to add a typed gateReason discriminant so the sections drop ownerTitle and the card drops its copy tables — flagging as a small, easily-added follow-up if preferred.
  • usePcaOverview emitting the full model (deleting PcaAccountCard's ownerIsPrimaryWallet fallback) is deferred — it widens a shared bell/dashboard/settings poller's dependency surface; low value, higher blast radius.

Files changed

File What
packages/node-ui/src/ui/pca/address.ts New. One shared case-insensitive eqAddress leaf (replaces 7 copies).
packages/node-ui/src/ui/pca/ownerAccess.ts New. resolvePcaOwnerAccess core + usePcaOwnerAccess + ownerModeWritesEnabled + the typed model.
packages/node-ui/src/ui/pca/ownerActions.ts Classifier delegates to the model; ownerActionSubmitterForKind; useOwnerActionSubmitter({access}) resolve-once (with unknown→resolving self-heal).
packages/node-ui/src/ui/pages/conviction/ConvictionDetailView.tsx usePcaOwnerAccess; one access threaded to the sections; owner submitter resolved once.
.../FundingSection.tsx · .../PublishingWalletsSection.tsx · .../LifecycleSection.tsx Take access (+ retained ownerTitle).
.../ConvictionOverview.tsx enhancedAccounts.map(resolvePcaOwnerAccess); deleted ownerModeFor.
.../PcaAccountCard.tsx Imports the shared union; writesEnabled via the shared formula.
.../ApproveWalletsModal.tsx targetWalletManaged/signableOwners from the model; target owner resolved once; cross-account deregisters resolve per-account.
.../web3/walletOwnerActionSubmitter.ts Only the eqAddress import swap — inv-16 guards byte-untouched.
test/pca-owner-access.test.ts (new) · test/pca-owner-actions.test.ts · test/pca-approve-modal.test.ts Direct model unit tests + call-site-parity matrix + access-path no-fetch/self-heal + mixed-owner self-coverage pin.

Test plan

  • Full inv-16/inv-17 oracle green + unmodified: pca-owner-actions, pca-wallet-owner-actions (the mid-flow account/chain/provider-swap-abort crux), pca-approve-batch, pca-approve-modal, pca-detail-view, pca-account-card, pca-owner-access194 passed.
  • Comprehensive PCA/wallet/web3 domain — 579 passed, 0 failures.
  • tsc -p src/ui/tsconfig.json — zero-delta (41 pre-existing graph-viz errors, none in touched files).
  • Internal 5-lens adversarial gate vs main — inv-16/inv-17 preserved; 2 edges caught + fixed.
  • CI green (build + kosava-node-ui-e2e — note: the wm-swm-vm-ui-cycle triples-migration e2e flake is unrelated / tracked in Intermittent WM→SWM promote migration fails to land the root in shared memory (blocks node-ui e2e on main) #1464).
  • Spend-free testnet browser smoke of the owner-mode surfaces (daemon-owned / read-only / wrong-network gating renders correctly).

Jurij Skornik and others added 5 commits July 5, 2026 23:44
… shared leaf (#1375)

Precursor for the owner-access model consolidation. Case-insensitive EVM
address equality was hand-copied 7× across the PCA owner-access + wallet layers.
Extract one pure leaf `pca/address.ts` (no imports) and replace every copy with
an import, aliased to each file's existing local name so every call site stays
byte-identical (only delete-local-def + add-import per file). detailOwnerMode
re-exports eqAddress so ConvictionDetailView's import is untouched.

inv-16: in walletOwnerActionSubmitter.ts only the local eqAddress def was
swapped for the import — loadContext / assertStillConnected and the per-prompt
guards are byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
…tests (#1375 item 2)

New pure classifier `pca/ownerAccess.ts:resolvePcaOwnerAccess()` — the SINGLE
encoding of the owner/signing classification that four sites hand-copied
(detailOwnerMode, ownerActions.resolveOwnerActionSubmitterKind,
ConvictionOverview.ownerModeFor, ApproveWalletsModal.signerKindForAccount).

Preserves both invariants exactly:
- inv-17 (owner precedence): daemon check FIRST + the redundant
  `&& !eqAddress(owner, primaryWallet)` guard on the wallet branch.
- Address-only classification: `mode` is derived from addresses only; the
  wrong-network fold is NOT in the classifier — `writesEnabled` gates writes
  separately (daemon always; wallet only on the right network), matching the
  async submitter (address-only kind + separate network block). This keeps every
  consumer's effective (classify + gate) behavior when it migrates.

Model: { mode, wrongNetwork(raw), writesEnabled, submitterKind, ownerUnknownCause?,
signableOwners }; two distinct unknown causes (no-snapshot vs wallets-unreadable);
`usePcaOwnerAccess` sync hook. Vocabulary (SignableOwner/OwnerActionSubmitterKind)
re-exported type-only — single definitions stay in walletBinding/ownerActions.
No consumer migrated yet; the submitter's per-prompt inv-16 guards untouched.

Tests: direct unit coverage (the model was only ever tested indirectly;
detailOwnerMode had no direct test) + an 8-scenario × 6-assertion CALL-SITE
PARITY matrix asserting the core reproduces every former site's effective
decision for the daemon==connected, wrong-network, and external cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
…access model (#1375 item 2)

Migrate the render-layer consumers off their per-surface owner-mode copies onto
the centralized model, byte-identical render + gating:
- ConvictionDetailView: inline ownerMode/writesEnabled block → usePcaOwnerAccess
  (hoisted above the early returns per Rules of Hooks); drop the hand-injected
  'unknown'; DetailBody threads one `access`; ownerGateReason re-expressed on
  access.mode/access.wrongNetwork (copy unchanged — gateReason deferred).
- Funding/PublishingWallets/LifecycleSection: the 3-prop owner bundle
  (ownerMode+ownerWritesEnabled+ownerTitle) collapses to `access` + the retained
  `ownerTitle` string (2 props; ownerTitle interpolates accountId/pool state the
  model doesn't carry yet).
- ConvictionOverview: enhancedAccounts.map → resolvePcaOwnerAccess(...); deleted
  the private ownerModeFor; group buckets still key on access.mode; walletWrongNetwork
  ≡ mode==='wallet' && wrongNetwork.
- PcaAccountCard: re-declared union deleted (= PcaOwnerMode from the model); writes
  gate via the shared ownerModeWritesEnabled(mode, wrongNetwork) formula on its
  fallback-resolved mode (usePcaOverview supplies only a partial signal — the
  fallback is removed in P5). Copy tables kept (gateReason deferred).
- ApproveWalletsModal: targetWalletManaged = access.mode==='wallet' && access.writesEnabled;
  signableOwners = access.signableOwners. signerKindForAccount + the submitter LEFT
  untouched (P4 / inv-16).

Adds `ownerModeWritesEnabled` as the ONE writesEnabled formula (resolvePcaOwnerAccess
delegates to it) so the card's fallback path shares it instead of re-deriving.

Verified: P3 oracle 162 green (pca-detail-view/account-card/overview/approve-modal/
owner-access/owner-actions, unmodified) + broader PCA domain 574/574 + tsc zero-delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
…odal (#1375 item 3)

Stop re-discovering the owner/signer on every write. The per-write
fetchPca+fetchWalletsBalances in resolveOwnerActionSubmitterForAccount only drove
submitter SELECTION (daemon/wallet/read-only) — the actual wallet-prompt safety
(loadContext + assertStillConnected + live eth_accounts/eth_chainId + allowance
re-check) lives inside walletOwnerActionSubmitter and runs per-prompt regardless.

- resolveOwnerActionSubmitterKind now delegates to resolvePcaOwnerAccess(...).
  submitterKind — the last inv-17 copy collapses onto the model.
- New pure ownerActionSubmitterForKind(kind, walletDeps) — selection only.
- useOwnerActionSubmitter({access}) builds the submitter ONCE from
  access.submitterKind; register/deregister/topUp submit directly (no re-fetch).
  Without access (create-time ownerKey, cross-account deregister), the resolving
  path is kept verbatim, INCLUDING its pre-flight walletUnavailableReason.
- web3/walletOwnerActionSubmitter.ts is byte-untouched — inv-16 guards intact;
  selection moves up-front, liveness stays call-time (safe because P3's
  writesEnabled gate blocks the button when the wallet is unusable).

Callers: ConvictionDetailView + ApproveWalletsModal's TARGET `owner` (registerAgent)
resolve once — killing the N-per-wallet re-resolve on a batch approve. The
cross-account deregisters stay per-account: deregisterRenew (the old renew account)
on the resolving `deregisterOwner`, and deregisterSelf on a new resolving
`crossAccountDeregister` — because self-coverage's prevAccountId varies per wallet
and can be owned differently than the target (binding it to the target's submitter
would misroute → NotAccountOwner revert). New pin test covers that mixed-owner case.

Verified: full inv-16/inv-17 oracle 193 green (existing tests unmodified; +access-path
no-fetch tests +mixed-owner self-coverage pin) + PCA/wallet/web3 579 + tsc zero-delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
…s unknown (#1375)

Adversarial-gate MEDIUM: the item-3 access-path pins the submitter from the
caller's PCA snapshot. When that snapshot transiently misses — or a just-created
replacement PCA (renew) isn't chain-readable yet — `access.mode` is 'unknown', so
`submitterKind` is 'read-only' and every Approve click fails read-only on a
genuinely daemon-owned account with no recovery (the modal's useFetch never
refetches). Origin re-resolved per click and self-healed.

Fix: `useOwnerActionSubmitter` takes the access-path only when the owner is
actually classified; on `access.mode === 'unknown'` it falls back to the RESOLVING
(re-fetching) path, so a write re-fetches the owner and self-heals on retry. This
protects every consumer, not just ApproveWalletsModal. Pin test added.

Verified: full inv-16/inv-17 oracle 194 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
Comment thread packages/node-ui/src/ui/pca/ownerActions.ts Outdated
Comment thread packages/node-ui/src/ui/pca/ownerAccess.ts Outdated
Comment thread packages/node-ui/src/ui/pca/ownerActions.ts Outdated
…+ hook split + pure model (#1375)

Address PR #1468 review (1🔴 + 3🟡):

- 🔴 (T1) Resolve-once wallet writes could sign with a stale owner classification:
  the access-path pinned the wallet submitter from a render-time snapshot, so a
  wallet-owned top-up with a changed owner/connected-wallet could run
  approveExactIfNeeded (TRAC allowance + gas) before the owner-only call reverts
  NotAccountOwner. FIX: on the access-path, when submitterKind==='wallet' (or
  mode==='unknown'), route writes through the RESOLVING path — re-fetch the owner
  + re-classify per write, returning read-only FIRST when the connected signer is
  no longer the owner, so no wallet approve/write is attempted. daemon + read-only
  keep resolve-once (server-side / no-write; safe). Regression test: stale-owner
  wallet topUp throws ReadOnlyOwnerActionError with neither the wallet approve nor
  pcaTopUp called.
- 🟡 (T3) Split the submitter hook into explicit entry points that encode the
  same-account vs cross-account boundary in the API: useResolvedOwnerActionSubmitter
  ({access}) (same-account, resolve-once + the T1 wallet re-verify) and
  useResolvingOwnerActionSubmitter({accountId?,ownerKey?}) (per-call). Deleted the
  combined useOwnerActionSubmitter. Callers migrated; create/settle unchanged.
- 🟡 (T2, partial) Split ownerAccess.ts into a PURE core (resolvePcaOwnerAccess +
  ownerModeWritesEnabled + types, no React/store) and a hook file
  (pca/usePcaOwnerAccess.ts). PcaAccountCard imports the pure formula with no store
  wiring pulled in. (Held: moving signableOwners/submitterKind off the model — that
  re-fragments the single model item 2 asked for.)
- 🟡 (T4) Extended the wallet-owned wrong-network ConvictionDetailView test to
  assert every owner-write control (top-up, + Approve publishing wallet, Renew)
  disabled, not just top-up; probe stays enabled.

inv-16 (walletOwnerActionSubmitter.ts byte-untouched) + inv-17 (daemon-first +
redundant guard, single-sourced on the model) preserved; independently re-verified
by an adversarial pass. Full oracle 195 green + unmodified assertions; tsc zero-delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
Comment thread packages/node-ui/src/ui/pages/conviction/ApproveWalletsModal.tsx
Comment thread packages/node-ui/src/ui/pca/ownerAccess.ts
… drop the last duplicate classifier (#1375)

Review round 2:
- 🔴 (T5) ApproveWalletsModal could pin a valid daemon-owned target as read-only
  while wallets load: it built `access` without a walletsUnknown signal, so if
  fetchPca resolved before fetchWalletsBalances (owner known, primaryWallet
  undefined) a daemon-owned PCA classified as external → the resolved read-only
  submitter rejected a valid owner approval. FIX: pass `walletsUnknown: !wb`, so the
  load window is 'unknown' (not external) — the resolved submitter then routes
  through the re-fetching path (the T1 fallback), re-reads wallets, and routes
  daemon once wallets[0] is known. Test: daemon owner + walletsUnknown ⇒ 'unknown',
  registerAgent re-fetches to daemon (pcaAddAgent), never the wallet submitter,
  never read-only.
- 🟡 (T6) Actually leave ONE owner-mode classifier: `detailOwnerMode` had no source
  caller (ConvictionDetailView uses usePcaOwnerAccess; eqAddress is imported from
  address.ts) — deleted it; the parity test's mode oracle stays on the inline
  ConvictionOverview.ownerModeFor reproduction.

inv-16 (walletOwnerActionSubmitter.ts byte-untouched) + inv-17 preserved. Full
oracle 188 green + unmodified; tsc zero-delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
Comment thread packages/node-ui/src/ui/pages/conviction/LifecycleSection.tsx
Jurij Skornik and others added 2 commits July 6, 2026 02:17
…+ renew test (#1375)

Review round 3:
- 🟡 (T2) The model was a cross-layer catch-all: sections received submitterKind /
  signableOwners they never read. Shrink PcaOwnerAccess to the pure owner state
  { mode, wrongNetwork, writesEnabled, ownerUnknownCause } and derive the layer
  concerns at their boundaries: submitterKindForOwnerMode(mode) beside ownerActions,
  signableOwnersFor(primaryWallet, connectedWallet) beside walletBinding. ownerAccess.ts
  is now a pure leaf (address.js only). Behavior-preserving — the derivations produce
  the same values; the T1 wallet re-verify is unchanged (submitterKindForOwnerMode(mode)
  === 'wallet' || mode === 'unknown' → resolving path), inv-17 precedence stays on `mode`.
- 🟡 (T7) Add a renew-chain test for a wallet-owned PCA (owner == connected wallet,
  wallets[0] != connected) asserting the renew CreatePcaModal gets
  initialOwnerKey === 'hardware'; the daemon-owned counterpart asserts 'hot'.

inv-16 (walletOwnerActionSubmitter.ts byte-untouched) + inv-17 preserved; T1/T5
access-path tests unmodified. Full oracle 209 green; tsc zero-delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
… window (#1468)

R4 — when the PCA snapshot loads before the wallet balances, the target
owner-access is classified `unknown` (the walletsUnknown self-heal that keeps a
daemon-owned PCA from being mis-pinned read-only). But the render-time
`targetWalletManaged` was false in that window, so a target write that the
resolved submitter re-fetches and routes to the BROWSER-WALLET signer (owner ==
connected wallet) ran with no device-progress step and deviceTotal 0 →
walletBatchSigning off → the modal stayed freely dismissable / Cancel unlocked
during an in-flight on-chain wallet signature.

Arm the lock pessimistically whenever the target write could open a wallet
signature: `access.mode === 'unknown' && owner == connectedWallet`. This makes
runApproveBatch set the device step + cancel/dismiss lock BEFORE the in-flight
wallet action. Daemon-owned targets (owner == wallets[0], normally != the
connected wallet) are unaffected and still self-heal to the daemon.

Tests (both proven red without the fix):
- loading-window: a connected-owner target still arms the wallet-signing lock
- inverse guard: a daemon-owned target (owner != connected wallet) is NOT
  falsely locked and self-heals to the daemon (pins the sameAddress conjunct)

inv-16 (walletOwnerActionSubmitter.ts byte-untouched) and inv-17 (owner
precedence) preserved. 4-lens adversarial gate: GO. tsc zero-delta; build:ui ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3swmeQGT8WGttaZsp1NQJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PCA HW: consolidate owner access, progress, and detail-view orchestration

2 participants