Perch policy-document integration: doc-only smart accounts, policy UI, and account recovery - #207
willemneal wants to merge 44 commits into
Conversation
Integrate perch (stellar-registry/perch) at the doc layer: the perch
PolicyDoc becomes nido's policy source of truth, lowered onto the OZ
context rules nido accounts already run on.
- packages/perch: vendored @stellar-registry/perch (perch-js) pinned to
perch rev f5676a6, plus the frozen golden vectors. npm cannot install
a git dep from a repo subdirectory, so a vendored workspace package
under the final npm name is the workspace-compatible pin; VENDORED.md
carries the provenance and the TODO to swap to the npm release once
perch-publish-p1 lands (imports won't change).
- packages/contract-bindings/perch-interpreter: generated bindings for
the perch interpreter (canonical registry testnet deployment, wasm
f8320d30…), regen via `just bindings-perch-interpreter`.
fix-bindings.sh now skips the Context shim when the generator (CLI 27)
defines its own Context type.
- passkey-sdk src/policyDoc: the new doc layer.
* build: buildPolicyDoc/scopedSessionKeyDoc — nido conventions
(passkey signers as external(verifier, key), session keys as
delegated(G…)); the v1 template composes named-function + expiry
constraints with a cumulative cap.
* lower: faithful TS mirror of perch-compile (INV-1/INV-2): bare
all-rules ride OZ natively; constrained rules attach the perch
interpreter (canonical [MinSigners, FnIn?, args…, All] program);
caps attach nido's stock spending-limit policy as a sibling;
not-after-ledger (exclusive) → valid_until (inclusive, -1).
* txs: one add_context_rule TxBuild per rule via the smart-account
bindings, install params spec-encoded (dual-SDK hazard #72).
* decompile: best-effort chain rules → doc view with per-rule
{doc|raw} fallback and the committed doc_hash from on-chain
interpreter programs.
* deployment: perch canonical addresses derived offline from the
content-addressed registry pins (never hardcoded at call sites),
asserted against perch's testnet_pins in tests.
- tests: doc_hash parity vs perch golden vectors, byte-exact wire
parity for interpreter programs + spending-limit params, lowering
invariants, template round-trip (lower → chain shape → decompile),
raw-fallback coverage, address-derivation pins.
Perch gaps found (reported upstream rather than forked): the TS schema
has no threshold principals (Rust perch-ir does) and the TS RuleBuilder
has no .cap() — cap-carrying docs are built via requestToPolicyDoc.
Both perch packages are now on npm, so replace the interim workspace plumbing with the published releases: - @stellar-registry/perch 0.2.0 (adds threshold principals + RuleBuilder .cap): remove the vendored packages/perch copy and depend on the npm package. lowerDoc now lowers Principals::threshold to the interpreter's MinSigners(m) quorum (a threshold rule is never policy-free, per perch-compile INV-2); the earlier all-only note is gone. - @stellar-registry/perch-interpreter 0.1.0 (upstream bindings; captain ruled bindings live in perch): drop the nido-owned @nidohq/perch-interpreter binding and its npm-publish.yml entry, and point the policyDoc module at the published client. The published packages ship only dist/, not perch's golden vectors, so the doc_hash + wire-format fixtures move to packages/passkey-sdk/src/policyDoc/testdata/ (frozen; provenance in its README). A root overrides entry pins the interpreter bindings to the workspace's single @stellar/stellar-sdk 15.x copy — the #72 dual-SDK hazard, now a compile-time type conflict since the upstream bindings target sdk 17. tsc + vitest (250 passing, incl the new threshold-lowering tests and the relocated golden parity) + astro build all green.
The previous npm-swap commit regenerated package-lock.json from scratch (rm node_modules + lock), which re-resolved the whole workspace to newer transitive deps and pulled in lightningcss platform binaries marked non-optional — breaking `npm ci` on the Example dApp PR Preview leg with EBADPLATFORM (lightningcss-android-arm64 on a linux/x64 runner). Rebuild the lock from the pre-swap baseline plus only the perch changes: @stellar-registry/perch 0.1→0.2, the new @stellar-registry/perch-interpreter, and removal of the vendored/interim packages. The interpreter's nested @stellar/stellar-sdk 17.x is deduped to the workspace's single 15.x copy via the existing root override (the #72 dual-SDK requirement). Net lock diff is now exactly those perch entries — no lightningcss, no unrelated platform-specific churn. `npm ci` clean-installs; tsc + vitest (250) + frontend and example-dApp builds all green.
The swap commit dropped the `packages/perch/dist/` line from .gitignore and then `git add -A` swept the stale build output (left on disk from an earlier npm install) into git, even as it deleted the package's source. Remove the whole leftover directory — the vendored package is gone; only its orphaned build artifacts were tracked.
Adds apply_doc/applied_doc_hash/doc_rule_ids to nido-smart-account, consuming perch's stateless doc-compiler protocol (git-pinned perch-doc-compiler at rev f5676a6, client mode) with the compiler and interpreter addresses derived from the same testnet pins the SDK already carries. HYBRID: apply_doc diffs only its own tracked rules; the default passkey rule, recovery rule and all legacy mutators are untouched. Stores the canonical doc_hash, emits the full doc JSON as a DocApplied event, refuses capped docs (DocCapUnsupported) rather than installing weaker than reviewed. A [patch] redirects perch's theahaco stellar-accounts fork pin to nido's upstream OZ rev so the wire types unify — a flagged adoption cost. e2e (real wasm account + real compiler/interpreter at derived addresses): install/hash/event + doc recovery parity, hybrid re-apply diff, cap refusal, network binding. Factory: no code change needed (embedded wasm); probe test proves new accounts expose the doc surface.
- Regenerate @nidohq/smart-account bindings from the apply_doc wasm (just bindings smart-account; only src/index.ts changes). - buildApplyDocTx: one-tx apply mode beside buildDocInstallTxs — submits the CANONICAL doc JSON to apply_doc, so the DocApplied event carries the canonical form; refuses wrong-network and capped docs before any network round-trip (capped docs keep the per-rule install path). - readPolicy: pure three-tier classifier. (a) doc-verified: event- recovered doc matches the stored applied_doc_hash AND its lowering matches the live doc-managed rules (scope/name/signers/expiry/ interpreter attachment, plus each fetched program's committed doc_hash); (b) doc-drift: hash verifies but live rules drifted — doc returned with the drift list; (c) decompiled: existing decompileRules fallback. - Tests: tier a/b/c coverage incl. the spec'd legacy-mutation drift case; suite: 260 passing.
…eadPolicy Captain extension to the spike: keep a lossless on-chain copy of the applied policy document so the showcase can read it without event history or an indexer (the DocApplied event stays as-is and remains the eventual recovery method). - apply_doc persists the full canonical doc JSON in a PERSISTENT entry (DOC_JSON) — persistent, not instance, so the KB-scale blob is not loaded on every __check_auth; only doc_hash + rule ids stay in instance storage. New get_applied_doc view returns it. - apply_doc now REFUSES non-canonical byte submissions (DocNotCanonical, code 17): stored == emitted == canonical by construction, so either copy verifies against the stored hash with a bare sha256. A flagged divergence from perch's format-agnostic compiler; buildApplyDocTx already always submits canonicalJson(doc). - SDK readPolicy: tier-a doc source is now view-first (storedDocJson from get_applied_doc), event recovery is the in-tier fallback; drift tier unchanged. Candidates are trusted only if they parse AND hash to the stored identity. - Tests: round-trip through the view (bare sha256 of chain-returned bytes == stored hash), view tracks latest apply, non-canonical refusal, view-first + corrupt-view-falls-back-to-event SDK cases. Bindings regenerated. Suites: cargo workspace green, SDK 262 green.
Captain ruling: drop hybrid. The OZ mutation surface (add_signer, remove_signer, remove_context_rule, add_policy, remove_policy, update_context_rule_*, add_multisig_recovery) is no longer implemented or exported; apply_doc atomically replaces EVERY rule except the protected zk-recovery rule. The constructor's default passkey rule lasts until the first apply; the re-imported anti-brick check (DocAdminLockout) refuses docs without a policy-free self-admin rule. Caps now install in-contract: CompiledCap lowers onto nido's stock spending-limit policy at its pinned deployed address (same audited-pin pattern as the compiler/interpreter); DocCapUnsupported retired. add_context_rule survives solely as the zk-recovery COMPLETION vehicle, hard-gated (DocOnlyWritePath) to the completion window: has_pending || completion_granted. The grant is new — Policy::enforce consumes the pending during the completing call's __check_auth, BEFORE the entry point body runs, so the controller now records a same-ledger TEMPORARY CompletionGrant at consume time and exposes a completion_granted view. Tests: unit suite rewritten for the doc-only surface (16 green); integration tests ported — policy-mechanics suites stage rule shapes through as_contract library helpers (test-only backdoor), guard e2e checks moved onto the surviving guarded ops (initiate_upgrade), apply_doc e2e now proves whole-rule-set replacement, cap install, anti-brick; 89 green; factory 28 green; clippy pedantic clean.
- Regenerate @nidohq/smart-account bindings from the doc-only wasm: the rule mutators are gone from the client; add_context_rule (completion vehicle), the doc surface, reads, and recovery machinery remain. - Remove buildDocInstallTxs (the per-rule install route) and its DocInstallStep types/tests — apply_doc is the sole apply route. - buildApplyDocTx: capped docs now ride the single apply_doc tx (the contract lowers caps onto its pinned stock spending-limit policy); the client-side cap refusal is gone. - readPolicy: two tiers — doc-verified (view-first, event fallback, hash-verified) / decompiled fallback. The drift tier disappears by construction: no mutators, no drift. - policyBlocks: scoped-session-key install/revoke and multisig-recovery install/revoke/rotation now THROW doc-only guidance (session keys have the doc route: compose + buildApplyDocTx; M-of-N friend recovery has NO doc route in doc v1 — headline cost, in the PR description). The read-side (fromChain/summarize) still renders existing rules. The frontend's remove-context-rule op builder throws the same guidance. SDK vitest 256 green; frontend build + 408 tests green; cargo gates green.
Testnet deploy (identity: theahaco, the existing factory admin & registry name owner): - smart-account wasm f962dc8e… uploaded on-chain (embed==installed verified). The unverified/smart-account registry LABEL is owned by a different author key, so the 0.2.0 label publish was refused (WasmNameAlreadyTaken) — functional path is the direct upload, exactly the deploy script's own fallback; noted in DEPLOYED.md. - Fresh doc-only factory CCJFOM6UGOH7JSAX22C3FAECG5657HKIUYDBTCMUMILKDA6LOA2J2EGG (admin theahaco); registry name 'factory' repointed to it (direct registry-contract invoke — the newer scaffold-registry CLI defaults to a different root registry than nido's unverified one). - First zk-recovery testnet deploy, spike params (delay 60s, window 7d, max-cancels 2, floor 0): pool CAUZ6WFU… (factory-wired, reached via the factory's set_recovery_pool override; not registry-named), UltraHonk zk-verifier CDMNKDMP… with the committed depth-24 vk. Deployed via plain stellar CLI — the deploy-zk-recovery.mjs DEPLOY_SECRET path can't read a Secure-Store identity, and the CLI ctor-args bug it worked around is fixed. - Live probe: create_account minted CBPE5AJY… (genesis leaf 0 inserted); the account's interface exposes apply_doc/get_applied_doc/ applied_doc_hash/doc_rule_ids and none of the legacy mutators; both doc views return null (no doc applied yet). - DEPLOYED.md rewritten for the new topology; passkey-sdk REGISTRY_FALLBACKS.factory updated (registry name already repointed; fallback fires only when the registry is unreachable). SDK 256 + frontend 408 tests green.
…re-bytes e2e Live testnet failure (captain repro): every apply_doc trapped with Error(Object, UnexpectedSize) decoding compile_doc's return. Root cause: the account's wire types came from perch SOURCE rev f5676a6 (6-field CompiledRule, with cap), but the canonical DEPLOYED compiler — hash 3645bd0d, still the registry's LATEST publish; perch tagged doc-compiler-v0.2.0 but never published a newer wasm — is an older build: 5-field CompiledRule, no cap lowering, and CapUnsupported=6 in its error enum. The pins were right; the source-vs-artifact skew was the bug, and the e2e masked it by registering the NATIVE source-rev compiler at the derived address. Fix, aligned to ground truth: - contracts/smart-account: drop the perch-doc-compiler crate entirely; mirror the DEPLOYED wasm's spec locally (CompiledDoc/CompiledRule/ RuleScope/DocCompilerError incl. CapUnsupported; install as raw Vec<Val> passthrough — no interpreter type surface to mirror). Cap install branch removed (the deployed compiler refuses capped docs); DocCapUnsupported=10 returns as the relay of CapUnsupported. Cap support resumes when perch publishes the cap-capable compiler (the lowering branch is in this branch's history). - Workspace: perch git deps reduced to perch-ir only; the theahaco stellar-accounts [patch] is GONE (nothing pulls the fork anymore) — that adoption cost disappears with artifact-aligned wire types. - e2e now registers the FETCHED ON-CHAIN BYTES of the compiler + interpreter (fixtures/perch/*.wasm) at the derived addresses, plus a live-shape guard test: sha256(fixture) must equal the account's pins. The capped-doc test now proves the CapUnsupported refusal against the real deployed build. This configuration reproduces the live trap class locally. - factory: Upgradable::upgrade override clears the cached embedded-wasm hash (stale-cache-after-upgrade bug found preparing the in-place testnet upgrade) + admin-gated refresh_account_wasm_hash for factories upgraded from pre-override code; two unit tests. - SDK: buildApplyDocTx pre-refuses capped docs with the deployed-compiler reason; bindings regenerated (smart-account, factory). Gates: just check/test green (7 apply_doc e2e vs deployed bytes, factory 30), SDK 257, frontend 408.
…ed end to end - Uploaded account wasm b25e3287… (deployed-artifact-aligned wire types); in-place factory upgrade to f5374df1… on the SAME address CCJFOM6UGOH7JSAX22C3FAECG5657HKIUYDBTCMUMILKDA6LOA2J2EGG, then refresh_account_wasm_hash (the new admin entry) repaired the stale embed-hash cache the old code left behind. Registry name + pool wiring untouched. - LIVE PROBE, the captain's exact failing flow: an add-admin-key policy document (owner passkey + second admin key on the self-admin rule) applied on-chain to fresh account CD5X4AWM…7JQI, signed via the wallet's own path (bindings-recorded auth entry + synthetic WebAuthn assertion over digest(payload || [0])). Verified live: applied_doc_hash 4fb7ae94…, get_applied_doc returns the full canonical JSON, doc_rule_ids [2], recovery rule preserved, default rule replaced. The previous wasm's UnexpectedSize trap is gone. - DEPLOYED.md updated (new wasm hash, realignment note, live-probe evidence).
Perch's release CI published + deployed the cap-capable doc-compiler 0.2.1 (publish receipt on the perch-doc-compiler-v0.2.1 release): registry moved to CDX2DMYM…, compiler CDWBJPDM… (wasm 35f248f0…), interpreter CDR2OTZI… (wasm f63cae53…, same generation). Verified the receipt's contract id equals the content-address derivation. - contracts/smart-account: pins bumped to the new registry + both 0.2.1 hashes; wire types mirror the 0.2.1 spec (6-field CompiledRule with cap; DocCompilerError back to 5 variants — CapUnsupported is gone); cap lowering restored onto the pinned stock spending-limit policy; anti-brick re-checks cap-free admin; DocCapUnsupported retired again. - fixtures: both perch wasms refetched from the NEW on-chain instances; pin-fixture sha guard unchanged (and passing). - e2e: capped doc installs interpreter + pinned spending-limit on one rule, proven against the fetched 0.2.1 build; 7 green. - SDK: deployment.ts pins moved (new registry, interpreter f63cae53…, compiler hash added; golden derived addresses updated); the buildApplyDocTx cap refusal is gone. Gates: just check/test green; SDK 256; frontend 408.
Adds a Policy page (/account/policy) that visualizes every context rule on a smart account and a builder for adding new rules. Inspector: fetches all context rules (fetchAllChainRules) and renders each as a card — scope (any contract / one contract / contract creation), signers (passkeys vs delegated keys), attached policy conditions (labeled from the registry where known), and expiry classified against the current ledger. Each rule gets a plain-language sentence of what it permits, and rule 0 is flagged as the account's primary authority. Builder: composes a new context rule (scope, one or more passkey/delegated signers, optional spending-limit policy, optional expiry), validates it, lowers it to the smart-account add_context_rule arguments, and submits through the account's existing passkey signing path (signAndSubmit). No silent signing — the on-chain write always goes through the user's passkey. The display model (policyView) and draft validation/lowering (policyDraft) are pure and unit-tested (34 tests); the Security page stays the curated front door for recovery/session keys, this is the general view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iRUGB6K1XCEsdHoxEzsbP
The policy page now runs the SDK's three-tier readPolicy: - tier a: an applied document recovered losslessly (get_applied_doc storage view first, DocApplied event history as fallback) and verified against the stored doc_hash renders as the document itself — the doc's own rule names, signer ids, and function lists, with a doc_hash badge and a provenance badge (stored on chain / from event history). - tier b: same view plus the SDK's drift findings, rendered prominently. - tier c: the existing raw rule cards, unchanged. The builder gains a doc mode (default) with the canned v1 template — a scoped session key (delegated signer, one contract's named functions, expiry) plus an optional cumulative spending cap — with a live canonical JSON + doc_hash preview. Uncapped docs apply through the account's one-transaction apply_doc surface; capped docs (and accounts without the surface) install per-rule via the SDK's client-side lowering, where the cap rides the stock spending-limit policy. The raw add_context_rule form stays as the second mode. New pure libs (docView, docDraft, docPolicyFetch helpers) carry the display models, validation, and route choice; all vitest-covered. @stellar-registry/perch-interpreter joins the frontend deps for the typed get_program read (stellar-sdk pinned by the root override).
The status-message demo dApp gains a second delegation option: request a SCOPED SESSION KEY as a perch policy document. Its existing local G keypair becomes a delegated signer restricted to the note function, and the click-through the showcase demonstrates is: dApp requests → wallet builds the doc → /sign/ applies it → the wallet's Policy page shows the lossless document. New wallet page /security/delegate-doc/ (doc sibling of /security/ delegate/): parses the request (pure lib lib/policy/docRequest), offers duration + optional spending-cap controls, previews the document's canonical JSON and doc_hash — the exact identity the account stores on chain — and hands off to /sign/ with a new apply-policy-doc descriptor. The expiry ledger is anchored once at page load so the previewed hash is byte-identical to what gets signed. /sign/ renders the descriptor as a review card built from the document itself (rule name, key, functions, cap, expiry, doc_hash) with the canonical JSON behind a toggle; buildOperation routes it through buildApplyDocTx (uncapped, apply_doc surface) or a single-rule per-rule install, refusing multi-rule docs on the per-rule route offline. Anti-redirect-abuse validation and the ?delegation=ok|cancelled return contract are carried over from the passkey delegate flow unchanged.
…puts Rebased onto 856e258 (on-chain canonical doc JSON + get_applied_doc): pass the storage-read doc as storedDocJson and an event-recovered doc as eventDocJson, matching the SDK's view-first input surface instead of funnelling both through the event field.
Same conventions as account-ui.spec.ts: built-HTML anchors, no-fatal-JS boot checks, and client-side validation that fires before any network call — doc-mode template validation, the mode toggle, delegate-doc request rejection (missing origin) and offline doc-JSON preview.
Captain ruling: every policy write goes through apply_doc — the per-rule add_context_rule lowering is gone from the builder, the delegate-doc flow, and the /sign/ descriptor (route field dropped), and the builder's raw-rule mode is removed with it. chooseApplyRoute/docHasCap die. apply_doc REPLACES the applied document, so both write surfaces are now update-aware: they read the account's applied doc (get_applied_doc, event fallback), UPSERT the requested session rule into it (upsertSessionRule: same-key signer reuse, id-collision allocation, orphan pruning, cross-network refusal), and preview the merged document. A new pure diff (diffPolicyDocs) classifies rules added / removed / modified — with field-level change lines on modified rules, including rekeyed-signer surfacing — and renderDocDiffHtml shows it on the builder, the delegate-doc page, and /sign/ (prevDocJson carried in the descriptor) before the user confirms. First-time applies render as all-new. Fail-closed guards: the delegate-doc flow refuses accounts without the doc surface and refuses to build an update over an unrecoverable applied doc; fetchDocSurface/fetchAppliedDocJson now swallow constructor throws (an invalid account id reads as no-surface instead of escaping). Note: capped documents now rely on the reworked apply_doc gaining cap support on the base branch (PR 201's in-flight update); until that lands the SDK's buildApplyDocTx still refuses caps at build time.
…, two-tier read Rebased onto 464f6b0 (apply_doc is the sole policy write path): - First applies now upsert into an owner-admin BASELINE (ownerAdminBaseline: the account's live primary passkey with a policy-free self-admin rule) instead of submitting a standalone session doc — apply_doc replaces EVERY rule including the constructor default, and the contract's DocAdminLockout refuses documents without that admin shape. upsertSessionRule now requires a non-null base; the builder and delegate-doc page read the passkey off rule 0 (fetchDefaultRuleAuthInfo) when nothing is applied yet, and fail closed when neither an applied doc nor the passkey can be read. - readPolicy is two-tier now (no mutators → no drift by construction): drop the docRuleIds input, the drift renderings, and the drift model fields; the doc view badge is always Verified · lossless. - Capped documents ride the single apply_doc tx (the contract lowers caps onto its pinned spending-limit policy) — no client-side special case remains. - e2e: the offline builder expectation flips to the fail-closed message; the first-apply preview happy path moves to pure unit coverage (renderDocDiffHtml + upsert-into-baseline tests).
Captain feature: the builder gains an Admin keys tab alongside the session-key template. Adding enrolls another ADMIN signer — a brand-new passkey created via the WebAuthn ceremony on submit (verifier resolved from the account's own rule) or a pasted external/delegated key — as a policy-free cap-free self-admin rule, the same shape the contract's anti-brick check requires; each admin key gets its OWN rule (all principals are N-of-N, so sharing one would force co-signing). Removal falls out of the same machinery: pick an enrolled admin key, review the removal diff, apply — refusing the LAST admin key up front with a human-readable reason (DocAdminLockout would reject the document anyway). Both paths are doc updates through the shared apply plumbing: compose against the loaded baseline, show the added/removed signer + rule in the what-changes diff like any other doc change, one apply_doc, passkey ceremony via signAndSubmit. The add form refuses duplicate rule names (never a silent rule replacement) and keys that already hold admin authority. Pure lib: isAdminRule/adminRules/nextAdminRuleName, validateAdminKeyDraft, addAdminKey, removeAdminRule in docDraft, with the signer-merge/prune/revalidate helpers factored out of upsertSessionRule and shared. 10 new vitest cases; new @fast e2e spec for the admin tab (mount, defaults, offline fail-closed).
Captain feedback from live testing of the showcase: - Navigation: the Security page gains an 'Account policy' nav card (existing navrow conventions) linking to /account/policy/, and the delegate-doc preview links to the Policy page where the applied document will be visible. The dApp's return banner already linked. - Readable previews: the builder (both tabs) and the delegate-doc page now render the 'document after this update' as compact rule cards from the inspector's own display model — signer chips with the doc's ids, one mini-card per rule with the permission sentence and function/cap/expiry facts — with the canonical JSON folded behind a 'Raw document JSON' toggle instead of dumped raw. The what-changes diff panel is unchanged and stays legible alongside. renderDocPreviewHtml is a pure string builder in PolicyInspector, unit-tested; styles shared across the policy and delegate-doc pages.
Captain's live test failed with Error(Contract, #19): the dApp's 'Delegate this dApp' button routes through /security/delegate/, which still emitted add_context_rule — the doc-only gate refused it exactly as designed. This closes that escaped path and sweeps the rest: - /security/delegate/ is now a policy-document update, same discipline as delegate-doc: baseline load (applied doc, or owner-admin baseline on first apply; fail-closed otherwise), the dApp's session passkey declared as an external signer against the account's own verifier, a 'session-key' rule upserted (re-delegation REPLACES it — the diff shows it as modified), what-changes diff + readable doc preview + doc_hash, and an apply-policy-doc handoff. The query-param contract and anti-redirect-abuse validation are unchanged, so startDelegation keeps working. The spending-limit control moved here from /sign/ (doc grants are confirm-only there); caps ride the doc (compiler 0.2.1 base). - Both delegate pages now build their /sign/ operation through ONE shared helper (buildSessionGrantOperation in docRequest) — the regression tests pin that it emits apply-policy-doc, carries prevDocJson on updates, and replaces same-named rules. - operationBuilders: the add-context-rule branch now THROWS doc-only guidance offline (like remove-context-rule) so a stale stashed request fails with a message instead of #19 on-chain; dead SmartAccountClient plumbing dropped. Regression tests cover both refusals. - Emitter sweep: the only remaining add_context_rule builder is zkRecovery's recovery COMPLETION (the contract's sanctioned gated window); multisig-recovery paths already throw doc-only guidance in the SDK. No other legacy mutator emitters in frontend or sdk. - Rebased onto 1979dca (caps re-enabled on perch compiler 0.2.1 + realigned testnet wasm). - New @fast specs for the converted delegate page (param rejection, fail-closed baseline); passkey-signer draft coverage in docDraft.
…ption C)
Ruling on the admin composition (needs-decision admin-any-principals):
keep ONE policy-free self-admin rule per admin — each independent full
authority, which already IS 'any admin may act' — no contract change,
no interpreter-gated admin rule (doc v1 has no 'any' principal type;
threshold m=1 always lowers interpreter-attached, which the anti-brick
check refuses by design). What changes is naming and presentation:
- Naming: the founder keeps signer id 'owner' (rule 'admin'); added
admins get signer ids admin-2, admin-3, … mirroring their default
rule names — no more bare 'admin' signer colliding with the rule name.
nextAdminRuleName picks the slot free as BOTH rule name and signer id.
- Single-list UI: the policy page's doc view folds every admin rule
into ONE 'Admin keys' card ('Any of these N keys can act … each holds
independent full authority'), non-admin rules keep their per-rule
cards; DocRuleView carries isAdmin. The builder's admin tab copy says
the same; its list, add/remove flows, and the diff preview already
present the set as one list.
Tests updated for the new ids plus new coverage: isAdmin classification
and the consolidated card (one data-doc-admins card, both keys listed,
no per-rule admin cards).
'It could be confusing for new users that there are both.' The founder's signer id is now 'admin' (rule name 'admin'); added admins stay admin-2, admin-3, …. The 'owner' id disappears everywhere: - adminBaseline (né ownerAdminBaseline) declares the founder as 'admin' in the first-apply document; add/remove flows and UI copy follow (the doc-head card no longer says 'owner' either). - Legacy applied docs that still declare 'owner': renameLegacyOwner is a guarded pure migration (only when no distinct 'admin' signer exists, so two different keys can never merge) applied inside rebuildDoc — so the rename rides the user's NEXT doc update through any compose path, and the diff preview renders it (signer 'owner' removed / 'admin' added, referencing rules modified). - Display layer: summarizeDoc migrates ids before rendering, so an un-updated legacy doc already shows 'admin' on the policy page and in previews — the two names never appear at once. Tests: fixtures moved to the new id; new coverage for the composed rename (visible in the diff) and the distinct-admin guard; composed signerId return values map through the rename.
… labeled Captain live repro: a NEW account's /account/policy showed no document (nothing applied yet → decompiled tier → doc section hidden). Now a doc-surface account with no applied document renders the SYNTHESIZED baseline — the founder admin rule over the default rule's live passkey, built by the same adminBaseline the builder and both delegate flows compose against on first apply (fetchUnappliedBaseline, shared) — as the current effective policy, with the full rule-card + admin-list + raw-JSON-toggle presentation. Clearly labeled: 'Not yet applied' badge, lead copy saying the first policy edit applies exactly this document, and 'Document hash (once applied)' for the would-be identity. The first real edit applies it and the label drops (readPolicy then reads doc-verified as usual). readDocPolicy exposes the unapplied state (surface supported, no stored hash); page and flows provably share one baseline. New fixture test: the new-account doc view renders labeled, with the admins card and raw toggle, and an applied render never carries the label.
Transition specification (docs/recovery/TRANSITION_SPEC.md) and an executable TypeScript reference state machine + adversarial test suite (packages/recovery-spec/) implementing Stage 1 of the perch/nido recovery plan in firstmate/data/perch-zk-recovery-scout-p5/follow-up.md §8. Scope: Stage 1 only (spec + reference model), no contracts or circuits. Section 7 (pending-activity policy) is modeled as an explicit, non-defaulted parameter with every candidate branch exercised - it is recorded as an open gate, not resolved here.
Compares two ways to complete a doc-hash-committed recovery attempt on the doc-only smart account, per firstmate/data/perch-zk-recovery-scout-p5/ follow-up.md §8 Stage 2: Variant A authorizes the existing apply_doc with no smart-account code changes; Variant B adds a dedicated complete_recovery entry point sharing apply_doc's internal pipeline, gated by a value-bound single-use completion grant instead of the original spike's ledger-scoped boolean flag (follow-up.md §3.1). New contracts/recovery-doc-completion crate implements a minimal controlled- test-authenticator recovery controller for both variants. 15 new integration tests (crates/integration-tests/tests/it/recovery_stage2_*.rs) drive real account authorization and a real perch doc-compiler end to end, proving exact-target installation, single/atomic completion, and that ordinary admin authorization cannot exercise recovery-only authority under either variant. docs/recovery/stage2-findings.md recommends Variant A, with the call-ordering analysis explaining why it needs no completion-grant primitive at all while Variant B does.
Stage 3 of the staged recovery plan (firstmate/data/perch-zk-recovery-scout-p5/follow-up.md §8): contracts/recovery-controller (shared controller implementing guardian-only, ZK-only, and combined evidence paths against Stage 1's proposal model, completing via Stage 2's Variant A) and contracts/recovery-verifier (a new, fully constructorless UltraHonk verifier). circuits/zk_recovery_doc adapts the existing zk_recovery Noir circuit to bind a target-document hash instead of a raw pubkey, as a NEW, isolated circuit crate (not an in-place edit — the M1 circuits/zk_recovery module and its 33+ integration tests are byte-for-byte untouched and re-verified green). All three modes validated end-to-end against real contracts, including a real bb-proved UltraHonk proof verified on-chain (recovery_stage3_zk_only.rs, recovery_stage3_combined.rs) and the full guardian-only lifecycle through a real apply_doc completion (recovery_stage3_guardian_only.rs), matching Stage 2's Variant A call-ordering proof. See contracts/recovery-controller/src/lib.rs's crate doc comment for the architecture and the canonical "Known limits" list, and docs/recovery/stage3-measurements.md for proof generation/verification measurements. just test and just check both green. Client/SDK work is in progress in a follow-up commit.
The matrix wasn't kept in sync when these two Stage 3 crates were added (same maintenance gap the justfile's fmt-pkgs list already had for recovery-doc-completion, fixed in the previous commit).
packages/passkey-sdk/src/recoveryStage3/: enrollment config builders, target-document construction + diff (lost-key vs compromise, per follow-up.md §4.2), attempt/evidence builders for all three modes, read wrappers, and a JS reimplementation of the Rust contract's compute_doc_auth_hash (parity-tested against the same pinned zk.rs fixture, cross-validating the whole ZK path end to end). scripts/generate-recovery-proof.mjs: a Node CLI shelling out to nargo/bb for proof generation (no in-browser/mobile proving — a named limit, see the crate doc comment) — verified for real against the pinned circuits/zk_recovery_doc fixture: computed root/nullifier/auth_hash and VK sha256 both matched the committed Rust-side values exactly. packages/frontend/src/pages/security/recover-v3/: a plain, linear experimental page (enroll -> status -> begin attempt w/ diff preview -> evidence -> complete), reusing existing signing infrastructure (signAndSubmit, walletConnect.ts's kit) rather than inventing a new flow. Does not touch or modify the existing /security/recover (M1) page. Contract bindings generated for recovery-controller/recovery-verifier. Verified: tsc clean, 257/257 passkey-sdk tests, npm run build + astro check clean for the frontend.
…_hash commitment Captain live-tested PR 206 and found a real testnet account's Enroll click was a no-op: `RecoveryController::enroll` only writes the controller's own storage — nothing ever checked or established the account's own `recovery_controller` field, so an account already wired to a different controller (or never wired at all) got orphaned, never-cross-called config. Adds `packages/passkey-sdk/src/recoveryStage3/accountWiring.ts` (checkAccountWiring/buildWireAccountTx) and wires it into the recover-v3 page: Enroll is disabled until wiring is confirmed, a "wire account" action handles the fresh-account case, and a different-controller mismatch blocks with an explanation instead of writing dead state. Also investigated follow-up.md §5.5's "reviewable configuration commitment in the doc" ask and confirmed it's unreachable today: perch's schema is strict, nido's lowering throws for the one principal shape that could fit, and the deployed/pinned perch-doc-compiler's wire-level CompiledRule has no field for an arbitrary policy address at all. Added RecoveryController::config_hash (sha256(xdr(RecoveryConfig)), on-chain, recomputable) as an equivalent, independently verifiable substitute, and documented the doc-embedding finding in the crate's Known Limits.
…-wired to M1 Added a live-testnet Playwright probe (tests/e2e/testnet/recover-v3-wiring.testnet.spec.ts) for the account-wiring fix. Running it surfaced something more specific than the wiring check was written to handle: a BRAND NEW account from the doc-only factory is not "fresh and unwired" — it already reports recovery_controller() == CAUZ6WFU... (the M1 nido-zk-recovery pool) at construction, per DEPLOYED.md's M2 genesis-insert behavior. So the captain's bug wasn't a one-off misconfiguration on his test account; it's the universal starting state for every account this factory has ever minted, and the only way into this Stage 3 controller today is the real 7-day rule-removal migration. Updated recovery-controller's crate doc comment and accountWiring.ts's module doc comment to state this as a confirmed, live-verified fact instead of a hypothetical case. The probe itself asserts the mismatch-detection path (the part that IS live-reachable): checkAccountWiring correctly reports 'wired-to-different-controller', Enroll stays disabled, and even a forced click is refused by the handler's own defensive re-check before any transaction is built.
Regenerating bindings (needed to add config_hash to the TS client) with a newer stellar-cli emitted a package.json missing the @nidohq scope (name: "recovery-controller" instead of "@nidohq/recovery-controller"), downgraded version 0.1.0 -> 0.0.0, and dropped publishConfig. npm workspaces no longer recognized it as satisfying passkey-sdk's "@nidohq/recovery-controller": "^0.1.0" dependency, so any fresh `npm install` (the PR-preview deploy jobs) tried the public registry and 404'd. Restored the fields to match every sibling bindings package.
…sh in measurements Adds two entries to stage3-measurements.md's limits/enrollment-data sections mirroring what the account-wiring fix and its live probe established: (1) account wiring is a separate, currently-unreachable-without-a-7-day-migration precondition from enroll, and (2) config_hash is the on-chain substitute for follow-up.md §5.5's reviewable-commitment ask, since literal doc embedding is blocked by the deployed perch-doc-compiler's wire protocol.
…egacy stub)
Second captain live-fail on PR 206: installing 1-of-1 friend recovery on the
REAL /security/ page (not the recover-v3 spike) failed with
"multisig-recovery.buildInstall: doc-only: the account has no rule
mutators; M-of-N friend recovery is not yet expressible as a policy
document" — a stale, inaccurate error from the 201 rework. buildInstall
threw unconditionally for every account regardless of state; the stub's
"doc v1 has all-signers principals only" claim was also stale (threshold
has existed since perch 0.2.0).
Rewrites multisigRecoveryModule to route through Stage 3's
RecoveryController (GuardianOnly mode) instead of the dead stub:
- buildInstall checks account wiring first (checkAccountWiring): wires
then enrolls a fresh account (two ops, one InvokeHostFunction each,
submitted sequentially), just enrolls an already-wired account, and
REFUSES with an accurate error (naming the real 7-day
initiate_recovery_rule_removal -> execute_recovery_rule_removal
constraint, not the false doc-schema claim) for an account already wired
to a different controller.
- Two real, documented implementer choices where the config has no
scriptable default: baseline_doc_hash is an inert sentinel hash (this
simplified form never exposes Compromise-mode recovery, the only case
that field is checked against); pending_activity_policy defaults to
Freeze, the more conservative of the two options TRANSITION_SPEC.md §10 /
follow-up.md §7 explicitly forbid a SPEC-level default for (this is an
implementer default at the UI layer, not a silently-chosen spec default).
- buildRevoke now explains the real constraint (no one-step revoke exists;
needs the same 7-day migration) instead of throwing the stale doc-only
message.
- fromChain recognizes BOTH the legacy multisig-policy on-chain-signers
shape (unchanged, so an already-installed legacy rule doesn't lose its
only Revoke path from the UI) and the new Stage 3 shape (guardians from
PolicyState, not on-chain signers — Stage 3's rule is zero-signer
CallContract(self)).
- policyChainFetch.ts's fetchPolicyState gets a branch for the Stage 3
controller address, reading RecoveryController::config and shaping it to
{guardians, threshold} for fromChain.
- New packages/passkey-sdk/src/recoveryStage3/deployment.ts holds the
canonical testnet controller/verifier addresses (not registry-resolvable
yet); recorded in DEPLOYED.md with provenance/verification notes.
Live-probed end to end against real testnet
(tests/e2e/testnet/security-recovery-install.testnet.spec.ts): a genuinely
fresh, unwired account (raw-deployed, bypassing the doc-only factory's
universal M1 pre-wiring found while fixing captain issue #1) completes
"Set up recovery" for 1 of 1 friend through the real production form, and
reloading /security/ renders "1 of 1 friend can rotate this account's
signers and rules" — confirming the full wire -> enroll -> render round
trip. Independently confirmed on-chain via `stellar contract invoke
... config` / `recovery_controller` (both match exactly).
just check (fmt + clippy -D pedantic) green; passkey-sdk: tsc clean,
263/263 vitest; astro check + npm run build clean.
Third captain live-fail on PR 206: enrolling ZK recovery first (via /security/'s "Add ZK recovery") wired the account directly to the M1 nido-zk-recovery pool -- a DIFFERENT controller from the guardian flow's Stage 3 RecoveryController -- so adding guardian recovery second always hit the wired-to-different-controller refusal shipped in the previous fix, and vice versa. On this stack ZK and guardians must be able to coexist on ONE controller (AuthMode::Combined) regardless of which is added first. A. New RecoveryController::reconfigure entry point (removes the "no reconfigure entry point" Known Limit -- lib.rs's crate doc comment explains what replaced it and its remaining bound). Accepts only two strictly-additive transitions (GuardianOnly -> Combined, ZkOnly -> Combined); every other field must match the stored config exactly or it refuses; blocked while has_pending; Profile::Loss needs only account.require_auth(), Profile::Protected + existing GuardianOnly needs the enrolled guardian quorum's nested require_auth_for_args in the same transaction, Profile::Protected + existing ZkOnly explicitly refuses (ReconfigureZkEvidenceUnsupported -- a real ZK reconfigure-evidence path needs a new circuit auth_hash domain, out of scope here; reusing an existing domain was considered and rejected as a cross-domain replay hazard). 12 new unit tests cover both transitions, all rejection cases, and both profiles. Deployed as a NEW testnet instance (v2) -- the constructorless controller has no upgrade/admin entry point at all, so the existing deployed instance could not gain reconfigure in place. See deployment.ts/DEPLOYED.md for the v1 -> v2 addresses and the "explicit upgrade = new immutable artifact" rationale. B. Killed the factory's genesis trap: contracts/factory::create_account/ create_account_v2 no longer unconditionally wire every new account to the M1 pool (recovery_controller: Some(M1), atomic genesis Merkle-leaf insert) -- a live probe already proved this made it impossible for any fresh account to ever reach Stage 3 without a real 7-day migration. Accounts now mint with recovery_controller: None; create_account_v2's commitment argument is ignored (kept for ABI compatibility). Redeployed the live factory in place (same address, upgrade + refresh_account_wasm_hash) and live-verified a freshly minted account reads recovery_controller() == null. C. Client flows route through Stage 3 consistently: security/index.astro's runZkEnrollment and new-account/index.astro's enrollRecoveryPostCreate both now check wiring (checkAccountWiring), wire if needed, and enroll-or-reconfigure based on RecoveryController::config(account)'s actual presence/mode -- instead of enroll_zk_recovery(M1 pool) directly. multisigRecoveryModule.buildInstall gained the mirror case: reconfigure (adding guardians) when ZK was already enrolled on the target controller. Shared enrollment defaults (baseline-doc sentinel, pending-activity policy, delay/expiry/max-cancels) extracted to defaults.ts so both flows agree on every field reconfigure requires to match exactly. D. Live-probed both orderings against the real production /security/ forms (tests/e2e/testnet/recovery-stage3-combined.testnet.spec.ts) -- caught and fixed a real bug in the process: policyChainFetch.ts's fetchRecoveryControllerState only returned guardian data for GuardianOnly mode, so a Combined-mode account's "N of M friends can rotate..." block silently vanished from the Security page even though the on-chain config was correct. Both accounts independently confirmed via RecoveryController::config() to reach identical Combined state (same guardians/threshold/verifier/zk_pool) regardless of order. just check / just test green (workspace); tsc/vitest/astro check/npm build green (client).
… fm/nido-perch-policy-integration-n9 # Conflicts: # CLAUDE.md
…fm/nido-perch-policy-integration-n9
…taged spikes
Consolidation pass (PRs 200/201/202/204/205/206 merging into one branch):
strip "spike"/"Stage N (spike)" framing from titles, top-level docs, and
inline comments now that the whole staged plan is complete and shipping
together, while keeping every substantive technical claim and the
deliberately-open pending-activity decision (follow-up.md §7) exactly as
documented.
- AGENTS.md: reframe "Recovery spec (Stage 1) + completion experiment
(Stage 2) + end-to-end spike (Stage 3)" as "Account recovery (guardian
quorum + ZK)" — one section describing three layers, not three gated
deliverables.
- DEPLOYED.md: drop "spike deploy"/"spike PR"/"spike params"/"spike build"
qualifiers; the deploys and params are real testnet facts, not caveats
about throwaway status.
- docs/recovery/{TRANSITION_SPEC,stage2-findings,stage3-measurements}.md:
reword each doc's "Status: Spike, Stage N... not authorization to proceed"
gate language (obsolete now that all stages shipped together) into a
plain description of the document's role in the finished feature.
- 18 "SPIKE (...)"/"STAGE N SPIKE (...)" bracket-marker code comments across
9 contract/SDK files reworded to state their content directly.
- Remove "captain live-fail #N" / "NNN.msg" firstmate-process references
from contract doc comments, test file headers, and deployment.ts —
replaced with a plain description of what each fix actually does.
- packages/recovery-spec/package.json: drop "Stage 1 spike ... Not
production code" from the description.
just check / just test (workspace) and tsc/vitest/astro check (passkey-sdk,
frontend) all re-verified green after this pass — comment-only + doc
changes, no behavioral diff.
Captain directive for the PR 200/201/202/204/205/206 consolidation: "full suites green + one live smoke of the combined UI (policy page + a doc apply)". Adds a real testnet probe driving /account/policy/ (the policy inspector + doc builder from the policy-document UI work, PR 202) end to end: load the page for a live account, switch to the Admin keys tab, add a delegated-key admin rule through the actual form, sign via the account's own passkey, and independently confirm on-chain via applied_doc_hash() that a real apply_doc landed and changed the hash. Reloads afterward and checks the new rule renders — proving the page's read path agrees with what it just wrote, not just an optimistic in-memory update. Reuses the already-live ZK/guardian-convergence probe account (no new deploy needed) and is idempotent against a re-run: if a prior run already added the same admin key, it skips the add (the contract's own duplicate refusal proves the same real path) and verifies the current rendered state instead of asserting a hash change. Verified live: first run added the admin rule and independently confirmed the hash change; second run correctly detected the existing rule and skipped to verification. Passes on testnet-chromium.
Plan expiredYour subscription has expired. Please renew your subscription to continue using CI/CD integration and other features. |
|
Example dApp preview deployed! https://example-pr-207.mysoroban.pages.dev The |
|
Preview deployed! Account URLs use numeric preview suffixes, for example |
elizabethengelman
left a comment
There was a problem hiding this comment.
just commenting things that i've gotten to so far. i'll dig back in again tomorrow!
| - name-registry | ||
| - recovery-controller | ||
| - recovery-verifier | ||
| - smart-account |
There was a problem hiding this comment.
we should also add:
- preauth-sweep-policy
- recovery-doc-completion
There was a problem hiding this comment.
Good catch — recovery-doc-completion and preauth-sweep-policy were both missing from the Scout matrix. Fixing this as part of the branch-stack rebuild (recovery-doc-completion lands with the recovery layer; preauth-sweep-policy is an unrelated pre-existing gap I'm fixing alongside it since I'm already touching this file).
| doctest = false | ||
|
|
||
| [dependencies] | ||
| soroban-sdk = { workspace = true, features = ["hazmat-address"] } |
There was a problem hiding this comment.
not a huge deal, but just curious why we need this feature
| #![no_std] | ||
| #![allow(dead_code)] | ||
|
|
||
| //! Stage 2 bounded experiment (`firstmate/data/perch-zk-recovery-scout-p5/follow-up.md` |
There was a problem hiding this comment.
follow-up.md doesn't seem to be included in this PR - is this doc comment still relevant?
| #![no_std] | ||
| #![allow(dead_code)] | ||
|
|
||
| //! Stage 3 of the staged Perch/Nido recovery plan |
There was a problem hiding this comment.
Not sure I fully understand the different stages in this - maybe they are no longer relevant? But if they are, could we have them documented somewhere?
| //! SPIKE: perch `apply_doc` for the nido smart account — DOC-ONLY (the | ||
| //! captain's ruling on this spike: `apply_doc` is the sole policy write path, | ||
| //! including for the dapp). |
There was a problem hiding this comment.
| //! SPIKE: perch `apply_doc` for the nido smart account — DOC-ONLY (the | |
| //! captain's ruling on this spike: `apply_doc` is the sole policy write path, | |
| //! including for the dapp). | |
| //! perch `apply_doc` for the nido smart account — DOC-ONLY (`apply_doc` is the sole policy write path, including for the dapp). |
| //! policy (the stateful cumulative cap the stateless interpreter cannot | ||
| //! express) beside the interpreter, resolved from the pinned deployed | ||
| //! address — live since perch published the cap-capable compiler 0.2.1. | ||
| //! OZ enforces all attached policies (AND). |
There was a problem hiding this comment.
| //! OZ enforces all attached policies (AND). | |
| //! OZ enforces all attached policies (policies are AND-ed). |
| // canonical, and either verifies against the stored hash by a bare | ||
| // sha256, no client-side canonicalization needed. The SDK's | ||
| // `buildApplyDocTx` always submits canonical bytes. (A spike-flagged | ||
| // divergence from upstream perch, easy to relax.) |
There was a problem hiding this comment.
(A spike-flagged divergence from upstream perch, easy to relax.)
Just adding this comment to confirm my understanding - i think that this means that this is something that differs in Nido from Perch - perch doesn't care if the submitted bytes are in a canonical form or not. But Nido does, and this is so we can make sure that the Nido's stored doc bytes always hash to nido's stored doc_hash. both need to be in canonical form for that to hold, otherwise a minified document for example could fail its own verification check.
There was a problem hiding this comment.
That's exactly right — canonical-only keeps stored == emitted == canonical as an invariant everywhere, without needing client-side canonicalization to verify. Adopting your phrasing into the comment, it's clearer than what was there.
| matches!(r.scope, RuleScope::SelfAdmin) | ||
| && !r.signers.is_empty() | ||
| && r.install.is_empty() | ||
| && r.cap.is_empty() |
There was a problem hiding this comment.
does this check need to check the rules' valid_until?
| // --- `apply_doc` (see `doc.rs`). 10 is the doc layer's own | ||
| // refusal; 11–15 mirror perch's `DocCompilerError` variants 1–5 (offset | ||
| // +10 so the two error spaces can't collide in this contract's codes); | ||
| // 16 is the fail-closed cross-call fallback. --- | ||
| // 10 was `DocCapUnsupported` (twice: the hybrid cut's own refusal, | ||
| // then the relay of the pre-cap deployed compiler's `CapUnsupported`). | ||
| // Compiler 0.2.1 lowers caps, so the code is retired again. | ||
| /// Compiler: the submitted document bytes are not UTF-8. |
There was a problem hiding this comment.
| // --- `apply_doc` (see `doc.rs`). 10 is the doc layer's own | |
| // refusal; 11–15 mirror perch's `DocCompilerError` variants 1–5 (offset | |
| // +10 so the two error spaces can't collide in this contract's codes); | |
| // 16 is the fail-closed cross-call fallback. --- | |
| // 10 was `DocCapUnsupported` (twice: the hybrid cut's own refusal, | |
| // then the relay of the pre-cap deployed compiler's `CapUnsupported`). | |
| // Compiler 0.2.1 lowers caps, so the code is retired again. | |
| /// Compiler: the submitted document bytes are not UTF-8. | |
| // --- `apply_doc` (see `doc.rs`). 10 is skipped (see below for why); | |
| // 11–15 mirror perch's `DocCompilerError` variants 1–5 (offset | |
| // +10 so the two error spaces can't collide in this contract's codes); | |
| // 16 is the fail-closed cross-call fallback. --- | |
| // 10 was `DocCapUnsupported` (twice: the hybrid cut's own refusal, | |
| // then the relay of the pre-cap deployed compiler's `CapUnsupported`). | |
| // Compiler 0.2.1 lowers caps, so the code is retired again. | |
| /// Compiler: the submitted document bytes are not UTF-8. |
There was a problem hiding this comment.
This whole +10-offset scheme is gone now — apply_doc's error space is composed via soroban-sdk-tools's scerr (ApplyDocError::DocCompiler(DocCompilerError) via #[from_contract_client]) instead of a hand-maintained numeric offset, so there's no "10 is skipped" case to document anymore. Landing as part of the branch-stack rebuild.
| @@ -95,6 +149,21 @@ pub enum NidoSmartAccountError { | |||
| #[contractclient(name = "RecoveryControllerClient")] | |||
| trait RecoveryController { | |||
| fn has_pending(e: Env, account: Address) -> bool; | |||
| // Doc-only completion: true iff a completion was consumed for `account` | |||
There was a problem hiding this comment.
| // Doc-only completion: true iff a completion was consumed for `account` | |
| // True iff a completion was consumed for `account` |
There was a problem hiding this comment.
Applied — simplified to just "True iff a completion was consumed for account", the rest of the comment already covers the mechanics.
| // DOC-ONLY: the `add_multisig_recovery` typed wrapper is REMOVED. It was | ||
| // a doc-bypassing rule mutator (install an M-of-N friends rule via the | ||
| // stock multisig policy). Doc v1 cannot express M-of-N principals, so | ||
| // this product flow has NO doc equivalent yet -- a headline cost of the | ||
| // doc-only ruling, recorded in the PR description. |
There was a problem hiding this comment.
| // DOC-ONLY: the `add_multisig_recovery` typed wrapper is REMOVED. It was | |
| // a doc-bypassing rule mutator (install an M-of-N friends rule via the | |
| // stock multisig policy). Doc v1 cannot express M-of-N principals, so | |
| // this product flow has NO doc equivalent yet -- a headline cost of the | |
| // doc-only ruling, recorded in the PR description. |
There was a problem hiding this comment.
Applied — deleted this tombstone comment entirely. It's also stale now beyond just being noise: once the recovery layer (this same stack's next PR) lands, M-of-N friend recovery IS available again, via the RecoveryController's GuardianOnly mode instead of a raw policy-document rule.
| // ----------------------------------------------------------------- | ||
| // Variant B: a dedicated recovery completion entry | ||
| // point, compared against Variant A (recovery completing through the | ||
| // EXISTING `apply_doc` above with no code changes at all) in | ||
| // `firstmate/data/perch-zk-recovery-scout-p5/follow-up.md` §8 Stage 2. | ||
| // See `docs/recovery/stage2-findings.md` for the full comparison and | ||
| // recommendation. | ||
| // ----------------------------------------------------------------- | ||
|
|
||
| /// The Variant B completion vehicle: calls the exact same internal | ||
| /// parse/validate/compile/install/commit pipeline [`Self::apply_doc`] | ||
| /// uses ([`crate::doc::apply`]) -- shared private pipeline, no separate | ||
| /// raw mutator (follow-up.md §5.1). Requires this account's own auth, | ||
| /// exactly like `apply_doc`; the recovery controller's zero-signer | ||
| /// `CallContract(self)` rule can satisfy that auth (via its `Policy`, | ||
| /// gating this exact call -- see | ||
| /// `nido_recovery_doc_completion::Policy::enforce`), but so can the | ||
| /// account's ordinary admin rule, since `require_auth()` alone cannot | ||
| /// tell the entry point's body WHICH rule authorized this specific call. | ||
| /// | ||
| /// That is the one structural difference from `apply_doc`: `apply_doc`'s | ||
| /// own guard (`guard_no_pending`, block-while-pending) happens to end up | ||
| /// correct no matter which rule authorized the call, because by the time | ||
| /// its body runs, a legitimate completion has already cleared the | ||
| /// pending as a side effect of its OWN `enforce` -- there is nothing left | ||
| /// to distinguish. A DEDICATED, recovery-only entry point needs the | ||
| /// opposite polarity ("permit only when completing"), and that state is | ||
| /// exactly what `enforce` consumed before this body could read it | ||
| /// (`docs/recovery/stage2-findings.md`'s call-ordering section). So this | ||
| /// body independently cross-calls the controller's | ||
| /// `take_completion_grant`, which returns the target document hash | ||
| /// `enforce` bound to THIS exact call (or `None` if this call's own auth | ||
| /// did not resolve through the recovery policy at all) -- a single-use, | ||
| /// value-bound hand-off, not the general boolean/ledger flag | ||
| /// follow-up.md §3.1 and §5.1 warn against: it can authorize nothing | ||
| /// beyond the one document it was granted for, and it is consumed the | ||
| /// instant this body reads it. |
There was a problem hiding this comment.
I think it'd be helpful to move this (and all other Variant A vs B description & decision making) into the docs dir, and out of the code. It's super helpful context to keep, but I think if we isolate it to the docs it would make the source code easier to read & maintain going forward.
| // ----------------------------------------------------------------- | |
| // Variant B: a dedicated recovery completion entry | |
| // point, compared against Variant A (recovery completing through the | |
| // EXISTING `apply_doc` above with no code changes at all) in | |
| // `firstmate/data/perch-zk-recovery-scout-p5/follow-up.md` §8 Stage 2. | |
| // See `docs/recovery/stage2-findings.md` for the full comparison and | |
| // recommendation. | |
| // ----------------------------------------------------------------- | |
| /// The Variant B completion vehicle: calls the exact same internal | |
| /// parse/validate/compile/install/commit pipeline [`Self::apply_doc`] | |
| /// uses ([`crate::doc::apply`]) -- shared private pipeline, no separate | |
| /// raw mutator (follow-up.md §5.1). Requires this account's own auth, | |
| /// exactly like `apply_doc`; the recovery controller's zero-signer | |
| /// `CallContract(self)` rule can satisfy that auth (via its `Policy`, | |
| /// gating this exact call -- see | |
| /// `nido_recovery_doc_completion::Policy::enforce`), but so can the | |
| /// account's ordinary admin rule, since `require_auth()` alone cannot | |
| /// tell the entry point's body WHICH rule authorized this specific call. | |
| /// | |
| /// That is the one structural difference from `apply_doc`: `apply_doc`'s | |
| /// own guard (`guard_no_pending`, block-while-pending) happens to end up | |
| /// correct no matter which rule authorized the call, because by the time | |
| /// its body runs, a legitimate completion has already cleared the | |
| /// pending as a side effect of its OWN `enforce` -- there is nothing left | |
| /// to distinguish. A DEDICATED, recovery-only entry point needs the | |
| /// opposite polarity ("permit only when completing"), and that state is | |
| /// exactly what `enforce` consumed before this body could read it | |
| /// (`docs/recovery/stage2-findings.md`'s call-ordering section). So this | |
| /// body independently cross-calls the controller's | |
| /// `take_completion_grant`, which returns the target document hash | |
| /// `enforce` bound to THIS exact call (or `None` if this call's own auth | |
| /// did not resolve through the recovery policy at all) -- a single-use, | |
| /// value-bound hand-off, not the general boolean/ledger flag | |
| /// follow-up.md §3.1 and §5.1 warn against: it can authorize nothing | |
| /// beyond the one document it was granted for, and it is consumed the | |
| /// instant this body reads it. |
There was a problem hiding this comment.
Agreed and applied — trimmed complete_recovery's doc comment down to a short pointer at docs/recovery/stage2-findings.md (which already has this exact analysis in full, in §3 "Call-ordering analysis"); the code comment now just says what the entry point does and where to read why it needs the grant hand-off apply_doc doesn't.
| // `StubRecoveryPolicy` test double -- only ever cross-called against a | ||
| // Stage 2 `nido-recovery-doc-completion` controller from | ||
| // `complete_recovery`, so no existing controller needs to export it. | ||
| fn take_completion_grant(e: Env, account: Address) -> Option<BytesN<32>>; |
There was a problem hiding this comment.
Based on the comment above, it sounds like this fn isnt being used in the current state of the code -can we remove it from the trait?
There was a problem hiding this comment.
It is used — by complete_recovery (Variant B, see the reply on that entry point below). Kept for the same reason: it's the value-bound grant hand-off Variant B's live comparison test suite exercises. Not removing it alongside complete_recovery for now — same follow-up-cleanup flag as that reply.
| Some(controller) => { | ||
| let client = RecoveryControllerClient::new(e, &controller); | ||
| let me = e.current_contract_address(); | ||
| client.has_pending(&me) || client.completion_granted(&me) |
There was a problem hiding this comment.
client.completion_granted always returns false for the real controller, so in_completion_window here reduces to just has_pending.
I don't think the new recovery system (Variant A) ever calls add_context_rule at all, so this check is opening up add_context_rule for the entire duration a recovery is pending. That seems like risk that we don't need to have for the new recovery system. Is there a reason to keep this open, or can it be removed?
There was a problem hiding this comment.
Good catch on completion_granted always being false for the real controller — that's correct, in_completion_window does reduce to has_pending for Variant A/the Stage 3 controller.
On removing it, though: this gate isn't Variant-A-specific — it predates the recovery-controller entirely. It's add_context_rule's own completion vehicle for the ORIGINAL M1 nido-zk-recovery pool-based flow (rotate in a new passkey via add_context_rule(Default, "recovered", ...)), which is still a live, supported opt-in recovery path alongside the new controller (see DEPLOYED.md — new accounts mint with recovery_controller: None, and users can opt into either the old M1 pool flow or the new Stage 3 controller). That flow genuinely needs add_context_rule open during its own completion window, so this isn't something Variant A's arrival lets us close.
It IS a real, already-documented risk though — contracts/recovery-controller/src/lib.rs's crate doc comment ("Known limits") calls this out explicitly: any has_pending-reporting controller (including this one) makes an ordinarily-admin-authorized add_context_rule call possible for the whole pending-recovery duration, and closing it needs a smart-account code change — which is out of scope for Variant A's "zero smart-account changes" property. Not closed in this PR, but not silently accepted either — it's in that doc comment's limits list. Happy to open a tracking issue for it if that's useful.
There was a problem hiding this comment.
gotcha - i think i missed the fact that we were keeping the original nido-zk-recovery live as an optional thing. Makes sense that that flow needs add_context_rule.
I think that an issue would be helpful for tracking this!
There was a problem hiding this comment.
@willemneal I just created an issue, feel free to update as needed!
#214
| /// Which evidence factor(s) an account's recovery requires. Follow-up.md | ||
| /// §5.2's HARD requirement: `GuardianOnly` must not require ANY ZK | ||
| /// machinery — no secret, no Merkle witness, no proof. Enforced at `enroll` | ||
| /// (see `lib.rs`), not just documented here. |
There was a problem hiding this comment.
| /// Which evidence factor(s) an account's recovery requires. Follow-up.md | |
| /// §5.2's HARD requirement: `GuardianOnly` must not require ANY ZK | |
| /// machinery — no secret, no Merkle witness, no proof. Enforced at `enroll` | |
| /// (see `lib.rs`), not just documented here. | |
| `GuardianOnly` must not require ANY ZK | |
| /// machinery — no secret, no Merkle witness, no proof. Enforced at `enroll` | |
| /// (see `lib.rs`), not just documented here. |
There was a problem hiding this comment.
This text no longer exists — the follow-up.md citation was already stripped by an earlier sanitization pass (now reads "is a HARD requirement: it must not require ANY ZK machinery..." with no external-doc citation, matches your suggestion's substance).
| /// Follow-up.md §2.1: routine recovery-CONFIGURATION change authority (not | ||
| /// modeled by this experiment — no `reconfigure` entry point exists, see the | ||
| /// crate doc comment's "Known limits" — this field is retained on | ||
| /// `RecoveryConfig` because it is part of the account's reviewable | ||
| /// commitment even though this experiment fixes it at enrollment). |
There was a problem hiding this comment.
Not sure if this needs to be included here
| /// Follow-up.md §2.1: routine recovery-CONFIGURATION change authority (not | |
| /// modeled by this experiment — no `reconfigure` entry point exists, see the | |
| /// crate doc comment's "Known limits" — this field is retained on | |
| /// `RecoveryConfig` because it is part of the account's reviewable | |
| /// commitment even though this experiment fixes it at enrollment). |
There was a problem hiding this comment.
This text is gone for a different reason than just wording — it's now stale rather than removable-as-noise: reconfigure/config_hash shipped in the recovery-controller's v2 (see DEPLOYED.md), so "no reconfigure entry point exists" is no longer true. The Profile doc comment now explains what Loss vs Protected actually requires for reconfigure instead.
| /// TEMPORARY: value-bound completion marker, written by `enforce` the | ||
| /// instant it consumes an attempt. Not read by this experiment's | ||
| /// Variant-A-only completion path (Variant A needs no such bridge — see | ||
| /// `docs/recovery/stage2-findings.md` §3) but kept so a future Variant B | ||
| /// vehicle could reuse it without a storage-key ABI break. | ||
| CompletionGrant(Address), |
There was a problem hiding this comment.
can we remove this because it seems to be for variant b?
There was a problem hiding this comment.
Right that it's for Variant B — same as take_completion_grant/complete_recovery above, it's the completion signal Policy::enforce writes and take_completion_grant reads-and-deletes. Kept live for the same reason (grounds the Stage 2 comparison in real, tested code); flagged as the same follow-up cleanup.
| //! The Stage 3 shared recovery controller (follow-up.md §5.2): ONE | ||
| //! deployed instance, shared across accounts, dispatching to a | ||
| //! guardian-quorum adapter and/or a ZK-verifier adapter per account's | ||
| //! enrolled `AuthMode` — `Combined` checks BOTH against the SAME attempt | ||
| //! commitment before promoting it. Completion is Variant A | ||
| //! (`docs/recovery/stage2-findings.md`'s recommendation): this contract's | ||
| //! `Policy::enforce` gates the account's EXISTING `apply_doc` entry point, | ||
| //! zero smart-account code changes, same call-ordering argument Stage 2 | ||
| //! already proved (see `enforce`'s doc comment below). |
There was a problem hiding this comment.
| //! The Stage 3 shared recovery controller (follow-up.md §5.2): ONE | |
| //! deployed instance, shared across accounts, dispatching to a | |
| //! guardian-quorum adapter and/or a ZK-verifier adapter per account's | |
| //! enrolled `AuthMode` — `Combined` checks BOTH against the SAME attempt | |
| //! commitment before promoting it. Completion is Variant A | |
| //! (`docs/recovery/stage2-findings.md`'s recommendation): this contract's | |
| //! `Policy::enforce` gates the account's EXISTING `apply_doc` entry point, | |
| //! zero smart-account code changes, same call-ordering argument Stage 2 | |
| //! already proved (see `enforce`'s doc comment below). | |
| //! Shared recovery controller: ONE | |
| //! deployed instance, shared across accounts, dispatching to a | |
| //! guardian-quorum adapter and/or a ZK-verifier adapter per account's | |
| //! enrolled `AuthMode` — `Combined` checks BOTH against the SAME attempt | |
| //! commitment before promoting it. This contract's | |
| //! `Policy::enforce` gates the account's EXISTING `apply_doc` entry point. |
There was a problem hiding this comment.
This text already matches your suggestion almost verbatim — an earlier pass already dropped the "Stage 3"/follow-up.md framing from this module doc comment.
|
Splitting this into a stack of four focused PRs, each standalone and referencing stellar-registry/perch#83 where the perch-side feature matters — reviewing as one unit was asking a lot of any one PR:
Every comment on both review passes here has a disposition recorded on its own thread — fixes landed in the layer that owns the content, a few were already moot by the time of the second pass (superseded by other fixes in the same rebuild), and the couple of open architectural questions (whether the Variant B completion machinery should eventually come out of production code, and the add_context_rule completion-window's pre-existing shared risk) got real answers rather than silent drops — see the thread replies for both. CI is green on all four. Closing this in favor of the stack. |
Perch policy-document integration: doc-only smart accounts, policy UI, and account recovery
Nido smart accounts now use a Perch policy document as their single source
of truth for on-chain authority. This PR brings that integration together
end to end: the doc-only contract layer, the policy page and document
builder, the dApp connect/sign/delegate flows, and guardian + ZK account
recovery — six previously separate branches, merged onto one, reconciled
where their frontend surfaces overlapped, and reframed as one feature.
What this ships
Doc-only smart accounts.
apply_docis the account's sole policy writepath: one transaction parses, validates, lowers, and atomically installs a
whole rule set, and stores the canonical document alongside its hash
(
get_applied_doc, lossless — no indexer needed to reconstruct a liveaccount's policy). The factory embeds the doc-capable smart-account wasm at
deploy time, so every account this factory creates gets the doc surface for
free.
Policy page, builder, and dApp flow.
/account/policy/is the account'sfull policy inspector: every live rule, the currently-applied document, and
a builder for session keys and admin keys that always previews the exact
diff before submitting.
/security/delegate-doc/and/sign/carry thesame doc-aware flow into third-party dApp requests — a dApp can request a
scoped session key or a message signature and the user sees precisely what
changes before approving.
Account recovery — guardian quorum, ZK, or both. A shared
RecoveryController(contracts/recovery-controller) implementsguardian-only, ZK-only, and combined recovery against one proposal-
commitment model, completing through the account's own
apply_doc(nosmart-account code changes for completion). Guardian-only enrollment needs
no ZK machinery at all. An account enrolled in one evidence factor can add
the other later via
reconfigure, converging onCombinedregardless ofwhich was set up first. A companion
recovery-verifier(constructorlessUltraHonk verifier, VK baked in at compile time) and an adapted Noir circuit
(
circuits/zk_recovery_doc, isolated from the pre-existing M1 circuit)back the ZK path with real, on-chain-verified proofs.
The design work behind this is recorded in three layers, still visible in
the code/doc layout — see
AGENTS.md's "Account recovery" section: atransition spec + executable reference model (
docs/recovery/TRANSITION_SPEC.md,packages/recovery-spec/), a completion-mechanism comparison(
docs/recovery/stage2-findings.md), and the controller/circuitimplementation with real measurements (
docs/recovery/stage3-measurements.md).Reconciling two frontend lineages
The recovery work and the policy-page/dApp work developed on sibling
branches, both stacked on the doc-only contracts. Both touched
packages/frontend/src/pages/security/index.astro; everywhere else theywere disjoint. Merged with one real conflict (a stale
CLAUDE.md/AGENTS.mdsplit, resolved by keeping the
@AGENTS.mdpointer convention and foldingthe other side's content in) — full workspace + frontend suites, plus the
fast UI e2e tier across all three browsers, re-verified green after the
merge and again after a documentation pass that removed "spike"/staged-
experiment framing now that all three stages ship together (see the
docs:commit on this branch for the exact scope of that pass).Explicitly still open
follow-up.md§7 — what happens to ordinary account activity and pendingpolicy writes during a live recovery attempt (freeze / continue / restrict)
— remains a deliberately undecided, documented gate. Nothing in this PR
resolves it or infers a default from adjacent behavior; see
contracts/recovery-controller/src/lib.rs's crate doc comment for the full"Known limits" list, including the pre-existing
add_context_rulecompletion-window gap this PR does not close (would need a smart-account
change) and the recovery config's deployed-testnet limitations
(
reconfigure's Protected-profile ZK-evidence path, no factory/registryauto-wiring).
Verification
just check/just test(full Rust workspace) greentsc --noEmitclean, vitest 265/265astro check0 errors, production build(
npm run build) clean, 17 pagesjust test-e2e, all three browsers): 98 passed/account/policy/driving a realapply_doc(add an admin key, verify the hash changed on-chain, reload and
confirm the page reflects it) —
tests/e2e/testnet/account-policy-doc-apply.testnet.spec.tsa real
bb-proved UltraHonk proof verified on-chain, both convergingon
Combinedmode regardless of enrollment order(
tests/e2e/testnet/recovery-stage3-combined.testnet.spec.ts)setup UI both route through the same controller instead of a legacy
stub or a separate pool (
tests/e2e/testnet/security-recovery-install.testnet.spec.ts)Superseding
This branch supersedes and closes #200, #201, #202, #204, #205, #206 — all
their work is included here, reconciled and re-verified together. Those
branches remain on origin for history.