spike: doc-only perch apply_doc in the smart account (sole write path, doc-hash + JSON + event) - #201
Closed
willemneal wants to merge 9 commits into
Closed
willemneal wants to merge 9 commits into
willemneal wants to merge 9 commits into
Conversation
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.
|
Example dApp preview deployed! https://example-pr-201.mysoroban.pages.dev The |
|
Preview deployed! Account URLs use numeric preview suffixes, for example |
…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.
Closed
7 tasks
Contributor
Author
|
Superseded by #207, which merges this branch's work together with 200/201/202/204/205/206 into one reconciled, non-draft PR off main. This branch stays on origin for history. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this demonstrates
The doc-only perch model on nido's smart account, end to end:
contracts/smart-account:apply_doc(doc_json) -> doc_hashatomically replaces every context rule except the protected zk-recovery rule with the document's compiled rules. OZ's mutation surface (add_signer,remove_signer,remove_context_rule,add_policy,remove_policy,update_context_rule_*,add_multisig_recovery) is not implemented or exported at all — doc-only is structural, as in upstream perch. The account cross-calls perch's shared stateless doc-compiler contract (parse → validate → network-bind → canonicaldoc_hash→ lower); compiler and interpreter addresses are derived from pins — the same testnet pins the SDK'sdeployment.tscarries.DocAdminLockout): since the apply replaces the constructor's default passkey rule too, a document must carry at least one policy-free, cap-free self-admin rule with a signer or it is refused before anything changes — upstream perch'sAdminLockoutcheck, exactly as the hybrid cut's description predicted this decision would re-import.CCJMCPGA…, DEPLOYED.md) — the same audited-pin pattern as the compiler/interpreter; nido-owned deploys aren't content-addressed, so this is an address pin rather than a wasm-hash derivation.DocCapUnsupportedis retired.doc_hash(32 bytes) in instance storage; the full canonical doc JSON in a persistent entry (get_applied_doc— the lossless, no-indexer read the showcase uses); the same JSON emitted as aDocAppliedevent (the eventual recovery method).apply_docrefuses non-canonical bytes (DocNotCanonical), so stored == emitted == canonical and either copy verifies with a bare sha256.contracts/factory: still zero code change — it embeds the account wasm at build time; rebuild + republish is the whole deploy story for new accounts.packages/passkey-sdk):buildApplyDocTxis the only apply route —buildDocInstallTxs(per-rule installs) is deleted with the entry points it drove.readPolicyis now two tiers:doc-verified(view-first fromget_applied_doc, event recovery as fallback, hash-verified) /decompiled(pre-doc or foreign accounts). The drift tier disappears by construction — no mutators, no drift.The recovery-completion collision (the interesting find)
ZK recovery completes by installing a post-recovery rule via
add_context_rule, authorized by the zero-signer recovery rule — so a strict "no mutators" surface bricks recovery. The spike's resolution:add_context_rulesurvives as the completion vehicle only, hard-gated (DocOnlyWritePath) to the completion window:has_pending || completion_grantedon the account's recovery controller.completion_grantedis new, and load-bearing: the controller'sPolicy::enforceconsumes the pending during the completing call's__check_auth, before the entry-point body runs, so the body can never see the pending it was authorized under. The controller now records a same-ledger TEMPORARYCompletionGrantat consume time and exposes it as a view. Cost: anido-zk-recoverychange rides along with any doc-only adoption.add_multisig_recovery) and completion (buildRotation'sadd_signer/remove_signer/add_policy) both rode the removed mutators, and doc v1 cannot express M-of-N principals — so the flow has no route at all until doc v1 grows quorum principals plus a recovery-scoped apply, or a rotation-shaped completion vehicle is added. The SDK builders now throw with that explanation. This is the headline product cost of the ruling.Deferred decision remaining
Migration for existing accounts stays out of scope (new accounts only; deployed account wasm is immutable). The other deferred decision — sole write path — is what this revision implements.
Cost & observations
RecoveryRuleProtectedper-op checks (the recovery rule is now protected structurally — no entry point can touch it, andapply_docskips it), the drift read-tier, and the whole class of "legacy path edits a doc rule" states.apply_dockeeps the pending guard (it removes rules), so a live recovery still blocks re-docing while completion runs through the gated vehicle.CompletionGrantmachinery; and the dapp must rebuild policy editing as document editing — session-key add/revoke become "compose the current doc (fromreadPolicy) with/without the rule and apply" (scopedSessionKeyDoc+buildApplyDocTxexist; the UI flows are stubbed to throw doc-only guidance, not yet rebuilt).buildDocInstallTxsdeleted; per-rule policyBlock builders (scoped-session-key,multisig-recovery) and the friend-rotation executor now throw; the frontend'sremove-context-ruleop builder throws. Read/display paths (fromChain,summarize, decompiler) untouched. Frontend builds and its 408 tests pass — the fast e2e suite is UI-only, so CI is expected green while the throwing flows await their doc-route rebuilds.env.as_contractlibrary-call backdoors (test-only; unreachable on a real network). The guard e2e checks moved onto the surviving guarded ops (initiate_upgradeexercises the same controller cross-call). The old per-op guard/protection unit tests are deleted with their entry points.perch-smart-accounttrait crate is still not consumable (unpublished; git-unbuildable — it bakes git-ignored fetched wasm pins at build time; private storage/helpers). The account now matches its doc-only SEMANTICS — wipe-and-replace, anti-brick, sole write path — while still consuming only the wire protocol (perch-doc-compilerclient) and mirroring the trait surface. Remaining deltas from upstream: the preserved recovery rule + completion gate, nido's spending-limit pin instead of perch's content-addressed one, and canonical-bytes enforcement (upstream's compiler is format-agnostic; here stored == emitted == canonical so verification is a bare sha256 — trivial to relax).[patch]redirect (both v0.7.1) unifies the wire types and breaks if either pin moves.__check_auth), ~doc-size rent, overwritten per apply, TTL bumping left as a shortcut; the event remains the eventual method (indexer needed for event-only reading and for historical docs).Spike shortcuts (deliberate)
readPolicystays a pure classifier (caller fetches the view/event inputs).DocCompilerError); 17DocNotCanonical, 18DocAdminLockout, 19DocOnlyWritePath; 10 (DocCapUnsupported) retired.The deployed-artifact wire-type trap (live find #2)
The first doc-only deploy trapped every real
apply_docwithError(Object, UnexpectedSize)decodingcompile_doc's return. Root cause: the account's wire types came from perch source revf5676a6(6-fieldCompiledRule, withcap), but the canonical deployed compiler — still the registry's latest publish; perch taggeddoc-compiler-v0.2.0but never published a newer wasm — is an older build: 5-fieldCompiledRule, no cap lowering,CapUnsupported = 6in 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.The fix goes to ground truth: the account's wire types are now transcribed from the deployed wasm's own contract spec (fetched via
stellar contract fetch;installdecodes as rawVec<Val>passthrough, so no interpreter type surface needs mirroring), the e2e registers the fetched on-chain bytes at the derived addresses, and a pin-fixture guard assertssha256(fixture) == pin. Fallout worth noting:perch-ir(test-side canonicalization). The theahacostellar-accountsfork[patch]— previously a headline adoption cost — is gone: nothing pulls perch's Rust workspace anymore.CapUnsupported), and when perch's CI published the cap-capable 0.2.1 (new registryCDX2DMYM…; publish receipt on theperch-doc-compiler-v0.2.1release), the pin bump + wire re-transcription re-enabled the lowering onto nido's pinned stock spending-limit — proving the artifact-pinned model absorbs an upstream publish as a contained, testable bump.account_wasm_hashcaches the embedded hash in instance storage, so an upgrade would keep deploying the OLD account wasm. TheUpgradable::upgradeoverride now clears it, plus an admin-gatedrefresh_account_wasm_hashfor factories upgraded from pre-override code.Live on testnet (2026-09-10)
The spike is clickable end to end — fresh accounts through the repointed factory carry the doc surface:
unverified/factoryrepointed)CCJFOM6UGOH7JSAX22C3FAECG5657HKIUYDBTCMUMILKDA6LOA2J2EGGfe3b1878…8e59set_recovery_pool)CAUZ6WFUTTZCJQNNL5D3BNZSG7FYYGX46BDJE6G2XVVCGN76RKE5ESARCDMNKDMPSBUUOHCP6QKFLRP76TLYFCYBM7SICE77BQGFJTRL7MXOSIRDcreate_account, genesis leaf 0)CBPE5AJY3ZKQBJFZ7Q3WVK3ANR6ZROCIXGAELRRVKWBOVKJPX2SH2SV2— interface exposesapply_doc/get_applied_doc/applied_doc_hash/doc_rule_ids, no legacy mutators; both doc viewsnullapply_doc, end to endCD5X4AWMGW3KYNDUHDXQGTWAEXIV37ENFM6UP5BDZPAAII4OODKI7JQI: an add-admin-key document (owner passkey + second admin key on the self-admin rule) applied via the wallet signing path (recorded auth entry + WebAuthn assertion overdigest(payload ‖ [0])). Live state:applied_doc_hash4fb7ae94…,get_applied_docreturns the full canonical JSON,doc_rule_ids[2], recovery rule preserved, default rule replacedCCVIDH5JM6T5QQVZ7C6223Q55SYNRJLKNYGRD537BVC7IA6HHIWIOH4G: doc with admin +daily-pay(XLM SACtransfer, cap 5 XLM/day) applied in tx642f4950…. Rule 3 carries BOTH the pinned stock spending-limitCCJMCPGA…and the 0.2.1 interpreterCDR2OTZI…— the whole cap chain, including the real spending-limitinstallcross-call, liveCDX2DMYM…, compilerCDWBJPDM…(wasm35f248f0…), interpreterCDR2OTZI…(wasmf63cae53…) — receipt-verified, derivation-cross-checkedDeploy notes (details in DEPLOYED.md on the branch): the
unverified/smart-accountregistry label is owned by a different author key thantheahaco, so the version-label publish was refused — the functional path (direct wasm upload) is the deploy script's own fallback. The zk stack had to ship because the doc-only factory genesis-inserts unconditionally; the pool is reached via the factory's adminset_recovery_pooloverride, not a registry name.Test evidence
just check(fmt + clippy-Dpedantic) clean;just test(cargo workspace) green.get_applied_doc/event round-trip against the real perch compiler + interpreter at derived addresses; the full zk-recovery suite (initiate → timelock → completion through the gated vehicle → guard release) passes against the real controller with the new grant.passkey-sdkvitest 256 green; frontend build + 408 tests green.Perch consumed at git rev
f5676a6— the same rev PR #200's SDK pins mirror.