Repository navigation
Conversation
waledev1
force-pushed
the
solver/issue-39-b1120
branch
from
October 12, 2026 04:55
79b794a to
f8ef699
Compare
This branch has not been deployed
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.
Overview
This splits one key that currently does everything.
Today
locked_in,target_savings,spend_saveandmock_pooleach keep a singleAdminaddress in their ownDataKey.target_savingsstores that same address as both itsKeeperand the defaultFeeRecipient, and the scheduled entrypoints (process_period,deposit_to_blend,withdraw_from_blend) authenticate that one address. The hot key the keeper service holds is therefore also the cold key that rewrites APY/yield config, replaces the fee recipient, grants itself more roles and — once pause exists — freezes the vault. A compromise of the keeper server is a total compromise of the protocol, and every fee/forfeit flow pays the admin.This PR adopts OpenZeppelin
stellar-access(AccessControl) and the OZ pausable module across all four in-scope contracts:process_period, blend sweeps)Two-step admin transfer comes along with
AccessControlon all four contracts, and every state-changing user-facing function is now pause-guarded while every read stays callable.A note on OZ tags, because it is the one line outside
contracts/: tags are per-package inOpenZeppelin/stellar-contracts. The standalonestellar-pausable/stellar-pausable-macroscrates only exist on thev0.3.0line, whose workspace pinssoroban-sdk = "22.0.6". These contracts are on thesoroban-sdk = "23.0.3"line, so that crate cannot be linked here — the twoEnvtypes are incompatible and it does not compile. On thev0.5.1line — the tagstellar-accessandstellar-macrosalready sit on — the pausable module ships insidestellar-contract-utils, which is pinned tosoroban-sdk 23.0.2and links cleanly. That is what this PR adopts; the workspace'sstellar-pausableentries are left declared and unused, and unifying every OZ dependency onto one tag stays #35's job.Related Issue
Closes #39
Changes
Workspace
[MODIFY]
Cargo.tomlstellar-contract-utilsat tagv0.5.1— the pausable module for thesoroban-sdk 23line, matching the tagstellar-access/stellar-macrosare already on.stellar-pausable/stellar-pausable-macros(v0.3.0,soroban-sdk 22) entries cannot be linked from these contracts. They are left declared exactly as before.[MODIFY]
Cargo.lockcargo testrun below: the three OZv0.5.1packages that are now referenced, plus their entries in the four crates' dependency lists. The diff againststagingis additive only — 38 lines added, 0 removed.locked_in[MODIFY]
contracts/locked_in/src/lib.rs__constructor(env, admin, fee_recipient: Option<Address>, usdc_token): the admin is registered with OZaccess_control::set_admin, and the fee recipient is stored only whenSome— it never falls back to the admin.set_apy_tier,set_pooland the newset_fee_recipientare admin-gated throughrequire_admin, which compares the explicitcalleragainstaccess_control::get_adminand authenticates — so a valid keeper key is rejected. Reading the live admin also means a completed two-step transfer takes effect immediately.deposit_to_blend/withdraw_from_blendare#[only_role(caller, "keeper")].lock/unlockcarry#[when_not_paused].get_lock,get_user_locks,blend_position,get_apy_tiers,get_apy_for_duration,pool,usdc_token,fee_recipientstay callable while paused.Pausable(pause/unpauseare admin-gated and never pause-blocked) and#[default_impl] AccessControlfor role grants and the two-step admin transfer.[MODIFY]
contracts/locked_in/src/types.rsDataKey::Admin(OZ now owns the admin) and addsDataKey::FeeRecipient, holdingOption<Address>.[MODIFY]
contracts/locked_in/src/test.rstest_fee_recipient_is_distinct_and_not_the_admin,test_fee_recipient_defaults_to_nothing_not_admintest_keeper_cannot_call_admin_functions,test_keeper_can_call_keeper_functions,test_unprivileged_caller_cannot_call_privileged_functionstest_paused_rejects_deposits_and_withdrawals_but_not_readstest_two_step_admin_transfertarget_savings[MODIFY]
contracts/target_savings/src/lib.rs__constructor(env, admin, fee_recipient: Option<Address>, usdc_token).set_pool,set_fee_recipient,set_yield_apyare admin-gated viarequire_admin;process_period,deposit_to_blendandwithdraw_from_blendare#[only_role(caller, "keeper")]—process_periodnow takes the caller, so the scheduled pull is signed by the keeper role instead of the admin.create_target,manual_deposit,withdrawcarry#[when_not_paused]; every read (get_target,get_user_goals,get_goal_yield,blend_position,pool,yield_apy_bps,fee_recipient) stays callable while paused.Pausableand#[default_impl] AccessControl.[MODIFY]
contracts/target_savings/src/types.rsDataKey::Adminand the bespokeKeeperentry; the admin, thekeeperrole and the optionalFeeRecipientnow come from OZAccessControl.[MODIFY]
contracts/target_savings/src/test.rslocked_in.test_early_withdrawal_charges_forfeitnow asserts the forfeit lands on the distinct fee recipient.spend_save[MODIFY]
contracts/spend_save/src/lib.rs__constructor(env, admin, fee_recipient: Option<Address>, usdc_token); the newset_fee_recipientis admin-gated.enroll,spend,withdrawcarry#[when_not_paused];get_position,is_withdrawal_day_now,current_day_utc,blend_position,fee_recipientstay callable while paused.deposit_to_blend/withdraw_from_blendmove from "admin signs" to#[only_role(caller, "keeper")].Pausableand#[default_impl] AccessControl.[MODIFY]
contracts/spend_save/src/types.rsDataKey::Adminand addsDataKey::FeeRecipient.[MODIFY]
contracts/spend_save/src/test.rsTS_28THwithdrawal path exercised while paused.mock_pool[MODIFY]
contracts/mock_pool/src/lib.rs__constructor(env, admin, fee_recipient: Option<Address>, usdc_token, apy_bps).set_apy,fund_reserveand the newset_fee_recipientare admin-gated viarequire_admin.supply/withdraw— the pool's only state-changing user-facing functions — carry#[when_not_paused];get_position,get_principal,apy_bps,reserve_balance,fee_recipientstay callable while paused.Pausableand#[default_impl] AccessControl.[MODIFY]
contracts/mock_pool/src/types.rsDataKey::Adminand addsDataKey::FeeRecipient.[MODIFY]
contracts/mock_pool/src/test.rsABI impact and follow-ups
The role model needs a caller on every privileged entrypoint, so this is a breaking change to four contract interfaces rather than a purely internal refactor:
fee_recipient: Option<Address>:__constructor(admin, fee_recipient, usdc_token[, apy_bps]).caller: Address:set_apy_tier,set_poolinlocked_in;set_pool,set_yield_apyintarget_savings;set_apy,fund_reserveinmock_pool.set_fee_recipientis new onlocked_in,spend_saveandmock_pool, and ontarget_savingsit changes fromset_fee_recipient(recipient)toset_fee_recipient(caller, Option<Address>).deposit_to_blend/withdraw_from_blendgained a leadingcaller: Address, and so didtarget_savings::process_period— the scheduled pull is now signed by the keeper role, so the keeper service must pass its own address.fee_recipient()now returnsOption<Address>, the per-contractadmin()getter is gone (OZget_admin()returnsOption<Address>), andtarget_savings'set_keeper/keeper()are replaced bygrant_role/has_roleon the"keeper"symbol.mock_poolgainsset_fee_recipient/fee_recipient; it had no fee-recipient concept before, and the issue requires one on all four.pause/unpause/paused, plus OZgrant_role,revoke_role,renounce_role,set_role_admin,has_role,get_admin,transfer_admin_role,accept_admin_transfer,renounce_admin.packages/*bindings and thekeeper/call sites are consequently stale and need regeneration plus a call-site update forprocess_period. They are deliberately untouched here: the issue scopes the change to the contracts and asks forpackages/to be left alone.Verification Results
locked_in,target_savings,spend_save,mock_pooluse OZAccessControl#[default_impl] #[contractimpl] impl AccessControl for <Contract> {}, and each constructor callsaccess_control::set_admin#[only_role(caller, "keeper")]on every scheduled entrypoint;test_keeper_cannot_call_admin_functions(locked_in / target_savings / spend_save) andtest_keeper_key_cannot_call_admin_functions(mock_pool) assert the keeper key is rejected from every admin functionfee_recipient: Option<Address>on every constructor, stored only whenSome;test_fee_recipient_defaults_to_nothing_not_adminassertsfee_recipient().is_none()andtest_fee_recipient_is_distinct_and_not_the_adminasserts it differs fromget_admin()transfer_admin_role/accept_admin_transfervia#[default_impl];test_two_step_admin_transferin all four modules proves the old admin keeps control until the nominee accepts and loses it afterwards#[when_not_paused]onlock/unlock,create_target/manual_deposit/withdraw,enroll/spend/withdraw,supply/withdraw; admin functions and every read stay open while pausedtest_paused_rejects_deposits_and_withdrawals_but_not_readsin all four modules, which also asserts reads still succeed while paused and that unpausing restores writestest_unprivileged_caller_cannot_call_privileged_functionsin all four modules: every admin function, every keeper function,grant_role,revoke_role,pause,unpause, and (with authorizations dropped)set_role_admin/renounce_admincargo test --workspacepassesCloses #39