Skip to content

feat: atomic governance-to-treasury withdrawal for passed Funding proposals - #84

Merged
xqcxx merged 9 commits into
Stellar-Re-Code:mainfrom
Green-Cycle-Plus:feat/governance-treasury-execution
Aug 27, 2026
Merged

xqcxx merged 9 commits into
Stellar-Re-Code:mainfrom
Green-Cycle-Plus:feat/governance-treasury-execution

Conversation

@Wilfred007

Copy link
Copy Markdown
Contributor

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_version is snapshotted and a 7-day exec_deadline is 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:

  • The caller is the registered governance contract
  • The treasury ID matches its own address
  • The asset matches its bound token
  • The policy version matches the current one
  • The authorization hasn't expired
  • The (governance, proposal_id) pair hasn't been executed before
  • Both tracked and real token balances cover the amount

If any check fails, no state changes on either side — the proposal stays Passed and can be retried (unless the deadline has passed).

Security

  • No off-chain approval: governance.require_auth() ensures only the governance contract itself can invoke the withdrawal — Soroban enforces this at the host level
  • Replay prevention: GovExecuted(governance, proposal_id) stored in treasury persistent storage; failed attempts don't consume it
  • Cross-contract misuse: treasury_id must equal env.current_contract_address(), so a payload for treasury A can't be replayed against treasury B
  • Stale policy: signer/threshold/governance changes bump policy_version, invalidating any snapshotted authorization
  • Real balance check: token_client.balance() is queried before transfer, not just the internal counter
  • Atomic execution: token transfer, balance deduction, and receipt write happen in one Soroban invocation — a panic rolls back everything

Changes

Treasury (treasury/src/lib.rs):

  • execute_governance_withdrawal() — cross-contract withdrawal entry point
  • set_governance() / get_governance() — register/lookup authorized governance contract
  • GovernanceWithdrawalReceipt — on-chain audit record per (governance, proposal_id)
  • New errors: GovernanceUnauthorized, AssetMismatch, TreasuryMismatch, AuthorizationExpired, AuthorizationReplayed

Governance (governance/src/lib.rs):

  • finalize() — snapshots policy_version and sets exec_deadline for passed Funding proposals
  • execute_proposal() — calls treasury.execute_governance_withdrawal() for Funding actions
  • Proposal struct: added policy_version: u32, exec_deadline: u64
  • FUNDING_EXEC_WINDOW constant (7 days)
  • get_treasury_config() helper for cross-contract config query

Tests

Test What it covers
test_funding_proposal_executes_atomic_treasury_withdrawal Happy path: tokens arrive, balance updates, proposal marked Executed, replay rejected
test_funding_proposal_stale_policy_after_signer_change_fails_atomically Policy change after passage blocks execution, no tokens move
test_funding_proposal_fails_when_governance_not_registered Unregistered governance contract rejected
test_funding_proposal_execution_deadline_enforced Expired authorization rejected
funding_proposal_execution_transfers_atomically (integration) End-to-end with real token client and receipt verification
funding_proposal_fails_when_governance_not_authorized (integration) Deny path: no tokens move, proposal stays Passed
funding_proposal_insufficient_treasury_balance_does_not_execute (integration) Failure atomicity + retry after funding the treasury
funding_proposal_replay_and_cross_contract_misuse_is_rejected (integration) Replay guard + cross-treasury misuse + per-treasury scoping

Closes #80

“Wilfred007” 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
Wilfred007 force-pushed the feat/governance-treasury-execution branch from d525ee2 to b01eb83 Compare August 24, 2026 02:46

@xqcxx xqcxx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@xqcxx
xqcxx merged commit f01ac31 into Stellar-Re-Code:main Aug 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Hard] Execute passed funding proposals through a policy-versioned treasury authorization path

2 participants