Skip to content

Reconcile redemption reserve, add emergency timelock, storage-exhaustion stress test - #1134

Merged
joelpeace48-cell merged 3 commits into
FinesseStudioLab:mainfrom
miraclesonly:fix/issues-834-838-835
Aug 27, 2026
Merged

Reconcile redemption reserve, add emergency timelock, storage-exhaustion stress test#1134
joelpeace48-cell merged 3 commits into
FinesseStudioLab:mainfrom
miraclesonly:fix/issues-834-838-835

Conversation

@miraclesonly

@miraclesonly miraclesonly commented Aug 27, 2026

Copy link
Copy Markdown

Summary

Closes #834
Closes #838
Closes #835
Closes #837

#834 — redemption reserve reconciliation. redeem() checked payouts against a mirrored
REDEMPTION_RESERVE u64 counter kept in lock-step by fund_reserve/withdraw_reserve, but never
reconciled against the actual SAC token balance the contract holds. An external transfer, rounding,
or a partial failure elsewhere could desync the two, letting a redemption over-pay past what the
contract can actually cover. redeem() now bounds every payout by
min(mirrored counter, token::Client::balance(contract)), computed and compared entirely in i128
instead of casting the asset amount down to u64 for the comparison/update. Two new tests: one
asserting the mirrored counter and the real SAC balance stay in lockstep after a normal redemption,
one that manually desyncs the counter above the real balance and confirms redeem() still rejects
the over-payout.

#838 — emergency timelock. Adds queue_timelock/execute_timelock/cancel_timelock: an
admin-only, delay-gated primitive for sensitive single-admin actions (upgrade, redemption-rate
change, reserve withdrawal) that don't need the multi-voter approval the existing quorum-based
governance timelock (issue #735) requires — just a mandatory delay so a compromised or mistaken
admin key can't act instantly. queue_timelock(op_hash) records eta_ledger = now + configured delay; execute_timelock reverts with TimeLockActive before eta_ledger and clears the entry
after; cancel_timelock vetoes at any time. Follows the same "record here, caller dispatches the
actual effect after this returns" pattern as the existing execute_privileged_op. Note: wiring
this as a hard gate on upgrade/set_redemption_rate/withdraw_reserve themselves is left as a
follow-up — doing so now would change those functions' error surface and risk breaking existing
callers/tests that call them directly with just admin+nonce today. Five new tests cover default
delay, configuring a custom delay, early-execution rejection, queue→execute, cancel, and
double-queue rejection.

#835 — storage-exhaustion stress test. Adds
contracts/campaign/src/test.rs::test_high_volume_registration_stress, registering 3,000 distinct
participants in one run and asserting every registration succeeds and is recorded — tiny-N unit
tests can't catch a regression that reintroduces an unbounded per-instance-entry collection, since
the collection would still be small at N=2 or N=3. New docs/STORAGE_MODEL.md documents the
per-entry size budget and which storage keys are safe-by-construction (persistent, per-key, like
PARTICIPANT and the new TIMELOCK_ENTRY) vs. the bug class this guards against (an instance-storage
collection that grows one entry without bound). Wires a named CI step into contracts-ci.yml so a
regression here is called out on its own rather than buried in cargo test --workspace output.

#837 — external audit. Not addressed in this PR: commissioning and publishing an independent
smart-contract audit isn't something a PR can close — it requires actually engaging and paying an
external firm. I've left a comment on that issue instead of claiming it here.

Pre-existing issue found (not fixed here)

contracts/campaign's existing test suite (both src/test.rs and src/fuzz_test.rs) currently
fails to compile on main, unrelated to this PR — several assert_eq! sites expect the pre-SDK-bump
Result<Result<T, Error>, _> shape from try_* client calls, and one set_merkle_root call site
passes a stale extra argument. I verified this predates this PR by stashing this PR's test.rs
changes and reproducing the same failures against main directly. I couldn't get a full green
cargo test run for the campaign crate as a result, though cargo check --lib passes and the new
stress test's use of the plain (non-try) register() client call follows the same pattern as
already-passing neighboring tests in the same file. Filing a follow-up issue for this separately.

Test plan

  • cargo test --lib in contracts/rewards — all new tests pass (redeem conservation/desync,
    5 timelock tests)
  • cargo check --lib in contracts/campaign passes
  • cargo test --lib in contracts/campaign — blocked by the pre-existing, unrelated compile
    failure noted above
  • Manual review against each issue's acceptance criteria

Closes FinesseStudioLab#834

redeem() checked the requested payout against a mirrored REDEMPTION_RESERVE
u64 counter that is updated in lock-step with fund_reserve/withdraw_reserve
but never reconciled against the SAC token balance the contract actually
holds. Any external transfer, rounding, or partial failure elsewhere could
desync the two, letting a redemption over-pay past what the contract can
actually cover.

redeem() now bounds every payout by min(mirrored counter, real SAC balance
via token::Client::balance), computed and compared entirely in i128 instead
of casting the (potentially larger-range) asset amount down to u64 for the
comparison and the reserve update. withdraw_reserve/fund_reserve are
unchanged since their own SAC transfer already reverts safely on
insufficient real balance.

Adds two tests: one asserting the mirrored counter and the SAC balance stay
in lockstep after a normal redemption (the conservation invariant the issue
asks for), and one that manually desyncs the mirrored counter above the
real balance and confirms redeem() still rejects the over-payout rather
than trusting the stale counter.
Closes FinesseStudioLab#838, closes FinesseStudioLab#835

Adds queue_timelock/execute_timelock/cancel_timelock to the rewards
contract (in the same lib.rs edited by the prior commit): an admin-only,
delay-gated primitive alongside the existing quorum-based governance
timelock (issue FinesseStudioLab#735) for sensitive single-admin actions (upgrade,
redemption-rate change, reserve withdrawal) that don't need multi-voter
approval, just a mandatory delay so a compromised or mistaken admin key
can't act instantly. queue_timelock(op_hash) records eta_ledger = now +
configured delay; execute_timelock reverts with TimeLockActive before eta
and clears the entry after; cancel_timelock vetoes at any time. Same
"record here, caller dispatches the actual effect after this returns"
pattern as the existing execute_privileged_op. Wiring this as a hard gate
on upgrade/set_redemption_rate/withdraw_reserve themselves is left as a
follow-up — doing so now would change those functions' error surface and
risk breaking existing callers/tests that call them directly with just
admin+nonce.

Adds contracts/campaign/src/test.rs::test_high_volume_registration_stress,
registering 3,000 distinct participants in one run and asserting every
registration succeeds and is recorded — the high-N class of test issue
FinesseStudioLab#835 asks for, since tiny-N unit tests can't catch a regression that
reintroduces an unbounded per-instance-entry collection. Documents the
per-entry size budget and which storage keys are safe-by-construction
(persistent, per-key) vs. the bug class being guarded against
(instance-storage collections that grow one entry without bound) in the
new docs/STORAGE_MODEL.md. Wires a named CI step for this test into
contracts-ci.yml so a regression is called out on its own rather than
buried in `cargo test --workspace` output.

Note: contracts/campaign's existing test suite (both src/test.rs and
src/fuzz_test.rs) currently fails to compile on main for reasons unrelated
to this change — several `assert_eq!` sites expect the pre-SDK-bump
`Result<Result<T, Error>, _>` shape from `try_*` client calls, and
set_merkle_root is called with a stale extra argument. This pre-dates this
PR (verified by stashing this commit's test.rs changes and reproducing the
same failures against main). I could not get a full green `cargo test` run
for the campaign crate as a result, though `cargo check --lib` passes and
the new test's use of the plain (non-try) `register()` client call follows
the same pattern as already-passing neighboring tests in the same file. I'll
file a follow-up issue documenting this separately.
@drips-wave

drips-wave Bot commented Aug 27, 2026

Copy link
Copy Markdown

@miraclesonly Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

let user = Address::generate(&env);

// 100 bps = 0.01 asset per point.
client.set_redemption_rate(&admin, &0, &asset, &100);
let (client, admin, asset, contract_id) = setup_redemption(&env);
let user = Address::generate(&env);

client.set_redemption_rate(&admin, &0, &asset, &10_000); // 1:1
let (client, admin) = setup_timelock(&env);

assert_eq!(client.timelock_delay(), DEFAULT_TIMELOCK_DELAY);
client.set_timelock_delay(&admin, &0, &500);
fn test_timelock_early_execute_rejected() {
let env = Env::default();
let (client, admin) = setup_timelock(&env);
client.set_timelock_delay(&admin, &0, &1_000);
fn test_timelock_queue_then_execute() {
let env = Env::default();
let (client, admin) = setup_timelock(&env);
client.set_timelock_delay(&admin, &0, &100);
@joelpeace48-cell
joelpeace48-cell merged commit 016d695 into FinesseStudioLab:main Aug 27, 2026
4 of 17 checks passed
joelpeace48-cell pushed a commit that referenced this pull request Aug 28, 2026
Closes #851

Fixes a build-blocking bug on main first: two independently-merged PRs
each added a new Error variant at discriminant 46 (TimelockNotFound,
from #1134, and BelowMinClaim, from a separately-merged issues-850-860-
861-848 batch) — colliding once merged together, so
`cargo check -p trivela-rewards-contract` currently fails with
E0081 "discriminant value 46 assigned more than once" on main. Renumbers
BelowMinClaim to 48 (the next free slot); safe since nothing could have
successfully deployed relying on that specific discriminant value while
the crate didn't compile.

Adds contracts/rewards/fuzz/fuzz_targets/fuzz_multisig.rs: adversarial
fuzzing of verify_multisig (used by set_paused) covering duplicate
signers, unknown (unregistered) signers, corrupted signatures, and
nonce replay — extending the existing fuzz_balance.rs coverage to the
co-admin ed25519 multisig path. Asserts try_set_paused never panics for
any signature-set shape, and that success implies a sufficiently large
set of distinct, valid, registered signatures plus an unconsumed nonce;
replaying the exact same call after a success must then be rejected.

Wires both fuzz_balance and the new fuzz_multisig into
contract-fuzzing.yml as libFuzzer (cargo-fuzz) runs, time-boxed to the
workflow's existing FUZZ_DURATION, alongside the pre-existing proptest-
based fuzz_* unit tests that workflow already ran (which weren't
actually running the cargo-fuzz targets in contracts/rewards/fuzz/ at
all before this).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants