Repository navigation
feat: atomic governance-to-treasury withdrawal for passed Funding proposals - #84
Merged
xqcxx merged 9 commits intoAug 27, 2026
Conversation
added 9 commits
August 23, 2026 17:49
…rage Add error codes for governance-authorized withdrawals (GovernanceUnauthorized, AssetMismatch, TreasuryMismatch, AuthorizationExpired, AuthorizationReplayed), storage keys for the authorized governance address and replay guard, and the GovernanceWithdrawalReceipt audit struct.
…nance Add the cross-contract withdrawal path that lets the registered governance contract atomically transfer funds for a passed Funding proposal. The authorization payload is bound to proposal_id, treasury_id, destination, asset, amount, policy_version, and expiry. Every field is re-verified against live treasury state before the token transfer. Includes set_governance, get_governance, and get_governance_withdrawal query functions.
Cover atomic execution, replay rejection, unauthorized caller, wrong treasury ID, asset mismatch, expired authorization, stale policy, and insufficient funds with retry-after-fix. Each test verifies the replay guard is not consumed on failure.
…ruct Add TreasuryUnavailable, ExecutionExpired, and FundingExecutionFailed error codes. Extend Proposal with policy_version (snapshotted at finalize) and exec_deadline (7-day window) fields. Add FUNDING_EXEC_WINDOW constant and import TreasuryConfig from the treasury contract.
At finalize() time, snapshot the treasury's current policy_version and set a 7-day execution deadline for passed Funding proposals. At execute_proposal() time, call treasury.execute_governance_withdrawal() with the bound payload. If the treasury call fails, the proposal stays Passed and the error is propagated. Add get_treasury_config helper for cross-contract config query.
Test atomic treasury withdrawal, stale policy rejection after signer change, unregistered governance rejection, execution deadline enforcement, and replay resistance. Update existing test snapshots for new Proposal fields.
Add INV-X4 tests: atomic funding execution, governance-not-authorized denial, insufficient funds failure-atomicity with retry, and replay/cross-contract misuse rejection. Update existing integration test snapshots for new Proposal fields and renamed test functions.
…error Building multiple Soroban contract crates in a single cargo build with --target wasm32-unknown-unknown causes the linker to merge object files from all crates, producing duplicate symbol errors for shared exports like 'upgrade'. Build each contract crate in its own invocation.
soroban-sdk's #[contractimpl] exports every public method as a raw wasm symbol regardless of crate, so depending on another contract crate just to reuse its generated Client type pulled that crate's entire compiled object (exports included) into the dependent's wasm binary, colliding with the dependent's own same-named exports (initialize, upgrade, get_config) at link time. Replace those cross-crate Client imports with minimal local traits annotated with #[contractclient], which generates only the client-side invoke stubs without linking the callee's contract implementation. governance also gets a field-mirrored TreasuryConfig so it no longer needs to import treasury's contract crate at all.
Wilfred007
force-pushed
the
feat/governance-treasury-execution
branch
from
August 24, 2026 02:46
d525ee2 to
b01eb83
Compare
xqcxx
approved these changes
Aug 27, 2026
xqcxx
left a comment
Contributor
There was a problem hiding this comment.
Reviewed the governance-to-treasury authorization path. The caller, treasury, asset, expiry, policy-version, balance, and replay checks are all enforced before the atomic transfer.
Closed
4 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.
Problem
A passed Funding proposal in governance had no direct path to move treasury funds. The previous implementation queued a multi-sig withdrawal proposal inside the treasury, which still required signer approvals — meaning a DAO vote alone could never release funds.
Solution
Add a dedicated cross-contract execution path: when a Funding proposal passes and is executed, the governance contract calls
treasury.execute_governance_withdrawal()in a single atomic invocation that transfers real tokens to the recipient. No additional treasury signer approval step is involved.The authorization payload is bound to seven fields — proposal ID, treasury ID, destination, asset, amount, policy version, and expiry — all re-verified against live on-chain state at execution time.
How it works
At finalize time (governance): when a Funding proposal passes quorum, the treasury's current
policy_versionis snapshotted and a 7-dayexec_deadlineis set. Any treasury policy change after this point (signer add/remove, threshold change, governance re-registration) invalidates the authorization.At execute time (governance → treasury): the governance contract calls
execute_governance_withdrawal()with the snapshotted payload. The treasury verifies:(governance, proposal_id)pair hasn't been executed beforeIf any check fails, no state changes on either side — the proposal stays
Passedand can be retried (unless the deadline has passed).Security
governance.require_auth()ensures only the governance contract itself can invoke the withdrawal — Soroban enforces this at the host levelGovExecuted(governance, proposal_id)stored in treasury persistent storage; failed attempts don't consume ittreasury_idmust equalenv.current_contract_address(), so a payload for treasury A can't be replayed against treasury Bpolicy_version, invalidating any snapshotted authorizationtoken_client.balance()is queried before transfer, not just the internal counterChanges
Treasury (
treasury/src/lib.rs):execute_governance_withdrawal()— cross-contract withdrawal entry pointset_governance()/get_governance()— register/lookup authorized governance contractGovernanceWithdrawalReceipt— on-chain audit record per(governance, proposal_id)GovernanceUnauthorized,AssetMismatch,TreasuryMismatch,AuthorizationExpired,AuthorizationReplayedGovernance (
governance/src/lib.rs):finalize()— snapshotspolicy_versionand setsexec_deadlinefor passed Funding proposalsexecute_proposal()— callstreasury.execute_governance_withdrawal()for Funding actionsProposalstruct: addedpolicy_version: u32,exec_deadline: u64FUNDING_EXEC_WINDOWconstant (7 days)get_treasury_config()helper for cross-contract config queryTests
test_funding_proposal_executes_atomic_treasury_withdrawaltest_funding_proposal_stale_policy_after_signer_change_fails_atomicallytest_funding_proposal_fails_when_governance_not_registeredtest_funding_proposal_execution_deadline_enforcedfunding_proposal_execution_transfers_atomically(integration)funding_proposal_fails_when_governance_not_authorized(integration)funding_proposal_insufficient_treasury_balance_does_not_execute(integration)funding_proposal_replay_and_cross_contract_misuse_is_rejected(integration)Closes #80