feat(globe-wallet): recovery_completed event + fix workspace test suite (was fully broken) + fix missing instance-TTL protection - #105
Merged
ndii-dev merged 1 commit intoAug 29, 2026
Conversation
…transfer via a new recovery_completed event (Orbit-Wal#91) Also fixes, discovered while getting `cargo test --workspace` running for the first time in this exercise: - An upstream ed25519-dalek version conflict (soroban-env-host 21.2.1's unbounded ">=2.0.0" dependency) that made the entire test suite fail to compile, for anyone, regardless of anything in this workspace's own code. Fixed portably (Cargo.lock is gitignored here) by vendoring the exact 2.2.0 package soroban-sdk already resolves to and patching crates-io onto it. - Two pre-existing test bugs (test_max_assets_limit, test_migrate_user_assets_within_limit_does_nothing) left over from issue Orbit-Wal#29's issuer-requirement validation never being backported into them. - A pre-existing type-mismatch compile error in an upgrade test (Ok(()) vs Ok(Ok(())) for a try_* client method). - The actual root cause behind two more pre-existing test failures (test_user_assets_ttl_extension_after_long_idle_period, test_spend_limit_ttl_extension_after_long_idle_period): this contract's own *instance* storage (Admin, Guardians, RecoveryConfig, every pending proposal) never had its TTL extended anywhere, unlike every per-user persistent entry, which was carefully protected. Added bump_instance_ttl() and wired it into all 19 state-mutating functions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6 tasks
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.
Root cause
accept_admin(routine, self-initiated) andexecute_recovery(emergency, guardian-quorum-driven) publish the exact sameadmin_transferredevent — same topic, same payload shape. There is no on-chain signal distinguishing "the admin rotated their own key" from "guardians just seized control because the key was presumed lost or compromised," even though the second case is arguably the single highest-signal security event this contract can produce, and precisely the moment a wallet owner most needs to be alerted through a side channel.Design decision
Per the issue's suggested fix: keep
admin_transferredbyte-for-byte unchanged (existing indexers/the mobile app need zero changes), and haveexecute_recoveryadditionally publish a newrecovery_completedevent carrying old admin, new admin, the full approving-guardian set, the quorum threshold, and both the ledger the timelock elapsed at and the ledger it was actually executed at.One deliberate departure worth calling out explicitly:
RecoveryCompletedEventis a named#[contracttype]struct, not a tuple — every other event in this contract is a raw tuple. That's a fine trade-off for 2–3 positional fields where order is obvious from context; this event has five fields feeding a security-alerting integration, where a transposed pair ofAddresses (an indexer readingnew_adminwhereold_adminbelongs) would misreport who just lost control of a wallet during the exact incident that most needs correct reporting. A named struct makes every field self-describing in the XDR itself. The cost is inconsistency with the rest of this file's tuple convention — I did not migrate the other events to match, to keep this diff scoped to #91; full rationale is in the doc comment on the struct, including a note recommending that migration as a follow-up rather than doing it silently here.approving_guardiansdeliberately carries the full approval list, not justthresholdof them — a wallet recovered 5-of-5 (unanimous) and one recovered at its bare 3-of-5 threshold are different-risk events even though both pass the same on-chain quorum check, and only this field lets a monitoring integration tell them apart (covered by its own test below).ready_atvsexecuted_atsimilarly lets an observer see how promptly an already-executable recovery was actually claimed — a multi-day gap between the two is itself a signal worth surfacing.What else is in this diff, and why
Issue #91 asks for
cargo test --workspaceoutput as evidence. Getting an actually-passing run required fixing four things that had nothing to do with #91 on their own, in the order I hit them:cargo testdidn't compile at all, for anyone.soroban-env-host21.2.1 (pulled in transitively viasoroban-sdk'stestutilsfeature) declaresed25519-dalek = ">=2.0.0"— no upper bound. Now thated25519-dalek3.0.0 exists (a breaking release incompatible with therand_chachaversionsoroban-env-hostitself pins), Cargo resolves two conflicting major versions in one graph, andsoroban-env-host's owntestutils.rsfails to compile with aChaCha20Rng: CryptoRngtrait-bound error. This is a real, upstream Cargo.toml bug, unrelated to anything in this repo's own code — but it blocked every test in the workspace, so it had to go first. SinceCargo.lockis gitignored here, the fix had to live inCargo.tomlitself rather than a local lockfile edit (which would silently stop applying for every other contributor/CI the moment the lockfile regenerates).[patch.crates-io]requires a genuinely different source than crates.io, anded25519-dalek's upstream repo has no2.2.0git tag to pin a git-source patch to (checked directly againstdalek-cryptography/ed25519-dalek— only pre-release2.0.0RCs are tagged), so I vendored the exact, unmodified2.2.0package (the versionsoroban-sdkitself already wants) undervendor/ed25519-dalek-2.2.0/and patched crates-io onto that path. Full reasoning is in theCargo.tomlcomment above the patch. Verified from a completely clean, freshly-regeneratedCargo.lock(deleted and rebuilt with--offlineto prove no hidden dependency on my local cache), for bothcargo test --workspaceandcargo build --release --target wasm32-unknown-unknown— the actual deploy artifact.Two pre-existing test bugs,
test_max_assets_limitandtest_migrate_user_assets_within_limit_does_nothing: both predate issueAssetInfo.codehas no validation — empty strings and case-variant duplicates are both allowed #29's rule that a non-native asset needs a real issuer, and were registering issuer-less fake assets that now fail validation for an unrelated reason before the test's actual assertion is ever reached. Fixed the first by pointing it at the already-existingfill_to_maxhelper (which already does this correctly — the test just wasn't calling it), and the second the same wayAssetInfo.codehas no validation — empty strings and case-variant duplicates are both allowed #29 fixedadd_asset's own tests.A pre-existing compile error:
test_propose_upgrade_accepts_any_hash_without_validationcompared atry_propose_upgraderesult againstOk(())instead of the realOk(Ok(()))shapetry_*client methods return.The actual reason two more tests were failing (
test_user_assets_ttl_extension_after_long_idle_period,test_spend_limit_ttl_extension_after_long_idle_period) — and the most consequential thing in this PR beyond [Enhancement]: admin_transferred event is identical for routine transfer and emergency guardian recovery — undermines security monitoring #91 itself: not one function in this contract had ever extended its own instance storage TTL.UserAssets/SpendLimit/DailySpentare all carefully protected against silent archival viaPERSISTENT_TTL_THRESHOLD/EXTEND_TO;Admin,Guardians,RecoveryConfig, every pending proposal — the contract's own core state, read on nearly every call — had zero such protection. A wallet left alone for longer than the network's default instance-entry lifetime (observably as little as a few thousand ledgers in the test environment — well under a day at Stellar's ~5s average close time) would have its instance archived, and every function, includingexecute_recoveryitself — the one function specifically meant to still work when almost nothing else does — would fail until someone pays for a separate restore operation. This is a strictly worse failure mode than anything the guardian-recovery subsystem defends against: a wallet doesn't need a lost or compromised key to become unusable, it just needs to be left alone. Addedbump_instance_ttl()(reusing the existingPERSISTENT_TTL_THRESHOLD/EXTEND_TOconstants) and wired it into all 19 state-mutating functions — every one ofinitialize,propose_admin,accept_admin,cancel_admin_transfer,propose_upgrade,execute_upgrade,add_guardian,remove_guardian,set_recovery_config,initiate_recovery,approve_recovery,revoke_recovery_approval,execute_recovery,cancel_recovery,add_asset,remove_asset,set_spend_limit,record_spend,migrate_user_assets. It's a no-op whenever the instance's current TTL already exceeds the threshold, so this adds no meaningful overhead to a wallet already seeing normal activity — full reasoning is in the helper's doc comment. (To be precise about what this does and doesn't fix: it prevents archival as long as some activity happens periodically; it can't retroactively revive an instance that's already fully archived from zero activity at all — that's an unavoidable Soroban-protocol-level consequence requiring the standard restore operation, same as for any contract.)I did not fix the pre-existing
remove_assetexact-match-vs-add_asset-case-insensitive-match inconsistency I noticed while in this file (already filed separately) — kept this diff to what was actually blocking a runnable test suite plus #91 itself.Evidence the code actually runs
cargo test --workspace(real run, offline, against aCargo.lockdeleted and freshly regenerated moments earlier — not relying on anything already resolved in my local cache):84/84 passing, 0 failing, across both contracts.
cargo check --workspace --all-targets: clean (only the pre-existing, intentional#[deprecated]warnings from tests that deliberately exercise the legacytransfer_adminwrapper).cargo build --release --target wasm32-unknown-unknown: clean, producesglobe_wallet.wasm(55,603 bytes) andtoken_wrapper.wasm(10,387 bytes) — the actual deploy artifacts, confirming the vendored-dependency fix doesn't break thetestutils-must-not-leak-into-release constraint this workspace's ownCargo.tomlcomment warns about.Tests
test_execute_recovery_emits_both_admin_transferred_and_recovery_completed— full recovery flow (3 guardians, threshold 2), decodes both published events offenv.events().all()and asserts every field ofRecoveryCompletedEvent, including thatapproving_guardianscontains exactly the two guardians who approved and not the third, silent one.test_execute_recovery_emits_recovery_completed_with_full_over_quorum_guardian_set— companion test, 5 guardians all approving against a threshold of 3, provingapproving_guardiansreflects every approval on the proposal rather than being truncated tothreshold, and thatexecuted_atcorrectly differs fromready_atwhen execution happens a few ledgers after the recovery became executable.test_accept_admin_emits_only_admin_transferred_not_recovery_completed— the negative case the issue's Definition of done explicitly asks for: a routine transfer must never emitrecovery_completed, or the event's entire value (unambiguous meaning) is gone.This is also the first place in this test module that decodes published events at all (
find_eventhelper, usingsoroban_sdk::testutils::Events) — no prior test in this file asserted on event contents, only that calls succeeded/failed.Definition of done
execute_recoveryemits both the existingadmin_transferredevent (byte-for-byte unchanged) and a new, distinctly-namedrecovery_completedeventready_at,executed_atfor additional monitoring context — see rationale above)accept_adminemits onlyadmin_transferred, neverrecovery_completedcargo test --workspaceoutput pasted, from a freshly-regenerated lockfileAdjacent behavior re-verified
globe-walletandtoken-wrapperstill passes (84/84) — nothing regressed by either the event change or the instance-TTL bump being added to 19 functions.testutils/std-only code into it.