Skip to content

feat(soroban): harden circuit breaker, liquidation, token allowances, and staking metadata - #1068

Merged
Ceejaytech25 merged 4 commits into
ceejaylaboratory:mainfrom
0xDeon:feat/soroban-contract-hardening
Aug 28, 2026
Merged

feat(soroban): harden circuit breaker, liquidation, token allowances, and staking metadata#1068
Ceejaytech25 merged 4 commits into
ceejaylaboratory:mainfrom
0xDeon:feat/soroban-contract-hardening

Conversation

@0xDeon

@0xDeon 0xDeon commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #982
Closes #984
Closes #986
Closes #987

Combines PRs #1055, #1056, #1057, #1058 into a single branch. Four independent Soroban contract fixes, one commit each, no overlapping files. Rebased onto current main.

Commit Contract Issue
feat(soroban): add emergency pause/resume toggle in Circuit Breaker src/circuit_breaker/lib.rs #982
fix(soroban): make collateral health factor evaluation zero-debt safe src/liquidation/src/lib.rs #986
feat(soroban): expire operator approval allowances by ledger sequence src/token/lib.rs #984
feat(soroban): validate and bound NFT metadata updates in Liquid Staking contracts/liquid_staking/src/lib.rs #987

Test evidence

cargo test -p circuit_breaker   41 passed
cargo test -p liquidation       18 passed
cargo test -p sep41-token       48 passed

Note on contracts/liquid_staking

Its 25 tests could not be run locally: the contracts/ workspace has a pre-existing break on mainsoroban-env-host fails to compile against ed25519-dalek 3.0.0 (ChaCha20Rng: CryptoRng unsatisfied). Verified by checking out unmodified main in a clean worktree, so it is independent of this branch. cargo check --tests -p liquid_staking passes against a locally pinned dalek 2.2.0; that pin is deliberately not committed.

The liquid staking commit's diff is now +195/-3. The original PR #1058 showed +1291/-1099 because it also reformatted the whole file; that reformatting has been dropped so the diff is reviewable.

Rationale for each change is in the individual commit messages.

0xDeon added 4 commits August 27, 2026 15:09
The circuit breaker could be tripped by governance, authorized bots, and
autonomous oracle/volume triggers, but the only way back out was the
timelocked initiate_unpause/execute_unpause pair. That timelock is correct
for routine resumption, but it means a false-positive trip (a benign volume
spike, a transient oracle deviation) keeps the protocol halted for the full
timelock window, where the outage is the incident.

Add an admin-only unpause(admin) escape hatch that resumes immediately:

- unpause() requires admin auth, rejects the not-paused case, clears the
  tier, and emits (cb, resumed) with (admin, timestamp).
- It also zeroes UnpauseUnlocksAt, so a previously scheduled unpause cannot
  fire afterwards and silently re-tier the protocol from a stale target.
- Bots are deliberately excluded: they may trip the breaker but must not be
  able to resume it.

Track an explicit IsPaused boolean in instance storage, set in initialize()
and updated in apply_trip(), execute_unpause(), and unpause() so it can
never drift from PauseTier, and expose it via is_paused(). Note that
execute_unpause() to a narrower tier keeps IsPaused true, since the
protocol is still halted.

Tests cover both directions of the state machine: is_paused after
initialize, trip/unpause round-trips across all three tiers, immediate
resume without advancing the ledger, pending-unpause cancellation, the
stale execute_unpause path panicking, non-admin and bot rejection, repeated
pause/unpause cycles with trip-count accounting, tier-narrowing, and the
exact resumed event payload.

41 tests pass (31 pre-existing, 10 new).
The liquidation crate did not compile: create_vault bound `id` immutably
and then reassigned it, and in doing so wrote the same vault under two
different ids while advancing the counter twice, leaking an id per vault.

Beyond that, both liquidation paths computed an unguarded health factor
before reaching the safe one. liquidate() and partial_liquidate() each
fetched a u128 collateral price and divided by vault.debt_amount directly,
so any vault with zero debt panicked on division by zero. Each function
then recomputed the health factor via get_health_factor and asserted again,
leaving the first computation as dead code whose only reachable effect was
that panic. partial_liquidate additionally duplicated its ratio, incentive,
and vault-mutation logic, and used unchecked arithmetic throughout.

Route both paths exclusively through get_health_factor, which already
returns i128::MAX for zero debt and is oracle-priced, so a debt-free vault
now reports infinite health and fails the healthy-vault assertion instead
of panicking. Delete the duplicated pre-checks and dead bindings, and use
checked arithmetic for the seized collateral, incentive, and vault updates.

Name the magic thresholds: HF_ONE_BPS (10_000 = 1.0),
LIQUIDATION_THRESHOLD_BPS (12_000), PARTIAL_LIQUIDATION_THRESHOLD_BPS
(15_000), and add is_liquidatable(), which encodes the rule that a vault at
or above a health factor of 1.0 is never liquidatable.

Note the health factor is i128, not u128, so the infinite-health sentinel
is i128::MAX.

Tests cover zero debt with and without collateral, zero debt after a full
liquidation, both liquidation entry points rejecting a debt-free vault, the
exactly-1.0 and just-below-1.0 boundary, sequential vault ids, and a
partial liquidation reducing debt and collateral.

18 tests pass (9 pre-existing, 9 new); the crate previously did not build.
Allowances were stored as a bare i128 with no lifetime, so an approval
stayed spendable forever unless the owner remembered to overwrite it. A
spender approved once retained that authority indefinitely, and abandoned
approvals kept paying storage rental with no way to reclaim them.

Store allowances as AllowanceValue { amount, expiration_ledger } and gate
every read on env.ledger().sequence() <= expiration_ledger. The bound is
inclusive: an allowance is still spendable on its expiration ledger.

approve() now takes expiration_ledger and rejects one already in the past,
so a non-zero approval cannot be created dead. Approving zero is treated as
a revocation: the record is removed rather than stored as a zero, and any
expiration is accepted, since refusing to revoke over a stale argument
would be the more dangerous failure.

permit() carries expiration_ledger inside the signed auth payload, so a
relayer cannot extend the lifetime of an approval beyond what the owner
signed.

Storage is reclaimed on two paths: transfer_from purges a lapsed record as
it reads it, and an allowance spent down to zero is removed instead of
being written back. Neither covers the common case of an expired approval
that is simply never used again, and a spend attempt against an expired
allowance panics, so Soroban reverts the invocation and takes that purge
with it. purge_expired_allowance() therefore exists as its own entry point.
It is permissionless because it can only delete a record that has already
lapsed and is unspendable, and it is idempotent.

Operator approval (set_approval_for_all) is unchanged and remains
independent of per-token allowance expiry.

allowance() keeps returning i128 and now reports 0 once expired, so
existing callers stay correct; allowance_expiration() exposes the deadline.

46 tests pass (31 pre-existing, 15 new), covering the inclusive boundary,
reads and spends after expiry, both purge paths, revocation, re-approval
extending a deadline, partial spends preserving it, and operator bypass.
update_contract_meta already required admin authorization: it calls
require_auth() on the caller and compares it against the stored admin,
panicking with OnlyAdmin otherwise, and test_update_contract_meta_non_admin
covered that. The issue's premise that any caller can rewrite the staking
position metadata does not hold against the current code, so the
authorization check is left as it is and covered by additional tests rather
than reimplemented.

What was genuinely missing is any bound on what the admin can store. The
description, icon_url, and website strings were written to instance storage
at whatever length was supplied, so a single update could commit the
contract to unbounded storage rental. Reject any field longer than
MAX_METADATA_LEN (256 bytes) with a new MetadataUriTooLong error. The bound
is inclusive, so a URI of exactly 256 bytes is accepted.

The emitted event carried only the ContractMetadata struct, so a subscriber
wanting the new URI had to destructure it. Lead the event data with
icon_url, keeping the full struct behind it so existing consumers continue
to work.

Also add the missing `Events` import to the test module, without which the
crate's tests did not compile at all.

Tests cover a non-admin update being rejected and leaving prior metadata
intact, a URI at exactly the limit being accepted, each of the three fields
being rejected one byte over, an oversized update leaving state untouched,
and the update emitting an event.

25 tests pass (17 pre-existing, 8 new).
@0xDeon
0xDeon force-pushed the feat/soroban-contract-hardening branch from c6b8b43 to 39de66f Compare August 27, 2026 14:27
@Ceejaytech25
Ceejaytech25 merged commit f213f7c into ceejaylaboratory:main Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants