Permissionless TTL maintenance for named, already-existing guardians
and requests โ extending TTL creates no authority (ยง16.1). Also
covers any pending threshold change and any pending removal for the
given guardians: those are only bumped otherwise at propose_* time,
so a proposal left unapplied long enough after its delay elapses
could archive before apply_* is ever called, silently losing an
already-authorized governance action rather than just delaying it
(independent security review finding). bump_ttl no-ops on keys
that don't exist, so calling this with no pending changes is safe.
Initialized/Admin/GuardianThreshold/PendingGuardianThreshold
live in instance storage (one shared TTL, refreshed automatically by
ensure_initialized on every call that reaches it), so a single
extend_ttl call below covers all of them together.
fn extend_ttl(
env: soroban_sdk::Env,
guardians: soroban_sdk::Vec,
request_ids: soroban_sdk::Vec>,
)
fn initialize(
env: soroban_sdk::Env,
admin: soroban_sdk::Address,
guardian_threshold: u32,
) -> Result<(), RecoveryManagerError>
fn is_guardian(env: soroban_sdk::Env, guardian: soroban_sdk::Address) -> bool
Registers a guardian. The guardian is recorded immediately
(is_guardian reflects it right away), but its approval does not
count toward any threshold until GUARDIAN_ACTIVATION_DELAY_LEDGERS
have passed โ see the constant's doc comment for why. Removing a
guardian, unlike adding one, is itself timelocked โ see
propose_remove_guardian.
fn add_guardian(
env: soroban_sdk::Env,
guardian: soroban_sdk::Address,
) -> Result<(), RecoveryManagerError>
fn contract_name(env: soroban_sdk::Env) -> soroban_sdk::Symbol
Opens a recovery request. Callable by admin/owner or by any
currently-active guardian โ this is a deliberate fix, not the
original design: requiring the admin's own authorization to open a
recovery request means recovery is unusable in exactly the scenario
it exists for (the owner's key is lost or destroyed, not merely
"compromised but still controllable by its holder"). A guardian who
suspects the owner is compromised, or who is trying to help an owner
who has genuinely lost access, can start the process unassisted;
approve_recovery's threshold and finalize_recovery's timelock
are what actually gate the outcome, not who was allowed to open the
request.
replacement_signers/replacement_policies must describe a
satisfiable context rule (at least one signer or one policy) โ see
RecoveryRequest's doc comment for why recovery needs to carry
this at all, not just replacement_owner. Rejected fast, here, if
replacement_signers is empty, rather than letting a genuinely
unsatisfiable recovery r
fn open_recovery(
env: soroban_sdk::Env,
caller: soroban_sdk::Address,
request_id: soroban_sdk::BytesN<32>,
replacement_owner: soroban_sdk::Address,
replacement_signers: soroban_sdk::Vec,
replacement_policies: soroban_sdk::Map,
earliest_ledger: u32,
) -> Result<(), RecoveryManagerError>
fn request_status(
env: soroban_sdk::Env,
request_id: soroban_sdk::BytesN<32>,
) -> Result
fn cancel_recovery(
env: soroban_sdk::Env,
request_id: soroban_sdk::BytesN<32>,
) -> Result<(), RecoveryManagerError>
fn approve_recovery(
env: soroban_sdk::Env,
request_id: soroban_sdk::BytesN<32>,
guardian: soroban_sdk::Address,
) -> Result<(), RecoveryManagerError>
fn finalize_recovery(
env: soroban_sdk::Env,
request_id: soroban_sdk::BytesN<32>,
) -> Result
Recomputes, from current guardian registrations, how many of this
request's historical approvers are still valid right now. This is
the number finalize_recovery actually checks against the
threshold โ never the raw approvers.len().
fn live_approval_count(
env: soroban_sdk::Env,
request_id: soroban_sdk::BytesN<32>,
) -> Result
Permissionless view of the current freeze epoch โ see
request_guardian_freeze's doc comment for why this is a monotonic
counter rather than a consumable flag. smart_account:: apply_guardian_freeze compares this against the last epoch it
applied to decide whether there is a genuinely new, not-yet-applied
freeze request.
fn guardian_freeze_epoch(env: soroban_sdk::Env) -> u32
Applies a pending guardian removal once its delay has elapsed.
Permissionless: the authorization decision (the admin proposing this
specific removal) already happened, so when an already-authorized
removal lands needs no further gate. Does not touch any open
request's stored approvers list โ that history is preserved as an
audit trail โ but the removed guardian's past approval stops
counting toward live_approval_count / finalize_recovery from
this point on, because both recompute against current guardian
registration rather than trusting the historical approval.
fn apply_guardian_removal(
env: soroban_sdk::Env,
guardian: soroban_sdk::Address,
) -> Result<(), RecoveryManagerError>
Applies a pending guardian threshold change once its delay has
elapsed. Permissionless: the authorization decision (the admin
proposing this specific change) already happened, so when an
already-authorized change lands needs no further gate. Doesn't call
ensure_initialized (there is nothing to permission-check), so it
explicitly extends the instance TTL itself on this write, the same
way smart_account::apply_adapter_change does for its own
instance-storage write.
fn apply_threshold_change(env: soroban_sdk::Env) -> Result<(), RecoveryManagerError>
Cancels a pending guardian removal before it takes effect. Admin-gated, letting a legitimate admin walk back an erroneous or no-longer-wanted proposal before its delay elapses.
fn cancel_guardian_removal(
env: soroban_sdk::Env,
guardian: soroban_sdk::Address,
) -> Result<(), RecoveryManagerError>
Cancels a pending guardian threshold change before it takes effect. Admin-gated, letting a legitimate admin walk back an erroneous or no-longer-wanted proposal before its delay elapses.
fn cancel_threshold_change(env: soroban_sdk::Env) -> Result<(), RecoveryManagerError>
Proposes removing a guardian. Does not take effect immediately โ
see apply_guardian_removal. The guardian remains fully active
(still counted by is_guardian, live_approval_count, and able to
approve requests or request a freeze) until the proposal is applied.
Independent security review finding: immediate removal under
single-admin control meant a compromised admin could dismantle the
guardian set with no delay for anyone to notice.
fn propose_remove_guardian(
env: soroban_sdk::Env,
guardian: soroban_sdk::Address,
) -> Result<(), RecoveryManagerError>
Real gap this session found: docs/TECHNICAL_ARCHITECTURE.md ยง12.7
documents the canonical recovery workflow as starting with "Guardian
... triggers freeze" โ but smart_account::freeze() was owner-only,
with no guardian-facing path at all. A single active guardian
requesting freeze is enough to raise the flag (unlike recovery
itself, which needs quorum + timelock to replace authority,
stopping active bleeding while recovery is still pending doesn't
need the same bar โ the worst case is an unnecessary pause, not a
loss of funds or authority). smart_account::apply_guardian_freeze
pulls this epoch the same way apply_recovery pulls a finalized
request: permissionlessly, and this contract has no knowledge of, or
dependency on, the treasury that pulls it.
Stores a monotonically increasing epoch rather than a boolean flag.
Security review finding on an earlier revision of this function: a
plain bool, consumed (cleared) by a permissionless
consume_guardian_freeze_request call, let any third party
fn request_guardian_freeze(
env: soroban_sdk::Env,
guardian: soroban_sdk::Address,
) -> Result<(), RecoveryManagerError>
Proposes a new guardian threshold. Does not take effect immediately
โ see apply_threshold_change. Once applied, it takes
effect for any request not yet finalized: finalize_recovery always
reads the threshold fresh rather than the value in effect when the
request was opened or approved.
fn propose_threshold_change(
env: soroban_sdk::Env,
new_threshold: u32,
) -> Result<(), RecoveryManagerError>