From 87bf46cc690fdde570a9449034c1e6bb8385fe68 Mon Sep 17 00:00:00 2001 From: hyckomat Date: Mon, 14 Sep 2026 10:38:40 +0200 Subject: [PATCH 1/2] Add fuzz test --- .github/repo-guard.toml | 1 + .github/workflows/futarchy-fuzz.yaml | 191 ++ fuzz/README.md | 116 + fuzz/__init__.py | 1 + fuzz/futarchy/__init__.py | 1 + fuzz/futarchy/constants.py | 152 + fuzz/futarchy/gen_wake_idl.py | 46 + fuzz/futarchy/instructions/__init__.py | 6 + .../instructions/admin_cancel_proposal.py | 119 + ...dmin_enqueue_multisig_proposal_approval.py | 68 + ..._enqueue_multisig_proposal_cancellation.py | 69 + .../admin_execute_multisig_proposal.py | 100 + .../instructions/admin_remove_proposal.py | 81 + .../admin_update_proposal_params.py | 154 + fuzz/futarchy/instructions/base.py | 70 + fuzz/futarchy/instructions/collect_fees.py | 97 + .../futarchy/instructions/conditional_swap.py | 258 ++ .../execute_multisig_proposal_approval.py | 84 + .../execute_multisig_proposal_cancellation.py | 72 + .../instructions/execute_passed_payload.py | 46 + .../instructions/finalize_proposal.py | 190 ++ .../initialize_buyback_token_proposal.py | 110 + fuzz/futarchy/instructions/initialize_dao.py | 322 ++ .../initialize_hostile_liquidate_proposal.py | 47 + .../initialize_hostile_takeover_proposal.py | 115 + .../initialize_large_spend_proposal.py | 80 + .../initialize_mint_tokens_proposal.py | 62 + .../instructions/initialize_proposal.py | 125 + ...itialize_spending_limit_change_proposal.py | 63 + fuzz/futarchy/instructions/launch_proposal.py | 466 +++ .../instructions/provide_liquidity.py | 217 ++ fuzz/futarchy/instructions/registry.py | 118 + .../instructions/set_spending_limit.py | 94 + .../futarchy/instructions/sponsor_proposal.py | 118 + fuzz/futarchy/instructions/spot_swap.py | 213 ++ .../instructions/stake_to_proposal.py | 122 + .../instructions/sync_spending_limit.py | 56 + .../futarchy/instructions/typed_initialize.py | 166 ++ .../instructions/unstake_from_proposal.py | 132 + fuzz/futarchy/instructions/update_dao.py | 151 + .../instructions/withdraw_liquidity.py | 167 ++ fuzz/futarchy/invariants/__init__.py | 16 + fuzz/futarchy/invariants/dao.py | 60 + fuzz/futarchy/invariants/positions.py | 32 + fuzz/futarchy/invariants/proposals.py | 81 + fuzz/futarchy/invariants/squads.py | 59 + fuzz/futarchy/invariants/tokens.py | 110 + fuzz/futarchy/pytypes/__init__.py | 7 + fuzz/futarchy/pytypes/_manifest.json | 14 + fuzz/futarchy/pytypes/futarchy.py | 2626 +++++++++++++++++ fuzz/futarchy/test_fuzz.py | 643 ++++ fuzz/futarchy/utils/__init__.py | 1 + fuzz/futarchy/utils/accounts.py | 211 ++ fuzz/futarchy/utils/assertions.py | 164 + fuzz/futarchy/utils/builders.py | 40 + fuzz/futarchy/utils/harness.py | 195 ++ fuzz/futarchy/utils/markets.py | 267 ++ fuzz/futarchy/utils/oracle.py | 95 + fuzz/futarchy/utils/parameters.py | 210 ++ fuzz/futarchy/utils/payloads.py | 130 + fuzz/futarchy/utils/proposals.py | 134 + fuzz/futarchy/utils/settlement.py | 70 + fuzz/futarchy/utils/setup.py | 248 ++ fuzz/futarchy/utils/squads.py | 706 +++++ fuzz/futarchy/utils/state.py | 76 + fuzz/futarchy/utils/swaps.py | 50 + fuzz/futarchy/utils/tokens.py | 104 + 67 files changed, 11215 insertions(+) create mode 100644 .github/workflows/futarchy-fuzz.yaml create mode 100644 fuzz/README.md create mode 100644 fuzz/__init__.py create mode 100644 fuzz/futarchy/__init__.py create mode 100644 fuzz/futarchy/constants.py create mode 100644 fuzz/futarchy/gen_wake_idl.py create mode 100644 fuzz/futarchy/instructions/__init__.py create mode 100644 fuzz/futarchy/instructions/admin_cancel_proposal.py create mode 100644 fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_approval.py create mode 100644 fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_cancellation.py create mode 100644 fuzz/futarchy/instructions/admin_execute_multisig_proposal.py create mode 100644 fuzz/futarchy/instructions/admin_remove_proposal.py create mode 100644 fuzz/futarchy/instructions/admin_update_proposal_params.py create mode 100644 fuzz/futarchy/instructions/base.py create mode 100644 fuzz/futarchy/instructions/collect_fees.py create mode 100644 fuzz/futarchy/instructions/conditional_swap.py create mode 100644 fuzz/futarchy/instructions/execute_multisig_proposal_approval.py create mode 100644 fuzz/futarchy/instructions/execute_multisig_proposal_cancellation.py create mode 100644 fuzz/futarchy/instructions/execute_passed_payload.py create mode 100644 fuzz/futarchy/instructions/finalize_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_buyback_token_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_dao.py create mode 100644 fuzz/futarchy/instructions/initialize_hostile_liquidate_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_hostile_takeover_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_large_spend_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_mint_tokens_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_proposal.py create mode 100644 fuzz/futarchy/instructions/initialize_spending_limit_change_proposal.py create mode 100644 fuzz/futarchy/instructions/launch_proposal.py create mode 100644 fuzz/futarchy/instructions/provide_liquidity.py create mode 100644 fuzz/futarchy/instructions/registry.py create mode 100644 fuzz/futarchy/instructions/set_spending_limit.py create mode 100644 fuzz/futarchy/instructions/sponsor_proposal.py create mode 100644 fuzz/futarchy/instructions/spot_swap.py create mode 100644 fuzz/futarchy/instructions/stake_to_proposal.py create mode 100644 fuzz/futarchy/instructions/sync_spending_limit.py create mode 100644 fuzz/futarchy/instructions/typed_initialize.py create mode 100644 fuzz/futarchy/instructions/unstake_from_proposal.py create mode 100644 fuzz/futarchy/instructions/update_dao.py create mode 100644 fuzz/futarchy/instructions/withdraw_liquidity.py create mode 100644 fuzz/futarchy/invariants/__init__.py create mode 100644 fuzz/futarchy/invariants/dao.py create mode 100644 fuzz/futarchy/invariants/positions.py create mode 100644 fuzz/futarchy/invariants/proposals.py create mode 100644 fuzz/futarchy/invariants/squads.py create mode 100644 fuzz/futarchy/invariants/tokens.py create mode 100644 fuzz/futarchy/pytypes/__init__.py create mode 100644 fuzz/futarchy/pytypes/_manifest.json create mode 100644 fuzz/futarchy/pytypes/futarchy.py create mode 100644 fuzz/futarchy/test_fuzz.py create mode 100644 fuzz/futarchy/utils/__init__.py create mode 100644 fuzz/futarchy/utils/accounts.py create mode 100644 fuzz/futarchy/utils/assertions.py create mode 100644 fuzz/futarchy/utils/builders.py create mode 100644 fuzz/futarchy/utils/harness.py create mode 100644 fuzz/futarchy/utils/markets.py create mode 100644 fuzz/futarchy/utils/oracle.py create mode 100644 fuzz/futarchy/utils/parameters.py create mode 100644 fuzz/futarchy/utils/payloads.py create mode 100644 fuzz/futarchy/utils/proposals.py create mode 100644 fuzz/futarchy/utils/settlement.py create mode 100644 fuzz/futarchy/utils/setup.py create mode 100644 fuzz/futarchy/utils/squads.py create mode 100644 fuzz/futarchy/utils/state.py create mode 100644 fuzz/futarchy/utils/swaps.py create mode 100644 fuzz/futarchy/utils/tokens.py diff --git a/.github/repo-guard.toml b/.github/repo-guard.toml index 2b27009c..e09d00a1 100644 --- a/.github/repo-guard.toml +++ b/.github/repo-guard.toml @@ -33,6 +33,7 @@ local_dev_solana_version = "1.17.34" [toolchain.workflow_solana_cli] ".github/workflows/anchor-test.yaml" = "1.17.31" ".github/workflows/generate-verifiable-builds.yaml" = "1.17.31" +".github/workflows/futarchy-fuzz.yaml" = "1.17.34" ".github/workflows/deploy-buffer.yaml" = "1.17.16" ".github/workflows/verify-build.yaml" = "1.17.16" diff --git a/.github/workflows/futarchy-fuzz.yaml b/.github/workflows/futarchy-fuzz.yaml new file mode 100644 index 00000000..edb6eeac --- /dev/null +++ b/.github/workflows/futarchy-fuzz.yaml @@ -0,0 +1,191 @@ +name: Futarchy fuzz + +on: + workflow_dispatch: + inputs: + sequences: + description: Number of independent fuzz sequences per worker + required: true + default: "500" + type: string + flows: + description: Number of flows executed in each sequence + required: true + default: "500" + type: string + seed: + description: Optional base seed for reproducing a previous run + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: futarchy-fuzz-${{ github.ref }} + cancel-in-progress: false + +jobs: + fuzz: + runs-on: ubuntu-22.04 + timeout-minutes: 300 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Prepare run artifact + shell: bash + env: + FUZZ_SEQUENCES: ${{ inputs.sequences }} + FUZZ_FLOWS: ${{ inputs.flows }} + FUZZ_SEED: ${{ inputs.seed }} + run: | + mkdir -p fuzz-artifacts + { + echo "commit=${GITHUB_SHA}" + echo "sequences=${FUZZ_SEQUENCES}" + echo "flows=${FUZZ_FLOWS}" + if [ -n "$FUZZ_SEED" ]; then + echo "seed=${FUZZ_SEED}" + else + echo "seed=" + fi + } > fuzz-artifacts/run-manifest.txt + + - name: Validate campaign inputs + shell: bash + env: + FUZZ_SEQUENCES: ${{ inputs.sequences }} + FUZZ_FLOWS: ${{ inputs.flows }} + run: | + if ! [[ "$FUZZ_SEQUENCES" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::sequences must be a positive integer" + exit 1 + fi + if ! [[ "$FUZZ_FLOWS" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::flows must be a positive integer" + exit 1 + fi + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + build-essential \ + libssl-dev \ + libudev-dev \ + pkg-config \ + python3-dev + + - name: Install Node.js, Solana, and Anchor + uses: metadaoproject/setup-anchor@aeeb5505f3fd3d52a1080d14863c6aa428c9fdf9 # v3.4 + with: + node-version: "20.18.0" + solana-cli-version: "1.17.34" + anchor-version: "0.29.0" + + - name: Install Rust toolchain + run: | + rustup toolchain install 1.89.0 --profile minimal + rustup default 1.89.0 + + - name: Restore build cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.cache/uv/ + target/ + ${{ runner.temp }}/wake-sol/target/ + key: futarchy-fuzz-build-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + futarchy-fuzz-build-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}- + futarchy-fuzz-build-${{ runner.os }}- + + - name: Build Wake.sol + run: | + python3 -m pip install --user "uv==0.8.14" + + wake_dir="${RUNNER_TEMP}/wake-sol" + git init "$wake_dir" + git -C "$wake_dir" remote add origin https://github.com/ack3-ai/wake.sol.git + git -C "$wake_dir" fetch --depth 1 origin 096d5bfed511d0f0a3e27c960468a756a6c6440b + git -C "$wake_dir" checkout --detach FETCH_HEAD + test "$(git -C "$wake_dir" rev-parse HEAD)" = \ + "096d5bfed511d0f0a3e27c960468a756a6c6440b" + + cd "$wake_dir" + "$HOME/.local/bin/uv" sync --frozen --no-install-project + "$HOME/.local/bin/uv" pip install "maturin[patchelf]==1.9.4" + "$HOME/.local/bin/uv" run --no-sync maturin develop --release --locked + .venv/bin/python -c "import wake_sol" + + - name: Build Futarchy + run: | + anchor build -p futarchy -- -- --locked + test -s target/deploy/futarchy.so + test -s verifiable-builds/conditional_vault.so + test -s tests/fixtures/squads_multisig.so + test -s tests/fixtures/squads-program-config + + - name: Verify generated bindings + run: | + wake_python="${RUNNER_TEMP}/wake-sol/.venv/bin/python" + wake_cli="${RUNNER_TEMP}/wake-sol/.venv/bin/wake-sol" + "$wake_python" fuzz/futarchy/gen_wake_idl.py + "$wake_cli" gen \ + --target-idl target/wake-idl \ + --out fuzz/futarchy/pytypes \ + --only FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq \ + --check \ + --strict + "$wake_python" -c "import fuzz.futarchy.pytypes.futarchy" + + - name: Run fuzz campaign + shell: bash + env: + FUZZ_SEQUENCES: ${{ inputs.sequences }} + FUZZ_FLOWS: ${{ inputs.flows }} + FUZZ_SEED: ${{ inputs.seed }} + run: | + wake_python="${RUNNER_TEMP}/wake-sol/.venv/bin/python" + wake_cli="${RUNNER_TEMP}/wake-sol/.venv/bin/wake-sol" + if [ -n "$FUZZ_SEED" ]; then + test_args=( + "$wake_python" -m pytest -q -s + --seed "$FUZZ_SEED" + fuzz/futarchy/test_fuzz.py + ) + echo "mode=single-process seed replay" >> fuzz-artifacts/run-manifest.txt + else + test_args=( + "$wake_cli" test -P -q + fuzz/futarchy/test_fuzz.py + ) + echo "mode=parallel, one worker per CPU" >> fuzz-artifacts/run-manifest.txt + fi + + set +e + "${test_args[@]}" 2>&1 | tee fuzz-artifacts/fuzz.log + fuzz_status=${PIPESTATUS[0]} + set -e + + if [ -d .wake-sol/logs ]; then + mkdir -p fuzz-artifacts/wake-logs + cp -R .wake-sol/logs/. fuzz-artifacts/wake-logs/ + fi + echo "exit_status=${fuzz_status}" >> fuzz-artifacts/run-manifest.txt + exit "$fuzz_status" + + - name: Upload fuzz results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: futarchy-fuzz-${{ github.run_id }}-${{ github.run_attempt }} + path: fuzz-artifacts/ + if-no-files-found: error + retention-days: 14 diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 00000000..b3bd4b54 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,116 @@ +# Futarchy Fuzz Test + +This directory contains the Wake.sol stateful fuzz test for the Futarchy +program at commit `05f8a5c8efc22f4cf157e313d6d768475526a004`. It has 29 +instruction-level happy paths, 29 unhappy paths, two support flows, and twelve +global invariants. Each instruction wrapper also checks its +own postconditions or atomic rollback behavior. + +## Running the test + +1. Install [Wake.sol](https://github.com/ack3-ai/wake.sol) in a Python virtual + environment at commit + `096d5bfed511d0f0a3e27c960468a756a6c6440b`. + +2. Activate the environment. + +3. From this repository's root, build the Futarchy program: + +```bash +anchor build -p futarchy +``` + +4. Create Wake's compatible IDL and regenerate the checked-in Python bindings: + +```bash +python fuzz/futarchy/gen_wake_idl.py +wake-sol gen \ + --target-idl target/wake-idl \ + --out fuzz/futarchy/pytypes \ + --only FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq \ + --strict +``` + +5. Ensure the built Futarchy artifact and checked-in dependencies are present: + +```text +target/deploy/futarchy.so +verifiable-builds/conditional_vault.so +tests/fixtures/squads_multisig.so +tests/fixtures/squads-program-config +``` + +6. Run the default 100-sequence, 500-flow campaign: + +```bash +python -m pytest -q -s fuzz/futarchy/test_fuzz.py +``` + +Use `FUTARCHY_FUZZ_SEQUENCES` and `FUTARCHY_FUZZ_FLOWS` to change the number of +sequences and flows for local or pipeline runs. + +The test prints a base seed. Reproduce that run with: + +```bash +BASE_SEED="" +python -m pytest -q -s --seed "$BASE_SEED" fuzz/futarchy/test_fuzz.py +``` + +Failures and their reproducible crash data are written under +`.wake-sol/logs/crashes/`. + +### GitHub Actions + +The `Futarchy fuzz` workflow runs only when manually started from the repository's +Actions page. Choose **Run workflow** and set the sequence and flow counts. Leave +the seed empty for a new campaign, or enter a previously printed base seed to +reproduce a run. + +For a new campaign, the workflow starts one worker per available CPU. The +default is 500 sequences of 500 flows per worker, and both values can be changed +when starting the workflow. Providing a seed runs an exact single-process replay +instead. The workflow has a five-hour timeout and uploads the run log, manifest, +and any Wake crash data as a +`futarchy-fuzz--` artifact retained for 14 days. + +## Flows + +Every instruction name below has a `_happy` flow for a valid transition +and a `_unhappy` flow for a small expected-failure case. + +| Area | Instruction flows | +| --- | --- | +| DAO creation | `initialize_dao` | +| Proposal creation | `initialize_proposal`, `initialize_large_spend_proposal`, `initialize_mint_tokens_proposal`, `initialize_spending_limit_change_proposal`, `initialize_hostile_takeover_proposal`, `initialize_hostile_liquidate_proposal`, `initialize_buyback_token_proposal` | +| Proposal lifecycle | `stake_to_proposal`, `unstake_from_proposal`, `sponsor_proposal`, `launch_proposal`, `finalize_proposal` | +| AMM | `provide_liquidity`, `withdraw_liquidity`, `spot_swap`, `conditional_swap`, `collect_fees` | +| DAO configuration | `update_dao`, `set_spending_limit`, `sync_spending_limit` | +| Squads administration | `admin_enqueue_multisig_proposal_approval`, `execute_multisig_proposal_approval`, `admin_enqueue_multisig_proposal_cancellation`, `execute_multisig_proposal_cancellation`, `admin_execute_multisig_proposal` | +| Proposal administration | `admin_cancel_proposal`, `admin_remove_proposal`, `admin_update_proposal_params` | + +Two additional flows support those instruction flows: + +- `advance_clock` moves the deterministic clock across proposal and TWAP + boundaries. +- `execute_passed_proposal_payload` executes approved proposal payloads through + Squads. + +Happy flows check their instruction's account and token transitions. Unhappy +flows require the intended error and verify that writable accounts roll back. + +## Global invariants + +| Invariant | Property checked after every flow | +| --- | --- | +| `dao_identity_is_canonical` | The DAO PDA, owner, mints, Squads accounts, and AMM vaults remain canonical. | +| `dao_configuration_stays_valid` | Mutable DAO configuration remains inside the program's valid bounds. | +| `underlying_token_supplies_are_conserved` | All base and quote tokens remain accounted for. | +| `spot_reserves_and_fees_are_fully_backed` | Spot vault balances equal reserves plus protocol fees. | +| `conditional_reserves_and_fees_are_fully_backed` | In Futarchy state, each outcome's spot-plus-conditional reserves and fees equal underlying vault tokens plus that outcome's vault tokens. | +| `conditional_token_supplies_are_backed` | Both conditional-token supplies are accounted for and collateral covers unresolved or resolved claims. | +| `positions_sum_to_total_liquidity` | Canonical AMM positions sum to total liquidity. | +| `proposal_account_graphs_are_canonical` | Proposal, market, vault, and conditional-mint links remain canonical. | +| `proposal_stake_custody_is_conserved` | Stake records equal proposal custody balances. | +| `enqueued_approvals_are_canonical` | Live Squads approval records have the correct PDA and transaction index. | +| `enqueued_cancellations_are_canonical` | Live Squads cancellation records have the correct PDA and transaction index. | +| `cancelled_transactions_never_execute` | Cancelled Squads proposals remain cancelled and never execute. | diff --git a/fuzz/__init__.py b/fuzz/__init__.py new file mode 100644 index 00000000..ba682b6c --- /dev/null +++ b/fuzz/__init__.py @@ -0,0 +1 @@ +"""Wake.sol fuzz harness packages for workspace programs.""" diff --git a/fuzz/futarchy/__init__.py b/fuzz/futarchy/__init__.py new file mode 100644 index 00000000..70090bc5 --- /dev/null +++ b/fuzz/futarchy/__init__.py @@ -0,0 +1 @@ +"""Wake.sol fuzz harness for Futarchy.""" diff --git a/fuzz/futarchy/constants.py b/fuzz/futarchy/constants.py new file mode 100644 index 00000000..a4277489 --- /dev/null +++ b/fuzz/futarchy/constants.py @@ -0,0 +1,152 @@ +"""Addresses and values used by the Futarchy fuzz harness.""" + +import os +from pathlib import Path + +from wake_sol import Pubkey + + +# Program artifacts +WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +FUTARCHY_SO = WORKSPACE_ROOT / "target" / "deploy" / "futarchy.so" +CONDITIONAL_VAULT_SO = ( + WORKSPACE_ROOT / "verifiable-builds" / "conditional_vault.so" +) +SQUADS_SO = WORKSPACE_ROOT / "tests" / "fixtures" / "squads_multisig.so" +SQUADS_PROGRAM_CONFIG_DATA = ( + WORKSPACE_ROOT / "tests" / "fixtures" / "squads-program-config" +) + + +# Program addresses +FUTARCHY_PROGRAM_ID = Pubkey( + "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" +) +CONDITIONAL_VAULT_PROGRAM_ID = Pubkey( + "VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg" +) +SQUADS_PROGRAM_ID = Pubkey( + "SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf" +) +SQUADS_PROGRAM_CONFIG = Pubkey( + "BSTq9w3kZwNwpBXJEvTZz2G9ZTNyKBvoSeXMvwb4cNZr" +) +SQUADS_PROGRAM_CONFIG_TREASURY = Pubkey( + "5DH2e3cJmFpyi6mk65EGFediunm4ui6BiKNUNrhWtD1b" +) +SQUADS_PERMISSIONLESS_MEMBER = Pubkey( + "EP3SoC2SvR3d4c2eXVBvhEMWSr2j3YtoCY3UMiQV7BPD" +) +COMPUTE_BUDGET_PROGRAM_ID = Pubkey( + "ComputeBudget111111111111111111111111111111" +) +SPL_MEMO_PROGRAM_ID = Pubkey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" +) +METADAO_MULTISIG_VAULT = Pubkey( + "6awyHMshBGVjJ3ozdSJdyyDE1CTAXUwrpNMaRGMsb4sf" +) + + +# Public external-program test identity +PERMISSIONLESS_ACCOUNT_SECRET = bytes( + [ + 249, 158, 188, 171, 243, 143, 1, 48, + 87, 243, 209, 153, 144, 106, 23, 88, + 161, 209, 65, 217, 199, 121, 0, 250, + 3, 203, 133, 138, 141, 112, 243, 38, + 198, 205, 120, 222, 160, 224, 151, 190, + 84, 254, 127, 178, 224, 195, 130, 243, + 145, 73, 20, 91, 9, 69, 222, 184, + 23, 1, 2, 196, 202, 206, 153, 192, + ] +) + + +# Canonical PDA seeds from Futarchy, Conditional Vault, and Squads +DAO_SEED = b"dao" +AMM_POSITION_SEED = b"amm_position" +PROPOSAL_SEED = b"proposal" +STAKE_SEED = b"stake" +EVENT_AUTHORITY_SEED = b"__event_authority" +ENQUEUED_APPROVAL_SEED = b"enqueued_approval" +ENQUEUED_CANCELLATION_SEED = b"enqueued_cancellation" +QUESTION_SEED = b"question" +CONDITIONAL_VAULT_SEED = b"conditional_vault" +CONDITIONAL_TOKEN_SEED = b"conditional_token" +SQUADS_PREFIX_SEED = b"multisig" +SQUADS_MULTISIG_SEED = b"multisig" +SQUADS_TRANSACTION_SEED = b"transaction" +SQUADS_PROPOSAL_SEED = b"proposal" +SQUADS_VAULT_SEED = b"vault" +SQUADS_SPENDING_LIMIT_SEED = b"spending_limit" + + +# Futarchy protocol constants +MIN_QUOTE_LIQUIDITY = 100_000 +MIN_PROPOSAL_DURATION_SECONDS = 24 * 60 * 60 +MAX_PASS_THRESHOLD_BPS = 1_000 +MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS = -1_000 +MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS = 1_000 +MIN_PROPOSAL_PASS_THRESHOLD_BPS = -9_999 +MAX_PROPOSAL_PASS_THRESHOLD_BPS = 9_999 +MAX_SPENDING_LIMIT_MEMBERS = 10 +PRICE_SCALE = 1_000_000_000_000 +TWAP_UPDATE_INTERVAL_SECONDS = 60 +MAX_BPS = 10_000 +INITIAL_LIQUIDITY_SCALE = 1_000_000_000 +MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS = 5 +EXECUTE_ARBITRARY_DURATION_SECONDS = 10 * 24 * 60 * 60 +BINARY_QUESTION_OUTCOMES = 2 +MIN_BUYBACK_CYCLE_COUNT = 2 +MIN_BUYBACK_CYCLE_SECONDS = 60 +MAX_BUYBACK_CYCLE_SECONDS = 365 * 24 * 60 * 60 +MAX_BUYBACK_START_DELAY_SECONDS = 30 * 24 * 60 * 60 +U32_MAX = 2**32 - 1 +U64_MAX = 2**64 - 1 +U128_MAX = 2**128 - 1 + + +# v0.8 launchpad production-style baseline +TOKEN_DECIMALS = 6 +TOKEN_SCALE = 10**TOKEN_DECIMALS +V08_APPROVED_RAISE = 500_000 * TOKEN_SCALE +V08_PARTICIPANT_TOKENS = 10_000_000 * TOKEN_SCALE +V08_LAUNCH_PRICE = ( + V08_APPROVED_RAISE * PRICE_SCALE // V08_PARTICIPANT_TOKENS +) +V08_TWAP_MAX_CHANGE = V08_LAUNCH_PRICE // 20 +V08_TWAP_START_DELAY_SECONDS = 24 * 60 * 60 +V08_SECONDS_PER_PROPOSAL = 3 * 24 * 60 * 60 +V08_MIN_QUOTE_FUTARCHIC_LIQUIDITY = 1 +V08_MIN_BASE_FUTARCHIC_LIQUIDITY = 1 +V08_BASE_TO_STAKE = 1_500_000 * TOKEN_SCALE +V08_PASS_THRESHOLD_BPS = 300 +V08_TEAM_SPONSORED_PASS_THRESHOLD_BPS = -300 +V08_MONTHLY_SPENDING_LIMIT = 25_000 * TOKEN_SCALE +V08_DAO_NONCE = 0 + + +# Synthetic deterministic fuzz-environment values +ACTOR_COUNT = 4 +BASE_TIMESTAMP = 1_700_000_000 +BASE_SLOT = 10_000 +ACTOR_LAMPORTS = 10_000_000_000 +PAYER_LAMPORTS = 100_000_000_000 +INITIAL_ACTOR_BASE_BALANCE = 2_000_000 * TOKEN_SCALE +INITIAL_ACTOR_QUOTE_BALANCE = 250_000 * TOKEN_SCALE +TRANSACTION_COMPUTE_UNIT_LIMIT = 400_000 +SQUADS_INVALID_PROPOSAL_STATUS_ERROR_CODE = 6008 +MARKET_TRADER_BASE_FUNDING = 1_000 * TOKEN_SCALE +MARKET_TRADER_QUOTE_FUNDING = 1_000 * TOKEN_SCALE + + +# SPL Token ABI sizes used only by independent packed-account readers +MINT_ACCOUNT_SIZE = 82 +TOKEN_ACCOUNT_SIZE = 165 + + +# Synthetic fuzz campaign size +SEQUENCES_COUNT = int(os.environ.get("FUTARCHY_FUZZ_SEQUENCES", "100")) +FLOWS_COUNT = int(os.environ.get("FUTARCHY_FUZZ_FLOWS", "500")) +LIQUIDATION_FLOW_FRACTION = 0.60 diff --git a/fuzz/futarchy/gen_wake_idl.py b/fuzz/futarchy/gen_wake_idl.py new file mode 100644 index 00000000..7fd01c58 --- /dev/null +++ b/fuzz/futarchy/gen_wake_idl.py @@ -0,0 +1,46 @@ +"""Create a Wake-only Anchor 0.29 IDL with correct ix discriminators.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re + + +WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +SOURCE_IDL = WORKSPACE_ROOT / "target" / "idl" / "futarchy.json" +OUTPUT_ROOT = WORKSPACE_ROOT / "target" / "wake-idl" +PROGRAM_ADDRESS = "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" + + +def _snake_case(name: str) -> str: + """Convert a legacy Anchor IDL camelCase instruction name to Rust form.""" + return re.sub(r"(? list[int]: + """Compute Anchor's first-eight-byte global instruction discriminator.""" + rust_name = _snake_case(name) + digest = hashlib.sha256(f"global:{rust_name}".encode()).digest()[:8] + return list(digest) + + +def generate() -> None: + """Copy the build IDL and add only Wake-required compatibility fields.""" + if not SOURCE_IDL.exists(): + raise FileNotFoundError(f"missing build IDL: {SOURCE_IDL}") + + idl = json.loads(SOURCE_IDL.read_text()) + for instruction in idl.get("instructions", []): + instruction["discriminator"] = _instruction_discriminator( + instruction["name"] + ) + + OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) + destination = OUTPUT_ROOT / f"{PROGRAM_ADDRESS}.json" + destination.write_text(json.dumps(idl, indent=2) + "\n") + + +if __name__ == "__main__": + generate() diff --git a/fuzz/futarchy/instructions/__init__.py b/fuzz/futarchy/instructions/__init__.py new file mode 100644 index 00000000..b0a552bd --- /dev/null +++ b/fuzz/futarchy/instructions/__init__.py @@ -0,0 +1,6 @@ +"""Readable instruction wrappers used by Futarchy fuzz-flow scenarios.""" + +from .registry import FutarchyInstructions + + +__all__ = ["FutarchyInstructions"] diff --git a/fuzz/futarchy/instructions/admin_cancel_proposal.py b/fuzz/futarchy/instructions/admin_cancel_proposal.py new file mode 100644 index 00000000..155ccc3a --- /dev/null +++ b/fuzz/futarchy/instructions/admin_cancel_proposal.py @@ -0,0 +1,119 @@ +"""Snapshot wrapper for Futarchy ``admin_cancel_proposal``.""" + +from __future__ import annotations + +from wake_sol import Account, random + +from .base import InstructionWrapper +from ..constants import ( + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + SQUADS_PROGRAM_ID, +) +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + ProposalAction, +) +from ..utils.state import ProposalAccounts +from ..utils.assertions import assert_changed_only +from ..utils.settlement import assert_market_settled +from ..utils.tokens import create_ata + + +class AdminCancelProposalInstruction(InstructionWrapper): + """Check council cancellation resolves Fail and restores the spot pool.""" + + def cancellable(self) -> list[ProposalAccounts]: + """Keep the terminal liquidation lane alive while testing cancellation.""" + return [ + proposal + for proposal in self.context.pending_proposals() + if not isinstance( + self.context.proposal_state(proposal).action, + ProposalAction.HostileLiquidate, + ) + ] + + def ensure_existing_market_atas(self, accounts: ProposalAccounts) -> None: + """Create accounts that launch normally creates before cancellation.""" + context = self.context + context.market_support.ensure_launch_accounts(accounts) + for field, mint in ( + ("amm_pass_base_vault", accounts.pass_base_mint), + ("amm_pass_quote_vault", accounts.pass_quote_mint), + ("amm_fail_base_vault", accounts.fail_base_mint), + ("amm_fail_quote_vault", accounts.fail_quote_mint), + ): + account = getattr(accounts, field) + if not account.exists: + setattr(accounts, field, create_ata(context.payer, context.dao, mint)) + + def build_instruction( + self, accounts: ProposalAccounts, admin: Account | None = None + ): + context = self.context + return FutarchyProgram.adminCancelProposal( + proposal=accounts.proposal, + dao=context.dao, + question=accounts.question, + squadsProposal=accounts.squads.proposal, + squadsMultisig=context.squads_multisig, + squadsMultisigProgram=SQUADS_PROGRAM_ID, + ammPassBaseVault=accounts.amm_pass_base_vault, + ammPassQuoteVault=accounts.amm_pass_quote_vault, + ammFailBaseVault=accounts.amm_fail_base_vault, + ammFailQuoteVault=accounts.amm_fail_quote_vault, + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + vaultProgram=CONDITIONAL_VAULT_PROGRAM_ID, + vaultEventAuthority=context.vault_event_authority, + quoteVault=accounts.quote_vault, + quoteVaultUnderlyingTokenAccount=accounts.quote_vault_underlying, + passQuoteMint=accounts.pass_quote_mint, + failQuoteMint=accounts.fail_quote_mint, + passBaseMint=accounts.pass_base_mint, + failBaseMint=accounts.fail_base_mint, + baseVault=accounts.base_vault, + baseVaultUnderlyingTokenAccount=accounts.base_vault_underlying, + admin=admin or context.proposal_admin, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and bool(self.cancellable()) + + def happy(self) -> None: + """Cancel one live blockable proposal and validate Fail resolution.""" + context = self.context + accounts = random.choice(self.cancellable()) + self.ensure_existing_market_atas(accounts) + before_dao = context.dao_state() + instruction = self.build_instruction(accounts) + before = self.snapshot(instruction) + context.proposal_admin.tx(self.compute_unit_limit(), instruction) + after_dao = context.dao_state() + assert_market_settled(context, accounts, before, False) + assert_changed_only( + before_dao, after_dao, amm=after_dao.amm, seqNum=before_dao.seqNum + 1 + ) + context.squads.assert_proposal_status(accounts.squads, "Rejected") + accounts.squads.rejected = True + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() and bool( + self.context.draft_proposals() + ) + + def unhappy(self) -> None: + """Reject cancelling a Draft and prove every CPI account is unchanged.""" + context = self.context + accounts = random.choice(context.draft_proposals()) + self.ensure_existing_market_atas(accounts) + instruction = self.build_instruction(accounts) + self.assert_fails_atomically( + context.proposal_admin, + instruction, + FutarchyProgram.ProposalNotActive, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_approval.py b/fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_approval.py new file mode 100644 index 00000000..f42bbb0f --- /dev/null +++ b/fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_approval.py @@ -0,0 +1,68 @@ +"""Wrapper for ``admin_enqueue_multisig_proposal_approval``.""" + +from __future__ import annotations + +from wake_sol import random + +from .base import InstructionWrapper +from ..pytypes.futarchy import ( + EnqueuedMultisigProposalApproval, + Futarchy as FutarchyProgram, +) +from ..utils.builders import token_transfer_instruction + + +class AdminEnqueueMultisigProposalApprovalInstruction(InstructionWrapper): + """Check the admin/liquidator gate and canonical temporary PDA contents.""" + + def new_transaction(self, purpose: str): + context = self.context + recipient = random.choice(context.actors) + amount = min(1, context.council_quote_balance()) + payload = token_transfer_instruction( + context.council_quote_account, + context.quote_atas_by_owner[recipient.pubkey], + context.council, + amount, + ) + return context.squads.prepare( + payload, + purpose=purpose, + allow_admin_execute=True, + ) + + def can_happy(self) -> bool: + return self.context.is_spot() + + def happy(self) -> None: + """Enqueue a fresh active Squads proposal with the current authority.""" + context = self.context + prepared = self.new_transaction("enqueue approval flow") + admin = context.enqueue_authority() + instruction, enqueued = context.squads.build_enqueue(prepared, admin) + assert not enqueued.exists + admin.tx(instruction) + decoded = EnqueuedMultisigProposalApproval.decode(enqueued.data) + assert decoded.dao == context.dao.pubkey + assert decoded.transactionIndex == prepared.index + prepared.enqueued_approval = enqueued + + def can_unhappy(self) -> bool: + return True + + def unhappy(self) -> None: + """Reject an enqueue in Futarchy state or with invalid authority/state.""" + context = self.context + prepared = self.new_transaction("invalid enqueue flow") + if not context.is_spot(): + admin = context.ops_admin + expected = FutarchyProgram.PoolNotInSpotState + elif context.is_liquidated(): + admin = context.ops_admin + expected = FutarchyProgram.InvalidLiquidator + else: + context.squads.approve_for_dependency(prepared) + admin = context.ops_admin + expected = FutarchyProgram.InvalidSquadsProposalStatus + instruction, _ = context.squads.build_enqueue(prepared, admin) + self.assert_fails_atomically(admin, instruction, expected) diff --git a/fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_cancellation.py b/fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_cancellation.py new file mode 100644 index 00000000..35af2095 --- /dev/null +++ b/fuzz/futarchy/instructions/admin_enqueue_multisig_proposal_cancellation.py @@ -0,0 +1,69 @@ +"""Wrapper for ``admin_enqueue_multisig_proposal_cancellation``.""" + +from __future__ import annotations + +from wake_sol import random + +from .base import InstructionWrapper +from ..pytypes.futarchy import ( + EnqueuedMultisigProposalCancellation, + Futarchy as FutarchyProgram, +) +from ..utils.builders import memo_instruction + + +class AdminEnqueueMultisigProposalCancellationInstruction(InstructionWrapper): + """Check the authority gate and canonical one-shot cancellation PDA.""" + + def candidates(self): + return [ + transaction + for transaction in self.context.squads_transactions + if transaction.approved + and not transaction.executed + and not transaction.cancelled + and transaction.purpose != "hostile liquidation" + and ( + transaction.enqueued_cancellation is None + or not transaction.enqueued_cancellation.exists + ) + ] + + def can_happy(self) -> bool: + return bool(self.candidates()) + + def happy(self) -> None: + """Enqueue cancellation of an Approved proposal.""" + context = self.context + prepared = random.choice(self.candidates()) + context.squads.assert_proposal_status(prepared, "Approved") + authority = context.enqueue_authority() + instruction, enqueued = context.squads.build_enqueue_cancellation( + prepared, authority + ) + assert not enqueued.exists + authority.tx(instruction) + decoded = EnqueuedMultisigProposalCancellation.decode(enqueued.data) + assert decoded.dao == context.dao.pubkey + assert decoded.transactionIndex == prepared.index + prepared.enqueued_cancellation = enqueued + + def can_unhappy(self) -> bool: + return True + + def unhappy(self) -> None: + """Reject cancellation enqueue while the proposal is still Active.""" + context = self.context + prepared = context.squads.prepare( + memo_instruction("cancellation requires approval"), + purpose="invalid cancellation enqueue", + ) + authority = context.enqueue_authority() + instruction, _ = context.squads.build_enqueue_cancellation( + prepared, authority + ) + self.assert_fails_atomically( + authority, + instruction, + FutarchyProgram.SquadsProposalNotApproved, + ) diff --git a/fuzz/futarchy/instructions/admin_execute_multisig_proposal.py b/fuzz/futarchy/instructions/admin_execute_multisig_proposal.py new file mode 100644 index 00000000..7c60224d --- /dev/null +++ b/fuzz/futarchy/instructions/admin_execute_multisig_proposal.py @@ -0,0 +1,100 @@ +"""Wrapper for ``admin_execute_multisig_proposal``.""" + +from __future__ import annotations + +from wake_sol import random + +from .base import InstructionWrapper +from ..constants import ( + SQUADS_INVALID_PROPOSAL_STATUS_ERROR_CODE, + SQUADS_PROGRAM_ID, +) +from ..pytypes.futarchy import Futarchy as FutarchyProgram +from ..utils.builders import token_transfer_instruction +from ..utils.payloads import assert_payload_effects + + +class AdminExecuteMultisigProposalInstruction(InstructionWrapper): + """Check the administrative Squads execution bridge with an external payload.""" + + def prepare(self, purpose: str, *, approve: bool): + context = self.context + recipient = random.choice(context.actors) + destination = context.quote_atas_by_owner[recipient.pubkey] + amount = min(1, context.council_quote_balance()) + payload = token_transfer_instruction( + context.council_quote_account, + destination, + context.council, + amount, + ) + if approve: + prepared = context.squads.prepare_and_approve( + payload, + purpose=purpose, + allow_admin_execute=True, + ) + else: + prepared = context.squads.prepare( + payload, + purpose=purpose, + allow_admin_execute=True, + ) + return prepared, destination, amount + + def build_instruction(self, prepared, admin=None): + context = self.context + return FutarchyProgram.adminExecuteMultisigProposal( + dao=context.dao, + admin=admin or context.proposal_admin, + squadsMultisig=context.squads_multisig, + squadsMultisigProposal=prepared.proposal, + squadsMultisigVaultTransaction=prepared.transaction, + squadsMultisigProgram=SQUADS_PROGRAM_ID, + remaining_accounts=prepared.message_accounts, + ) + + def can_happy(self) -> bool: + return ( + self.context.is_spot() + and self.context.council_quote_balance() > 0 + ) + + def happy(self) -> None: + """Execute an approved external payload and verify its exact token delta.""" + context = self.context + prepared, _, _ = self.prepare( + "admin execute happy", approve=True + ) + context.squads.assert_proposal_status(prepared, "Approved") + admin = context.proposal_admin + before = self.snapshot( + *prepared.instructions, + extra_accounts=(context.dao, context.base_mint, context.quote_mint), + ) + admin.tx( + self.compute_unit_limit(), + self.build_instruction(prepared, admin), + ) + context.squads.assert_proposal_status(prepared, "Executed") + assert_payload_effects(context, prepared, before) + prepared.executed = True + + def can_unhappy(self) -> bool: + return self.context.is_spot() + + def unhappy(self) -> None: + """Reject an Active, unapproved Squads transaction atomically.""" + context = self.context + prepared, _, _ = self.prepare("admin execute unapproved", approve=False) + context.squads.assert_proposal_status(prepared, "Active") + admin = context.proposal_admin + instruction = self.build_instruction(prepared, admin) + self.assert_fails_atomically( + admin, + instruction, + SQUADS_INVALID_PROPOSAL_STATUS_ERROR_CODE, + before=(self.compute_unit_limit(),), + ) + context.squads.assert_proposal_status(prepared, "Active") + assert not prepared.executed diff --git a/fuzz/futarchy/instructions/admin_remove_proposal.py b/fuzz/futarchy/instructions/admin_remove_proposal.py new file mode 100644 index 00000000..717fd6a5 --- /dev/null +++ b/fuzz/futarchy/instructions/admin_remove_proposal.py @@ -0,0 +1,81 @@ +"""Snapshot wrapper for Futarchy ``admin_remove_proposal``.""" + +from __future__ import annotations + +from wake_sol import Account, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + Proposal, + ProposalState, +) +from ..utils.state import ProposalAccounts +from ..utils.squads import decode_squads_proposal +from ..utils.assertions import assert_changed_only + + +class AdminRemoveProposalInstruction(InstructionWrapper): + """Check the direct administrative Draft-to-Removed transition.""" + + def build_instruction( + self, proposal: ProposalAccounts, admin: Account | None = None + ): + context = self.context + return FutarchyProgram.adminRemoveProposal( + proposal=proposal.proposal, + dao=context.dao, + admin=admin or context.proposal_admin, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return bool(self.context.draft_proposals()) + + def happy(self) -> None: + """Remove one Draft while leaving its stake custody withdrawable.""" + context = self.context + proposal = random.choice(context.draft_proposals()) + before_dao = context.dao_state() + before_proposal = context.proposal_state(proposal) + squads_status = decode_squads_proposal( + proposal.squads.proposal.data + ).status + context.squads.assert_proposal_status(proposal.squads, squads_status) + admin = context.proposal_admin + admin.tx(self.build_instruction(proposal, admin)) + after_dao = context.dao_state() + after = Proposal.decode(proposal.proposal.data) + assert_changed_only(before_dao, after_dao, seqNum=before_dao.seqNum + 1) + assert_changed_only(before_proposal, after, state=ProposalState.Removed()) + context.squads.assert_proposal_status(proposal.squads, squads_status) + + def can_unhappy(self) -> bool: + return bool(self.context.proposals) and any( + not isinstance( + self.context.proposal_state(proposal).state, + ProposalState.Draft, + ) + for proposal in self.context.proposals + ) + + def unhappy(self) -> None: + """Reject removing a proposal that has already left Draft.""" + context = self.context + candidates = [ + proposal + for proposal in context.proposals + if not isinstance( + context.proposal_state(proposal).state, ProposalState.Draft + ) + ] + proposal = random.choice(candidates) + admin = context.proposal_admin + instruction = self.build_instruction(proposal, admin) + self.assert_fails_atomically( + admin, + instruction, + FutarchyProgram.ProposalNotInDraftState, + ) diff --git a/fuzz/futarchy/instructions/admin_update_proposal_params.py b/fuzz/futarchy/instructions/admin_update_proposal_params.py new file mode 100644 index 00000000..bf5d12e2 --- /dev/null +++ b/fuzz/futarchy/instructions/admin_update_proposal_params.py @@ -0,0 +1,154 @@ +"""Snapshot wrapper for Futarchy ``admin_update_proposal_params``.""" + +from __future__ import annotations + +from wake_sol import Account, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + AdminUpdateProposalParamsArgs, + Futarchy as FutarchyProgram, + Proposal, + ProposalAction, + ProposalState, +) +from ..utils.parameters import ( + valid_proposal_duration, + valid_proposal_pass_threshold_bps, +) +from ..utils.state import ProposalAccounts +from ..utils.assertions import assert_changed_only + + +class AdminUpdateProposalParamsInstruction(InstructionWrapper): + """Check Draft-only updates and preservation of every omitted field.""" + + @staticmethod + def build_args( + duration: int | None, threshold: int | None + ) -> AdminUpdateProposalParamsArgs: + return AdminUpdateProposalParamsArgs( + durationInSeconds=duration, + passThresholdBps=threshold, + ) + + def build_instruction( + self, + proposal: ProposalAccounts, + args: AdminUpdateProposalParamsArgs, + admin: Account | None = None, + ): + context = self.context + return FutarchyProgram.adminUpdateProposalParams( + args, + dao=context.dao, + proposal=proposal.proposal, + admin=admin or context.ops_admin, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def arbitrary_drafts(self): + return [ + proposal + for proposal in self.context.draft_proposals() + if isinstance( + self.context.proposal_state(proposal).action, + ProposalAction.ExecuteArbitrary, + ) + ] + + def typed_drafts(self): + return [ + proposal + for proposal in self.context.draft_proposals() + if not isinstance( + self.context.proposal_state(proposal).action, + ProposalAction.ExecuteArbitrary, + ) + ] + + def live_arbitrary(self): + """Return arbitrary proposals whose immutable live terms are frozen.""" + return [ + proposal + for proposal in self.context.proposals + if proposal.proposal.exists + and isinstance( + self.context.proposal_state(proposal).action, + ProposalAction.ExecuteArbitrary, + ) + and isinstance( + self.context.proposal_state(proposal).state, + ProposalState.Pending, + ) + ] + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and bool(self.arbitrary_drafts()) + + def happy(self) -> None: + """Update one or both arbitrary-Draft terms and verify exact fields.""" + context = self.context + proposal = random.choice(self.arbitrary_drafts()) + duration = random.choice((None, valid_proposal_duration())) + threshold = random.choice( + (None, valid_proposal_pass_threshold_bps()) + ) + if duration is None and threshold is None: + duration = 864_000 + args = self.build_args(duration, threshold) + before_dao = context.dao_state() + before = context.proposal_state(proposal) + context.ops_admin.tx(self.build_instruction(proposal, args)) + after_dao = context.dao_state() + after = Proposal.decode(proposal.proposal.data) + assert_changed_only(before_dao, after_dao, seqNum=before_dao.seqNum + 1) + assert_changed_only( + before, after, + durationInSeconds=duration if duration is not None else before.durationInSeconds, + passThresholdBps=threshold if threshold is not None else before.passThresholdBps, + ) + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() and bool( + self.live_arbitrary() + or self.arbitrary_drafts() + or self.typed_drafts() + ) + + def unhappy(self) -> None: + """Reject live terms, typed changes, or a no-op arbitrary update.""" + context = self.context + live = self.live_arbitrary() + arbitrary = self.arbitrary_drafts() + typed = self.typed_drafts() + cases = [] + if live: + cases.append( + ( + random.choice(live), + self.build_args(864_000, None), + FutarchyProgram.ProposalNotInDraftState, + ) + ) + if typed: + cases.append( + ( + random.choice(typed), + self.build_args(864_000, None), + FutarchyProgram.InvalidProposalKind, + ) + ) + if arbitrary: + cases.append( + ( + random.choice(arbitrary), + self.build_args(None, None), + FutarchyProgram.EmptyProposalParamsUpdate, + ) + ) + proposal, args, expected = random.choice(cases) + instruction = self.build_instruction(proposal, args) + self.assert_fails_atomically(context.ops_admin, instruction, expected) diff --git a/fuzz/futarchy/instructions/base.py b/fuzz/futarchy/instructions/base.py new file mode 100644 index 00000000..a716c555 --- /dev/null +++ b/fuzz/futarchy/instructions/base.py @@ -0,0 +1,70 @@ +"""Shared snapshot, execution, and atomic-failure behavior for wrappers.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from wake_sol import Account, Instruction + +from ..constants import ( + COMPUTE_BUDGET_PROGRAM_ID, + TRANSACTION_COMPUTE_UNIT_LIMIT, +) +from ..utils.assertions import ( + AccountSnapshot, + assert_atomic_failure, + writable_accounts, +) + + +class InstructionWrapper: + """Bind one public instruction to the fuzz context and snapshot checks.""" + + def __init__(self, context: Any) -> None: + self.context = context + + @staticmethod + def snapshot( + *instructions: Instruction, + extra_accounts: Iterable[Account] = (), + ) -> AccountSnapshot: + """Snapshot every writable meta plus explicitly relevant accounts.""" + return AccountSnapshot.take( + writable_accounts(instructions, extra_accounts) + ) + + @staticmethod + def compute_unit_limit( + units: int = TRANSACTION_COMPUTE_UNIT_LIMIT, + ) -> Instruction: + """Build Solana's SetComputeUnitLimit instruction.""" + return Instruction( + COMPUTE_BUDGET_PROGRAM_ID, + [], + bytes([2]) + units.to_bytes(4, "little"), + ) + + def assert_fails_atomically( + self, + signer: Account, + instruction: Any, + expected_error: Any, + *, + before: tuple[Any, ...] = (), + signers: tuple[Account, ...] = (), + extra_accounts: tuple[Account, ...] = (), + ) -> None: + """Require the expected error without changing any writable account.""" + instructions = (*before, instruction) + assert_atomic_failure( + lambda: signer.tx( + *before, + instruction, + signers=list(signers), + ), + expected_error, + instructions, + fee_payer=signer, + extra_accounts=extra_accounts, + ) diff --git a/fuzz/futarchy/instructions/collect_fees.py b/fuzz/futarchy/instructions/collect_fees.py new file mode 100644 index 00000000..930fc8e4 --- /dev/null +++ b/fuzz/futarchy/instructions/collect_fees.py @@ -0,0 +1,97 @@ +"""Snapshot-checked wrapper for Futarchy ``collect_fees``.""" + +from __future__ import annotations + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import Futarchy as FutarchyProgram, PoolState +from ..utils.tokens import token_balance +from ..utils.assertions import assert_changed_only + + +class CollectFeesInstruction(InstructionWrapper): + """Check exact protocol-fee transfers without changing LP reserves.""" + + def build_instruction(self, admin=None): + context = self.context + return FutarchyProgram.collectFees( + dao=context.dao, + admin=admin or context.fee_admin, + baseTokenAccount=context.fee_base_account, + quoteTokenAccount=context.fee_quote_account, + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + state = self.context.dao_state().amm.state + return isinstance(state, PoolState.Spot) and ( + state.spot.baseProtocolFeeBalance > 0 + or state.spot.quoteProtocolFeeBalance > 0 + ) + + def happy(self) -> None: + """Collect a nonzero accrued fee balance from one or both sides.""" + context = self.context + before_dao = context.dao_state() + assert isinstance(before_dao.amm.state, PoolState.Spot) + old_spot = before_dao.amm.state.spot + assert ( + old_spot.baseProtocolFeeBalance + + old_spot.quoteProtocolFeeBalance + > 0 + ) + admin = context.fee_admin + instruction = self.build_instruction(admin) + before = self.snapshot( + instruction, + extra_accounts=( + context.dao, + context.fee_base_account, + context.fee_quote_account, + context.amm_base_vault, + context.amm_quote_vault, + ), + ) + admin.tx(instruction) + after_dao = context.dao_state() + assert isinstance(after_dao.amm.state, PoolState.Spot) + new_spot = after_dao.amm.state.spot + assert_changed_only( + before_dao, after_dao, amm=after_dao.amm, seqNum=before_dao.seqNum + 1 + ) + assert_changed_only(before_dao.amm, after_dao.amm, state=after_dao.amm.state) + assert_changed_only( + old_spot, new_spot, baseProtocolFeeBalance=0, quoteProtocolFeeBalance=0 + ) + assert token_balance(context.fee_base_account) == ( + before.account(context.fee_base_account).token_balance() + + old_spot.baseProtocolFeeBalance + ) + assert token_balance(context.fee_quote_account) == ( + before.account(context.fee_quote_account).token_balance() + + old_spot.quoteProtocolFeeBalance + ) + assert token_balance(context.amm_base_vault) == ( + before.account(context.amm_base_vault).token_balance() + - old_spot.baseProtocolFeeBalance + ) + assert token_balance(context.amm_quote_vault) == ( + before.account(context.amm_quote_vault).token_balance() + - old_spot.quoteProtocolFeeBalance + ) + + def can_unhappy(self) -> bool: + return not self.context.is_spot() + + def unhappy(self) -> None: + """Reject collection while conditional markets own AMM state.""" + admin = self.context.fee_admin + instruction = self.build_instruction(admin) + self.assert_fails_atomically( + admin, + instruction, + FutarchyProgram.PoolNotInSpotState, + ) diff --git a/fuzz/futarchy/instructions/conditional_swap.py b/fuzz/futarchy/instructions/conditional_swap.py new file mode 100644 index 00000000..0b18ee1d --- /dev/null +++ b/fuzz/futarchy/instructions/conditional_swap.py @@ -0,0 +1,258 @@ +"""Snapshot-checked wrapper for Futarchy ``conditional_swap``.""" + +from __future__ import annotations + +from wake_sol import Account, AnchorError, random, svm + +from .base import InstructionWrapper +from ..constants import ( + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + TOKEN_SCALE, + TWAP_UPDATE_INTERVAL_SECONDS, +) +from ..pytypes.futarchy import ( + ConditionalSwapParams, + Futarchy as FutarchyProgram, + Market, + PoolState, + ProposalAction, + SwapType, +) +from ..utils.swaps import assert_swap_transition +from ..utils.state import ProposalAccounts +from ..utils.tokens import token_balance + + +class ConditionalSwapInstruction(InstructionWrapper): + """Check conditional user transfers and all three reserve products.""" + + @staticmethod + def build_params( + market: Market, + direction: SwapType, + amount: int, + minimum_output: int = 0, + ) -> ConditionalSwapParams: + return ConditionalSwapParams( + market=market, + swapType=direction, + inputAmount=amount, + minOutputAmount=minimum_output, + ) + + @staticmethod + def user_accounts( + proposal: ProposalAccounts, + trader: Account, + market: Market, + direction: SwapType, + ) -> tuple[Account, Account]: + pass_market = market == Market.Pass + base_mint = proposal.pass_base_mint if pass_market else proposal.fail_base_mint + quote_mint = ( + proposal.pass_quote_mint if pass_market else proposal.fail_quote_mint + ) + base = proposal.conditional_accounts[(trader.pubkey, base_mint.pubkey)] + quote = proposal.conditional_accounts[(trader.pubkey, quote_mint.pubkey)] + return (quote, base) if direction == SwapType.Buy else (base, quote) + + def build_instruction( + self, + proposal: ProposalAccounts, + trader: Account, + input_account: Account, + output_account: Account, + params: ConditionalSwapParams, + ): + context = self.context + context.market_support.ensure_launch_accounts(proposal) + return FutarchyProgram.conditionalSwap( + params, + dao=context.dao, + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + proposal=proposal.proposal, + ammPassBaseVault=proposal.amm_pass_base_vault, + ammPassQuoteVault=proposal.amm_pass_quote_vault, + ammFailBaseVault=proposal.amm_fail_base_vault, + ammFailQuoteVault=proposal.amm_fail_quote_vault, + trader=trader, + userInputAccount=input_account, + userOutputAccount=output_account, + baseVault=proposal.base_vault, + baseVaultUnderlyingTokenAccount=proposal.base_vault_underlying, + quoteVault=proposal.quote_vault, + quoteVaultUnderlyingTokenAccount=proposal.quote_vault_underlying, + passBaseMint=proposal.pass_base_mint, + failBaseMint=proposal.fail_base_mint, + passQuoteMint=proposal.pass_quote_mint, + failQuoteMint=proposal.fail_quote_mint, + conditionalVaultProgram=CONDITIONAL_VAULT_PROGRAM_ID, + vaultEventAuthority=context.vault_event_authority, + question=proposal.question, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and bool(self.available_trades()) + + def available_trades( + self, + ) -> list[tuple[ProposalAccounts, Account, Market, SwapType]]: + """Find trades that can obtain a positive conditional-token input.""" + context = self.context + result: list[tuple[ProposalAccounts, Account, Market, SwapType]] = [] + for proposal in context.pending_proposals(): + action = context.proposal_state(proposal).action + choices = ( + ((Market.Pass, SwapType.Buy), (Market.Fail, SwapType.Sell)) + if isinstance(action, ProposalAction.HostileLiquidate) + else tuple( + (market, direction) + for market in (Market.Pass, Market.Fail) + for direction in (SwapType.Buy, SwapType.Sell) + ) + ) + for trader in context.actors: + for market, direction in choices: + pass_market = market == Market.Pass + mint = ( + proposal.pass_quote_mint + if pass_market and direction == SwapType.Buy + else proposal.fail_quote_mint + if direction == SwapType.Buy + else proposal.pass_base_mint + if pass_market + else proposal.fail_base_mint + ) + conditional = proposal.conditional_accounts.get( + (trader.pubkey, mint.pubkey) + ) + underlying = ( + context.quote_atas_by_owner[trader.pubkey] + if direction == SwapType.Buy + else context.base_atas_by_owner[trader.pubkey] + ) + if ( + conditional is not None + and conditional.exists + and token_balance(conditional) > 0 + ) or token_balance(underlying) > 0: + result.append((proposal, trader, market, direction)) + return result + + def prepare_positive_trade( + self, + ) -> tuple[ + ProposalAccounts, + Account, + Market, + SwapType, + Account, + Account, + ]: + """Select and fund one trade, then require a positive input balance.""" + proposal, trader, market, direction = random.choice( + self.available_trades() + ) + self.context.market_support.ensure_trader_tokens(proposal, trader) + input_account, output_account = self.user_accounts( + proposal, trader, market, direction + ) + assert token_balance(input_account) > 0 + return ( + proposal, + trader, + market, + direction, + input_account, + output_account, + ) + + def happy(self) -> None: + """Trade pass/fail tokens and verify the observed output transition.""" + context = self.context + ( + proposal, + trader, + market, + direction, + input_account, + output_account, + ) = self.prepare_positive_trade() + action = context.proposal_state(proposal).action + maximum = min( + token_balance(input_account), + (1_000 if isinstance(action, ProposalAction.HostileLiquidate) else 100) + * TOKEN_SCALE, + ) + amount = ( + maximum + if isinstance(action, ProposalAction.HostileLiquidate) + else random.randint(1, maximum) + ) + params = self.build_params(market, direction, amount) + instruction = self.build_instruction( + proposal, trader, input_account, output_account, params + ) + if isinstance(action, ProposalAction.HostileLiquidate): + svm.warp_to_timestamp( + svm.clock.unix_timestamp + TWAP_UPDATE_INTERVAL_SECONDS + ) + before_dao = context.dao_state() + timestamp = svm.clock.unix_timestamp + before = self.snapshot( + instruction, + extra_accounts=(context.dao, input_account, output_account), + ) + trader.tx(self.compute_unit_limit(), instruction) + after_dao = context.dao_state() + assert token_balance(input_account) == ( + before.account(input_account).token_balance() - amount + ) + output = token_balance(output_account) - before.account( + output_account + ).token_balance() + assert output >= params.minOutputAmount + assert isinstance(before_dao.amm.state, PoolState.Futarchy) + assert isinstance(after_dao.amm.state, PoolState.Futarchy) + assert_swap_transition( + before_dao, after_dao, market, direction, amount, output, timestamp + ) + + def can_unhappy(self) -> bool: + return self.can_happy() + + def unhappy(self) -> None: + """Reject Spot selection or positive impossible slippage atomically.""" + context = self.context + ( + proposal, + trader, + market, + direction, + input_account, + output_account, + ) = self.prepare_positive_trade() + amount = random.randint( + 1, min(token_balance(input_account), 100 * TOKEN_SCALE) + ) + if random.choice((False, True)): + params = self.build_params(Market.Spot, direction, amount) + expected = AnchorError.RequireNeqViolated + else: + params = self.build_params( + market, direction, amount, 2**64 - 1 + ) + expected = FutarchyProgram.SwapSlippageExceeded + instruction = self.build_instruction( + proposal, trader, input_account, output_account, params + ) + self.assert_fails_atomically( + trader, + instruction, + expected, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/execute_multisig_proposal_approval.py b/fuzz/futarchy/instructions/execute_multisig_proposal_approval.py new file mode 100644 index 00000000..76bc6a90 --- /dev/null +++ b/fuzz/futarchy/instructions/execute_multisig_proposal_approval.py @@ -0,0 +1,84 @@ +"""Wrapper for ``execute_multisig_proposal_approval``.""" + +from __future__ import annotations + +from wake_sol import AnchorError, random + +from .base import InstructionWrapper +from ..constants import SQUADS_PROGRAM_ID +from ..pytypes.futarchy import Futarchy as FutarchyProgram +from ..utils.builders import memo_instruction + + +class ExecuteMultisigProposalApprovalInstruction(InstructionWrapper): + """Check permissionless voting and closure of the one-shot authorization.""" + + def queued(self): + return [ + transaction + for transaction in self.context.squads_transactions + if transaction.enqueued_approval is not None + and transaction.enqueued_approval.exists + and not transaction.approved + ] + + def build_instruction(self, prepared, *, proposal=None): + context = self.context + assert prepared.enqueued_approval is not None + if proposal is not None: + return self._with_proposal(prepared, proposal) + return context.squads.build_approve( + prepared, + prepared.enqueued_approval, + context.payer, + ) + + def _with_proposal(self, prepared, proposal): + context = self.context + return FutarchyProgram.executeMultisigProposalApproval( + dao=context.dao, + rentReceiver=context.payer, + squadsMultisig=context.squads_multisig, + squadsMultisigProposal=proposal, + enqueuedApproval=prepared.enqueued_approval, + squadsMultisigProgram=SQUADS_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return self.context.is_spot() and bool(self.queued()) + + def happy(self) -> None: + """Approve an enqueued Squads proposal and close its authorization PDA.""" + context = self.context + prepared = random.choice(self.queued()) + enqueued = prepared.enqueued_approval + proposal_before = bytes(prepared.proposal.data) + context.payer.tx(self.build_instruction(prepared)) + assert enqueued is not None and not enqueued.exists + assert bytes(prepared.proposal.data) != proposal_before + context.squads.assert_proposal_status(prepared, "Approved") + prepared.enqueued_approval = None + prepared.approved = True + + def can_unhappy(self) -> bool: + return self.context.is_spot() + + def unhappy(self) -> None: + """Reject a Squads proposal PDA that does not match the enqueued index.""" + context = self.context + first = context.squads.prepare( + memo_instruction("approval seeds A"), purpose="approval seeds A" + ) + second = context.squads.prepare( + memo_instruction("approval seeds B"), purpose="approval seeds B" + ) + admin = context.enqueue_authority() + enqueue, enqueued = context.squads.build_enqueue(first, admin) + admin.tx(enqueue) + first.enqueued_approval = enqueued + instruction = self.build_instruction(first, proposal=second.proposal) + self.assert_fails_atomically( + context.payer, + instruction, + AnchorError.ConstraintSeeds, + ) diff --git a/fuzz/futarchy/instructions/execute_multisig_proposal_cancellation.py b/fuzz/futarchy/instructions/execute_multisig_proposal_cancellation.py new file mode 100644 index 00000000..f63876e0 --- /dev/null +++ b/fuzz/futarchy/instructions/execute_multisig_proposal_cancellation.py @@ -0,0 +1,72 @@ +"""Wrapper for ``execute_multisig_proposal_cancellation``.""" + +from __future__ import annotations + +from wake_sol import AnchorError, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID, SQUADS_INVALID_PROPOSAL_STATUS_ERROR_CODE +from ..utils.accounts import derive_enqueued_cancellation +from ..utils.builders import memo_instruction + + +class ExecuteMultisigProposalCancellationInstruction(InstructionWrapper): + """Check permissionless cancellation and one-shot PDA closure.""" + + def queued(self): + return [ + transaction + for transaction in self.context.squads_transactions + if transaction.enqueued_cancellation is not None + and transaction.enqueued_cancellation.exists + and not transaction.executed + and not transaction.cancelled + ] + + def can_happy(self) -> bool: + return bool(self.queued()) + + def happy(self) -> None: + """Cancel an Approved proposal and close its authorization PDA.""" + context = self.context + prepared = random.choice(self.queued()) + enqueued = prepared.enqueued_cancellation + assert enqueued is not None + proposal_before = bytes(prepared.proposal.data) + context.payer.tx( + context.squads.build_cancel(prepared, enqueued, context.payer) + ) + assert not enqueued.exists + assert bytes(prepared.proposal.data) != proposal_before + context.squads.assert_proposal_status(prepared, "Cancelled") + prepared.enqueued_cancellation = None + prepared.cancelled = True + self.assert_fails_atomically( + context.payer, context.squads.build_execute(prepared), + SQUADS_INVALID_PROPOSAL_STATUS_ERROR_CODE, + before=(self.compute_unit_limit(),), + signers=(context.permissionless_account,), + ) + context.squads.assert_proposal_status(prepared, "Cancelled") + + def can_unhappy(self) -> bool: + return self.context.is_spot() + + def unhappy(self) -> None: + """Reject execution when no cancellation authorization exists.""" + context = self.context + prepared = context.squads.prepare_and_approve( + memo_instruction("missing cancellation authorization"), + purpose="invalid cancellation execution", + ) + enqueued, _ = derive_enqueued_cancellation( + context.dao, prepared.index, FUTARCHY_PROGRAM_ID + ) + instruction = context.squads.build_cancel( + prepared, enqueued, context.payer + ) + self.assert_fails_atomically( + context.payer, + instruction, + AnchorError.AccountNotInitialized, + ) diff --git a/fuzz/futarchy/instructions/execute_passed_payload.py b/fuzz/futarchy/instructions/execute_passed_payload.py new file mode 100644 index 00000000..f7669f4d --- /dev/null +++ b/fuzz/futarchy/instructions/execute_passed_payload.py @@ -0,0 +1,46 @@ +"""Support wrapper for ordinary top-level execution of approved Squads payloads.""" + +from __future__ import annotations + +from wake_sol import random + +from .base import InstructionWrapper +from ..utils.payloads import assert_payload_effects, payload_state_allows_execution + + +class ExecutePassedPayloadInstruction(InstructionWrapper): + """Exercise proposal payloads without re-entering Futarchy through admin CPI.""" + + def candidates(self): + passed_transaction_keys = { + proposal.squads.transaction.pubkey + for proposal in self.context.passed_proposals() + } + return [ + transaction + for transaction in self.context.squads_transactions + if transaction.approved + and not transaction.executed + and not transaction.cancelled + and not transaction.disabled + and ( + transaction.transaction.pubkey in passed_transaction_keys + or transaction.allow_admin_execute + ) + and payload_state_allows_execution(self.context, transaction) + ] + + def can_happy(self) -> bool: + return bool(self.candidates()) + + def happy(self) -> None: + """Execute a passed proposal or estate transaction directly in Squads.""" + context = self.context + prepared = random.choice(self.candidates()) + before = self.snapshot( + *prepared.instructions, + extra_accounts=(context.dao, context.base_mint, context.quote_mint), + ) + context.squads.assert_proposal_status(prepared, "Approved") + context.squads.execute_top_level(prepared) + assert_payload_effects(context, prepared, before) diff --git a/fuzz/futarchy/instructions/finalize_proposal.py b/fuzz/futarchy/instructions/finalize_proposal.py new file mode 100644 index 00000000..bacfc59d --- /dev/null +++ b/fuzz/futarchy/instructions/finalize_proposal.py @@ -0,0 +1,190 @@ +"""Snapshot-checked wrapper for Futarchy ``finalize_proposal``.""" + +from __future__ import annotations + +from wake_sol import AnchorError, random, svm + +from .base import InstructionWrapper +from ..constants import ( + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + MAX_BPS, + SQUADS_PROGRAM_ID, +) +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + PoolState, + ProposalAction, +) +from ..utils.oracle import calculate_twap +from ..utils.assertions import assert_changed_only +from ..utils.settlement import assert_market_settled +from ..utils.state import ProposalAccounts + + +class FinalizeProposalInstruction(InstructionWrapper): + """Check maturity, TWAP outcome, resolution, and return to Spot.""" + + @staticmethod + def assert_action_side_effects( + before_dao, + after_dao, + action, + should_pass: bool, + now: int, + ) -> None: + """Check the direct DAO writes performed by finalization.""" + expected_liquidator = before_dao.liquidator + expected_spending_limit = before_dao.initialSpendingLimit + expected_spending_limit_dirty = before_dao.spendingLimitDirty + expected_failed_takeover_at = before_dao.lastFailedTakeoverAt + expected_failed_liquidation_at = before_dao.lastFailedLiquidationAt + expected_buyback_finalized_at = before_dao.lastBuybackFinalizedAt + + if isinstance(action, ProposalAction.HostileTakeover) and not should_pass: + expected_failed_takeover_at = now + elif isinstance(action, ProposalAction.HostileLiquidate): + if should_pass: + expected_liquidator = action.liquidator + if expected_spending_limit is not None: + expected_spending_limit = None + expected_spending_limit_dirty = True + else: + expected_failed_liquidation_at = now + elif isinstance(action, ProposalAction.BuybackToken): + expected_buyback_finalized_at = now + + assert_changed_only( + before_dao, after_dao, + amm=after_dao.amm, seqNum=before_dao.seqNum + 1, + liquidator=expected_liquidator, + initialSpendingLimit=expected_spending_limit, + spendingLimitDirty=expected_spending_limit_dirty, + lastFailedTakeoverAt=expected_failed_takeover_at, + lastFailedLiquidationAt=expected_failed_liquidation_at, + lastBuybackFinalizedAt=expected_buyback_finalized_at, + ) + + def build_instruction(self, accounts: ProposalAccounts): + context = self.context + context.market_support.ensure_launch_accounts(accounts) + return FutarchyProgram.finalizeProposal( + proposal=accounts.proposal, + dao=context.dao, + question=accounts.question, + squadsProposal=accounts.squads.proposal, + squadsMultisig=context.squads_multisig, + squadsMultisigProgram=SQUADS_PROGRAM_ID, + ammPassBaseVault=accounts.amm_pass_base_vault, + ammPassQuoteVault=accounts.amm_pass_quote_vault, + ammFailBaseVault=accounts.amm_fail_base_vault, + ammFailQuoteVault=accounts.amm_fail_quote_vault, + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + vaultProgram=CONDITIONAL_VAULT_PROGRAM_ID, + vaultEventAuthority=context.vault_event_authority, + quoteVault=accounts.quote_vault, + quoteVaultUnderlyingTokenAccount=accounts.quote_vault_underlying, + passQuoteMint=accounts.pass_quote_mint, + failQuoteMint=accounts.fail_quote_mint, + passBaseMint=accounts.pass_base_mint, + failBaseMint=accounts.fail_base_mint, + baseVault=accounts.base_vault, + baseVaultUnderlyingTokenAccount=accounts.base_vault_underlying, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return bool(self.context.finalizable_proposals()) + + def happy(self) -> None: + """Finalize one mature market and independently verify its outcome.""" + context = self.context + finalizable = context.finalizable_proposals() + liquidations = [ + proposal + for proposal in finalizable + if isinstance( + context.proposal_state(proposal).action, + ProposalAction.HostileLiquidate, + ) + ] + accounts = random.choice(liquidations or finalizable) + before_dao = context.dao_state() + before_proposal = context.proposal_state(accounts) + assert isinstance(before_dao.amm.state, PoolState.Futarchy) + now = svm.clock.unix_timestamp + pass_twap = calculate_twap(before_dao.amm.state.pass_.oracle, now) + fail_twap = calculate_twap(before_dao.amm.state.fail.oracle, now) + threshold = ( + fail_twap * (MAX_BPS + before_proposal.passThresholdBps) // MAX_BPS + ) + should_pass = pass_twap > threshold + instruction = self.build_instruction(accounts) + before = self.snapshot(instruction) + context.payer.tx(self.compute_unit_limit(), instruction) + after_dao = context.dao_state() + assert_market_settled(context, accounts, before, should_pass) + self.assert_action_side_effects( + before_dao, + after_dao, + before_proposal.action, + should_pass, + now, + ) + context.squads.assert_proposal_status( + accounts.squads, + "Approved" if should_pass else "Rejected", + ) + if should_pass: + accounts.squads.approved = True + if isinstance( + before_proposal.action, + ProposalAction.HostileLiquidate, + ): + assert ( + context.enqueue_authority().pubkey + == before_proposal.action.liquidator + ) + else: + accounts.squads.rejected = True + + def negative_candidates(self): + context = self.context + if not context.pending_proposals(): + return [] + now = svm.clock.unix_timestamp + result = [] + state = context.dao_state().amm.state + assert isinstance(state, PoolState.Futarchy) + twaps_started = all( + pool.oracle.lastUpdatedTimestamp + > pool.oracle.createdAtTimestamp + pool.oracle.startDelaySeconds + for pool in (state.pass_, state.fail) + ) + for accounts in context.pending_proposals(): + proposal = context.proposal_state(accounts) + if now < proposal.timestampEnqueued + proposal.durationInSeconds: + result.append((accounts, FutarchyProgram.ProposalTooYoung)) + elif not twaps_started: + result.append((accounts, FutarchyProgram.MarketsTooYoung)) + elif any( + pool.oracle.aggregator == 0 for pool in (state.pass_, state.fail) + ): + result.append((accounts, AnchorError.RequireNeqViolated)) + return result + + def can_unhappy(self) -> bool: + return bool(self.negative_candidates()) + + def unhappy(self) -> None: + """Reject a too-young or uncranked market and prove full CPI rollback.""" + accounts, expected = random.choice(self.negative_candidates()) + instruction = self.build_instruction(accounts) + self.assert_fails_atomically( + self.context.payer, + instruction, + expected, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/initialize_buyback_token_proposal.py b/fuzz/futarchy/instructions/initialize_buyback_token_proposal.py new file mode 100644 index 00000000..2059e5ec --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_buyback_token_proposal.py @@ -0,0 +1,110 @@ +"""Wrapper for ``initialize_buyback_token_proposal``.""" + +from __future__ import annotations + +from wake_sol import Instruction, random + +from .typed_initialize import TypedInitializeInstruction +from ..constants import ( + MAX_BUYBACK_CYCLE_SECONDS, + MAX_BUYBACK_START_DELAY_SECONDS, + MIN_BUYBACK_CYCLE_COUNT, + MIN_BUYBACK_CYCLE_SECONDS, + U64_MAX, +) +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitializeBuybackTokenProposalArgs, + ProposalAction, +) +from ..utils.builders import memo_instruction + + +class InitializeBuybackTokenProposalInstruction(TypedInitializeInstruction): + """Validate venue bounds and preserve the exact buyback declaration.""" + + kind_name = "buyback token" + + def happy_args(self) -> InitializeBuybackTokenProposalArgs: + context = self.context + cash_cap = context.council_quote_balance() // 4 + total_cap = ( + context.instructions.launch_proposal.treasury_quote_value() // 4 + if context.is_spot() + else cash_cap + ) + amounts = [ + max(1, min(U64_MAX, cap + offset)) + for cap in (cash_cap, total_cap) + for offset in (-1, 0, 1) + ] + amounts.append(random.randint(1, max(1, min(U64_MAX, total_cap)))) + return InitializeBuybackTokenProposalArgs( + quoteAmount=random.choice(amounts), + cycleCount=random.choice((MIN_BUYBACK_CYCLE_COUNT, 3, 10)), + cycleFrequencySeconds=random.choice( + ( + MIN_BUYBACK_CYCLE_SECONDS, + 3_600, + 86_400, + MAX_BUYBACK_CYCLE_SECONDS, + ) + ), + startDelaySeconds=random.choice( + (0, 60, 86_400, MAX_BUYBACK_START_DELAY_SECONDS) + ), + minPrice=random.choice((None, 1_000_000)), + maxPrice=random.choice((None, 2_000_000)), + ) + + def payload(self, args, accounts) -> tuple[Instruction, ...]: + def price(value): + return "none" if value is None else str(value) + + return ( + memo_instruction( + "metadao-buyback/1 " + f"proposal={accounts.proposal.pubkey} " + f"spend={args.quoteAmount} " + f"cycles={args.cycleCount} " + f"cycle_seconds={args.cycleFrequencySeconds} " + f"start_delay={args.startDelaySeconds} " + f"min_price={price(args.minPrice)} " + f"max_price={price(args.maxPrice)}" + ), + ) + + def build_instruction(self, args, accounts, **overrides): + return FutarchyProgram.initializeBuybackTokenProposal( + args, **self.common_accounts(accounts) + ) + + def assert_action(self, action, args) -> None: + assert isinstance(action, ProposalAction.BuybackToken) + assert action.quoteAmount == args.quoteAmount + assert action.cycleCount == args.cycleCount + assert action.cycleFrequencySeconds == args.cycleFrequencySeconds + assert action.startDelaySeconds == args.startDelaySeconds + assert action.minPrice == args.minPrice + assert action.maxPrice == args.maxPrice + + def unhappy_case(self): + choice = random.randint(0, 4) + args = self.happy_args() + if choice == 0: + args.quoteAmount = 0 + expected = FutarchyProgram.InvalidBuybackAmount + elif choice == 1: + args.cycleCount = random.choice((0, MIN_BUYBACK_CYCLE_COUNT - 1)) + expected = FutarchyProgram.InvalidBuybackCycleCount + elif choice == 2: + args.cycleFrequencySeconds = MIN_BUYBACK_CYCLE_SECONDS - 1 + expected = FutarchyProgram.InvalidBuybackCycleFrequency + elif choice == 3: + args.startDelaySeconds = MAX_BUYBACK_START_DELAY_SECONDS + 1 + expected = FutarchyProgram.InvalidBuybackStartDelay + else: + args.minPrice = 2 + args.maxPrice = 1 + expected = FutarchyProgram.InvalidBuybackPriceBand + return args, expected, 2, {} diff --git a/fuzz/futarchy/instructions/initialize_dao.py b/fuzz/futarchy/instructions/initialize_dao.py new file mode 100644 index 00000000..cbd38b57 --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_dao.py @@ -0,0 +1,322 @@ +"""Snapshot-checked wrapper for Futarchy ``initialize_dao``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from wake_sol import Account, random, svm + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID, SQUADS_PROGRAM_ID +from ..pytypes.futarchy import ( + Dao, + Futarchy as FutarchyProgram, + InitialSpendingLimit, + InitializeDaoParams, + PoolState, +) +from ..utils.accounts import ( + derive_dao, + derive_squads_multisig, + derive_squads_spending_limit, + derive_squads_vault, +) +from ..utils.parameters import ( + invalid_spending_limit, + production_dao_config, + valid_dao_config, + valid_spending_limit, +) + + +@dataclass(slots=True) +class DaoInitializationAccounts: + """All addresses whose seeds depend on one DAO creator and nonce.""" + + creator: Account + nonce: int + dao: Account + squads_multisig: Account + council: Account + spending_limit: Account + amm_base_vault: Account + amm_quote_vault: Account + + +class InitializeDaoInstruction(InstructionWrapper): + """Build parameters/accounts and verify real DAO/Squads initialization.""" + + @staticmethod + def build_params( + config: dict[str, int], + *, + nonce: int, + spending_limit: InitialSpendingLimit | None, + team: Account, + ) -> InitializeDaoParams: + """Build generated parameters from named protocol configuration.""" + return InitializeDaoParams( + twapInitialObservation=config["twap_initial_observation"], + twapMaxObservationChangePerUpdate=config[ + "twap_max_observation_change_per_update" + ], + twapStartDelaySeconds=config["twap_start_delay_seconds"], + minQuoteFutarchicLiquidity=config[ + "min_quote_futarchic_liquidity" + ], + minBaseFutarchicLiquidity=config[ + "min_base_futarchic_liquidity" + ], + baseToStake=config["base_to_stake"], + passThresholdBps=config["pass_threshold_bps"], + secondsPerProposal=config["seconds_per_proposal"], + nonce=nonce, + initialSpendingLimit=spending_limit, + teamSponsoredPassThresholdBps=config[ + "team_sponsored_pass_threshold_bps" + ], + teamAddress=team.pubkey, + ) + + def derive_accounts( + self, creator: Account, nonce: int, base_mint: Account, quote_mint: Account + ) -> DaoInitializationAccounts: + """Derive every Futarchy and Squads address for an initialization.""" + context = self.context + dao, _ = derive_dao(creator, nonce, FUTARCHY_PROGRAM_ID) + multisig, _ = derive_squads_multisig(dao, SQUADS_PROGRAM_ID) + council, _ = derive_squads_vault(multisig, SQUADS_PROGRAM_ID) + spending_limit, _ = derive_squads_spending_limit( + multisig, dao, SQUADS_PROGRAM_ID + ) + amm_base = Account(svm.token.ata_address(dao, base_mint)) + amm_quote = Account(svm.token.ata_address(dao, quote_mint)) + return DaoInitializationAccounts( + creator=creator, + nonce=nonce, + dao=dao, + squads_multisig=multisig, + council=council, + spending_limit=spending_limit, + amm_base_vault=amm_base, + amm_quote_vault=amm_quote, + ) + + def build_instruction( + self, + params: InitializeDaoParams, + accounts: DaoInitializationAccounts, + *, + base_mint: Account | None = None, + quote_mint: Account | None = None, + ) -> Any: + """Wire the DAO plus every nested Squads CPI account.""" + context = self.context + return FutarchyProgram.initializeDao( + params, + dao=accounts.dao, + daoCreator=accounts.creator, + payer=context.payer, + baseMint=base_mint or context.base_mint, + quoteMint=quote_mint or context.quote_mint, + squadsMultisig=accounts.squads_multisig, + squadsMultisigVault=accounts.council, + squadsProgram=SQUADS_PROGRAM_ID, + squadsProgramConfig=context.squads_program_config, + squadsProgramConfigTreasury=context.squads_program_config_treasury, + spendingLimit=accounts.spending_limit, + futarchyAmmBaseVault=accounts.amm_base_vault, + futarchyAmmQuoteVault=accounts.amm_quote_vault, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def assert_initialized( + self, + accounts: DaoInitializationAccounts, + params: InitializeDaoParams, + base_mint: Account, + quote_mint: Account, + ) -> None: + """Check the initialized account graph and every parameter snapshot.""" + dao = Dao.decode(accounts.dao.data) + assert dao.nonce == params.nonce + assert dao.daoCreator == accounts.creator.pubkey + assert dao.squadsMultisig == accounts.squads_multisig.pubkey + assert dao.squadsMultisigVault == accounts.council.pubkey + assert dao.baseMint == base_mint.pubkey + assert dao.quoteMint == quote_mint.pubkey + assert dao.seqNum == 1 + assert dao.proposalCount == 0 + assert dao.passThresholdBps == params.passThresholdBps + assert dao.secondsPerProposal == params.secondsPerProposal + assert dao.twapInitialObservation == params.twapInitialObservation + assert ( + dao.twapMaxObservationChangePerUpdate + == params.twapMaxObservationChangePerUpdate + ) + assert dao.twapStartDelaySeconds == params.twapStartDelaySeconds + assert ( + dao.minQuoteFutarchicLiquidity + == params.minQuoteFutarchicLiquidity + ) + assert ( + dao.minBaseFutarchicLiquidity + == params.minBaseFutarchicLiquidity + ) + assert dao.baseToStake == params.baseToStake + assert dao.initialSpendingLimit == params.initialSpendingLimit + assert ( + dao.teamSponsoredPassThresholdBps + == params.teamSponsoredPassThresholdBps + ) + assert dao.teamAddress == params.teamAddress + assert isinstance(dao.amm.state, PoolState.Spot) + assert dao.amm.totalLiquidity == 0 + assert dao.amm.baseMint == base_mint.pubkey + assert dao.amm.quoteMint == quote_mint.pubkey + assert dao.amm.state.spot.baseReserves == 0 + assert dao.amm.state.spot.quoteReserves == 0 + assert ( + dao.amm.state.spot.oracle.initialObservation + == params.twapInitialObservation + ) + assert ( + dao.amm.state.spot.oracle.maxObservationChangePerUpdate + == params.twapMaxObservationChangePerUpdate + ) + assert dao.amm.state.spot.oracle.startDelaySeconds == 0 + assert accounts.squads_multisig.owner == SQUADS_PROGRAM_ID + assert accounts.spending_limit.exists == ( + params.initialSpendingLimit is not None + ) + if accounts.spending_limit.exists: + assert accounts.spending_limit.owner == SQUADS_PROGRAM_ID + self.context.squads.assert_spending_limit( + params.initialSpendingLimit, + account=accounts.spending_limit, + multisig=accounts.squads_multisig, + dao=accounts.dao, + quote_mint=quote_mint, + ) + assert accounts.amm_base_vault.owner == svm.token.program_id + assert accounts.amm_quote_vault.owner == svm.token.program_id + + def initialize_main(self) -> None: + """Initialize the sequence's primary DAO at the production baseline.""" + context = self.context + accounts = DaoInitializationAccounts( + creator=context.dao_creator, + nonce=context.dao_nonce, + dao=context.dao, + squads_multisig=context.squads_multisig, + council=context.council, + spending_limit=context.squads_spending_limit, + amm_base_vault=context.amm_base_vault, + amm_quote_vault=context.amm_quote_vault, + ) + spending_limit = InitialSpendingLimit( + amountPerMonth=context.initial_spending_limit_amount, + members=[context.payer.pubkey], + ) + params = self.build_params( + production_dao_config(), + nonce=context.dao_nonce, + spending_limit=spending_limit, + team=context.team_address, + ) + instruction = self.build_instruction(params, accounts) + context.payer.tx( + self.compute_unit_limit(), + instruction, + signers=[context.dao_creator], + ) + self.assert_initialized( + accounts, params, context.base_mint, context.quote_mint + ) + + def can_happy(self) -> bool: + """Auxiliary DAOs remain meaningful only before terminal liquidation.""" + return not self.context.is_liquidated() + + def happy(self) -> None: + """Initialize a fresh auxiliary DAO and verify all created accounts.""" + context = self.context + creator = Account.new() + creator.label = "auxiliary DAO creator" + svm.airdrop(creator, context.actor_lamports) + context.next_aux_nonce += 1 + accounts = self.derive_accounts( + creator, + context.next_aux_nonce, + context.base_mint, + context.quote_mint, + ) + config = valid_dao_config() + limit = ( + valid_spending_limit(context.member_candidates) + if random.choice((False, True)) + else None + ) + params = self.build_params( + config, + nonce=accounts.nonce, + spending_limit=limit, + team=random.choice(context.team_candidates), + ) + instruction = self.build_instruction(params, accounts) + before = self.snapshot(instruction) + context.payer.tx( + self.compute_unit_limit(), instruction, signers=[creator] + ) + assert not before.account(accounts.dao).exists + self.assert_initialized( + accounts, params, context.base_mint, context.quote_mint + ) + context.auxiliary_daos.append(accounts) + context.register_token_account(context.base_mint, accounts.amm_base_vault) + context.register_token_account(context.quote_mint, accounts.amm_quote_vault) + + def can_unhappy(self) -> bool: + """The invalid-mint case is independent of proposal state.""" + return not self.context.is_liquidated() + + def unhappy(self) -> None: + """Reject an invalid mint or spending limit with full CPI rollback.""" + context = self.context + creator = Account.new() + creator.label = "invalid DAO creator" + svm.airdrop(creator, context.actor_lamports) + context.next_aux_nonce += 1 + invalid_mint = random.choice((False, True)) + base_mint = context.quote_mint if invalid_mint else context.base_mint + accounts = self.derive_accounts( + creator, context.next_aux_nonce, base_mint, context.quote_mint + ) + spending_limit = None + expected = FutarchyProgram.InvalidMint + if not invalid_mint: + spending_limit, error_name = invalid_spending_limit( + context.member_candidates + ) + expected = getattr(FutarchyProgram, error_name) + params = self.build_params( + production_dao_config(), + nonce=accounts.nonce, + spending_limit=spending_limit, + team=context.team_address, + ) + instruction = self.build_instruction( + params, + accounts, + base_mint=base_mint, + quote_mint=context.quote_mint, + ) + self.assert_fails_atomically( + context.payer, + instruction, + expected, + before=(self.compute_unit_limit(),), + signers=(creator,), + ) diff --git a/fuzz/futarchy/instructions/initialize_hostile_liquidate_proposal.py b/fuzz/futarchy/instructions/initialize_hostile_liquidate_proposal.py new file mode 100644 index 00000000..8e7841cf --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_hostile_liquidate_proposal.py @@ -0,0 +1,47 @@ +"""Wrapper for ``initialize_hostile_liquidate_proposal``.""" + +from __future__ import annotations + +from wake_sol import Instruction, random + +from .typed_initialize import TypedInitializeInstruction +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitializeHostileLiquidateProposalArgs, + ProposalAction, +) +from ..utils.builders import memo_instruction + + +class InitializeHostileLiquidateProposalInstruction( + TypedInitializeInstruction +): + """Check the declared liquidator and independently reconstructed payload.""" + + kind_name = "hostile liquidation" + + def happy_args(self) -> InitializeHostileLiquidateProposalArgs: + return InitializeHostileLiquidateProposalArgs( + liquidator=random.choice(self.context.liquidator_candidates).pubkey + ) + + def payload(self, args, accounts) -> tuple[Instruction, ...]: + context = self.context + accounts.liquidator = context.signers_by_pubkey[args.liquidator] + memo = memo_instruction( + "Intellectual property transferred to the DAO upon initialization " + "will be transferred back to the original team." + ) + return (memo,) + + def build_instruction(self, args, accounts, **overrides): + return FutarchyProgram.initializeHostileLiquidateProposal( + args, **self.common_accounts(accounts) + ) + + def assert_action(self, action, args) -> None: + assert isinstance(action, ProposalAction.HostileLiquidate) + assert action.liquidator == args.liquidator + + def unhappy_case(self): + return self.happy_args(), FutarchyProgram.QuestionMustBeBinary, 3, {} diff --git a/fuzz/futarchy/instructions/initialize_hostile_takeover_proposal.py b/fuzz/futarchy/instructions/initialize_hostile_takeover_proposal.py new file mode 100644 index 00000000..172b9808 --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_hostile_takeover_proposal.py @@ -0,0 +1,115 @@ +"""Wrapper for ``initialize_hostile_takeover_proposal``.""" + +from __future__ import annotations + +from wake_sol import Instruction, random + +from .typed_initialize import TypedInitializeInstruction +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitializeHostileTakeoverProposalArgs, + ProposalAction, + SetSpendingLimitArgs, + SpendingLimitAction, + UpdateDaoParams, +) +from ..utils.parameters import invalid_spending_limit, valid_spending_limit + + +class InitializeHostileTakeoverProposalInstruction(TypedInitializeInstruction): + """Validate the declared team takeover and optional spending-limit action.""" + + kind_name = "hostile takeover" + + def happy_args(self) -> InitializeHostileTakeoverProposalArgs: + current_team = self.context.dao_state().teamAddress + new_team = random.choice( + [ + team + for team in self.context.team_candidates + if team.pubkey != current_team + ] + ) + action = random.choice( + ( + SpendingLimitAction.Keep(), + SpendingLimitAction.Remove(), + SpendingLimitAction.Set( + valid_spending_limit(self.context.member_candidates) + ), + ) + ) + return InitializeHostileTakeoverProposalArgs( + newTeamAddress=new_team.pubkey, + spendingLimitAction=action, + ) + + def payload(self, args, accounts) -> tuple[Instruction, ...]: + context = self.context + update = FutarchyProgram.updateDao( + UpdateDaoParams( + passThresholdBps=None, + secondsPerProposal=None, + twapInitialObservation=None, + twapMaxObservationChangePerUpdate=None, + twapStartDelaySeconds=None, + minQuoteFutarchicLiquidity=None, + minBaseFutarchicLiquidity=None, + baseToStake=None, + teamSponsoredPassThresholdBps=None, + teamAddress=args.newTeamAddress, + ), + dao=context.dao, + squadsMultisigVault=context.council, + eventAuthority=context.event_authority, + program=context.program_id, + ) + instructions = [update] + config = None + if isinstance(args.spendingLimitAction, SpendingLimitAction.Remove): + config = SetSpendingLimitArgs(config=None) + elif isinstance(args.spendingLimitAction, SpendingLimitAction.Set): + config = SetSpendingLimitArgs(config=args.spendingLimitAction._0) + if config is not None: + instructions.append( + FutarchyProgram.setSpendingLimit( + config, + dao=context.dao, + squadsMultisigVault=context.council, + eventAuthority=context.event_authority, + program=context.program_id, + ) + ) + return tuple(instructions) + + def build_instruction(self, args, accounts, **overrides): + return FutarchyProgram.initializeHostileTakeoverProposal( + args, **self.common_accounts(accounts) + ) + + def assert_action(self, action, args) -> None: + assert isinstance(action, ProposalAction.HostileTakeover) + assert action.newTeamAddress == args.newTeamAddress + assert action.spendingLimitAction == args.spendingLimitAction + + def unhappy_case(self): + if random.choice((False, True)): + args = self.happy_args() + args.newTeamAddress = self.context.dao_state().teamAddress + return args, FutarchyProgram.InvalidTeamAddress, 2, {} + invalid, error_name = invalid_spending_limit( + self.context.member_candidates + ) + current_team = self.context.dao_state().teamAddress + new_team = random.choice( + [ + team + for team in self.context.team_candidates + if team.pubkey != current_team + ] + ) + args = InitializeHostileTakeoverProposalArgs( + newTeamAddress=new_team.pubkey, + spendingLimitAction=SpendingLimitAction.Set(invalid), + ) + return args, getattr(FutarchyProgram, error_name), 2, {} diff --git a/fuzz/futarchy/instructions/initialize_large_spend_proposal.py b/fuzz/futarchy/instructions/initialize_large_spend_proposal.py new file mode 100644 index 00000000..6a61551f --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_large_spend_proposal.py @@ -0,0 +1,80 @@ +"""Wrapper for ``initialize_large_spend_proposal``.""" + +from __future__ import annotations + +from wake_sol import Instruction + +from .typed_initialize import TypedInitializeInstruction +from ..constants import U64_MAX +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitializeLargeSpendProposalArgs, + ProposalAction, +) +from ..utils.builders import token_transfer_instruction +from ..utils.state import ProposalAccounts + + +class InitializeLargeSpendProposalInstruction(TypedInitializeInstruction): + """Validate the spend cap and the stored vault-transfer declaration.""" + + kind_name = "large spend" + + def happy_args(self) -> InitializeLargeSpendProposalArgs: + limit = self.context.dao_state().initialSpendingLimit + assert limit is not None + return InitializeLargeSpendProposalArgs( + amount=max(1, min(limit.amountPerMonth * 3, 10_000_000_000)) + ) + + def payload( + self, args: InitializeLargeSpendProposalArgs, accounts: ProposalAccounts + ) -> tuple[Instruction, ...]: + context = self.context + team = self.context.dao_state().teamAddress + destination = context.ensure_underlying_ata(team, context.quote_mint) + return ( + token_transfer_instruction( + context.council_quote_account, + destination, + context.council, + args.amount, + ), + ) + + def build_instruction(self, args, accounts, **overrides): + return FutarchyProgram.initializeLargeSpendProposal( + args, **self.common_accounts(accounts) + ) + + def assert_action(self, action, args) -> None: + assert isinstance(action, ProposalAction.LargeSpend) + assert action.amount == args.amount + assert action.teamAddress == self.context.dao_state().teamAddress + + def unhappy_case(self): + limit = self.context.dao_state().initialSpendingLimit + if limit is None: + amount = 1 + expected = FutarchyProgram.NoSpendingLimit + else: + amount = limit.amountPerMonth * 3 + 1 + assert amount <= U64_MAX + expected = FutarchyProgram.SpendCapExceeded + args = InitializeLargeSpendProposalArgs(amount=amount) + return args, expected, 2, {} + + def can_happy(self) -> bool: + return ( + super().can_happy() + and self.context.dao_state().initialSpendingLimit is not None + ) + + def can_unhappy(self) -> bool: + if not super().can_unhappy(): + return False + limit = self.context.dao_state().initialSpendingLimit + return ( + limit is None + or limit.amountPerMonth <= (U64_MAX - 1) // 3 + ) diff --git a/fuzz/futarchy/instructions/initialize_mint_tokens_proposal.py b/fuzz/futarchy/instructions/initialize_mint_tokens_proposal.py new file mode 100644 index 00000000..af9d25b8 --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_mint_tokens_proposal.py @@ -0,0 +1,62 @@ +"""Wrapper for ``initialize_mint_tokens_proposal``.""" + +from __future__ import annotations + +from wake_sol import AnchorError, Instruction, random + +from .typed_initialize import TypedInitializeInstruction +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitializeMintTokensProposalArgs, + ProposalAction, +) +from ..utils.builders import token_mint_to_instruction + + +class InitializeMintTokensProposalInstruction(TypedInitializeInstruction): + """Validate direct vault mint authority and the stored mint declaration.""" + + kind_name = "mint tokens" + + def happy_args(self) -> InitializeMintTokensProposalArgs: + recipient = random.choice(self.context.actors) + return InitializeMintTokensProposalArgs( + amount=random.choice((1, 1_000_000, 1_000_000_000)), + recipient=recipient.pubkey, + ) + + def payload(self, args, accounts) -> tuple[Instruction, ...]: + context = self.context + recipient = context.ensure_underlying_ata(args.recipient, context.base_mint) + return ( + token_mint_to_instruction( + context.base_mint, + recipient, + context.council, + args.amount, + ), + ) + + def build_instruction(self, args, accounts, **overrides): + base_mint = overrides.get("base_mint", self.context.base_mint) + return FutarchyProgram.initializeMintTokensProposal( + args, + **self.common_accounts(accounts), + baseMint=base_mint, + mintGovernor=None, + mintAuthority=None, + ) + + def assert_action(self, action, args) -> None: + assert isinstance(action, ProposalAction.MintTokens) + assert action.amount == args.amount + assert action.recipient == args.recipient + + def unhappy_case(self): + args = self.happy_args() + return ( + args, + AnchorError.ConstraintAddress, + 2, + {"base_mint": self.context.quote_mint}, + ) diff --git a/fuzz/futarchy/instructions/initialize_proposal.py b/fuzz/futarchy/instructions/initialize_proposal.py new file mode 100644 index 00000000..65d65530 --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_proposal.py @@ -0,0 +1,125 @@ +"""Snapshot-checked wrapper for arbitrary ``initialize_proposal``.""" + +from __future__ import annotations + +from wake_sol import AnchorError, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + Dao, + Futarchy as FutarchyProgram, + ProposalAction, +) +from ..utils.accounts import derive_proposal +from ..utils.builders import memo_instruction +from ..utils.proposals import assert_initialized_proposal +from ..utils.state import ProposalAccounts + + +class InitializeProposalInstruction(InstructionWrapper): + """Create real Squads/market prerequisites, then initialize the proposal.""" + + def build_instruction(self, accounts: ProposalAccounts): + """Wire one active Squads proposal to its binary conditional markets.""" + context = self.context + return FutarchyProgram.initializeProposal( + proposal=accounts.proposal, + squadsProposal=accounts.squads.proposal, + squadsMultisig=context.squads_multisig, + dao=context.dao, + question=accounts.question, + quoteVault=accounts.quote_vault, + baseVault=accounts.base_vault, + proposer=context.proposal_proposer, + payer=context.payer, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def _prepare(self, purpose: str) -> ProposalAccounts: + context = self.context + payload = memo_instruction(f"futarchy fuzz arbitrary {purpose}") + prepared = context.squads.prepare(payload, purpose=purpose) + return context.market_support.proposal_accounts( + prepared, salt=purpose.encode() + ) + + def _assert_success( + self, accounts: ProposalAccounts, before_dao: Dao + ) -> None: + context = self.context + def assert_action(action) -> None: + assert isinstance(action, ProposalAction.ExecuteArbitrary) + + assert_initialized_proposal( + context, + accounts, + before_dao, + assert_action, + ) + context.register_proposal(accounts) + + def create_baseline(self) -> ProposalAccounts: + """Create one initial arbitrary draft for immediate lifecycle coverage.""" + accounts = self._prepare("baseline arbitrary") + before = self.context.dao_state() + self.context.payer.tx( + self.compute_unit_limit(), self.build_instruction(accounts) + ) + self._assert_success(accounts, before) + return accounts + + def can_happy(self) -> bool: + return not self.context.is_liquidated() + + def happy(self) -> None: + """Initialize a fresh arbitrary proposal and validate its account graph.""" + accounts = self._prepare( + f"arbitrary {self.context.squads_transaction_index + 1}" + ) + before = self.context.dao_state() + self.context.payer.tx( + self.compute_unit_limit(), self.build_instruction(accounts) + ) + self._assert_success(accounts, before) + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() + + def unhappy(self) -> None: + """Reject a question whose oracle is not the new proposal PDA.""" + context = self.context + prepared = context.squads.prepare( + memo_instruction("invalid arbitrary proposal"), + purpose="invalid arbitrary", + ) + proposal, _ = derive_proposal( + prepared.proposal, FUTARCHY_PROGRAM_ID + ) + proposal.label = "invalid arbitrary proposal" + wrong_oracle = random.choice(context.actors) + market = context.market_support.create( + wrong_oracle, + salt=bytes(proposal.pubkey), + ) + accounts = ProposalAccounts( + proposal=proposal, + squads=prepared, + question=market.question, + base_vault=market.base_vault, + quote_vault=market.quote_vault, + base_vault_underlying=market.base_vault_underlying, + quote_vault_underlying=market.quote_vault_underlying, + fail_base_mint=market.base_mints[0], + pass_base_mint=market.base_mints[1], + fail_quote_mint=market.quote_mints[0], + pass_quote_mint=market.quote_mints[1], + ) + instruction = self.build_instruction(accounts) + self.assert_fails_atomically( + context.payer, + instruction, + AnchorError.ConstraintRaw, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/initialize_spending_limit_change_proposal.py b/fuzz/futarchy/instructions/initialize_spending_limit_change_proposal.py new file mode 100644 index 00000000..73c1ad3c --- /dev/null +++ b/fuzz/futarchy/instructions/initialize_spending_limit_change_proposal.py @@ -0,0 +1,63 @@ +"""Wrapper for ``initialize_spending_limit_change_proposal``.""" + +from __future__ import annotations + +from wake_sol import Instruction, random + +from .typed_initialize import TypedInitializeInstruction +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitialSpendingLimit, + InitializeSpendingLimitChangeProposalArgs, + ProposalAction, +) +from ..utils.parameters import ( + invalid_spending_limit, + valid_spending_limit, +) + + +class InitializeSpendingLimitChangeProposalInstruction( + TypedInitializeInstruction +): + """Check bounded members and the exact set/remove declaration.""" + + kind_name = "spending limit change" + + def happy_args(self) -> InitializeSpendingLimitChangeProposalArgs: + has_limit = self.context.dao_state().initialSpendingLimit is not None + if has_limit and random.choice((False, True)): + config = None + else: + config = valid_spending_limit(self.context.member_candidates) + return InitializeSpendingLimitChangeProposalArgs(config=config) + + def payload(self, args, accounts) -> tuple[Instruction, ...]: + instruction = FutarchyProgram.setSpendingLimit( + self.context.instructions.set_spending_limit.build_args(args.config), + dao=self.context.dao, + squadsMultisigVault=self.context.council, + eventAuthority=self.context.event_authority, + program=self.context.program_id, + ) + return (instruction,) + + def build_instruction(self, args, accounts, **overrides): + return FutarchyProgram.initializeSpendingLimitChangeProposal( + args, **self.common_accounts(accounts) + ) + + def assert_action(self, action, args) -> None: + assert isinstance(action, ProposalAction.SpendingLimitChange) + assert action.config == args.config + + def unhappy_case(self): + config, error_name = invalid_spending_limit( + self.context.member_candidates + ) + return ( + InitializeSpendingLimitChangeProposalArgs(config=config), + getattr(FutarchyProgram, error_name), + 2, + {}, + ) diff --git a/fuzz/futarchy/instructions/launch_proposal.py b/fuzz/futarchy/instructions/launch_proposal.py new file mode 100644 index 00000000..3836d00d --- /dev/null +++ b/fuzz/futarchy/instructions/launch_proposal.py @@ -0,0 +1,466 @@ +"""Snapshot-checked wrapper for Futarchy ``launch_proposal``.""" + +from __future__ import annotations + +from wake_sol import Account, random, svm + +from .base import InstructionWrapper +from ..constants import ( + FLOWS_COUNT, + FUTARCHY_PROGRAM_ID, + LIQUIDATION_FLOW_FRACTION, + PRICE_SCALE, + U128_MAX, +) +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + PoolState, + Proposal, + ProposalAction, + ProposalState, +) +from ..utils.state import ProposalAccounts +from ..utils.assertions import assert_changed_only +from ..utils.oracle import assert_initial_oracle +from ..utils.proposals import ( + is_currently_sponsored, + proposal_kind_expectation, +) +from ..utils.squads import decode_squads_proposal +from ..utils.tokens import token_balance + + +class LaunchProposalInstruction(InstructionWrapper): + """Check Draft gates and the exact Spot-to-Futarchy reserve split.""" + + @staticmethod + def requires_sponsorship(action) -> bool: + return proposal_kind_expectation(action).sponsorship == "required" + + def liquidation_lane_open(self) -> bool: + """Delay the terminal proposal until ordinary flows have had coverage.""" + return self.context.flow_num >= int( + FLOWS_COUNT * LIQUIDATION_FLOW_FRACTION + ) + + def treasury_quote_value(self) -> int: + """Value the supplied treasury quote account and council LP position.""" + context = self.context + dao = context.dao_state() + value = token_balance(context.council_quote_account) + position = context.position_accounts.get(context.council.pubkey) + if position is None or not position.exists or dao.amm.totalLiquidity == 0: + return value + from ..pytypes.futarchy import AmmPosition + + decoded = AmmPosition.decode(position.data) + spot = dao.amm.state.spot + observed_quote = min( + spot.quoteReserves, + min(U128_MAX, spot.baseReserves * spot.oracle.lastObservation) // PRICE_SCALE, + ) + return value + decoded.liquidity * observed_quote // dao.amm.totalLiquidity + + def has_minimum_market_liquidity(self) -> bool: + """Return whether each half-pool meets the current DAO launch floor.""" + context = self.context + if not context.is_spot(): + return False + dao = context.dao_state() + spot = dao.amm.state.spot + return ( + spot.baseReserves // 2 >= dao.minBaseFutarchicLiquidity + and spot.quoteReserves // 2 >= dao.minQuoteFutarchicLiquidity + ) + + @staticmethod + def squads_status(accounts: ProposalAccounts) -> str: + """Read the real Squads proposal status used by the launch guard.""" + return decode_squads_proposal(accounts.squads.proposal.data).status + + @staticmethod + def cooldown_started_at(action, dao) -> int | None: + if isinstance(action, ProposalAction.HostileTakeover): + return dao.lastFailedTakeoverAt + if isinstance(action, ProposalAction.HostileLiquidate): + return dao.lastFailedLiquidationAt + if isinstance(action, ProposalAction.BuybackToken): + return dao.lastBuybackFinalizedAt + return None + + def launchable(self) -> list[ProposalAccounts]: + context = self.context + if ( + context.is_liquidated() + or not context.is_spot() + or not self.has_minimum_market_liquidity() + ): + return [] + dao = context.dao_state() + now = svm.clock.unix_timestamp + result: list[ProposalAccounts] = [] + for accounts in context.draft_proposals(): + proposal = context.proposal_state(accounts) + assert isinstance(proposal.state, ProposalState.Draft) + sponsored = is_currently_sponsored(proposal, dao) + if ( + not sponsored + and proposal.state.amountStaked < dao.baseToStake + ): + continue + if ( + self.requires_sponsorship(proposal.action) + and not sponsored + ): + continue + expectation = proposal_kind_expectation(proposal.action) + if proposal.durationInSeconds <= expectation.twap_start_delay_seconds: + continue + cooldown_started_at = self.cooldown_started_at(proposal.action, dao) + if ( + cooldown_started_at is not None + and now < cooldown_started_at + expectation.cooldown_seconds + ): + continue + if self.squads_status(accounts) != "Active": + continue + if isinstance(proposal.action, ProposalAction.HostileLiquidate): + if not self.liquidation_lane_open(): + continue + elif isinstance(proposal.action, ProposalAction.BuybackToken): + if self.treasury_quote_value() < proposal.action.quoteAmount * 4: + continue + elif isinstance(proposal.action, ProposalAction.LargeSpend): + limit = dao.initialSpendingLimit + if limit is None: + continue + if proposal.action.amount > min( + limit.amountPerMonth * 3, 2**64 - 1 + ): + continue + if proposal.action.teamAddress != dao.teamAddress: + continue + result.append(accounts) + return result + + def default_remaining(self, proposal: ProposalAccounts) -> tuple[Account, ...]: + action = self.context.proposal_state(proposal).action + if isinstance(action, ProposalAction.BuybackToken): + accounts = [self.context.council_quote_account] + position = self.context.position_accounts.get( + self.context.council.pubkey + ) + if position is not None and position.exists: + accounts.append(position) + return tuple(sorted(accounts, key=lambda account: bytes(account.pubkey))) + return () + + def build_instruction( + self, + proposal: ProposalAccounts, + *, + remaining: tuple[Account, ...] | None = None, + ): + context = self.context + context.market_support.ensure_launch_accounts(proposal) + return FutarchyProgram.launchProposal( + proposal=proposal.proposal, + baseVault=proposal.base_vault, + quoteVault=proposal.quote_vault, + passBaseMint=proposal.pass_base_mint, + passQuoteMint=proposal.pass_quote_mint, + failBaseMint=proposal.fail_base_mint, + failQuoteMint=proposal.fail_quote_mint, + dao=context.dao, + payer=context.payer, + ammPassBaseVault=proposal.amm_pass_base_vault, + ammPassQuoteVault=proposal.amm_pass_quote_vault, + ammFailBaseVault=proposal.amm_fail_base_vault, + ammFailQuoteVault=proposal.amm_fail_quote_vault, + squadsMultisig=context.squads_multisig, + squadsProposal=proposal.squads.proposal, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + remaining_accounts=( + self.default_remaining(proposal) + if remaining is None + else remaining + ), + ) + + def can_happy(self) -> bool: + return bool(self.launchable()) + + def happy(self) -> None: + """Launch one eligible Draft and validate both conditional pools.""" + context = self.context + launchable = self.launchable() + liquidations = [ + proposal + for proposal in launchable + if isinstance( + context.proposal_state(proposal).action, + ProposalAction.HostileLiquidate, + ) + ] + accounts = random.choice(liquidations or launchable) + before_dao = context.dao_state() + before_proposal = context.proposal_state(accounts) + assert isinstance(before_dao.amm.state, PoolState.Spot) + old_spot = before_dao.amm.state.spot + if isinstance(before_proposal.action, ProposalAction.BuybackToken): + amount = before_proposal.action.quoteAmount + assert amount * 4 <= self.treasury_quote_value() + if amount * 4 > context.council_quote_balance(): + self.assert_fails_atomically( + context.payer, + self.build_instruction( + accounts, remaining=(context.council_quote_account,) + ), + FutarchyProgram.BuybackCapExceeded, + before=(self.compute_unit_limit(),), + ) + instruction = self.build_instruction(accounts) + context.payer.tx(self.compute_unit_limit(), instruction) + after_dao = context.dao_state() + after_proposal = Proposal.decode(accounts.proposal.data) + assert isinstance(after_dao.amm.state, PoolState.Futarchy) + assert_changed_only( + before_dao, after_dao, amm=after_dao.amm, seqNum=before_dao.seqNum + 1 + ) + assert_changed_only(before_dao.amm, after_dao.amm, state=after_dao.amm.state) + assert_changed_only( + before_proposal, + after_proposal, + state=ProposalState.Pending(), + timestampEnqueued=svm.clock.unix_timestamp, + ) + context.squads.assert_proposal_status(accounts.squads, "Active") + base_half = old_spot.baseReserves // 2 + quote_half = old_spot.quoteReserves // 2 + assert_changed_only( + old_spot, after_dao.amm.state.spot, + baseReserves=old_spot.baseReserves - base_half, + quoteReserves=old_spot.quoteReserves - quote_half, + ) + for pool in (after_dao.amm.state.pass_, after_dao.amm.state.fail): + assert pool.baseReserves == base_half + assert pool.quoteReserves == quote_half + assert pool.baseProtocolFeeBalance == 0 + assert pool.quoteProtocolFeeBalance == 0 + assert_initial_oracle( + pool.oracle, + before_dao, + svm.clock.unix_timestamp, + proposal_kind_expectation(before_proposal.action).twap_start_delay_seconds, + ) + for account in ( + accounts.amm_pass_base_vault, + accounts.amm_pass_quote_vault, + accounts.amm_fail_base_vault, + accounts.amm_fail_quote_vault, + ): + assert account is not None and account.exists + + def negative_candidates(self): + context = self.context + if not context.proposals: + return [] + dao = context.dao_state() + now = svm.clock.unix_timestamp + result = [] + for accounts in context.draft_proposals(): + proposal = context.proposal_state(accounts) + assert isinstance(proposal.state, ProposalState.Draft) + if context.is_liquidated(): + result.append((accounts, FutarchyProgram.DaoLiquidated, None)) + continue + sponsored = is_currently_sponsored(proposal, dao) + if ( + not sponsored + and proposal.state.amountStaked < dao.baseToStake + ): + result.append( + ( + accounts, + FutarchyProgram.InsufficientStakeToLaunch, + None, + ) + ) + elif ( + self.requires_sponsorship(proposal.action) + and not sponsored + ): + result.append( + (accounts, FutarchyProgram.ProposalNotTeamSponsored, None) + ) + else: + expectation = proposal_kind_expectation(proposal.action) + if ( + proposal.durationInSeconds + <= expectation.twap_start_delay_seconds + ): + result.append( + ( + accounts, + FutarchyProgram.ProposalDurationTooShort, + None, + ) + ) + continue + cooldown_started_at = self.cooldown_started_at( + proposal.action, dao + ) + if ( + cooldown_started_at is not None + and now + < cooldown_started_at + expectation.cooldown_seconds + ): + result.append( + ( + accounts, + FutarchyProgram.ProposalKindCooldownActive, + None, + ) + ) + continue + if self.squads_status(accounts) != "Active": + result.append( + ( + accounts, + FutarchyProgram.InvalidSquadsProposalStatus, + None, + ) + ) + continue + + valid_action_accounts = True + if isinstance(proposal.action, ProposalAction.LargeSpend): + limit = dao.initialSpendingLimit + if limit is None: + result.append( + (accounts, FutarchyProgram.NoSpendingLimit, None) + ) + valid_action_accounts = False + elif proposal.action.amount > min( + limit.amountPerMonth * 3, 2**64 - 1 + ): + result.append( + (accounts, FutarchyProgram.SpendCapExceeded, None) + ) + valid_action_accounts = False + elif proposal.action.teamAddress != dao.teamAddress: + result.append( + (accounts, FutarchyProgram.StaleTeamAddress, None) + ) + valid_action_accounts = False + else: + result.append( + ( + accounts, + FutarchyProgram.UnexpectedLaunchAccounts, + (context.outsider,), + ) + ) + elif isinstance(proposal.action, ProposalAction.BuybackToken): + if not context.is_spot(): + result.append( + ( + accounts, + FutarchyProgram.PoolNotInSpotState, + None, + ) + ) + continue + result.extend( + ( + (accounts, FutarchyProgram.BuybackCapExceeded, ()), + ( + accounts, + FutarchyProgram.InvalidTreasuryAccount, + (context.outsider,), + ), + ( + accounts, + FutarchyProgram.TreasuryAccountsNotSorted, + ( + context.council_quote_account, + context.council_quote_account, + ), + ), + ) + ) + valid_action_accounts = ( + self.treasury_quote_value() + >= proposal.action.quoteAmount * 4 + ) + if not valid_action_accounts: + result.append( + ( + accounts, + FutarchyProgram.BuybackCapExceeded, + self.default_remaining(accounts), + ) + ) + elif ( + context.council_quote_balance() < proposal.action.quoteAmount * 4 + ): + result.append( + ( + accounts, + FutarchyProgram.BuybackCapExceeded, + (context.council_quote_account,), + ) + ) + else: + result.append( + ( + accounts, + FutarchyProgram.UnexpectedLaunchAccounts, + (context.outsider,), + ) + ) + + if not valid_action_accounts: + continue + valid_remaining = self.default_remaining(accounts) + if not context.is_spot(): + result.append( + ( + accounts, + FutarchyProgram.PoolNotInSpotState, + valid_remaining, + ) + ) + elif not self.has_minimum_market_liquidity(): + result.append( + ( + accounts, + FutarchyProgram.InsufficientLiquidity, + valid_remaining, + ) + ) + return result + + def can_unhappy(self) -> bool: + return bool(self.negative_candidates()) + + def unhappy(self) -> None: + """Reject one unsatisfied launch gate and prove no accounts are created.""" + launchable = self.launchable() + if launchable and random.choice((False, False, True)): + accounts = random.choice(launchable) + self.context.squads.approve_for_dependency(accounts.squads) + expected = FutarchyProgram.InvalidSquadsProposalStatus + remaining = self.default_remaining(accounts) + else: + accounts, expected, remaining = random.choice( + self.negative_candidates() + ) + instruction = self.build_instruction(accounts, remaining=remaining) + self.assert_fails_atomically( + self.context.payer, + instruction, + expected, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/provide_liquidity.py b/fuzz/futarchy/instructions/provide_liquidity.py new file mode 100644 index 00000000..5002743d --- /dev/null +++ b/fuzz/futarchy/instructions/provide_liquidity.py @@ -0,0 +1,217 @@ +"""Snapshot-checked wrapper for Futarchy ``provide_liquidity``.""" + +from __future__ import annotations + +from wake_sol import Account, AnchorError, random + +from .base import InstructionWrapper +from ..constants import ( + FUTARCHY_PROGRAM_ID, + INITIAL_LIQUIDITY_SCALE, + MIN_QUOTE_LIQUIDITY, + TOKEN_SCALE, +) +from ..pytypes.futarchy import ( + AmmPosition, + Dao, + Futarchy as FutarchyProgram, + PoolState, + ProvideLiquidityParams, +) +from ..utils.accounts import derive_amm_position +from ..utils.tokens import token_balance +from ..utils.assertions import assert_changed_only + + +class ProvideLiquidityInstruction(InstructionWrapper): + """Check deposits, reserve growth, and immutable LP-position accounting.""" + + @staticmethod + def build_params( + quote: int, maximum_base: int, minimum_liquidity: int, authority: Account + ) -> ProvideLiquidityParams: + return ProvideLiquidityParams( + quoteAmount=quote, + maxBaseAmount=maximum_base, + minLiquidity=minimum_liquidity, + positionAuthority=authority.pubkey, + ) + + def position(self, authority: Account) -> Account: + context = self.context + account = context.position_accounts.get(authority.pubkey) + if account is None: + account, _ = derive_amm_position( + context.dao, authority, FUTARCHY_PROGRAM_ID + ) + account.label = f"AMM position for {authority.label}" + context.position_accounts[authority.pubkey] = account + return account + + def build_instruction( + self, + provider: Account, + authority: Account, + params: ProvideLiquidityParams, + ): + context = self.context + return FutarchyProgram.provideLiquidity( + params, + dao=context.dao, + liquidityProvider=provider, + liquidityProviderBaseAccount=context.base_atas_by_owner[ + provider.pubkey + ], + liquidityProviderQuoteAccount=context.quote_atas_by_owner[ + provider.pubkey + ], + payer=context.payer, + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + ammPosition=self.position(authority), + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + @staticmethod + def amounts(dao: Dao, quote: int, maximum_base: int) -> tuple[int, int]: + """Independently calculate the exact base and LP amounts for assertions.""" + assert isinstance(dao.amm.state, PoolState.Spot) + spot = dao.amm.state.spot + if dao.amm.totalLiquidity == 0: + return maximum_base, quote * INITIAL_LIQUIDITY_SCALE + base = (quote * spot.baseReserves + spot.quoteReserves - 1) // ( + spot.quoteReserves + ) + liquidity = quote * dao.amm.totalLiquidity // spot.quoteReserves + return base, liquidity + + def initial_providers(self) -> list[Account]: + """Return actors able to satisfy both sides of an initial deposit.""" + context = self.context + return [ + actor + for actor in context.actors + if token_balance(context.quote_atas_by_owner[actor.pubkey]) + >= MIN_QUOTE_LIQUIDITY + and token_balance(context.base_atas_by_owner[actor.pubkey]) > 0 + ] + + def can_happy(self) -> bool: + context = self.context + if context.is_liquidated() or not context.is_spot(): + return False + dao = context.dao_state() + if dao.amm.totalLiquidity == 0: + return bool(self.initial_providers()) + return bool(context.affordable_providers(10_000 * TOKEN_SCALE)) + + def happy(self) -> None: + """Provide initial or proportional liquidity and check exact deltas.""" + context = self.context + before_dao = context.dao_state() + if before_dao.amm.totalLiquidity == 0: + provider = random.choice(self.initial_providers()) + maximum_quote = min( + token_balance(context.quote_atas_by_owner[provider.pubkey]), + 10_000 * TOKEN_SCALE, + ) + quote = random.randint(MIN_QUOTE_LIQUIDITY, maximum_quote) + maximum_base = min( + token_balance(context.base_atas_by_owner[provider.pubkey]), + max(1, quote * 20), + ) + else: + provider, maximum_quote = random.choice( + context.affordable_providers(10_000 * TOKEN_SCALE) + ) + quote = random.randint(1, maximum_quote) + maximum_base, _ = self.amounts(before_dao, quote, 0) + authority = random.choice((provider, provider, context.council)) + base, liquidity = self.amounts(before_dao, quote, maximum_base) + requested_base = ( + base + if before_dao.amm.totalLiquidity == 0 + else base + random.randint(0, 3) + ) + base, liquidity = self.amounts(before_dao, quote, requested_base) + params = self.build_params( + quote, + requested_base, + random.randint(0 if before_dao.amm.totalLiquidity == 0 else 1, liquidity), + authority, + ) + instruction = self.build_instruction(provider, authority, params) + position = self.position(authority) + tracked = ( + context.dao, + context.base_atas_by_owner[provider.pubkey], + context.quote_atas_by_owner[provider.pubkey], + context.amm_base_vault, + context.amm_quote_vault, + position, + ) + before = self.snapshot(instruction, extra_accounts=tracked) + provider.tx(instruction, signers=[context.payer]) + after = self.snapshot(instruction, extra_accounts=tracked) + after_dao = after.decode(context.dao, Dao) + assert isinstance(after_dao.amm.state, PoolState.Spot) + assert_changed_only( + before_dao, after_dao, amm=after_dao.amm, seqNum=before_dao.seqNum + 1 + ) + assert_changed_only( + before_dao.amm, after_dao.amm, state=after_dao.amm.state, + totalLiquidity=before_dao.amm.totalLiquidity + liquidity, + ) + assert_changed_only( + before_dao.amm.state.spot, after_dao.amm.state.spot, + baseReserves=before_dao.amm.state.spot.baseReserves + base, + quoteReserves=before_dao.amm.state.spot.quoteReserves + quote, + ) + old_position = ( + 0 + if not before.account(position).exists + else before.decode(position, AmmPosition).liquidity + ) + decoded_position = after.decode(position, AmmPosition) + assert decoded_position.dao == context.dao.pubkey + assert decoded_position.positionAuthority == authority.pubkey + assert decoded_position.liquidity == old_position + liquidity + assert token_balance(context.base_atas_by_owner[provider.pubkey]) == ( + before.account( + context.base_atas_by_owner[provider.pubkey] + ).token_balance() + - base + ) + assert token_balance(context.quote_atas_by_owner[provider.pubkey]) == ( + before.account( + context.quote_atas_by_owner[provider.pubkey] + ).token_balance() + - quote + ) + + def can_unhappy(self) -> bool: + return bool(self.context.actors) + + def unhappy(self) -> None: + """Reject one state-appropriate invalid deposit and prove rollback.""" + context = self.context + provider = random.choice(context.actors) + authority = provider + dao = context.dao_state() + if context.is_liquidated(): + params = self.build_params(1, 1, 0, authority) + expected = FutarchyProgram.DaoLiquidated + elif not context.is_spot(): + params = self.build_params(1, 1, 1, authority) + expected = FutarchyProgram.PoolNotInSpotState + elif dao.amm.totalLiquidity == 0: + params = self.build_params( + MIN_QUOTE_LIQUIDITY - 1, 1, 0, authority + ) + expected = AnchorError.RequireGteViolated + else: + params = self.build_params(1, 2**64 - 1, 0, authority) + expected = AnchorError.RequireGtViolated + instruction = self.build_instruction(provider, authority, params) + self.assert_fails_atomically(provider, instruction, expected) diff --git a/fuzz/futarchy/instructions/registry.py b/fuzz/futarchy/instructions/registry.py new file mode 100644 index 00000000..67ebab3f --- /dev/null +++ b/fuzz/futarchy/instructions/registry.py @@ -0,0 +1,118 @@ +"""Construct one concise wrapper registry for every in-scope instruction.""" + +from __future__ import annotations + +from typing import Any + +from .admin_cancel_proposal import AdminCancelProposalInstruction +from .admin_enqueue_multisig_proposal_cancellation import ( + AdminEnqueueMultisigProposalCancellationInstruction, +) +from .admin_enqueue_multisig_proposal_approval import ( + AdminEnqueueMultisigProposalApprovalInstruction, +) +from .admin_execute_multisig_proposal import AdminExecuteMultisigProposalInstruction +from .admin_remove_proposal import AdminRemoveProposalInstruction +from .admin_update_proposal_params import AdminUpdateProposalParamsInstruction +from .base import InstructionWrapper +from .collect_fees import CollectFeesInstruction +from .conditional_swap import ConditionalSwapInstruction +from .execute_multisig_proposal_approval import ( + ExecuteMultisigProposalApprovalInstruction, +) +from .execute_multisig_proposal_cancellation import ( + ExecuteMultisigProposalCancellationInstruction, +) +from .execute_passed_payload import ExecutePassedPayloadInstruction +from .finalize_proposal import FinalizeProposalInstruction +from .initialize_buyback_token_proposal import ( + InitializeBuybackTokenProposalInstruction, +) +from .initialize_dao import InitializeDaoInstruction +from .initialize_hostile_liquidate_proposal import ( + InitializeHostileLiquidateProposalInstruction, +) +from .initialize_hostile_takeover_proposal import ( + InitializeHostileTakeoverProposalInstruction, +) +from .initialize_large_spend_proposal import InitializeLargeSpendProposalInstruction +from .initialize_mint_tokens_proposal import InitializeMintTokensProposalInstruction +from .initialize_proposal import InitializeProposalInstruction +from .initialize_spending_limit_change_proposal import ( + InitializeSpendingLimitChangeProposalInstruction, +) +from .launch_proposal import LaunchProposalInstruction +from .provide_liquidity import ProvideLiquidityInstruction +from .set_spending_limit import SetSpendingLimitInstruction +from .sponsor_proposal import SponsorProposalInstruction +from .spot_swap import SpotSwapInstruction +from .stake_to_proposal import StakeToProposalInstruction +from .sync_spending_limit import SyncSpendingLimitInstruction +from .unstake_from_proposal import UnstakeFromProposalInstruction +from .update_dao import UpdateDaoInstruction +from .withdraw_liquidity import WithdrawLiquidityInstruction + + +class FutarchyInstructions: + """Expose wrappers under names matching the Rust public API.""" + + def __init__(self, context: Any) -> None: + self.initialize_dao = InitializeDaoInstruction(context) + self.initialize_proposal = InitializeProposalInstruction(context) + self.initialize_large_spend_proposal = ( + InitializeLargeSpendProposalInstruction(context) + ) + self.initialize_mint_tokens_proposal = ( + InitializeMintTokensProposalInstruction(context) + ) + self.initialize_spending_limit_change_proposal = ( + InitializeSpendingLimitChangeProposalInstruction(context) + ) + self.initialize_hostile_takeover_proposal = ( + InitializeHostileTakeoverProposalInstruction(context) + ) + self.initialize_hostile_liquidate_proposal = ( + InitializeHostileLiquidateProposalInstruction(context) + ) + self.initialize_buyback_token_proposal = ( + InitializeBuybackTokenProposalInstruction(context) + ) + self.stake_to_proposal = StakeToProposalInstruction(context) + self.unstake_from_proposal = UnstakeFromProposalInstruction(context) + self.launch_proposal = LaunchProposalInstruction(context) + self.finalize_proposal = FinalizeProposalInstruction(context) + self.update_dao = UpdateDaoInstruction(context) + self.set_spending_limit = SetSpendingLimitInstruction(context) + self.sync_spending_limit = SyncSpendingLimitInstruction(context) + self.spot_swap = SpotSwapInstruction(context) + self.conditional_swap = ConditionalSwapInstruction(context) + self.provide_liquidity = ProvideLiquidityInstruction(context) + self.withdraw_liquidity = WithdrawLiquidityInstruction(context) + self.collect_fees = CollectFeesInstruction(context) + self.sponsor_proposal = SponsorProposalInstruction(context) + self.admin_enqueue_multisig_proposal_approval = ( + AdminEnqueueMultisigProposalApprovalInstruction(context) + ) + self.execute_multisig_proposal_approval = ( + ExecuteMultisigProposalApprovalInstruction(context) + ) + self.admin_enqueue_multisig_proposal_cancellation = ( + AdminEnqueueMultisigProposalCancellationInstruction(context) + ) + self.execute_multisig_proposal_cancellation = ( + ExecuteMultisigProposalCancellationInstruction(context) + ) + self.admin_execute_multisig_proposal = ( + AdminExecuteMultisigProposalInstruction(context) + ) + self.admin_cancel_proposal = AdminCancelProposalInstruction(context) + self.admin_remove_proposal = AdminRemoveProposalInstruction(context) + self.admin_update_proposal_params = ( + AdminUpdateProposalParamsInstruction(context) + ) + self.execute_passed_payload = ExecutePassedPayloadInstruction(context) + + @staticmethod + def compute_limit(): + """Expose the shared compute-budget instruction to setup utilities.""" + return InstructionWrapper.compute_unit_limit() diff --git a/fuzz/futarchy/instructions/set_spending_limit.py b/fuzz/futarchy/instructions/set_spending_limit.py new file mode 100644 index 00000000..7eafdfa4 --- /dev/null +++ b/fuzz/futarchy/instructions/set_spending_limit.py @@ -0,0 +1,94 @@ +"""Snapshot wrapper for vault-signed Futarchy ``set_spending_limit``.""" + +from __future__ import annotations + +from wake_sol import random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + InitialSpendingLimit, + SetSpendingLimitArgs, +) +from ..utils.parameters import ( + invalid_spending_limit, + valid_spending_limit, +) +from ..utils.assertions import assert_changed_only + + +class SetSpendingLimitInstruction(InstructionWrapper): + """Check exact authoritative config replacement and dirty-flag behavior.""" + + @staticmethod + def build_args( + config: InitialSpendingLimit | None, + ) -> SetSpendingLimitArgs: + return SetSpendingLimitArgs(config=config) + + def build_instruction(self, args: SetSpendingLimitArgs): + context = self.context + return FutarchyProgram.setSpendingLimit( + args, + dao=context.dao, + squadsMultisigVault=context.council, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and self.context.is_spot() + + def happy(self) -> None: + """Set or remove the desired Squads limit through normal execution.""" + context = self.context + config = ( + None + if context.dao_state().initialSpendingLimit is not None + and random.choice((False, True)) + else valid_spending_limit(context.member_candidates) + ) + args = self.build_args(config) + inner = self.build_instruction(args) + prepared = context.squads.prepare_and_approve( + inner, purpose="set spending limit" + ) + before = context.dao_state() + spending_limit_existed = context.squads_spending_limit.exists + spending_limit_before = ( + bytes(context.squads_spending_limit.data) + if spending_limit_existed + else None + ) + context.squads.execute_top_level(prepared) + after = context.dao_state() + assert_changed_only( + before, after, seqNum=before.seqNum + 1, + initialSpendingLimit=config, spendingLimitDirty=True, + ) + assert context.squads_spending_limit.exists == spending_limit_existed + if spending_limit_existed: + assert bytes(context.squads_spending_limit.data) == spending_limit_before + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() and self.context.is_spot() + + def unhappy(self) -> None: + """Reject more than ten spending-limit members and prove rollback.""" + context = self.context + config, error_name = invalid_spending_limit(context.member_candidates) + inner = self.build_instruction(self.build_args(config)) + prepared = context.squads.prepare_and_approve( + inner, purpose="invalid spending limit" + ) + execute = context.squads.build_execute(prepared) + self.assert_fails_atomically( + context.payer, + execute, + getattr(FutarchyProgram, error_name), + before=(context.squads.compute_unit_limit(),), + signers=(context.permissionless_account,), + extra_accounts=(context.dao,), + ) + prepared.disabled = True diff --git a/fuzz/futarchy/instructions/sponsor_proposal.py b/fuzz/futarchy/instructions/sponsor_proposal.py new file mode 100644 index 00000000..1db1b8a2 --- /dev/null +++ b/fuzz/futarchy/instructions/sponsor_proposal.py @@ -0,0 +1,118 @@ +"""Snapshot-checked wrapper for Futarchy ``sponsor_proposal``.""" + +from __future__ import annotations + +from wake_sol import Account, AnchorError, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + Proposal, +) +from ..utils.proposals import ( + is_currently_sponsored, + proposal_kind_expectation, +) +from ..utils.state import ProposalAccounts +from ..utils.assertions import assert_changed_only + + +class SponsorProposalInstruction(InstructionWrapper): + """Check that only the current team can sponsor a Draft exactly once.""" + + def build_instruction( + self, proposal: ProposalAccounts, team: Account + ): + context = self.context + return FutarchyProgram.sponsorProposal( + proposal=proposal.proposal, + dao=context.dao, + teamAddress=team, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def unsponsored(self) -> list[ProposalAccounts]: + dao = self.context.dao_state() + return [ + proposal + for proposal in self.context.draft_proposals() + if not is_currently_sponsored( + self.context.proposal_state(proposal), dao + ) + and proposal_kind_expectation( + self.context.proposal_state(proposal).action + ).sponsorship + != "forbidden" + ] + + def sponsored(self) -> list[ProposalAccounts]: + dao = self.context.dao_state() + return [ + proposal + for proposal in self.context.draft_proposals() + if is_currently_sponsored( + self.context.proposal_state(proposal), dao + ) + ] + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and bool(self.unsponsored()) + + def happy(self) -> None: + """Sponsor one Draft and verify only its flag plus DAO sequence moves.""" + context = self.context + unsponsored = self.unsponsored() + proposal = random.choice(unsponsored) + team = context.signers_by_pubkey[context.dao_state().teamAddress] + before_dao = context.dao_state() + before_proposal = context.proposal_state(proposal) + instruction = self.build_instruction(proposal, team) + team.tx(instruction) + after_dao = context.dao_state() + after_proposal = Proposal.decode(proposal.proposal.data) + assert_changed_only(before_dao, after_dao, seqNum=before_dao.seqNum + 1) + assert_changed_only(before_proposal, after_proposal, sponsoredBy=team.pubkey) + assert before_proposal.sponsoredBy != before_dao.teamAddress + + def negative_candidates(self): + context = self.context + dao = context.dao_state() + candidates = [] + for proposal in context.draft_proposals(): + decoded = context.proposal_state(proposal) + policy = proposal_kind_expectation(decoded.action).sponsorship + if policy == "forbidden": + candidates.append( + ( + proposal, + context.signers_by_pubkey[dao.teamAddress], + FutarchyProgram.TeamSponsorshipForbidden, + ) + ) + elif is_currently_sponsored(decoded, dao): + candidates.append( + ( + proposal, + context.signers_by_pubkey[dao.teamAddress], + FutarchyProgram.ProposalAlreadySponsored, + ) + ) + else: + candidates.append( + (proposal, context.outsider, AnchorError.ConstraintHasOne) + ) + return candidates + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() and bool( + self.negative_candidates() + ) + + def unhappy(self) -> None: + """Reject duplicate sponsorship or a signer that is not the team.""" + context = self.context + proposal, team, expected = random.choice(self.negative_candidates()) + instruction = self.build_instruction(proposal, team) + self.assert_fails_atomically(team, instruction, expected) diff --git a/fuzz/futarchy/instructions/spot_swap.py b/fuzz/futarchy/instructions/spot_swap.py new file mode 100644 index 00000000..3dec8906 --- /dev/null +++ b/fuzz/futarchy/instructions/spot_swap.py @@ -0,0 +1,213 @@ +"""Snapshot-checked wrapper for Futarchy ``spot_swap``.""" + +from __future__ import annotations + +from wake_sol import Account, AnchorError, random, svm + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID, MAX_BPS, U64_MAX +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + Market, + PoolState, + SpotSwapParams, + SwapType, +) +from ..utils.swaps import PROTOCOL_TAKER_FEE_BPS, assert_swap_transition +from ..utils.tokens import token_balance + + +class SpotSwapInstruction(InstructionWrapper): + """Check exact user/vault transfers and AMM reserve-product monotonicity.""" + + @staticmethod + def build_params( + direction: SwapType, amount: int, minimum_output: int = 0 + ) -> SpotSwapParams: + return SpotSwapParams( + inputAmount=amount, + swapType=direction, + minOutputAmount=minimum_output, + ) + + def build_instruction(self, user: Account, params: SpotSwapParams): + context = self.context + return FutarchyProgram.spotSwap( + params, + dao=context.dao, + userBaseAccount=context.base_atas_by_owner[user.pubkey], + userQuoteAccount=context.quote_atas_by_owner[user.pubkey], + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + user=user, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + context = self.context + if not context.pool_has_liquidity(): + return False + return ( + not context.hostile_liquidation_market_active() + and bool(self.funded_trades()) + ) + + def funded_trades(self) -> list[tuple[Account, SwapType]]: + """Return user/direction pairs with a positive input balance.""" + context = self.context + return [ + (user, direction) + for user in context.actors + for direction in (SwapType.Buy, SwapType.Sell) + if token_balance( + context.quote_atas_by_owner[user.pubkey] + if direction == SwapType.Buy + else context.base_atas_by_owner[user.pubkey] + ) + > 0 + ] + + def happy(self) -> None: + """Swap in either direction and infer/check output from token deltas.""" + context = self.context + user, direction = random.choice(self.funded_trades()) + input_account = ( + context.quote_atas_by_owner[user.pubkey] + if direction == SwapType.Buy + else context.base_atas_by_owner[user.pubkey] + ) + output_account = ( + context.base_atas_by_owner[user.pubkey] + if direction == SwapType.Buy + else context.quote_atas_by_owner[user.pubkey] + ) + amm_input = ( + context.amm_quote_vault + if direction == SwapType.Buy + else context.amm_base_vault + ) + amm_output = ( + context.amm_base_vault + if direction == SwapType.Buy + else context.amm_quote_vault + ) + amount = random.randint( + 1, min(token_balance(input_account), 100_000_000) + ) + params = self.build_params(direction, amount) + instruction = self.build_instruction(user, params) + tracked = ( + context.dao, + input_account, + output_account, + amm_input, + amm_output, + ) + before_dao = context.dao_state() + timestamp = svm.clock.unix_timestamp + before = self.snapshot(instruction, extra_accounts=tracked) + user.tx(self.compute_unit_limit(), instruction) + after_dao = context.dao_state() + assert token_balance(input_account) == ( + before.account(input_account).token_balance() - amount + ) + assert token_balance(amm_input) == ( + before.account(amm_input).token_balance() + amount + ) + output = token_balance(output_account) - before.account( + output_account + ).token_balance() + assert output >= params.minOutputAmount + assert token_balance(amm_output) == ( + before.account(amm_output).token_balance() - output + ) + assert_swap_transition( + before_dao, after_dao, Market.Spot, direction, amount, output, timestamp + ) + + if isinstance(before_dao.amm.state, PoolState.Spot): + assert isinstance(after_dao.amm.state, PoolState.Spot) + old = before_dao.amm.state.spot + new = after_dao.amm.state.spot + net_input = ( + amount * (MAX_BPS - PROTOCOL_TAKER_FEE_BPS) // MAX_BPS + ) + protocol_fee = amount - net_input + if direction == SwapType.Buy: + expected_output = ( + net_input * old.baseReserves + // (old.quoteReserves + net_input) + ) + assert new.quoteReserves == old.quoteReserves + net_input + assert new.baseReserves == old.baseReserves - expected_output + assert new.quoteProtocolFeeBalance == ( + old.quoteProtocolFeeBalance + protocol_fee + ) + assert ( + new.baseProtocolFeeBalance + == old.baseProtocolFeeBalance + ) + else: + expected_output = ( + net_input * old.quoteReserves + // (old.baseReserves + net_input) + ) + assert new.baseReserves == old.baseReserves + net_input + assert new.quoteReserves == old.quoteReserves - expected_output + assert new.baseProtocolFeeBalance == ( + old.baseProtocolFeeBalance + protocol_fee + ) + assert ( + new.quoteProtocolFeeBalance + == old.quoteProtocolFeeBalance + ) + assert output == expected_output + + def can_unhappy(self) -> bool: + return self.context.pool_has_liquidity() + + def unhappy(self) -> None: + """Reject insufficient input or positive impossible slippage.""" + context = self.context + user = random.choice(context.actors) + direction = random.choice((SwapType.Buy, SwapType.Sell)) + input_account = ( + context.quote_atas_by_owner[user.pubkey] + if direction == SwapType.Buy + else context.base_atas_by_owner[user.pubkey] + ) + if random.choice((False, True)): + params = self.build_params( + direction, token_balance(input_account) + 1 + ) + expected = FutarchyProgram.InsufficientBalance + else: + funded = self.funded_trades() + if not funded: + params = self.build_params( + direction, token_balance(input_account) + 1 + ) + expected = FutarchyProgram.InsufficientBalance + else: + user, direction = random.choice(funded) + input_account = ( + context.quote_atas_by_owner[user.pubkey] + if direction == SwapType.Buy + else context.base_atas_by_owner[user.pubkey] + ) + params = self.build_params( + direction, + random.randint( + 1, min(token_balance(input_account), 100_000_000) + ), + U64_MAX, + ) + expected = AnchorError.RequireGteViolated + instruction = self.build_instruction(user, params) + self.assert_fails_atomically( + user, + instruction, + expected, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/stake_to_proposal.py b/fuzz/futarchy/instructions/stake_to_proposal.py new file mode 100644 index 00000000..0a279441 --- /dev/null +++ b/fuzz/futarchy/instructions/stake_to_proposal.py @@ -0,0 +1,122 @@ +"""Snapshot-checked wrapper for Futarchy ``stake_to_proposal``.""" + +from __future__ import annotations + +from wake_sol import Account, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + Proposal, + ProposalState, + StakeAccount, + StakeToProposalParams, +) +from ..utils.state import ProposalAccounts +from ..utils.tokens import token_balance +from ..utils.assertions import assert_changed_only + + +class StakeToProposalInstruction(InstructionWrapper): + """Check stake custody, proposal aggregate, and canonical stake records.""" + + @staticmethod + def build_params(amount: int) -> StakeToProposalParams: + return StakeToProposalParams(amount=amount) + + def build_instruction( + self, proposal: ProposalAccounts, staker: Account, amount: int + ): + context = self.context + stake = context.stake_account(proposal, staker) + return FutarchyProgram.stakeToProposal( + self.build_params(amount), + proposal=proposal.proposal, + dao=context.dao, + stakerBaseAccount=context.base_atas_by_owner[staker.pubkey], + proposalBaseAccount=proposal.proposal_base_account, + stakeAccount=stake, + staker=staker, + payer=context.payer, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def candidates(self) -> list[tuple[ProposalAccounts, Account]]: + context = self.context + return [ + (proposal, actor) + for proposal in context.draft_proposals() + for actor in context.actors + if token_balance(context.base_atas_by_owner[actor.pubkey]) > 0 + ] + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and bool(self.candidates()) + + def happy(self) -> None: + """Create/top up one stake and verify all three accounting locations.""" + context = self.context + proposal, staker = random.choice(self.candidates()) + source = context.base_atas_by_owner[staker.pubkey] + custody = proposal.proposal_base_account + stake = context.stake_account(proposal, staker) + amount = random.randint(1, min(token_balance(source), 250_000_000_000)) + instruction = self.build_instruction(proposal, staker, amount) + before_dao = context.dao_state() + before_proposal = context.proposal_state(proposal) + before = self.snapshot( + instruction, + extra_accounts=(context.dao, proposal.proposal, source, custody, stake), + ) + context.payer.tx(instruction, signers=[staker]) + after_dao = context.dao_state() + after_proposal = Proposal.decode(proposal.proposal.data) + after_stake = StakeAccount.decode(stake.data) + assert isinstance(before_proposal.state, ProposalState.Draft) + assert_changed_only(before_dao, after_dao, seqNum=before_dao.seqNum + 1) + assert_changed_only( + before_proposal, after_proposal, + state=ProposalState.Draft(before_proposal.state.amountStaked + amount), + ) + old_stake = ( + 0 + if not before.account(stake).exists + else before.decode(stake, StakeAccount).amount + ) + assert after_stake.proposal == proposal.proposal.pubkey + assert after_stake.staker == staker.pubkey + assert after_stake.amount == old_stake + amount + if before.account(stake).exists: + assert_changed_only( + before.decode(stake, StakeAccount), after_stake, amount=old_stake + amount + ) + assert token_balance(source) == ( + before.account(source).token_balance() - amount + ) + assert token_balance(custody) == ( + before.account(custody).token_balance() + amount + ) + + def can_unhappy(self) -> bool: + return bool(self.context.draft_proposals()) + + def unhappy(self) -> None: + """Reject zero, over-balance, or post-liquidation stakes atomically.""" + context = self.context + proposal = random.choice(context.draft_proposals()) + staker = random.choice(context.actors) + if context.is_liquidated(): + amount = 0 + expected = FutarchyProgram.DaoLiquidated + elif random.choice((False, True)): + amount = 0 + expected = FutarchyProgram.InvalidAmount + else: + amount = token_balance(context.base_atas_by_owner[staker.pubkey]) + 1 + expected = FutarchyProgram.InsufficientTokenBalance + instruction = self.build_instruction(proposal, staker, amount) + self.assert_fails_atomically( + context.payer, instruction, expected, signers=(staker,) + ) diff --git a/fuzz/futarchy/instructions/sync_spending_limit.py b/fuzz/futarchy/instructions/sync_spending_limit.py new file mode 100644 index 00000000..5652c2b7 --- /dev/null +++ b/fuzz/futarchy/instructions/sync_spending_limit.py @@ -0,0 +1,56 @@ +"""Snapshot wrapper for Futarchy ``sync_spending_limit``.""" + +from __future__ import annotations + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID, SQUADS_PROGRAM_ID +from ..pytypes.futarchy import Futarchy as FutarchyProgram +from ..utils.assertions import assert_changed_only + + +class SyncSpendingLimitInstruction(InstructionWrapper): + """Check projection of the DAO record into Squads and dirty consumption.""" + + def build_instruction(self): + context = self.context + return FutarchyProgram.syncSpendingLimit( + dao=context.dao, + squadsMultisig=context.squads_multisig, + spendingLimit=context.squads_spending_limit, + rentPayer=context.payer, + squadsProgram=SQUADS_PROGRAM_ID, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return self.context.dao_state().spendingLimitDirty + + def happy(self) -> None: + """Recreate/remove Squads state and consume exactly one dirty write.""" + context = self.context + before = context.dao_state() + context.payer.tx(self.compute_unit_limit(), self.build_instruction()) + after = context.dao_state() + assert_changed_only( + before, after, seqNum=before.seqNum + 1, spendingLimitDirty=False + ) + should_exist = ( + before.liquidator is None and before.initialSpendingLimit is not None + ) + assert context.squads_spending_limit.exists == should_exist + projected_config = before.initialSpendingLimit if should_exist else None + context.squads.assert_spending_limit(projected_config) + + def can_unhappy(self) -> bool: + return not self.context.dao_state().spendingLimitDirty + + def unhappy(self) -> None: + """Reject an ungated monthly-budget reset and prove rollback.""" + instruction = self.build_instruction() + self.assert_fails_atomically( + self.context.payer, + instruction, + FutarchyProgram.SpendingLimitNotDirty, + before=(self.compute_unit_limit(),), + ) diff --git a/fuzz/futarchy/instructions/typed_initialize.py b/fuzz/futarchy/instructions/typed_initialize.py new file mode 100644 index 00000000..503b627f --- /dev/null +++ b/fuzz/futarchy/instructions/typed_initialize.py @@ -0,0 +1,166 @@ +"""Shared mechanics for the six typed proposal initializer wrappers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from wake_sol import Instruction + +from .base import InstructionWrapper +from ..constants import BINARY_QUESTION_OUTCOMES, FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import Dao +from ..utils.accounts import derive_proposal +from ..utils.proposals import assert_initialized_proposal +from ..utils.state import ProposalAccounts + + +class TypedInitializeInstruction(InstructionWrapper, ABC): + """Keep typed wrappers small while checking the same account transition.""" + + kind_name: str + + def common_accounts(self, accounts: ProposalAccounts) -> dict[str, Any]: + """Return the account block embedded by every typed initializer.""" + context = self.context + return dict( + proposal=accounts.proposal, + dao=context.dao, + squadsMultisig=context.squads_multisig, + squadsTransaction=accounts.squads.transaction, + squadsProposal=accounts.squads.proposal, + question=accounts.question, + baseVault=accounts.base_vault, + quoteVault=accounts.quote_vault, + proposer=context.proposal_proposer, + payer=context.payer, + permissionlessAccount=context.permissionless_account, + squadsProgram=context.squads_program_id, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + @abstractmethod + def happy_args(self) -> Any: + """Generate valid typed arguments.""" + + @abstractmethod + def payload( + self, args: Any, accounts: ProposalAccounts + ) -> tuple[Instruction, ...]: + """Independently build the Squads payload baked by the program.""" + + @abstractmethod + def build_instruction( + self, args: Any, accounts: ProposalAccounts, **overrides: Any + ) -> Instruction: + """Build the generated Futarchy instruction.""" + + @abstractmethod + def assert_action(self, action: Any, args: Any) -> None: + """Check the typed action snapshot stored on the proposal.""" + + @abstractmethod + def unhappy_case(self) -> tuple[Any, Any, int, dict[str, Any]]: + """Return args, error, outcome count, and optional account overrides.""" + + def _prepare_accounts( + self, + args: Any, + *, + outcomes: int = BINARY_QUESTION_OUTCOMES, + salt: bytes = b"", + ) -> ProposalAccounts: + context = self.context + placeholder = context.squads.preview_next( + purpose=self.kind_name + ) + proposal, _ = derive_proposal( + placeholder.proposal, FUTARCHY_PROGRAM_ID + ) + proposal.label = ( + f"Futarchy proposal {placeholder.index}: {self.kind_name}" + ) + market = context.market_support.create( + proposal, + outcomes=outcomes, + salt=self.kind_name.encode() + salt, + ) + accounts = ProposalAccounts( + proposal=proposal, + squads=placeholder, + question=market.question, + base_vault=market.base_vault, + quote_vault=market.quote_vault, + base_vault_underlying=market.base_vault_underlying, + quote_vault_underlying=market.quote_vault_underlying, + fail_base_mint=market.base_mints[0], + pass_base_mint=market.base_mints[1], + fail_quote_mint=market.quote_mints[0], + pass_quote_mint=market.quote_mints[1], + ) + prepared = context.squads.preview_next( + *self.payload(args, accounts), + purpose=self.kind_name, + ) + accounts.squads = prepared + return accounts + + def _assert_success( + self, + accounts: ProposalAccounts, + args: Any, + before_dao: Dao, + ) -> None: + context = self.context + context.squads.commit(accounts.squads) + assert_initialized_proposal( + context, + accounts, + before_dao, + lambda action: self.assert_action(action, args), + ) + context.register_proposal(accounts) + + def can_happy(self) -> bool: + return not self.context.is_liquidated() + + def happy(self) -> None: + """Initialize one valid typed proposal and validate stored intent.""" + context = self.context + args = self.happy_args() + accounts = self._prepare_accounts( + args, + salt=context.typed_market_nonce.to_bytes(4, "little"), + ) + context.typed_market_nonce += 1 + before = context.dao_state() + instruction = self.build_instruction(args, accounts) + context.payer.tx( + self.compute_unit_limit(), + instruction, + signers=[context.permissionless_account, context.proposal_proposer], + ) + self._assert_success(accounts, args, before) + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() + + def unhappy(self) -> None: + """Exercise one isolated typed guard and prove nested CPI rollback.""" + context = self.context + args, expected, outcomes, overrides = self.unhappy_case() + accounts = self._prepare_accounts( + args, + outcomes=outcomes, + salt=b"invalid" + context.typed_market_nonce.to_bytes(4, "little"), + ) + context.typed_market_nonce += 1 + instruction = self.build_instruction(args, accounts, **overrides) + self.assert_fails_atomically( + context.payer, + instruction, + expected, + before=(self.compute_unit_limit(),), + signers=(context.permissionless_account, context.proposal_proposer), + ) diff --git a/fuzz/futarchy/instructions/unstake_from_proposal.py b/fuzz/futarchy/instructions/unstake_from_proposal.py new file mode 100644 index 00000000..2e9b11b9 --- /dev/null +++ b/fuzz/futarchy/instructions/unstake_from_proposal.py @@ -0,0 +1,132 @@ +"""Snapshot-checked wrapper for Futarchy ``unstake_from_proposal``.""" + +from __future__ import annotations + +from wake_sol import Account, random, svm + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID, MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS +from ..pytypes.futarchy import ( + Futarchy as FutarchyProgram, + Proposal, + ProposalState, + StakeAccount, + UnstakeFromProposalParams, +) +from ..utils.state import ProposalAccounts +from ..utils.tokens import token_balance +from ..utils.assertions import assert_changed_only + + +class UnstakeFromProposalInstruction(InstructionWrapper): + """Check returned custody and Draft-only aggregate stake reduction.""" + + @staticmethod + def build_params(amount: int) -> UnstakeFromProposalParams: + return UnstakeFromProposalParams(amount=amount) + + def build_instruction( + self, + proposal: ProposalAccounts, + staker: Account, + stake: Account, + amount: int, + ): + context = self.context + return FutarchyProgram.unstakeFromProposal( + self.build_params(amount), + proposal=proposal.proposal, + dao=context.dao, + stakerBaseAccount=context.base_atas_by_owner[staker.pubkey], + proposalBaseAccount=proposal.proposal_base_account, + stakeAccount=stake, + baseMint=context.base_mint, + staker=staker, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def candidates(self): + return [ + item + for item in self.context.positive_stakes() + if svm.clock.unix_timestamp + >= self.context.proposal_state(item[0]).timestampEnqueued + + MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS + ] + + def too_early_candidates(self): + """Return launched positive stakes still inside the five-second lock.""" + now = svm.clock.unix_timestamp + return [ + item + for item in self.context.positive_stakes() + if self.context.proposal_state(item[0]).timestampEnqueued > 0 + and now + < self.context.proposal_state(item[0]).timestampEnqueued + + MIN_PROPOSAL_UNSTAKE_DELAY_SECONDS + ] + + def can_happy(self) -> bool: + return bool(self.candidates()) + + def happy(self) -> None: + """Unstake a random positive amount and verify the exact transfer.""" + context = self.context + proposal, staker, stake = random.choice(self.candidates()) + old_stake = StakeAccount.decode(stake.data) + amount = random.randint(1, old_stake.amount) + source = proposal.proposal_base_account + destination = context.base_atas_by_owner[staker.pubkey] + before_dao = context.dao_state() + before_proposal = context.proposal_state(proposal) + instruction = self.build_instruction( + proposal, staker, stake, amount + ) + before = self.snapshot( + instruction, + extra_accounts=( + context.dao, + proposal.proposal, + stake, + source, + destination, + ), + ) + staker.tx(instruction) + after_dao = context.dao_state() + after_proposal = Proposal.decode(proposal.proposal.data) + after_stake = StakeAccount.decode(stake.data) + assert_changed_only(before_dao, after_dao, seqNum=before_dao.seqNum + 1) + assert_changed_only(old_stake, after_stake, amount=old_stake.amount - amount) + expected_state = before_proposal.state + if isinstance(expected_state, ProposalState.Draft): + expected_state = ProposalState.Draft(expected_state.amountStaked - amount) + assert_changed_only(before_proposal, after_proposal, state=expected_state) + assert token_balance(source) == before.account(source).token_balance() - amount + assert token_balance(destination) == ( + before.account(destination).token_balance() + amount + ) + + def can_unhappy(self) -> bool: + return bool(self.too_early_candidates() or self.candidates()) + + def unhappy(self) -> None: + """Reject an early, zero, or excessive unstake and prove rollback.""" + context = self.context + too_early = self.too_early_candidates() + proposal, staker, stake = random.choice( + too_early or self.candidates() + ) + balance = StakeAccount.decode(stake.data).amount + if too_early: + amount = random.randint(1, balance) + expected = FutarchyProgram.ProposalNotReadyToUnstake + elif random.choice((False, True)): + amount = 0 + expected = FutarchyProgram.InvalidAmount + else: + amount = balance + 1 + expected = FutarchyProgram.InsufficientTokenBalance + instruction = self.build_instruction(proposal, staker, stake, amount) + self.assert_fails_atomically(staker, instruction, expected) diff --git a/fuzz/futarchy/instructions/update_dao.py b/fuzz/futarchy/instructions/update_dao.py new file mode 100644 index 00000000..ae2217ce --- /dev/null +++ b/fuzz/futarchy/instructions/update_dao.py @@ -0,0 +1,151 @@ +"""Snapshot-checked wrapper for vault-signed Futarchy ``update_dao``.""" + +from __future__ import annotations + +from typing import Any + +from wake_sol import Account, random + +from .base import InstructionWrapper +from ..constants import ( + EXECUTE_ARBITRARY_DURATION_SECONDS, + FUTARCHY_PROGRAM_ID, + MAX_PASS_THRESHOLD_BPS, + MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + MIN_PROPOSAL_DURATION_SECONDS, + MIN_QUOTE_LIQUIDITY, + MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + TOKEN_SCALE, + V08_LAUNCH_PRICE, + V08_PASS_THRESHOLD_BPS, + V08_SECONDS_PER_PROPOSAL, + V08_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + V08_TWAP_START_DELAY_SECONDS, + V08_TWAP_MAX_CHANGE, +) +from ..pytypes.futarchy import Futarchy as FutarchyProgram, UpdateDaoParams +from ..utils.assertions import assert_changed_only + + +class UpdateDaoInstruction(InstructionWrapper): + """Execute via Squads and verify supplied fields replace only themselves.""" + + @staticmethod + def build_params(**updates: Any) -> UpdateDaoParams: + """Build all optional fields, defaulting omitted values to ``None``.""" + names = ( + "passThresholdBps", + "secondsPerProposal", + "twapInitialObservation", + "twapMaxObservationChangePerUpdate", + "twapStartDelaySeconds", + "minQuoteFutarchicLiquidity", + "minBaseFutarchicLiquidity", + "baseToStake", + "teamSponsoredPassThresholdBps", + "teamAddress", + ) + return UpdateDaoParams(**{name: updates.get(name) for name in names}) + + def build_instruction( + self, + params: UpdateDaoParams, + signer_account: Account | None = None, + ): + context = self.context + return FutarchyProgram.updateDao( + params, + dao=context.dao, + squadsMultisigVault=signer_account or context.council, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def can_happy(self) -> bool: + return not self.context.is_liquidated() and self.context.is_spot() + + def happy(self) -> None: + """Apply one valid configuration change through normal Squads execution.""" + context = self.context + current = context.dao_state() + minimum_duration = max( + MIN_PROPOSAL_DURATION_SECONDS, + 2 * current.twapStartDelaySeconds, + ) + maximum_delay = current.secondsPerProposal // 2 + values = { + "passThresholdBps": random.choice( + (0, V08_PASS_THRESHOLD_BPS, MAX_PASS_THRESHOLD_BPS) + ), + "secondsPerProposal": random.choice( + ( + minimum_duration, + max(minimum_duration, V08_SECONDS_PER_PROPOSAL), + max(minimum_duration, EXECUTE_ARBITRARY_DURATION_SECONDS), + ) + ), + "twapInitialObservation": random.choice((0, V08_LAUNCH_PRICE)), + "twapMaxObservationChangePerUpdate": random.choice( + (1, V08_TWAP_MAX_CHANGE) + ), + "twapStartDelaySeconds": random.choice( + ( + 0, + min(V08_TWAP_START_DELAY_SECONDS, maximum_delay), + maximum_delay, + ) + ), + "minQuoteFutarchicLiquidity": random.choice( + (1, MIN_QUOTE_LIQUIDITY) + ), + "minBaseFutarchicLiquidity": random.choice( + (1, TOKEN_SCALE, 10 * TOKEN_SCALE) + ), + "baseToStake": random.choice( + (0, 100_000 * TOKEN_SCALE, 1_500_000 * TOKEN_SCALE) + ), + "teamSponsoredPassThresholdBps": random.choice( + ( + MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + V08_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + ) + ), + "teamAddress": random.choice(context.team_candidates).pubkey, + } + selected = random.choice(tuple(values)) + params = self.build_params(**{selected: values[selected]}) + inner = self.build_instruction(params) + prepared = context.squads.prepare_and_approve( + inner, purpose=f"update DAO {selected}" + ) + before = context.dao_state() + context.squads.execute_top_level(prepared) + after = context.dao_state() + assert_changed_only( + before, after, seqNum=before.seqNum + 1, **{selected: values[selected]} + ) + + def can_unhappy(self) -> bool: + return not self.context.is_liquidated() and self.context.is_spot() + + def unhappy(self) -> None: + """Reject a pass threshold above the DAO invariant and prove rollback.""" + context = self.context + params = self.build_params( + passThresholdBps=MAX_PASS_THRESHOLD_BPS + 1 + ) + inner = self.build_instruction(params) + prepared = context.squads.prepare_and_approve( + inner, purpose="invalid DAO threshold" + ) + execute = context.squads.build_execute(prepared) + self.assert_fails_atomically( + context.payer, + execute, + FutarchyProgram.PassThresholdTooHigh, + before=(context.squads.compute_unit_limit(),), + signers=(context.permissionless_account,), + extra_accounts=(context.dao,), + ) + prepared.disabled = True diff --git a/fuzz/futarchy/instructions/withdraw_liquidity.py b/fuzz/futarchy/instructions/withdraw_liquidity.py new file mode 100644 index 00000000..a5066090 --- /dev/null +++ b/fuzz/futarchy/instructions/withdraw_liquidity.py @@ -0,0 +1,167 @@ +"""Snapshot-checked wrapper for Futarchy ``withdraw_liquidity``.""" + +from __future__ import annotations + +from wake_sol import Account, random + +from .base import InstructionWrapper +from ..constants import FUTARCHY_PROGRAM_ID, U64_MAX +from ..pytypes.futarchy import ( + AmmPosition, + Futarchy as FutarchyProgram, + PoolState, + WithdrawLiquidityParams, +) +from ..utils.tokens import token_balance +from ..utils.assertions import assert_changed_only + + +class WithdrawLiquidityInstruction(InstructionWrapper): + """Check pro-rata payouts while proving protocol fees remain untouched.""" + + @staticmethod + def build_params( + liquidity: int, minimum_base: int = 0, minimum_quote: int = 0 + ) -> WithdrawLiquidityParams: + return WithdrawLiquidityParams( + liquidityToWithdraw=liquidity, + minBaseAmount=minimum_base, + minQuoteAmount=minimum_quote, + ) + + def build_instruction( + self, + authority: Account, + position: Account, + params: WithdrawLiquidityParams, + ): + context = self.context + if authority.pubkey == context.council.pubkey: + base_account = context.council_base_account + quote_account = context.council_quote_account + else: + base_account = context.base_atas_by_owner[authority.pubkey] + quote_account = context.quote_atas_by_owner[authority.pubkey] + return FutarchyProgram.withdrawLiquidity( + params, + dao=context.dao, + positionAuthority=authority, + liquidityProviderBaseAccount=base_account, + liquidityProviderQuoteAccount=quote_account, + ammBaseVault=context.amm_base_vault, + ammQuoteVault=context.amm_quote_vault, + ammPosition=position, + eventAuthority=context.event_authority, + program=FUTARCHY_PROGRAM_ID, + ) + + def candidates(self): + """Include the Squads-owned estate position after liquidation.""" + context = self.context + result = list(context.live_positions()) + position = context.position_accounts.get(context.council.pubkey) + if context.is_liquidated() and position is not None and position.exists: + decoded = AmmPosition.decode(position.data) + if decoded.liquidity > 0: + result.append((context.council, position, decoded)) + return result + + def recipient_accounts(self, authority: Account) -> tuple[Account, Account]: + context = self.context + if authority.pubkey == context.council.pubkey: + return context.council_base_account, context.council_quote_account + return ( + context.base_atas_by_owner[authority.pubkey], + context.quote_atas_by_owner[authority.pubkey], + ) + + def can_happy(self) -> bool: + return self.context.is_spot() and bool(self.candidates()) + + def happy(self) -> None: + """Withdraw a random LP share and verify exact reserve/token deltas.""" + context = self.context + authority, position, before_position = random.choice( + self.candidates() + ) + before_dao = context.dao_state() + assert isinstance(before_dao.amm.state, PoolState.Spot) + liquidity = random.randint(1, before_position.liquidity) + spot = before_dao.amm.state.spot + base = liquidity * spot.baseReserves // before_dao.amm.totalLiquidity + quote = liquidity * spot.quoteReserves // before_dao.amm.totalLiquidity + params = self.build_params( + liquidity, + random.randint(0, base), + random.randint(0, quote), + ) + instruction = self.build_instruction(authority, position, params) + base_account, quote_account = self.recipient_accounts(authority) + tracked = ( + context.dao, + position, + base_account, + quote_account, + context.amm_base_vault, + context.amm_quote_vault, + ) + before = self.snapshot(instruction, extra_accounts=tracked) + if authority.pubkey == context.council.pubkey: + prepared = context.squads.prepare_and_approve( + instruction, + purpose="post-liquidation council LP withdrawal", + allow_admin_execute=True, + ) + context.squads.execute_top_level(prepared) + else: + authority.tx(instruction) + after_dao = context.dao_state() + after_position = AmmPosition.decode(position.data) + assert isinstance(after_dao.amm.state, PoolState.Spot) + assert_changed_only( + before_dao, after_dao, amm=after_dao.amm, seqNum=before_dao.seqNum + 1 + ) + assert_changed_only( + before_dao.amm, after_dao.amm, state=after_dao.amm.state, + totalLiquidity=before_dao.amm.totalLiquidity - liquidity, + ) + assert_changed_only( + spot, after_dao.amm.state.spot, + baseReserves=spot.baseReserves - base, quoteReserves=spot.quoteReserves - quote, + ) + assert_changed_only( + before_position, after_position, + liquidity=before_position.liquidity - liquidity, + ) + assert token_balance(base_account) == ( + before.account(base_account).token_balance() + base + ) + assert token_balance(quote_account) == ( + before.account(quote_account).token_balance() + quote + ) + + def can_unhappy(self) -> bool: + return bool(self.context.live_positions()) + + def unhappy(self) -> None: + """Reject zero/excess LP, impossible slippage, or a live market.""" + context = self.context + authority, position, decoded = random.choice(context.live_positions()) + if not context.is_spot(): + params = self.build_params(min(1, decoded.liquidity)) + expected = FutarchyProgram.PoolNotInSpotState + else: + case = random.randint(0, 2) + if case == 0: + params = self.build_params(0) + expected = FutarchyProgram.ZeroLiquidityRemove + elif case == 1: + params = self.build_params(decoded.liquidity + 1) + expected = FutarchyProgram.InsufficientBalance + else: + params = self.build_params( + min(1, decoded.liquidity), U64_MAX, U64_MAX + ) + expected = FutarchyProgram.SwapSlippageExceeded + instruction = self.build_instruction(authority, position, params) + self.assert_fails_atomically(authority, instruction, expected) diff --git a/fuzz/futarchy/invariants/__init__.py b/fuzz/futarchy/invariants/__init__.py new file mode 100644 index 00000000..8f6d8559 --- /dev/null +++ b/fuzz/futarchy/invariants/__init__.py @@ -0,0 +1,16 @@ +"""Invariant mixins for Futarchy state, AMM accounting, and proposals.""" +"""Logically grouped cross-instruction invariants for the Futarchy harness.""" + +from .dao import DaoInvariants +from .positions import PositionInvariants +from .proposals import ProposalInvariants +from .squads import SquadsInvariants +from .tokens import TokenInvariants + +__all__ = [ + "DaoInvariants", + "PositionInvariants", + "ProposalInvariants", + "SquadsInvariants", + "TokenInvariants", +] diff --git a/fuzz/futarchy/invariants/dao.py b/fuzz/futarchy/invariants/dao.py new file mode 100644 index 00000000..0ed10121 --- /dev/null +++ b/fuzz/futarchy/invariants/dao.py @@ -0,0 +1,60 @@ +"""Small cross-instruction invariants for the primary DAO identity/config.""" + +from __future__ import annotations + +from wake_sol import invariant + +from ..constants import ( + FUTARCHY_PROGRAM_ID, + MAX_PASS_THRESHOLD_BPS, + MAX_SPENDING_LIMIT_MEMBERS, + MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + MIN_PROPOSAL_DURATION_SECONDS, + MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS, +) +from ..utils.accounts import derive_dao + + +class DaoInvariants: + """Validate stable PDA identity and the protocol's persistent bounds.""" + + @invariant() + def dao_identity_is_canonical(self) -> None: + """The main DAO remains at its seed-derived, Futarchy-owned address.""" + expected, bump = derive_dao( + self.dao_creator, self.dao_nonce, FUTARCHY_PROGRAM_ID + ) + dao = self.dao_state() + assert self.dao.pubkey == expected.pubkey + assert self.dao.owner == FUTARCHY_PROGRAM_ID + assert dao.pdaBump == bump + assert dao.daoCreator == self.dao_creator.pubkey + assert dao.baseMint == self.base_mint.pubkey + assert dao.quoteMint == self.quote_mint.pubkey + assert dao.squadsMultisig == self.squads_multisig.pubkey + assert dao.squadsMultisigVault == self.council.pubkey + assert dao.amm.ammBaseVault == self.amm_base_vault.pubkey + assert dao.amm.ammQuoteVault == self.amm_quote_vault.pubkey + + @invariant() + def dao_configuration_stays_valid(self) -> None: + """Every successful update leaves the same bounds enforced by Rust.""" + dao = self.dao_state() + assert dao.secondsPerProposal >= MIN_PROPOSAL_DURATION_SECONDS + assert dao.secondsPerProposal >= 2 * dao.twapStartDelaySeconds + assert 0 <= dao.passThresholdBps <= MAX_PASS_THRESHOLD_BPS + assert ( + MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS + <= dao.teamSponsoredPassThresholdBps + <= MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS + ) + assert dao.minBaseFutarchicLiquidity > 0 + assert dao.minQuoteFutarchicLiquidity > 0 + assert dao.twapMaxObservationChangePerUpdate > 0 + if dao.initialSpendingLimit is not None: + limit = dao.initialSpendingLimit + assert limit.amountPerMonth > 0 + assert 1 <= len(limit.members) <= MAX_SPENDING_LIMIT_MEMBERS + assert len(set(limit.members)) == len(limit.members) + if dao.liquidator is not None: + assert dao.initialSpendingLimit is None diff --git a/fuzz/futarchy/invariants/positions.py b/fuzz/futarchy/invariants/positions.py new file mode 100644 index 00000000..16826f8b --- /dev/null +++ b/fuzz/futarchy/invariants/positions.py @@ -0,0 +1,32 @@ +"""Cross-instruction invariants for Futarchy AMM position accounts.""" + +from __future__ import annotations + +from wake_sol import invariant + +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import AmmPosition +from ..utils.accounts import derive_amm_position + + +class PositionInvariants: + """Validate PDA identity, immutable authority, and aggregate LP supply.""" + + @invariant() + def positions_sum_to_total_liquidity(self) -> None: + """All normally created position balances equal embedded LP supply.""" + total = 0 + for authority_key, account in self.position_accounts.items(): + if not account.exists: + continue + authority = self.signers_by_pubkey.get(authority_key, self.council) + expected, _ = derive_amm_position( + self.dao, authority, FUTARCHY_PROGRAM_ID + ) + position = AmmPosition.decode(account.data) + assert account.pubkey == expected.pubkey + assert account.owner == FUTARCHY_PROGRAM_ID + assert position.dao == self.dao.pubkey + assert position.positionAuthority == authority_key + total += position.liquidity + assert total == self.dao_state().amm.totalLiquidity diff --git a/fuzz/futarchy/invariants/proposals.py b/fuzz/futarchy/invariants/proposals.py new file mode 100644 index 00000000..ce18d6d1 --- /dev/null +++ b/fuzz/futarchy/invariants/proposals.py @@ -0,0 +1,81 @@ +"""Cross-instruction invariants for proposals, markets, and stake custody.""" + +from __future__ import annotations + +from wake_sol import invariant + +from ..constants import CONDITIONAL_VAULT_PROGRAM_ID, FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import Proposal, ProposalAction, ProposalState, StakeAccount +from ..utils.accounts import ( + derive_conditional_mint, + derive_proposal, + derive_stake_account, +) +from ..utils.tokens import token_balance + + +class ProposalInvariants: + """Validate proposal graph identity and per-proposal stake conservation.""" + + @invariant() + def proposal_account_graphs_are_canonical(self) -> None: + """Every registered proposal and outcome mint matches its PDA links.""" + for accounts in self.proposals: + proposal = Proposal.decode(accounts.proposal.data) + expected, bump = derive_proposal( + accounts.squads.proposal, FUTARCHY_PROGRAM_ID + ) + assert accounts.proposal.pubkey == expected.pubkey + assert accounts.proposal.owner == FUTARCHY_PROGRAM_ID + assert proposal.pdaBump == bump + assert proposal.dao == self.dao.pubkey + assert proposal.squadsProposal == accounts.squads.proposal.pubkey + assert proposal.question == accounts.question.pubkey + assert proposal.baseVault == accounts.base_vault.pubkey + assert proposal.quoteVault == accounts.quote_vault.pubkey + if isinstance( + proposal.action, + ( + ProposalAction.HostileTakeover, + ProposalAction.HostileLiquidate, + ), + ): + assert proposal.sponsoredBy is None + for vault, mints in ( + ( + accounts.base_vault, + (accounts.fail_base_mint, accounts.pass_base_mint), + ), + ( + accounts.quote_vault, + (accounts.fail_quote_mint, accounts.pass_quote_mint), + ), + ): + for outcome, mint in enumerate(mints): + expected_mint, _ = derive_conditional_mint( + vault, outcome, CONDITIONAL_VAULT_PROGRAM_ID + ) + assert mint.pubkey == expected_mint.pubkey + + @invariant() + def proposal_stake_custody_is_conserved(self) -> None: + """Stake records sum to custody; Draft's aggregate equals the same sum.""" + for accounts in self.proposals: + total = 0 + for (proposal_key, staker_key), account in self.stake_accounts.items(): + if proposal_key != accounts.proposal.pubkey or not account.exists: + continue + staker = self.signers_by_pubkey[staker_key] + expected, bump = derive_stake_account( + accounts.proposal, staker, FUTARCHY_PROGRAM_ID + ) + stake = StakeAccount.decode(account.data) + assert account.pubkey == expected.pubkey + assert stake.bump == bump + assert stake.proposal == proposal_key + assert stake.staker == staker_key + total += stake.amount + assert token_balance(accounts.proposal_base_account) == total + state = self.proposal_state(accounts).state + if isinstance(state, ProposalState.Draft): + assert state.amountStaked == total diff --git a/fuzz/futarchy/invariants/squads.py b/fuzz/futarchy/invariants/squads.py new file mode 100644 index 00000000..75369e46 --- /dev/null +++ b/fuzz/futarchy/invariants/squads.py @@ -0,0 +1,59 @@ +"""Cross-instruction invariants for one-shot Futarchy Squads accounts.""" + +from __future__ import annotations + +from wake_sol import invariant + +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + EnqueuedMultisigProposalApproval, + EnqueuedMultisigProposalCancellation, +) +from ..utils.accounts import ( + derive_enqueued_approval, + derive_enqueued_cancellation, +) + + +class SquadsInvariants: + """Validate all live enqueue records against their transaction indexes.""" + + @invariant() + def enqueued_approvals_are_canonical(self) -> None: + """Every live one-shot record has the expected PDA and stored links.""" + for transaction in self.squads_transactions: + account = transaction.enqueued_approval + if account is None or not account.exists: + continue + expected, bump = derive_enqueued_approval( + self.dao, transaction.index, FUTARCHY_PROGRAM_ID + ) + decoded = EnqueuedMultisigProposalApproval.decode(account.data) + assert account.pubkey == expected.pubkey + assert decoded.pdaBump == bump + assert decoded.dao == self.dao.pubkey + assert decoded.transactionIndex == transaction.index + + @invariant() + def enqueued_cancellations_are_canonical(self) -> None: + """Every live cancellation record has the expected PDA and links.""" + for transaction in self.squads_transactions: + account = transaction.enqueued_cancellation + if account is None or not account.exists: + continue + expected, bump = derive_enqueued_cancellation( + self.dao, transaction.index, FUTARCHY_PROGRAM_ID + ) + decoded = EnqueuedMultisigProposalCancellation.decode(account.data) + assert account.pubkey == expected.pubkey + assert decoded.pdaBump == bump + assert decoded.dao == self.dao.pubkey + assert decoded.transactionIndex == transaction.index + + @invariant() + def cancelled_transactions_never_execute(self) -> None: + """Bookkeeping and the real Squads status agree for cancellations.""" + for transaction in self.squads_transactions: + if transaction.cancelled: + assert not transaction.executed + self.squads.assert_proposal_status(transaction, "Cancelled") diff --git a/fuzz/futarchy/invariants/tokens.py b/fuzz/futarchy/invariants/tokens.py new file mode 100644 index 00000000..fbfaebea --- /dev/null +++ b/fuzz/futarchy/invariants/tokens.py @@ -0,0 +1,110 @@ +"""Global token conservation and backing in both AMM states.""" + +from __future__ import annotations + +from wake_sol import invariant + +from ..pytypes.futarchy import PoolState +from ..utils.tokens import mint_supply, token_account_fields, token_balance + + +class TokenInvariants: + """Validate physical token ledgers independently from wrapper snapshots.""" + + def _assert_supply(self, mint, accounts) -> None: + total = 0 + for account in accounts.values(): + if not account.exists: + continue + account_mint, _, amount = token_account_fields(account) + assert account_mint == mint.pubkey + total += amount + assert total == mint_supply(mint) + + @invariant() + def underlying_token_supplies_are_conserved(self) -> None: + """Every base/quote atom is present in a registered token account.""" + self._assert_supply(self.base_mint, self.base_token_accounts) + self._assert_supply(self.quote_mint, self.quote_token_accounts) + + @invariant() + def spot_reserves_and_fees_are_fully_backed(self) -> None: + """In Spot state, each AMM vault equals reserves plus protocol fees.""" + state = self.dao_state().amm.state + if not isinstance(state, PoolState.Spot): + return + assert token_balance(self.amm_base_vault) == ( + state.spot.baseReserves + state.spot.baseProtocolFeeBalance + ) + assert token_balance(self.amm_quote_vault) == ( + state.spot.quoteReserves + state.spot.quoteProtocolFeeBalance + ) + + @invariant() + def conditional_reserves_and_fees_are_fully_backed(self) -> None: + """Each virtual outcome claim is backed by underlying plus that outcome. + + Exact equality assumes this fixture's absence of direct vault donations. + Launch divides reserves virtually; it does not split physical tokens. + """ + state = self.dao_state().amm.state + pending = self.pending_proposals() + if isinstance(state, PoolState.Spot): + assert not pending + return + assert len(pending) == 1 + accounts = pending[0] + for pool, base, quote in ( + (state.pass_, accounts.amm_pass_base_vault, accounts.amm_pass_quote_vault), + (state.fail, accounts.amm_fail_base_vault, accounts.amm_fail_quote_vault), + ): + for side, underlying, conditional in ( + ("base", self.amm_base_vault, base), + ("quote", self.amm_quote_vault, quote), + ): + liability = sum( + getattr(part, side + suffix) + for part in (state.spot, pool) + for suffix in ("Reserves", "ProtocolFeeBalance") + ) + assert liability == ( + token_balance(underlying) + token_balance(conditional) + ) + + @invariant() + def conditional_token_supplies_are_backed(self) -> None: + """Track both claim ledgers and their unresolved/resolved collateral.""" + for accounts in self.proposals: + question = bytes(accounts.question.data) + payouts = [int.from_bytes(question[i : i + 4], "little") for i in (76, 80)] + denominator = int.from_bytes(question[84:88], "little") + for custody, mints, vaults in ( + ( + accounts.base_vault_underlying, + (accounts.fail_base_mint, accounts.pass_base_mint), + (accounts.amm_fail_base_vault, accounts.amm_pass_base_vault), + ), + ( + accounts.quote_vault_underlying, + (accounts.fail_quote_mint, accounts.pass_quote_mint), + (accounts.amm_fail_quote_vault, accounts.amm_pass_quote_vault), + ), + ): + supplies = [] + for mint, vault in zip(mints, vaults): + holders = { + account.pubkey: account + for (_, mint_key), account in accounts.conditional_accounts.items() + if mint_key == mint.pubkey + } + if vault is not None: + holders[vault.pubkey] = vault + self._assert_supply(mint, holders) + supplies.append(mint_supply(mint)) + liability = ( + max(supplies) + if denominator == 0 + else sum(supply * payout for supply, payout in zip(supplies, payouts)) + // denominator + ) + assert token_balance(custody) >= liability diff --git a/fuzz/futarchy/pytypes/__init__.py b/fuzz/futarchy/pytypes/__init__.py new file mode 100644 index 00000000..6e01ea06 --- /dev/null +++ b/fuzz/futarchy/pytypes/__init__.py @@ -0,0 +1,7 @@ +"""Generated by `wake-sol gen`. Importing this package registers +every program below into wake_sol._interface.REGISTRY (import +side effect).""" +from . import futarchy as futarchy # noqa: F401 + +__all__ = ["futarchy"] +__generated_by__ = "wake-sol gen 0.2.0" diff --git a/fuzz/futarchy/pytypes/_manifest.json b/fuzz/futarchy/pytypes/_manifest.json new file mode 100644 index 00000000..33604164 --- /dev/null +++ b/fuzz/futarchy/pytypes/_manifest.json @@ -0,0 +1,14 @@ +{ + "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq": { + "schema_version": "1.1.0", + "program_address": "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq", + "program_name": "futarchy", + "module": "futarchy", + "source_root": "target/idl", + "idl_path": "target/wake-idl/FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq.json", + "idl_sha256": "05e0ea3e8509ef4a7c1508f4f6448f21ed62ae726224631aff5807ddc48b98f1", + "anchor_version": "0.6.2", + "generator_version": "wake-sol gen 0.2.0", + "generated_at": "2026-09-10T14:06:14Z" + } +} diff --git a/fuzz/futarchy/pytypes/futarchy.py b/fuzz/futarchy/pytypes/futarchy.py new file mode 100644 index 00000000..29e63c5c --- /dev/null +++ b/fuzz/futarchy/pytypes/futarchy.py @@ -0,0 +1,2626 @@ +# GENERATED by `wake-sol gen` — do not edit. Provenance pinned below. +# NOTE: instructions, accounts, events, and errors are emitted; `emit!` log-scan events decode at runtime. + + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional, Sequence + +from wake_sol._codec import ( + u8, + u16, + u32, + u64, + u128, + i16, + i64, + pubkey, + BorshEnumMeta, + BorshStruct, + variant, + AccountSlot, + BorshMeta, + InstructionMeta, + Kind, + MetaLike, + Serialization, + instruction, + build_interface_from_module, + compile_layout, + encode_ix_layout, + build_metas, + slot, + as_meta, +) +from wake_sol._interface import register +from wake_sol._errors import ProgramError, register_errors +from wake_sol._native import Instruction, Pubkey + + +PROGRAM_ID = Pubkey("FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq") +PROGRAM_NAME = "futarchy" + + +# ------------------------------------------------------------------------- # +# 1. types +# ------------------------------------------------------------------------- # +@dataclass +class CommonFields(BorshStruct): + slot: u64 + unixTimestamp: i64 + daoSeqNum: u64 + + +@dataclass +class AdminEnqueueMultisigProposalApprovalArgs(BorshStruct): + transactionIndex: u64 + + +@dataclass +class AdminEnqueueMultisigProposalCancellationArgs(BorshStruct): + transactionIndex: u64 + + +@dataclass +class AdminUpdateProposalParamsArgs(BorshStruct): + durationInSeconds: Optional[u32] + passThresholdBps: Optional[i16] + + +@dataclass +class ConditionalSwapParams(BorshStruct): + market: Market + swapType: SwapType + inputAmount: u64 + minOutputAmount: u64 + + +@dataclass +class InitializeBuybackTokenProposalArgs(BorshStruct): + quoteAmount: u64 + cycleCount: u32 + cycleFrequencySeconds: u32 + startDelaySeconds: u32 + minPrice: Optional[u64] + maxPrice: Optional[u64] + + +@dataclass +class InitializeDaoParams(BorshStruct): + twapInitialObservation: u128 + twapMaxObservationChangePerUpdate: u128 + twapStartDelaySeconds: u32 + minQuoteFutarchicLiquidity: u64 + minBaseFutarchicLiquidity: u64 + baseToStake: u64 + passThresholdBps: u16 + secondsPerProposal: u32 + nonce: u64 + initialSpendingLimit: Optional[InitialSpendingLimit] + teamSponsoredPassThresholdBps: i16 + teamAddress: pubkey + + +@dataclass +class InitializeHostileLiquidateProposalArgs(BorshStruct): + liquidator: pubkey + + +@dataclass +class InitializeHostileTakeoverProposalArgs(BorshStruct): + newTeamAddress: pubkey + spendingLimitAction: SpendingLimitAction + + +@dataclass +class InitializeLargeSpendProposalArgs(BorshStruct): + amount: u64 + + +@dataclass +class InitializeMintTokensProposalArgs(BorshStruct): + amount: u64 + recipient: pubkey + + +@dataclass +class InitializeSpendingLimitChangeProposalArgs(BorshStruct): + config: Optional[InitialSpendingLimit] + + +@dataclass +class ProvideLiquidityParams(BorshStruct): + quoteAmount: u64 + maxBaseAmount: u64 + minLiquidity: u128 + positionAuthority: pubkey + + +@dataclass +class SetSpendingLimitArgs(BorshStruct): + config: Optional[InitialSpendingLimit] + + +@dataclass +class SpotSwapParams(BorshStruct): + inputAmount: u64 + swapType: SwapType + minOutputAmount: u64 + + +@dataclass +class StakeToProposalParams(BorshStruct): + amount: u64 + + +@dataclass +class UnstakeFromProposalParams(BorshStruct): + amount: u64 + + +@dataclass +class UpdateDaoParams(BorshStruct): + passThresholdBps: Optional[u16] + secondsPerProposal: Optional[u32] + twapInitialObservation: Optional[u128] + twapMaxObservationChangePerUpdate: Optional[u128] + twapStartDelaySeconds: Optional[u32] + minQuoteFutarchicLiquidity: Optional[u64] + minBaseFutarchicLiquidity: Optional[u64] + baseToStake: Optional[u64] + teamSponsoredPassThresholdBps: Optional[i16] + teamAddress: Optional[pubkey] + + +@dataclass +class WithdrawLiquidityParams(BorshStruct): + liquidityToWithdraw: u128 + minBaseAmount: u64 + minQuoteAmount: u64 + + +@dataclass +class OptimisticProposal(BorshStruct): + squadsProposal: pubkey + enqueuedTimestamp: i64 + + +@dataclass +class InitialSpendingLimit(BorshStruct): + amountPerMonth: u64 + members: list[pubkey] + + +@dataclass +class FutarchyAmm(BorshStruct): + state: PoolState + totalLiquidity: u128 + baseMint: pubkey + quoteMint: pubkey + ammBaseVault: pubkey + ammQuoteVault: pubkey + + +@dataclass +class TwapOracle(BorshStruct): + aggregator: u128 + lastUpdatedTimestamp: i64 + createdAtTimestamp: i64 + lastPrice: u128 + lastObservation: u128 + maxObservationChangePerUpdate: u128 + initialObservation: u128 + startDelaySeconds: u32 + + +@dataclass +class Pool(BorshStruct): + oracle: TwapOracle + quoteReserves: u64 + baseReserves: u64 + quoteProtocolFeeBalance: u64 + baseProtocolFeeBalance: u64 + + +@dataclass +class InstructionParams(BorshStruct): + durationSeconds: u32 + passThresholdBps: i16 + teamSponsorshipPolicy: TeamSponsorshipPolicy + councilCanBlock: bool + cooldownSeconds: u32 + twapStartDelaySeconds: u32 + + +class PoolState(metaclass=BorshEnumMeta): + + @variant(0) + @dataclass + class Spot: + spot: Pool + + @variant(1) + @dataclass + class Futarchy: + spot: Pool + pass_: Pool + fail: Pool + + +class Market(IntEnum): + Spot = 0 + Pass = 1 + Fail = 2 + + +class SwapType(IntEnum): + Buy = 0 + Sell = 1 + + +class Token(IntEnum): + Base = 0 + Quote = 1 + + +class TeamSponsorshipPolicy(IntEnum): + Required = 0 + Optional = 1 + Forbidden = 2 + + +class SpendingLimitAction(metaclass=BorshEnumMeta): + + @variant(0) + @dataclass + class Keep: + pass + + @variant(1) + @dataclass + class Remove: + pass + + @variant(2) + @dataclass + class Set: + _0: InitialSpendingLimit + + +class ProposalAction(metaclass=BorshEnumMeta): + + @variant(0) + @dataclass + class LargeSpend: + amount: u64 + teamAddress: pubkey + + @variant(1) + @dataclass + class MintTokens: + amount: u64 + recipient: pubkey + + @variant(2) + @dataclass + class SpendingLimitChange: + config: Optional[InitialSpendingLimit] + + @variant(3) + @dataclass + class ExecuteArbitrary: + pass + + @variant(4) + @dataclass + class HostileTakeover: + newTeamAddress: pubkey + spendingLimitAction: SpendingLimitAction + + @variant(5) + @dataclass + class HostileLiquidate: + liquidator: pubkey + + @variant(6) + @dataclass + class BuybackToken: + quoteAmount: u64 + cycleCount: u32 + cycleFrequencySeconds: u32 + startDelaySeconds: u32 + minPrice: Optional[u64] + maxPrice: Optional[u64] + + +class ProposalState(metaclass=BorshEnumMeta): + + @variant(0) + @dataclass + class Draft: + amountStaked: u64 + + @variant(1) + @dataclass + class Pending: + pass + + @variant(2) + @dataclass + class Passed: + pass + + @variant(3) + @dataclass + class Failed: + pass + + @variant(4) + @dataclass + class Removed: + pass + + +# ------------------------------------------------------------------------- # +# 2. accounts +# ------------------------------------------------------------------------- # +@dataclass +class AmmPosition(BorshStruct): + dao: pubkey + positionAuthority: pubkey + liquidity: u128 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\x22\x61\x69\x4a\x11\xe2\xd4\x00", + discriminator_len=8, + ) + + +@dataclass +class Dao(BorshStruct): + amm: FutarchyAmm + nonce: u64 + daoCreator: pubkey + pdaBump: u8 + squadsMultisig: pubkey + squadsMultisigVault: pubkey + baseMint: pubkey + quoteMint: pubkey + proposalCount: u32 + passThresholdBps: u16 + secondsPerProposal: u32 + twapInitialObservation: u128 + twapMaxObservationChangePerUpdate: u128 + twapStartDelaySeconds: u32 + minQuoteFutarchicLiquidity: u64 + minBaseFutarchicLiquidity: u64 + baseToStake: u64 + seqNum: u64 + initialSpendingLimit: Optional[InitialSpendingLimit] + teamSponsoredPassThresholdBps: i16 + teamAddress: pubkey + optimisticProposal: Optional[OptimisticProposal] + isOptimisticGovernanceEnabled: bool + liquidator: Optional[pubkey] + lastFailedTakeoverAt: i64 + lastFailedLiquidationAt: i64 + spendingLimitDirty: bool + lastBuybackFinalizedAt: i64 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\xa3\x09\x2f\x1f\x34\x55\xc5\x31", + discriminator_len=8, + ) + + +@dataclass +class OldDao(BorshStruct): + amm: FutarchyAmm + nonce: u64 + daoCreator: pubkey + pdaBump: u8 + squadsMultisig: pubkey + squadsMultisigVault: pubkey + baseMint: pubkey + quoteMint: pubkey + proposalCount: u32 + passThresholdBps: u16 + secondsPerProposal: u32 + twapInitialObservation: u128 + twapMaxObservationChangePerUpdate: u128 + twapStartDelaySeconds: u32 + minQuoteFutarchicLiquidity: u64 + minBaseFutarchicLiquidity: u64 + baseToStake: u64 + seqNum: u64 + initialSpendingLimit: Optional[InitialSpendingLimit] + teamSponsoredPassThresholdBps: i16 + teamAddress: pubkey + optimisticProposal: Optional[OptimisticProposal] + isOptimisticGovernanceEnabled: bool + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\x51\xeb\xe1\x9c\x92\x93\xe0\x81", + discriminator_len=8, + ) + + +@dataclass +class EnqueuedMultisigProposalApproval(BorshStruct): + dao: pubkey + transactionIndex: u64 + pdaBump: u8 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\xac\xe1\xed\x48\x08\xf1\x1c\x7c", + discriminator_len=8, + ) + + +@dataclass +class EnqueuedMultisigProposalCancellation(BorshStruct): + dao: pubkey + transactionIndex: u64 + pdaBump: u8 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\x12\x30\x0d\x8f\x0b\xf4\xcb\x72", + discriminator_len=8, + ) + + +@dataclass +class Proposal(BorshStruct): + number: u32 + proposer: pubkey + timestampEnqueued: i64 + state: ProposalState + baseVault: pubkey + quoteVault: pubkey + dao: pubkey + pdaBump: u8 + question: pubkey + durationInSeconds: u32 + squadsProposal: pubkey + passBaseMint: pubkey + passQuoteMint: pubkey + failBaseMint: pubkey + failQuoteMint: pubkey + sponsoredBy: Optional[pubkey] + passThresholdBps: i16 + councilCanBlock: bool + action: ProposalAction + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\x1a\x5e\xbd\xbb\x74\x88\x35\x21", + discriminator_len=8, + ) + + +@dataclass +class OldProposal(BorshStruct): + number: u32 + proposer: pubkey + timestampEnqueued: i64 + state: ProposalState + baseVault: pubkey + quoteVault: pubkey + dao: pubkey + pdaBump: u8 + question: pubkey + durationInSeconds: u32 + squadsProposal: pubkey + passBaseMint: pubkey + passQuoteMint: pubkey + failBaseMint: pubkey + failQuoteMint: pubkey + isTeamSponsored: bool + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\x9e\x9e\x26\x58\x1e\x64\x9b\x71", + discriminator_len=8, + ) + + +@dataclass +class StakeAccount(BorshStruct): + proposal: pubkey + staker: pubkey + amount: u64 + bump: u8 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.ACCOUNT, + is_account_root=True, + discriminator=b"\x50\x9e\x43\x7c\x32\xbd\xc0\xff", + discriminator_len=8, + ) + + +# ------------------------------------------------------------------------- # +# 3. events +# ------------------------------------------------------------------------- # +@dataclass +class CollectFeesEvent(BorshStruct): + common: CommonFields + dao: pubkey + baseTokenAccount: pubkey + quoteTokenAccount: pubkey + ammBaseVault: pubkey + ammQuoteVault: pubkey + quoteMint: pubkey + baseMint: pubkey + quoteFeesCollected: u64 + baseFeesCollected: u64 + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xe2\x02\x35\x43\x84\x9e\x7d\xad", + discriminator_len=8, + ) + + +@dataclass +class InitializeDaoEvent(BorshStruct): + common: CommonFields + dao: pubkey + baseMint: pubkey + quoteMint: pubkey + passThresholdBps: u16 + secondsPerProposal: u32 + twapInitialObservation: u128 + twapMaxObservationChangePerUpdate: u128 + twapStartDelaySeconds: u32 + minQuoteFutarchicLiquidity: u64 + minBaseFutarchicLiquidity: u64 + baseToStake: u64 + initialSpendingLimit: Optional[InitialSpendingLimit] + squadsMultisig: pubkey + squadsMultisigVault: pubkey + teamSponsoredPassThresholdBps: i16 + teamAddress: pubkey + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x77\x30\x99\x74\x7f\x25\xe2\xe4", + discriminator_len=8, + ) + + +@dataclass +class UpdateDaoEvent(BorshStruct): + common: CommonFields + dao: pubkey + passThresholdBps: u16 + secondsPerProposal: u32 + twapInitialObservation: u128 + twapMaxObservationChangePerUpdate: u128 + twapStartDelaySeconds: u32 + minQuoteFutarchicLiquidity: u64 + minBaseFutarchicLiquidity: u64 + baseToStake: u64 + teamSponsoredPassThresholdBps: i16 + teamAddress: pubkey + isOptimisticGovernanceEnabled: bool + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x0c\x3a\xf4\xe0\xab\x19\x21\x38", + discriminator_len=8, + ) + + +@dataclass +class InitializeProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + dao: pubkey + question: pubkey + quoteVault: pubkey + baseVault: pubkey + proposer: pubkey + number: u32 + pdaBump: u8 + durationInSeconds: u32 + squadsProposal: pubkey + squadsMultisig: pubkey + squadsMultisigVault: pubkey + action: ProposalAction + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x8d\x38\xf6\xc0\xa8\xfe\x40\x6f", + discriminator_len=8, + ) + + +@dataclass +class StakeToProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + staker: pubkey + amount: u64 + totalStaked: u64 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x5e\x45\x44\x97\x5a\x95\xf0\xbf", + discriminator_len=8, + ) + + +@dataclass +class UnstakeFromProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + staker: pubkey + amount: u64 + totalStaked: u64 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x59\x81\x3f\xd3\x37\x21\xc2\xab", + discriminator_len=8, + ) + + +@dataclass +class LaunchProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + dao: pubkey + timestampEnqueued: i64 + totalStaked: u64 + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xb1\x37\xbf\x5d\xcb\x7c\xb8\x0b", + discriminator_len=8, + ) + + +@dataclass +class FinalizeProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + dao: pubkey + passMarketTwap: u128 + failMarketTwap: u128 + threshold: u128 + state: ProposalState + squadsProposal: pubkey + squadsMultisig: pubkey + postAmmState: FutarchyAmm + isTeamSponsored: bool + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x2d\x1d\x7a\xb5\x4f\xe0\x39\x8d", + discriminator_len=8, + ) + + +@dataclass +class SpotSwapEvent(BorshStruct): + common: CommonFields + dao: pubkey + user: pubkey + swapType: SwapType + inputAmount: u64 + outputAmount: u64 + minOutputAmount: u64 + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x1d\xfd\x79\xff\x52\x3c\x94\xc3", + discriminator_len=8, + ) + + +@dataclass +class ConditionalSwapEvent(BorshStruct): + common: CommonFields + dao: pubkey + proposal: pubkey + trader: pubkey + market: Market + swapType: SwapType + inputAmount: u64 + outputAmount: u64 + minOutputAmount: u64 + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x02\xa6\xc8\xa0\x5e\xd4\x44\x2d", + discriminator_len=8, + ) + + +@dataclass +class ProvideLiquidityEvent(BorshStruct): + common: CommonFields + dao: pubkey + liquidityProvider: pubkey + positionAuthority: pubkey + quoteAmount: u64 + baseAmount: u64 + liquidityMinted: u128 + minLiquidity: u128 + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x26\x02\x25\xee\xe5\xd6\xff\xeb", + discriminator_len=8, + ) + + +@dataclass +class WithdrawLiquidityEvent(BorshStruct): + common: CommonFields + dao: pubkey + liquidityProvider: pubkey + liquidityWithdrawn: u128 + minBaseAmount: u64 + minQuoteAmount: u64 + baseAmount: u64 + quoteAmount: u64 + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xd6\x06\xa1\x2d\xbf\x8e\x7c\xba", + discriminator_len=8, + ) + + +@dataclass +class SponsorProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + dao: pubkey + teamAddress: pubkey + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xca\xbe\xfa\xb0\x4f\xeb\x4d\xc2", + discriminator_len=8, + ) + + +@dataclass +class RemoveProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + dao: pubkey + admin: pubkey + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x87\x98\xfe\xe0\xd0\x51\xc5\xd7", + discriminator_len=8, + ) + + +@dataclass +class AdminUpdateProposalParamsEvent(BorshStruct): + common: CommonFields + dao: pubkey + proposal: pubkey + admin: pubkey + oldDurationInSeconds: u32 + newDurationInSeconds: u32 + oldPassThresholdBps: i16 + newPassThresholdBps: i16 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x14\xc3\xf3\x6a\xff\x5d\xee\xbb", + discriminator_len=8, + ) + + +@dataclass +class AdminCancelProposalEvent(BorshStruct): + common: CommonFields + proposal: pubkey + dao: pubkey + admin: pubkey + postAmmState: FutarchyAmm + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x76\x02\x57\x4b\x5b\xd9\x47\x7c", + discriminator_len=8, + ) + + +@dataclass +class CollectMeteoraDammFeesEvent(BorshStruct): + common: CommonFields + dao: pubkey + pool: pubkey + baseTokenAccount: pubkey + quoteTokenAccount: pubkey + quoteMint: pubkey + baseMint: pubkey + quoteFeesCollected: u64 + baseFeesCollected: u64 + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xc1\x72\xe1\xf1\xd1\xd5\xaf\x55", + discriminator_len=8, + ) + + +@dataclass +class AdminFixPositionAuthorityEvent(BorshStruct): + common: CommonFields + dao: pubkey + admin: pubkey + ammPosition: pubkey + oldAuthority: pubkey + newAuthority: pubkey + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xae\x4f\x3e\x01\x96\xb6\xec\x93", + discriminator_len=8, + ) + + +@dataclass +class SetSpendingLimitEvent(BorshStruct): + common: CommonFields + dao: pubkey + config: Optional[InitialSpendingLimit] + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\xf2\x8b\xfc\x53\x5f\x93\x0f\x3a", + discriminator_len=8, + ) + + +@dataclass +class SyncSpendingLimitEvent(BorshStruct): + common: CommonFields + dao: pubkey + spendingLimit: pubkey + config: Optional[InitialSpendingLimit] + + __borsh_meta__ = BorshMeta( + ser=Serialization.BORSH, + kind=Kind.EVENT, + is_account_root=False, + discriminator=b"\x9b\x23\xec\x54\xdb\x3c\x73\xa7", + discriminator_len=8, + ) + + +# ------------------------------------------------------------------------- # +# 4. instructions / builder (+ program-defined errors) +# ------------------------------------------------------------------------- # +_ENC_initializeDao = compile_layout(InitializeDaoParams) +_ENC_initializeLargeSpendProposal = compile_layout(InitializeLargeSpendProposalArgs) +_ENC_initializeMintTokensProposal = compile_layout(InitializeMintTokensProposalArgs) +_ENC_initializeSpendingLimitChangeProposal = compile_layout(InitializeSpendingLimitChangeProposalArgs) +_ENC_initializeHostileTakeoverProposal = compile_layout(InitializeHostileTakeoverProposalArgs) +_ENC_initializeHostileLiquidateProposal = compile_layout(InitializeHostileLiquidateProposalArgs) +_ENC_initializeBuybackTokenProposal = compile_layout(InitializeBuybackTokenProposalArgs) +_ENC_stakeToProposal = compile_layout(StakeToProposalParams) +_ENC_unstakeFromProposal = compile_layout(UnstakeFromProposalParams) +_ENC_updateDao = compile_layout(UpdateDaoParams) +_ENC_setSpendingLimit = compile_layout(SetSpendingLimitArgs) +_ENC_spotSwap = compile_layout(SpotSwapParams) +_ENC_conditionalSwap = compile_layout(ConditionalSwapParams) +_ENC_provideLiquidity = compile_layout(ProvideLiquidityParams) +_ENC_withdrawLiquidity = compile_layout(WithdrawLiquidityParams) +_ENC_adminEnqueueMultisigProposalApproval = compile_layout(AdminEnqueueMultisigProposalApprovalArgs) +_ENC_adminEnqueueMultisigProposalCancellation = compile_layout(AdminEnqueueMultisigProposalCancellationArgs) +_ENC_adminUpdateProposalParams = compile_layout(AdminUpdateProposalParamsArgs) + +class Futarchy: + program_id = PROGRAM_ID + + # ------------------------------- errors ------------------------------- # + # Defined by the program (IDL `errors[]`). Catch `Futarchy.Error` + # to match any of them; each is also importable from this module. + class Error(ProgramError): + """Base for every futarchy program error — catch it to match any of them.""" + + class AmmTooOld(Error): + code = 6000 + msg = 'Amms must have been created within 5 minutes (counted in slots) of proposal initialization' + + class InvalidInitialObservation(Error): + code = 6001 + msg = "An amm has an `initial_observation` that doesn't match the `dao`'s config" + + class InvalidMaxObservationChange(Error): + code = 6002 + msg = "An amm has a `max_observation_change_per_update` that doesn't match the `dao`'s config" + + class InvalidStartDelaySlots(Error): + code = 6003 + msg = "An amm has a `start_delay_slots` that doesn't match the `dao`'s config" + + class InvalidSettlementAuthority(Error): + code = 6004 + msg = 'One of the vaults has an invalid `settlement_authority`' + + class ProposalTooYoung(Error): + code = 6005 + msg = 'Proposal is too young to be executed or rejected' + + class MarketsTooYoung(Error): + code = 6006 + msg = 'Markets too young for proposal to be finalized. TWAP might need to be cranked' + + class ProposalAlreadyFinalized(Error): + code = 6007 + msg = 'This proposal has already been finalized' + + class InvalidVaultNonce(Error): + code = 6008 + msg = 'A conditional vault has an invalid nonce. A nonce should encode the proposal number' + + class ProposalNotPassed(Error): + code = 6009 + msg = "This proposal can't be executed because it isn't in the passed state" + + class InsufficientLiquidity(Error): + code = 6010 + msg = 'More liquidity needs to be in the AMM to launch this proposal' + + class ProposalDurationTooShort(Error): + code = 6011 + msg = 'Proposal duration must be longer 1 day and longer than 2 times the TWAP start delay' + + class PassThresholdTooHigh(Error): + code = 6012 + msg = 'Pass threshold must be less than 10%' + + class QuestionMustBeBinary(Error): + code = 6013 + msg = 'Question must have exactly 2 outcomes for binary futarchy' + + class InvalidSquadsProposalStatus(Error): + code = 6014 + msg = 'Squads proposal must be in Active status' + + class CastingOverflow(Error): + code = 6015 + msg = "Casting overflow. If you're seeing this, please report this" + + class InsufficientBalance(Error): + code = 6016 + msg = 'Insufficient balance' + + class ZeroLiquidityRemove(Error): + code = 6017 + msg = 'Cannot remove zero liquidity' + + class SwapSlippageExceeded(Error): + code = 6018 + msg = 'Swap slippage exceeded' + + class AssertFailed(Error): + code = 6019 + msg = 'Assert failed' + + class InvalidAdmin(Error): + code = 6020 + msg = 'Invalid admin' + + class ProposalNotInDraftState(Error): + code = 6021 + msg = 'Proposal is not in draft state' + + class InsufficientTokenBalance(Error): + code = 6022 + msg = 'Insufficient token balance' + + class InvalidAmount(Error): + code = 6023 + msg = 'Invalid amount' + + class InsufficientStakeToLaunch(Error): + code = 6024 + msg = 'Insufficient stake to launch proposal' + + class StakerNotFound(Error): + code = 6025 + msg = 'Staker not found in proposal' + + class PoolNotInSpotState(Error): + code = 6026 + msg = 'Pool must be in spot state' + + class InvalidDaoCreateLiquidity(Error): + code = 6027 + msg = "If you're providing liquidity, you must provide both base and quote token accounts" + + class InvalidStakeAccount(Error): + code = 6028 + msg = 'Invalid stake account' + + class InvariantViolated(Error): + code = 6029 + msg = 'An invariant was violated. You should get in contact with the MetaDAO team if you see this' + + class ProposalNotActive(Error): + code = 6030 + msg = 'Proposal needs to be active to perform a conditional swap' + + class InvalidTransaction(Error): + code = 6031 + msg = 'This Squads transaction should only contain calls to update spending limits' + + class ProposalAlreadySponsored(Error): + code = 6032 + msg = 'Proposal has already been sponsored' + + class InvalidTeamSponsoredPassThreshold(Error): + code = 6033 + msg = 'Team sponsored pass threshold must be between -10% and 10%' + + class InvalidTargetK(Error): + code = 6034 + msg = 'Target K must be greater than the current K' + + class InvalidTransactionMessage(Error): + code = 6035 + msg = 'Failed to compile transaction message for Squads vault transaction' + + class InvalidMint(Error): + code = 6036 + msg = 'Base mint and quote mint must be different' + + class ProposalNotReadyToUnstake(Error): + code = 6037 + msg = 'Proposal is not ready to be unstaked' + + class OptimisticGovernanceDisabled(Error): + code = 6038 + msg = 'Optimistic governance is disabled' + + class ActiveOptimisticProposalAlreadyEnqueued(Error): + code = 6039 + msg = 'An active optimistic proposal is already enqueued' + + class OptimisticProposalAlreadyPassed(Error): + code = 6040 + msg = 'Optimistic proposal has already passed' + + class InvalidSpendingLimitMint(Error): + code = 6041 + msg = "Invalid spending limit mint. Must be the same as the DAO's quote mint" + + class NoActiveOptimisticProposal(Error): + code = 6042 + msg = 'No active optimistic proposal' + + class DaoLiquidated(Error): + code = 6043 + msg = 'This DAO has been liquidated' + + class ProposalKindCooldownActive(Error): + code = 6044 + msg = 'A proposal of this kind finalized recently, so the cooldown must elapse first' + + class NoSpendingLimit(Error): + code = 6045 + msg = 'The DAO has no spending limit' + + class SpendCapExceeded(Error): + code = 6046 + msg = 'Amount exceeds the cap of 3x the monthly spending limit' + + class UnknownMintAuthority(Error): + code = 6047 + msg = "The base mint's authority is neither the treasury vault nor a mint governor" + + class ProposalNotTeamSponsored(Error): + code = 6048 + msg = 'This proposal kind must be team-sponsored before it can launch' + + class SpendingLimitNotDirty(Error): + code = 6049 + msg = "The spending limit record hasn't changed, so there is nothing to sync" + + class InvalidProposalKind(Error): + code = 6050 + msg = 'Wrong proposal kind for this instruction' + + class TooManySpendingLimitMembers(Error): + code = 6051 + msg = 'A spending limit can have at most 10 members' + + class InvalidLiquidator(Error): + code = 6052 + msg = 'Invalid liquidator' + + class InvalidProposalPassThreshold(Error): + code = 6053 + msg = 'Pass threshold must be between -99.99% and 99.99%' + + class EmptyProposalParamsUpdate(Error): + code = 6054 + msg = 'A proposal params update must set at least one field' + + class BuybackCapExceeded(Error): + code = 6055 + msg = 'Buyback amount exceeds 25% of the treasury' + + class InvalidBuybackAmount(Error): + code = 6056 + msg = 'Buyback total must be non-zero' + + class InvalidBuybackCycleFrequency(Error): + code = 6057 + msg = 'Cycle frequency must be between 60 seconds and 1 year' + + class InvalidBuybackStartDelay(Error): + code = 6058 + msg = 'Start delay must be at most 30 days' + + class InvalidBuybackPriceBand(Error): + code = 6059 + msg = 'min_price must be no greater than max_price' + + class InvalidTreasuryAccount(Error): + code = 6060 + msg = "A treasury account is neither a vault-owned quote account nor the treasury's AMM position" + + class TreasuryAccountsNotSorted(Error): + code = 6061 + msg = 'Treasury accounts must be in strictly ascending key order' + + class UnexpectedLaunchAccounts(Error): + code = 6062 + msg = "This proposal kind's launch takes no extra accounts" + + class InvalidSpendingLimitAccount(Error): + code = 6063 + msg = 'Spending limit account is not the canonical spending-limit PDA' + + class StaleTeamAddress(Error): + code = 6064 + msg = "The DAO's team has changed since this draft was created" + + class AccountNotMigrated(Error): + code = 6065 + msg = 'Account is not migrated to latest layout' + + class InvalidSpendingLimitAmount(Error): + code = 6066 + msg = "A spending limit's monthly amount must be non-zero" + + class EmptySpendingLimitMembers(Error): + code = 6067 + msg = 'A spending limit must have at least one member' + + class DuplicateSpendingLimitMember(Error): + code = 6068 + msg = "A spending limit's members must be unique" + + class InvalidBuybackCycleCount(Error): + code = 6069 + msg = 'A buyback must run at least two cycles' + + class InvalidTeamAddress(Error): + code = 6070 + msg = 'Invalid team address' + + class TeamSponsorshipForbidden(Error): + code = 6071 + msg = 'This proposal kind cannot be team-sponsored' + + class SquadsProposalNotApproved(Error): + code = 6072 + msg = 'Squads proposal must be in Approved status to be cancelled' + + @classmethod + @instruction(InstructionMeta( + name="initializeDao", + discriminator=b"\x80\xe2\x60\x5a\x27\x38\x18\xc4", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("daoCreator", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("systemProgram"), + AccountSlot("baseMint"), + AccountSlot("quoteMint"), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsMultisigVault"), + AccountSlot("squadsProgram"), + AccountSlot("squadsProgramConfig"), + AccountSlot("squadsProgramConfigTreasury", is_writable=True), + AccountSlot("spendingLimit", is_writable=True), + AccountSlot("futarchyAmmBaseVault", is_writable=True), + AccountSlot("futarchyAmmQuoteVault", is_writable=True), + AccountSlot("tokenProgram"), + AccountSlot("associatedTokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeDao(cls, params: InitializeDaoParams, *, dao: MetaLike, daoCreator: MetaLike, payer: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), baseMint: MetaLike, quoteMint: MetaLike, squadsMultisig: MetaLike, squadsMultisigVault: MetaLike, squadsProgram: MetaLike, squadsProgramConfig: MetaLike, squadsProgramConfigTreasury: MetaLike, spendingLimit: MetaLike, futarchyAmmBaseVault: MetaLike, futarchyAmmQuoteVault: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), associatedTokenProgram: MetaLike = Pubkey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x80\xe2\x60\x5a\x27\x38\x18\xc4", _ENC_initializeDao, params) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(daoCreator, True, False, False), + slot(payer, True, True, False), + slot(systemProgram, False, False, False), + slot(baseMint, False, False, False), + slot(quoteMint, False, False, False), + slot(squadsMultisig, False, True, False), + slot(squadsMultisigVault, False, False, False), + slot(squadsProgram, False, False, False), + slot(squadsProgramConfig, False, False, False), + slot(squadsProgramConfigTreasury, False, True, False), + slot(spendingLimit, False, True, False), + slot(futarchyAmmBaseVault, False, True, False), + slot(futarchyAmmQuoteVault, False, True, False), + slot(tokenProgram, False, False, False), + slot(associatedTokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeProposal", + discriminator=b"\x32\x49\x9c\x62\x81\x95\x15\x9e", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("squadsProposal"), + AccountSlot("squadsMultisig"), + AccountSlot("dao", is_writable=True), + AccountSlot("question"), + AccountSlot("quoteVault"), + AccountSlot("baseVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeProposal(cls, *, proposal: MetaLike, squadsProposal: MetaLike, squadsMultisig: MetaLike, dao: MetaLike, question: MetaLike, quoteVault: MetaLike, baseVault: MetaLike, proposer: MetaLike, payer: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x32\x49\x9c\x62\x81\x95\x15\x9e", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(squadsProposal, False, False, False), + slot(squadsMultisig, False, False, False), + slot(dao, False, True, False), + slot(question, False, False, False), + slot(quoteVault, False, False, False), + slot(baseVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeLargeSpendProposal", + discriminator=b"\xd7\xd5\x20\x11\xd9\xd0\x1b\x6a", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsTransaction", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("question"), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("permissionlessAccount", is_signer=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeLargeSpendProposal(cls, args: InitializeLargeSpendProposalArgs, *, proposal: MetaLike, dao: MetaLike, squadsMultisig: MetaLike, squadsTransaction: MetaLike, squadsProposal: MetaLike, question: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, proposer: MetaLike, payer: MetaLike, permissionlessAccount: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xd7\xd5\x20\x11\xd9\xd0\x1b\x6a", _ENC_initializeLargeSpendProposal, args) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsTransaction, False, True, False), + slot(squadsProposal, False, True, False), + slot(question, False, False, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(permissionlessAccount, True, False, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeMintTokensProposal", + discriminator=b"\x0f\xb5\x02\xdd\x5d\x6b\xdb\x0d", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsTransaction", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("question"), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("permissionlessAccount", is_signer=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("baseMint"), + AccountSlot("mintGovernor", is_optional=True), + AccountSlot("mintAuthority", is_optional=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeMintTokensProposal(cls, args: InitializeMintTokensProposalArgs, *, proposal: MetaLike, dao: MetaLike, squadsMultisig: MetaLike, squadsTransaction: MetaLike, squadsProposal: MetaLike, question: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, proposer: MetaLike, payer: MetaLike, permissionlessAccount: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), baseMint: MetaLike, mintGovernor: MetaLike | None = None, mintAuthority: MetaLike | None = None, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x0f\xb5\x02\xdd\x5d\x6b\xdb\x0d", _ENC_initializeMintTokensProposal, args) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsTransaction, False, True, False), + slot(squadsProposal, False, True, False), + slot(question, False, False, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(permissionlessAccount, True, False, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(baseMint, False, False, False), + slot(mintGovernor, False, False, True), + slot(mintAuthority, False, False, True), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeSpendingLimitChangeProposal", + discriminator=b"\x77\x91\x47\xea\x31\x36\x7c\x42", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsTransaction", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("question"), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("permissionlessAccount", is_signer=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeSpendingLimitChangeProposal(cls, args: InitializeSpendingLimitChangeProposalArgs, *, proposal: MetaLike, dao: MetaLike, squadsMultisig: MetaLike, squadsTransaction: MetaLike, squadsProposal: MetaLike, question: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, proposer: MetaLike, payer: MetaLike, permissionlessAccount: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x77\x91\x47\xea\x31\x36\x7c\x42", _ENC_initializeSpendingLimitChangeProposal, args) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsTransaction, False, True, False), + slot(squadsProposal, False, True, False), + slot(question, False, False, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(permissionlessAccount, True, False, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeHostileTakeoverProposal", + discriminator=b"\x0a\x06\x88\x19\xc6\xd9\xf1\x28", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsTransaction", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("question"), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("permissionlessAccount", is_signer=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeHostileTakeoverProposal(cls, args: InitializeHostileTakeoverProposalArgs, *, proposal: MetaLike, dao: MetaLike, squadsMultisig: MetaLike, squadsTransaction: MetaLike, squadsProposal: MetaLike, question: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, proposer: MetaLike, payer: MetaLike, permissionlessAccount: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x0a\x06\x88\x19\xc6\xd9\xf1\x28", _ENC_initializeHostileTakeoverProposal, args) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsTransaction, False, True, False), + slot(squadsProposal, False, True, False), + slot(question, False, False, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(permissionlessAccount, True, False, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeHostileLiquidateProposal", + discriminator=b"\x10\xc4\xf0\x47\xe0\x67\x52\xca", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsTransaction", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("question"), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("permissionlessAccount", is_signer=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeHostileLiquidateProposal(cls, args: InitializeHostileLiquidateProposalArgs, *, proposal: MetaLike, dao: MetaLike, squadsMultisig: MetaLike, squadsTransaction: MetaLike, squadsProposal: MetaLike, question: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, proposer: MetaLike, payer: MetaLike, permissionlessAccount: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x10\xc4\xf0\x47\xe0\x67\x52\xca", _ENC_initializeHostileLiquidateProposal, args) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsTransaction, False, True, False), + slot(squadsProposal, False, True, False), + slot(question, False, False, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(permissionlessAccount, True, False, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="initializeBuybackTokenProposal", + discriminator=b"\x4e\x1a\x49\xd3\x1c\x33\x8e\x6b", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsTransaction", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("question"), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("proposer", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("permissionlessAccount", is_signer=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def initializeBuybackTokenProposal(cls, args: InitializeBuybackTokenProposalArgs, *, proposal: MetaLike, dao: MetaLike, squadsMultisig: MetaLike, squadsTransaction: MetaLike, squadsProposal: MetaLike, question: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, proposer: MetaLike, payer: MetaLike, permissionlessAccount: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x4e\x1a\x49\xd3\x1c\x33\x8e\x6b", _ENC_initializeBuybackTokenProposal, args) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsTransaction, False, True, False), + slot(squadsProposal, False, True, False), + slot(question, False, False, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(proposer, True, False, False), + slot(payer, True, True, False), + slot(permissionlessAccount, True, False, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="stakeToProposal", + discriminator=b"\x0a\xa9\xaf\xee\x50\xdd\x25\x10", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("stakerBaseAccount", is_writable=True), + AccountSlot("proposalBaseAccount", is_writable=True), + AccountSlot("stakeAccount", is_writable=True), + AccountSlot("staker", is_signer=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("tokenProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def stakeToProposal(cls, params: StakeToProposalParams, *, proposal: MetaLike, dao: MetaLike, stakerBaseAccount: MetaLike, proposalBaseAccount: MetaLike, stakeAccount: MetaLike, staker: MetaLike, payer: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x0a\xa9\xaf\xee\x50\xdd\x25\x10", _ENC_stakeToProposal, params) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(stakerBaseAccount, False, True, False), + slot(proposalBaseAccount, False, True, False), + slot(stakeAccount, False, True, False), + slot(staker, True, False, False), + slot(payer, True, True, False), + slot(tokenProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="unstakeFromProposal", + discriminator=b"\xb3\xdc\xba\x56\x02\x60\x32\xa1", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("stakerBaseAccount", is_writable=True), + AccountSlot("proposalBaseAccount", is_writable=True), + AccountSlot("stakeAccount", is_writable=True), + AccountSlot("baseMint"), + AccountSlot("staker", is_signer=True, is_writable=True), + AccountSlot("tokenProgram"), + AccountSlot("systemProgram"), + AccountSlot("associatedTokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def unstakeFromProposal(cls, params: UnstakeFromProposalParams, *, proposal: MetaLike, dao: MetaLike, stakerBaseAccount: MetaLike, proposalBaseAccount: MetaLike, stakeAccount: MetaLike, baseMint: MetaLike, staker: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), associatedTokenProgram: MetaLike = Pubkey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xb3\xdc\xba\x56\x02\x60\x32\xa1", _ENC_unstakeFromProposal, params) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(stakerBaseAccount, False, True, False), + slot(proposalBaseAccount, False, True, False), + slot(stakeAccount, False, True, False), + slot(baseMint, False, False, False), + slot(staker, True, True, False), + slot(tokenProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(associatedTokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="launchProposal", + discriminator=b"\x10\xd3\xbd\x77\xf5\x48\x00\xe5", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("baseVault"), + AccountSlot("quoteVault"), + AccountSlot("passBaseMint"), + AccountSlot("passQuoteMint"), + AccountSlot("failBaseMint"), + AccountSlot("failQuoteMint"), + AccountSlot("dao", is_writable=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("ammPassBaseVault", is_writable=True), + AccountSlot("ammPassQuoteVault", is_writable=True), + AccountSlot("ammFailBaseVault", is_writable=True), + AccountSlot("ammFailQuoteVault", is_writable=True), + AccountSlot("squadsMultisig"), + AccountSlot("squadsProposal"), + AccountSlot("systemProgram"), + AccountSlot("tokenProgram"), + AccountSlot("associatedTokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def launchProposal(cls, *, proposal: MetaLike, baseVault: MetaLike, quoteVault: MetaLike, passBaseMint: MetaLike, passQuoteMint: MetaLike, failBaseMint: MetaLike, failQuoteMint: MetaLike, dao: MetaLike, payer: MetaLike, ammPassBaseVault: MetaLike, ammPassQuoteVault: MetaLike, ammFailBaseVault: MetaLike, ammFailQuoteVault: MetaLike, squadsMultisig: MetaLike, squadsProposal: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), associatedTokenProgram: MetaLike = Pubkey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x10\xd3\xbd\x77\xf5\x48\x00\xe5", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(baseVault, False, False, False), + slot(quoteVault, False, False, False), + slot(passBaseMint, False, False, False), + slot(passQuoteMint, False, False, False), + slot(failBaseMint, False, False, False), + slot(failQuoteMint, False, False, False), + slot(dao, False, True, False), + slot(payer, True, True, False), + slot(ammPassBaseVault, False, True, False), + slot(ammPassQuoteVault, False, True, False), + slot(ammFailBaseVault, False, True, False), + slot(ammFailQuoteVault, False, True, False), + slot(squadsMultisig, False, False, False), + slot(squadsProposal, False, False, False), + slot(systemProgram, False, False, False), + slot(tokenProgram, False, False, False), + slot(associatedTokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="finalizeProposal", + discriminator=b"\x17\x44\x33\xa7\x6d\xad\xbb\xa4", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("question", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("squadsMultisig"), + AccountSlot("squadsMultisigProgram"), + AccountSlot("ammPassBaseVault", is_writable=True), + AccountSlot("ammPassQuoteVault", is_writable=True), + AccountSlot("ammFailBaseVault", is_writable=True), + AccountSlot("ammFailQuoteVault", is_writable=True), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("vaultProgram"), + AccountSlot("vaultEventAuthority"), + AccountSlot("tokenProgram"), + AccountSlot("quoteVault", is_writable=True), + AccountSlot("quoteVaultUnderlyingTokenAccount", is_writable=True), + AccountSlot("passQuoteMint", is_writable=True), + AccountSlot("failQuoteMint", is_writable=True), + AccountSlot("passBaseMint", is_writable=True), + AccountSlot("failBaseMint", is_writable=True), + AccountSlot("baseVault", is_writable=True), + AccountSlot("baseVaultUnderlyingTokenAccount", is_writable=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def finalizeProposal(cls, *, proposal: MetaLike, dao: MetaLike, question: MetaLike, squadsProposal: MetaLike, squadsMultisig: MetaLike, squadsMultisigProgram: MetaLike, ammPassBaseVault: MetaLike, ammPassQuoteVault: MetaLike, ammFailBaseVault: MetaLike, ammFailQuoteVault: MetaLike, ammBaseVault: MetaLike, ammQuoteVault: MetaLike, vaultProgram: MetaLike, vaultEventAuthority: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), quoteVault: MetaLike, quoteVaultUnderlyingTokenAccount: MetaLike, passQuoteMint: MetaLike, failQuoteMint: MetaLike, passBaseMint: MetaLike, failBaseMint: MetaLike, baseVault: MetaLike, baseVaultUnderlyingTokenAccount: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x17\x44\x33\xa7\x6d\xad\xbb\xa4", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(question, False, True, False), + slot(squadsProposal, False, True, False), + slot(squadsMultisig, False, False, False), + slot(squadsMultisigProgram, False, False, False), + slot(ammPassBaseVault, False, True, False), + slot(ammPassQuoteVault, False, True, False), + slot(ammFailBaseVault, False, True, False), + slot(ammFailQuoteVault, False, True, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(vaultProgram, False, False, False), + slot(vaultEventAuthority, False, False, False), + slot(tokenProgram, False, False, False), + slot(quoteVault, False, True, False), + slot(quoteVaultUnderlyingTokenAccount, False, True, False), + slot(passQuoteMint, False, True, False), + slot(failQuoteMint, False, True, False), + slot(passBaseMint, False, True, False), + slot(failBaseMint, False, True, False), + slot(baseVault, False, True, False), + slot(baseVaultUnderlyingTokenAccount, False, True, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="updateDao", + discriminator=b"\x83\x48\x4b\x19\x70\xd2\x6d\x02", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisigVault", is_signer=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def updateDao(cls, daoParams: UpdateDaoParams, *, dao: MetaLike, squadsMultisigVault: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x83\x48\x4b\x19\x70\xd2\x6d\x02", _ENC_updateDao, daoParams) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(squadsMultisigVault, True, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="setSpendingLimit", + discriminator=b"\x27\x30\xed\xa1\x31\xab\x9b\xd0", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisigVault", is_signer=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def setSpendingLimit(cls, args: SetSpendingLimitArgs, *, dao: MetaLike, squadsMultisigVault: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x27\x30\xed\xa1\x31\xab\x9b\xd0", _ENC_setSpendingLimit, args) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(squadsMultisigVault, True, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="syncSpendingLimit", + discriminator=b"\x18\x6e\xa1\xed\xb9\xa1\xa9\x8d", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("spendingLimit", is_writable=True), + AccountSlot("rentPayer", is_signer=True, is_writable=True), + AccountSlot("squadsProgram"), + AccountSlot("systemProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def syncSpendingLimit(cls, *, dao: MetaLike, squadsMultisig: MetaLike, spendingLimit: MetaLike, rentPayer: MetaLike, squadsProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x18\x6e\xa1\xed\xb9\xa1\xa9\x8d", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(squadsMultisig, False, True, False), + slot(spendingLimit, False, True, False), + slot(rentPayer, True, True, False), + slot(squadsProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="resizeDao", + discriminator=b"\x8e\x34\x44\x51\x71\x0e\x5a\x28", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("spendingLimit"), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("systemProgram"), + ), + )) + def resizeDao(cls, *, dao: MetaLike, spendingLimit: MetaLike, payer: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x8e\x34\x44\x51\x71\x0e\x5a\x28", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(spendingLimit, False, False, False), + slot(payer, True, True, False), + slot(systemProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="resizeProposal", + discriminator=b"\x28\xd5\x58\xce\xd5\xb8\x1e\xb5", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao"), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("systemProgram"), + ), + )) + def resizeProposal(cls, *, proposal: MetaLike, dao: MetaLike, payer: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x28\xd5\x58\xce\xd5\xb8\x1e\xb5", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, False, False), + slot(payer, True, True, False), + slot(systemProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="spotSwap", + discriminator=b"\xa7\x61\x0c\xe7\xed\x4e\xa6\xfb", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("userBaseAccount", is_writable=True), + AccountSlot("userQuoteAccount", is_writable=True), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("user", is_signer=True), + AccountSlot("tokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def spotSwap(cls, params: SpotSwapParams, *, dao: MetaLike, userBaseAccount: MetaLike, userQuoteAccount: MetaLike, ammBaseVault: MetaLike, ammQuoteVault: MetaLike, user: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xa7\x61\x0c\xe7\xed\x4e\xa6\xfb", _ENC_spotSwap, params) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(userBaseAccount, False, True, False), + slot(userQuoteAccount, False, True, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(user, True, False, False), + slot(tokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="conditionalSwap", + discriminator=b"\xc2\x88\xdc\x59\xf2\xa9\x82\x9d", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("proposal"), + AccountSlot("ammPassBaseVault", is_writable=True), + AccountSlot("ammPassQuoteVault", is_writable=True), + AccountSlot("ammFailBaseVault", is_writable=True), + AccountSlot("ammFailQuoteVault", is_writable=True), + AccountSlot("trader", is_signer=True), + AccountSlot("userInputAccount", is_writable=True), + AccountSlot("userOutputAccount", is_writable=True), + AccountSlot("baseVault", is_writable=True), + AccountSlot("baseVaultUnderlyingTokenAccount", is_writable=True), + AccountSlot("quoteVault", is_writable=True), + AccountSlot("quoteVaultUnderlyingTokenAccount", is_writable=True), + AccountSlot("passBaseMint", is_writable=True), + AccountSlot("failBaseMint", is_writable=True), + AccountSlot("passQuoteMint", is_writable=True), + AccountSlot("failQuoteMint", is_writable=True), + AccountSlot("conditionalVaultProgram"), + AccountSlot("vaultEventAuthority"), + AccountSlot("question"), + AccountSlot("tokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def conditionalSwap(cls, params: ConditionalSwapParams, *, dao: MetaLike, ammBaseVault: MetaLike, ammQuoteVault: MetaLike, proposal: MetaLike, ammPassBaseVault: MetaLike, ammPassQuoteVault: MetaLike, ammFailBaseVault: MetaLike, ammFailQuoteVault: MetaLike, trader: MetaLike, userInputAccount: MetaLike, userOutputAccount: MetaLike, baseVault: MetaLike, baseVaultUnderlyingTokenAccount: MetaLike, quoteVault: MetaLike, quoteVaultUnderlyingTokenAccount: MetaLike, passBaseMint: MetaLike, failBaseMint: MetaLike, passQuoteMint: MetaLike, failQuoteMint: MetaLike, conditionalVaultProgram: MetaLike, vaultEventAuthority: MetaLike, question: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xc2\x88\xdc\x59\xf2\xa9\x82\x9d", _ENC_conditionalSwap, params) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(proposal, False, False, False), + slot(ammPassBaseVault, False, True, False), + slot(ammPassQuoteVault, False, True, False), + slot(ammFailBaseVault, False, True, False), + slot(ammFailQuoteVault, False, True, False), + slot(trader, True, False, False), + slot(userInputAccount, False, True, False), + slot(userOutputAccount, False, True, False), + slot(baseVault, False, True, False), + slot(baseVaultUnderlyingTokenAccount, False, True, False), + slot(quoteVault, False, True, False), + slot(quoteVaultUnderlyingTokenAccount, False, True, False), + slot(passBaseMint, False, True, False), + slot(failBaseMint, False, True, False), + slot(passQuoteMint, False, True, False), + slot(failQuoteMint, False, True, False), + slot(conditionalVaultProgram, False, False, False), + slot(vaultEventAuthority, False, False, False), + slot(question, False, False, False), + slot(tokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="provideLiquidity", + discriminator=b"\x28\x6e\x6b\x74\xae\x7f\x61\xcc", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("liquidityProvider", is_signer=True), + AccountSlot("liquidityProviderBaseAccount", is_writable=True), + AccountSlot("liquidityProviderQuoteAccount", is_writable=True), + AccountSlot("payer", is_signer=True, is_writable=True), + AccountSlot("systemProgram"), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("ammPosition", is_writable=True), + AccountSlot("tokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def provideLiquidity(cls, params: ProvideLiquidityParams, *, dao: MetaLike, liquidityProvider: MetaLike, liquidityProviderBaseAccount: MetaLike, liquidityProviderQuoteAccount: MetaLike, payer: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), ammBaseVault: MetaLike, ammQuoteVault: MetaLike, ammPosition: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x28\x6e\x6b\x74\xae\x7f\x61\xcc", _ENC_provideLiquidity, params) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(liquidityProvider, True, False, False), + slot(liquidityProviderBaseAccount, False, True, False), + slot(liquidityProviderQuoteAccount, False, True, False), + slot(payer, True, True, False), + slot(systemProgram, False, False, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(ammPosition, False, True, False), + slot(tokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="withdrawLiquidity", + discriminator=b"\x95\x9e\x21\xb9\x2f\xf3\xfd\x1f", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("positionAuthority", is_signer=True), + AccountSlot("liquidityProviderBaseAccount", is_writable=True), + AccountSlot("liquidityProviderQuoteAccount", is_writable=True), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("ammPosition", is_writable=True), + AccountSlot("tokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def withdrawLiquidity(cls, params: WithdrawLiquidityParams, *, dao: MetaLike, positionAuthority: MetaLike, liquidityProviderBaseAccount: MetaLike, liquidityProviderQuoteAccount: MetaLike, ammBaseVault: MetaLike, ammQuoteVault: MetaLike, ammPosition: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x95\x9e\x21\xb9\x2f\xf3\xfd\x1f", _ENC_withdrawLiquidity, params) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(positionAuthority, True, False, False), + slot(liquidityProviderBaseAccount, False, True, False), + slot(liquidityProviderQuoteAccount, False, True, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(ammPosition, False, True, False), + slot(tokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="collectFees", + discriminator=b"\xa4\x98\xcf\x63\x1e\xba\x13\xb6", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("admin", is_signer=True), + AccountSlot("baseTokenAccount", is_writable=True), + AccountSlot("quoteTokenAccount", is_writable=True), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("tokenProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def collectFees(cls, *, dao: MetaLike, admin: MetaLike, baseTokenAccount: MetaLike, quoteTokenAccount: MetaLike, ammBaseVault: MetaLike, ammQuoteVault: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xa4\x98\xcf\x63\x1e\xba\x13\xb6", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(admin, True, False, False), + slot(baseTokenAccount, False, True, False), + slot(quoteTokenAccount, False, True, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(tokenProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="sponsorProposal", + discriminator=b"\xc1\x39\xaa\x88\x65\xc4\x3a\xad", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("teamAddress", is_signer=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def sponsorProposal(cls, *, proposal: MetaLike, dao: MetaLike, teamAddress: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xc1\x39\xaa\x88\x65\xc4\x3a\xad", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(teamAddress, True, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="collectMeteoraDammFees", + discriminator=b"\x8b\xd4\x69\x76\x7e\x36\xd6\x8f", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("admin", is_signer=True, is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsMultisigVault", is_writable=True), + AccountSlot("squadsMultisigVaultTransaction", is_writable=True), + AccountSlot("squadsMultisigProposal", is_writable=True), + AccountSlot("squadsMultisigPermissionlessAccount", is_signer=True), + AccountSlot("dammV2Program"), + AccountSlot("dammV2EventAuthority"), + AccountSlot("poolAuthority"), + AccountSlot("pool"), + AccountSlot("position", is_writable=True), + AccountSlot("tokenAAccount", is_writable=True), + AccountSlot("tokenBAccount", is_writable=True), + AccountSlot("tokenAVault", is_writable=True), + AccountSlot("tokenBVault", is_writable=True), + AccountSlot("tokenAMint"), + AccountSlot("tokenBMint"), + AccountSlot("positionNftAccount"), + AccountSlot("owner"), + AccountSlot("tokenAProgram"), + AccountSlot("tokenBProgram"), + AccountSlot("systemProgram"), + AccountSlot("tokenProgram"), + AccountSlot("squadsProgram"), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def collectMeteoraDammFees(cls, *, dao: MetaLike, admin: MetaLike, squadsMultisig: MetaLike, squadsMultisigVault: MetaLike, squadsMultisigVaultTransaction: MetaLike, squadsMultisigProposal: MetaLike, squadsMultisigPermissionlessAccount: MetaLike, dammV2Program: MetaLike, dammV2EventAuthority: MetaLike, poolAuthority: MetaLike, pool: MetaLike, position: MetaLike, tokenAAccount: MetaLike, tokenBAccount: MetaLike, tokenAVault: MetaLike, tokenBVault: MetaLike, tokenAMint: MetaLike, tokenBMint: MetaLike, positionNftAccount: MetaLike, owner: MetaLike, tokenAProgram: MetaLike, tokenBProgram: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), squadsProgram: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x8b\xd4\x69\x76\x7e\x36\xd6\x8f", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(admin, True, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsMultisigVault, False, True, False), + slot(squadsMultisigVaultTransaction, False, True, False), + slot(squadsMultisigProposal, False, True, False), + slot(squadsMultisigPermissionlessAccount, True, False, False), + slot(dammV2Program, False, False, False), + slot(dammV2EventAuthority, False, False, False), + slot(poolAuthority, False, False, False), + slot(pool, False, False, False), + slot(position, False, True, False), + slot(tokenAAccount, False, True, False), + slot(tokenBAccount, False, True, False), + slot(tokenAVault, False, True, False), + slot(tokenBVault, False, True, False), + slot(tokenAMint, False, False, False), + slot(tokenBMint, False, False, False), + slot(positionNftAccount, False, False, False), + slot(owner, False, False, False), + slot(tokenAProgram, False, False, False), + slot(tokenBProgram, False, False, False), + slot(systemProgram, False, False, False), + slot(tokenProgram, False, False, False), + slot(squadsProgram, False, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="adminEnqueueMultisigProposalApproval", + discriminator=b"\x3d\xc7\xf1\x64\xee\xdc\x54\x4e", + accounts=( + AccountSlot("dao"), + AccountSlot("admin", is_signer=True, is_writable=True), + AccountSlot("squadsMultisig"), + AccountSlot("squadsMultisigProposal"), + AccountSlot("enqueuedApproval", is_writable=True), + AccountSlot("systemProgram"), + ), + )) + def adminEnqueueMultisigProposalApproval(cls, args: AdminEnqueueMultisigProposalApprovalArgs, *, dao: MetaLike, admin: MetaLike, squadsMultisig: MetaLike, squadsMultisigProposal: MetaLike, enqueuedApproval: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x3d\xc7\xf1\x64\xee\xdc\x54\x4e", _ENC_adminEnqueueMultisigProposalApproval, args) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, False, False), + slot(admin, True, True, False), + slot(squadsMultisig, False, False, False), + slot(squadsMultisigProposal, False, False, False), + slot(enqueuedApproval, False, True, False), + slot(systemProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="executeMultisigProposalApproval", + discriminator=b"\x7c\x90\xc9\xa4\xb6\xe2\xc1\xe0", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("rentReceiver", is_signer=True, is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsMultisigProposal", is_writable=True), + AccountSlot("enqueuedApproval", is_writable=True), + AccountSlot("squadsMultisigProgram"), + ), + )) + def executeMultisigProposalApproval(cls, *, dao: MetaLike, rentReceiver: MetaLike, squadsMultisig: MetaLike, squadsMultisigProposal: MetaLike, enqueuedApproval: MetaLike, squadsMultisigProgram: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x7c\x90\xc9\xa4\xb6\xe2\xc1\xe0", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(rentReceiver, True, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsMultisigProposal, False, True, False), + slot(enqueuedApproval, False, True, False), + slot(squadsMultisigProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="adminEnqueueMultisigProposalCancellation", + discriminator=b"\x82\x8d\x0b\x2d\x28\x5f\x83\x02", + accounts=( + AccountSlot("dao"), + AccountSlot("admin", is_signer=True, is_writable=True), + AccountSlot("squadsMultisig"), + AccountSlot("squadsMultisigProposal"), + AccountSlot("enqueuedCancellation", is_writable=True), + AccountSlot("systemProgram"), + ), + )) + def adminEnqueueMultisigProposalCancellation(cls, args: AdminEnqueueMultisigProposalCancellationArgs, *, dao: MetaLike, admin: MetaLike, squadsMultisig: MetaLike, squadsMultisigProposal: MetaLike, enqueuedCancellation: MetaLike, systemProgram: MetaLike = Pubkey("11111111111111111111111111111111"), remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x82\x8d\x0b\x2d\x28\x5f\x83\x02", _ENC_adminEnqueueMultisigProposalCancellation, args) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, False, False), + slot(admin, True, True, False), + slot(squadsMultisig, False, False, False), + slot(squadsMultisigProposal, False, False, False), + slot(enqueuedCancellation, False, True, False), + slot(systemProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="executeMultisigProposalCancellation", + discriminator=b"\xf9\x45\x9f\xff\xec\x15\xf3\x1e", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("rentReceiver", is_signer=True, is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsMultisigProposal", is_writable=True), + AccountSlot("enqueuedCancellation", is_writable=True), + AccountSlot("squadsMultisigProgram"), + ), + )) + def executeMultisigProposalCancellation(cls, *, dao: MetaLike, rentReceiver: MetaLike, squadsMultisig: MetaLike, squadsMultisigProposal: MetaLike, enqueuedCancellation: MetaLike, squadsMultisigProgram: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xf9\x45\x9f\xff\xec\x15\xf3\x1e", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(rentReceiver, True, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsMultisigProposal, False, True, False), + slot(enqueuedCancellation, False, True, False), + slot(squadsMultisigProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="adminExecuteMultisigProposal", + discriminator=b"\xe2\xdd\x28\xa0\xa9\x5a\x30\x2a", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("admin", is_signer=True, is_writable=True), + AccountSlot("squadsMultisig", is_writable=True), + AccountSlot("squadsMultisigProposal", is_writable=True), + AccountSlot("squadsMultisigVaultTransaction", is_writable=True), + AccountSlot("squadsMultisigProgram"), + ), + )) + def adminExecuteMultisigProposal(cls, *, dao: MetaLike, admin: MetaLike, squadsMultisig: MetaLike, squadsMultisigProposal: MetaLike, squadsMultisigVaultTransaction: MetaLike, squadsMultisigProgram: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xe2\xdd\x28\xa0\xa9\x5a\x30\x2a", ()) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(admin, True, True, False), + slot(squadsMultisig, False, True, False), + slot(squadsMultisigProposal, False, True, False), + slot(squadsMultisigVaultTransaction, False, True, False), + slot(squadsMultisigProgram, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="adminCancelProposal", + discriminator=b"\x5f\xe9\x79\xc1\x5a\x50\x93\xff", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("question", is_writable=True), + AccountSlot("squadsProposal", is_writable=True), + AccountSlot("squadsMultisig"), + AccountSlot("squadsMultisigProgram"), + AccountSlot("ammPassBaseVault", is_writable=True), + AccountSlot("ammPassQuoteVault", is_writable=True), + AccountSlot("ammFailBaseVault", is_writable=True), + AccountSlot("ammFailQuoteVault", is_writable=True), + AccountSlot("ammBaseVault", is_writable=True), + AccountSlot("ammQuoteVault", is_writable=True), + AccountSlot("vaultProgram"), + AccountSlot("vaultEventAuthority"), + AccountSlot("tokenProgram"), + AccountSlot("quoteVault", is_writable=True), + AccountSlot("quoteVaultUnderlyingTokenAccount", is_writable=True), + AccountSlot("passQuoteMint", is_writable=True), + AccountSlot("failQuoteMint", is_writable=True), + AccountSlot("passBaseMint", is_writable=True), + AccountSlot("failBaseMint", is_writable=True), + AccountSlot("baseVault", is_writable=True), + AccountSlot("baseVaultUnderlyingTokenAccount", is_writable=True), + AccountSlot("admin", is_signer=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def adminCancelProposal(cls, *, proposal: MetaLike, dao: MetaLike, question: MetaLike, squadsProposal: MetaLike, squadsMultisig: MetaLike, squadsMultisigProgram: MetaLike, ammPassBaseVault: MetaLike, ammPassQuoteVault: MetaLike, ammFailBaseVault: MetaLike, ammFailQuoteVault: MetaLike, ammBaseVault: MetaLike, ammQuoteVault: MetaLike, vaultProgram: MetaLike, vaultEventAuthority: MetaLike, tokenProgram: MetaLike = Pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), quoteVault: MetaLike, quoteVaultUnderlyingTokenAccount: MetaLike, passQuoteMint: MetaLike, failQuoteMint: MetaLike, passBaseMint: MetaLike, failBaseMint: MetaLike, baseVault: MetaLike, baseVaultUnderlyingTokenAccount: MetaLike, admin: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x5f\xe9\x79\xc1\x5a\x50\x93\xff", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(question, False, True, False), + slot(squadsProposal, False, True, False), + slot(squadsMultisig, False, False, False), + slot(squadsMultisigProgram, False, False, False), + slot(ammPassBaseVault, False, True, False), + slot(ammPassQuoteVault, False, True, False), + slot(ammFailBaseVault, False, True, False), + slot(ammFailQuoteVault, False, True, False), + slot(ammBaseVault, False, True, False), + slot(ammQuoteVault, False, True, False), + slot(vaultProgram, False, False, False), + slot(vaultEventAuthority, False, False, False), + slot(tokenProgram, False, False, False), + slot(quoteVault, False, True, False), + slot(quoteVaultUnderlyingTokenAccount, False, True, False), + slot(passQuoteMint, False, True, False), + slot(failQuoteMint, False, True, False), + slot(passBaseMint, False, True, False), + slot(failBaseMint, False, True, False), + slot(baseVault, False, True, False), + slot(baseVaultUnderlyingTokenAccount, False, True, False), + slot(admin, True, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="adminRemoveProposal", + discriminator=b"\xf2\xc7\x1b\x1c\x07\x6c\x7a\x49", + accounts=( + AccountSlot("proposal", is_writable=True), + AccountSlot("dao", is_writable=True), + AccountSlot("admin", is_signer=True, is_writable=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def adminRemoveProposal(cls, *, proposal: MetaLike, dao: MetaLike, admin: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\xf2\xc7\x1b\x1c\x07\x6c\x7a\x49", ()) + metas = build_metas( + PROGRAM_ID, + slot(proposal, False, True, False), + slot(dao, False, True, False), + slot(admin, True, True, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + @classmethod + @instruction(InstructionMeta( + name="adminUpdateProposalParams", + discriminator=b"\x93\xcf\xc8\x02\x42\x07\x77\xb4", + accounts=( + AccountSlot("dao", is_writable=True), + AccountSlot("proposal", is_writable=True), + AccountSlot("admin", is_signer=True), + AccountSlot("eventAuthority"), + AccountSlot("program"), + ), + )) + def adminUpdateProposalParams(cls, args: AdminUpdateProposalParamsArgs, *, dao: MetaLike, proposal: MetaLike, admin: MetaLike, eventAuthority: MetaLike, program: MetaLike, remaining_accounts: Sequence[MetaLike] = ()) -> Instruction: + data = encode_ix_layout(b"\x93\xcf\xc8\x02\x42\x07\x77\xb4", _ENC_adminUpdateProposalParams, args) + metas = build_metas( + PROGRAM_ID, + slot(dao, False, True, False), + slot(proposal, False, True, False), + slot(admin, True, False, False), + slot(eventAuthority, False, False, False), + slot(program, False, False, False), + ) + metas += [as_meta(m) for m in remaining_accounts] + return Instruction(PROGRAM_ID, metas, data) + + +# ------------------------------------------------------------------------- # +# 5. errors — module-level aliases +# ------------------------------------------------------------------------- # +FutarchyError = Futarchy.Error +AmmTooOld = Futarchy.AmmTooOld +InvalidInitialObservation = Futarchy.InvalidInitialObservation +InvalidMaxObservationChange = Futarchy.InvalidMaxObservationChange +InvalidStartDelaySlots = Futarchy.InvalidStartDelaySlots +InvalidSettlementAuthority = Futarchy.InvalidSettlementAuthority +ProposalTooYoung = Futarchy.ProposalTooYoung +MarketsTooYoung = Futarchy.MarketsTooYoung +ProposalAlreadyFinalized = Futarchy.ProposalAlreadyFinalized +InvalidVaultNonce = Futarchy.InvalidVaultNonce +ProposalNotPassed = Futarchy.ProposalNotPassed +InsufficientLiquidity = Futarchy.InsufficientLiquidity +ProposalDurationTooShort = Futarchy.ProposalDurationTooShort +PassThresholdTooHigh = Futarchy.PassThresholdTooHigh +QuestionMustBeBinary = Futarchy.QuestionMustBeBinary +InvalidSquadsProposalStatus = Futarchy.InvalidSquadsProposalStatus +CastingOverflow = Futarchy.CastingOverflow +InsufficientBalance = Futarchy.InsufficientBalance +ZeroLiquidityRemove = Futarchy.ZeroLiquidityRemove +SwapSlippageExceeded = Futarchy.SwapSlippageExceeded +AssertFailed = Futarchy.AssertFailed +InvalidAdmin = Futarchy.InvalidAdmin +ProposalNotInDraftState = Futarchy.ProposalNotInDraftState +InsufficientTokenBalance = Futarchy.InsufficientTokenBalance +InvalidAmount = Futarchy.InvalidAmount +InsufficientStakeToLaunch = Futarchy.InsufficientStakeToLaunch +StakerNotFound = Futarchy.StakerNotFound +PoolNotInSpotState = Futarchy.PoolNotInSpotState +InvalidDaoCreateLiquidity = Futarchy.InvalidDaoCreateLiquidity +InvalidStakeAccount = Futarchy.InvalidStakeAccount +InvariantViolated = Futarchy.InvariantViolated +ProposalNotActive = Futarchy.ProposalNotActive +InvalidTransaction = Futarchy.InvalidTransaction +ProposalAlreadySponsored = Futarchy.ProposalAlreadySponsored +InvalidTeamSponsoredPassThreshold = Futarchy.InvalidTeamSponsoredPassThreshold +InvalidTargetK = Futarchy.InvalidTargetK +InvalidTransactionMessage = Futarchy.InvalidTransactionMessage +InvalidMint = Futarchy.InvalidMint +ProposalNotReadyToUnstake = Futarchy.ProposalNotReadyToUnstake +OptimisticGovernanceDisabled = Futarchy.OptimisticGovernanceDisabled +ActiveOptimisticProposalAlreadyEnqueued = Futarchy.ActiveOptimisticProposalAlreadyEnqueued +OptimisticProposalAlreadyPassed = Futarchy.OptimisticProposalAlreadyPassed +InvalidSpendingLimitMint = Futarchy.InvalidSpendingLimitMint +NoActiveOptimisticProposal = Futarchy.NoActiveOptimisticProposal +DaoLiquidated = Futarchy.DaoLiquidated +ProposalKindCooldownActive = Futarchy.ProposalKindCooldownActive +NoSpendingLimit = Futarchy.NoSpendingLimit +SpendCapExceeded = Futarchy.SpendCapExceeded +UnknownMintAuthority = Futarchy.UnknownMintAuthority +ProposalNotTeamSponsored = Futarchy.ProposalNotTeamSponsored +SpendingLimitNotDirty = Futarchy.SpendingLimitNotDirty +InvalidProposalKind = Futarchy.InvalidProposalKind +TooManySpendingLimitMembers = Futarchy.TooManySpendingLimitMembers +InvalidLiquidator = Futarchy.InvalidLiquidator +InvalidProposalPassThreshold = Futarchy.InvalidProposalPassThreshold +EmptyProposalParamsUpdate = Futarchy.EmptyProposalParamsUpdate +BuybackCapExceeded = Futarchy.BuybackCapExceeded +InvalidBuybackAmount = Futarchy.InvalidBuybackAmount +InvalidBuybackCycleFrequency = Futarchy.InvalidBuybackCycleFrequency +InvalidBuybackStartDelay = Futarchy.InvalidBuybackStartDelay +InvalidBuybackPriceBand = Futarchy.InvalidBuybackPriceBand +InvalidTreasuryAccount = Futarchy.InvalidTreasuryAccount +TreasuryAccountsNotSorted = Futarchy.TreasuryAccountsNotSorted +UnexpectedLaunchAccounts = Futarchy.UnexpectedLaunchAccounts +InvalidSpendingLimitAccount = Futarchy.InvalidSpendingLimitAccount +StaleTeamAddress = Futarchy.StaleTeamAddress +AccountNotMigrated = Futarchy.AccountNotMigrated +InvalidSpendingLimitAmount = Futarchy.InvalidSpendingLimitAmount +EmptySpendingLimitMembers = Futarchy.EmptySpendingLimitMembers +DuplicateSpendingLimitMember = Futarchy.DuplicateSpendingLimitMember +InvalidBuybackCycleCount = Futarchy.InvalidBuybackCycleCount +InvalidTeamAddress = Futarchy.InvalidTeamAddress +TeamSponsorshipForbidden = Futarchy.TeamSponsorshipForbidden +SquadsProposalNotApproved = Futarchy.SquadsProposalNotApproved + +register_errors(Futarchy.Error) + + +# ------------------------------------------------------------------------- # +# 6. self-register (import side effect) +# ------------------------------------------------------------------------- # +register(build_interface_from_module(__name__, Futarchy, PROGRAM_ID, PROGRAM_NAME)) + + +# --- provenance (pinned) --- +__provenance__ = { + "schema_version": "1.1.0", + "program_address": "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq", + "program_name": "futarchy", + "module": "futarchy", + "source_root": "target/idl", + "idl_path": "target/wake-idl/FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq.json", + "idl_sha256": "05e0ea3e8509ef4a7c1508f4f6448f21ed62ae726224631aff5807ddc48b98f1", + "anchor_version": "0.6.2", + "generator_version": "wake-sol gen 0.2.0", +} diff --git a/fuzz/futarchy/test_fuzz.py b/fuzz/futarchy/test_fuzz.py new file mode 100644 index 00000000..beac3edf --- /dev/null +++ b/fuzz/futarchy/test_fuzz.py @@ -0,0 +1,643 @@ +"""Wake.sol fuzz campaign for the current Futarchy public API.""" + +from __future__ import annotations + +import pytest + +from wake_sol import FuzzTest, flow, random, svm + +pytest.importorskip( + "fuzz.futarchy.pytypes.futarchy", + reason="generate Futarchy Wake bindings with fuzz/futarchy/gen_wake_idl.py", +) + +from .constants import ( + CONDITIONAL_VAULT_SO, + FLOWS_COUNT, + FUTARCHY_SO, + SEQUENCES_COUNT, + SQUADS_PROGRAM_CONFIG_DATA, + SQUADS_SO, +) +from .instructions import FutarchyInstructions +from .invariants import ( + DaoInvariants, + PositionInvariants, + ProposalInvariants, + SquadsInvariants, + TokenInvariants, +) +from .utils.harness import FutarchyScenarioHelpers +from .utils.markets import ConditionalMarketSupport +from .utils.setup import FutarchySequenceSetup +from .utils.squads import SquadsSupport + + +pytestmark = pytest.mark.skipif( + not all( + artifact.exists() + for artifact in ( + FUTARCHY_SO, + CONDITIONAL_VAULT_SO, + SQUADS_SO, + SQUADS_PROGRAM_CONFIG_DATA, + ) + ), + reason="a Futarchy, Conditional Vault, or Squads test artifact is missing", +) + + +class FutarchyFuzz( + FutarchyScenarioHelpers, + DaoInvariants, + TokenInvariants, + PositionInvariants, + ProposalInvariants, + SquadsInvariants, + FuzzTest, +): + """Exercise each in-scope instruction through compact happy/unhappy flows.""" + + def pre_sequence(self) -> None: + """Build the real dependency graph and one immediately useful Draft.""" + setup = FutarchySequenceSetup(self) + setup.install_programs_and_clock() + setup.create_signers_and_addresses() + setup.create_tokens_and_bookkeeping() + self.squads = SquadsSupport(self) + self.market_support = ConditionalMarketSupport(self) + self.instructions = FutarchyInstructions(self) + setup.initialize_normal_state() + + # Time and ordinary Squads execution support the instruction-level flows. + + @flow(weight=350) + def advance_clock(self) -> None: + """Advance deterministic time far enough to exercise TWAP boundaries.""" + if self.hostile_liquidation_market_active(): + svm.warp_to_timestamp( + svm.clock.unix_timestamp + + random.randint(12 * 3_600, 36 * 3_600) + ) + return + svm.warp_to_timestamp( + svm.clock.unix_timestamp + random.randint(60, 3 * 86_400) + ) + + @flow( + weight=120, + precondition=lambda self: self.instructions.execute_passed_payload.can_happy(), + ) + def execute_passed_proposal_payload(self) -> None: + """Execute approved proposal payloads through top-level Squads.""" + return self.instructions.execute_passed_payload.happy() + + # DAO initialization. + + @flow( + weight=8, + max_times=1, + precondition=lambda self: self.instructions.initialize_dao.can_happy(), + ) + def initialize_dao_happy(self) -> None: + """Initialize an auxiliary DAO through Futarchy and Squads.""" + return self.instructions.initialize_dao.happy() + + @flow( + weight=8, + max_times=1, + precondition=lambda self: self.instructions.initialize_dao.can_unhappy(), + ) + def initialize_dao_unhappy(self) -> None: + """Reject identical base and quote mints atomically.""" + return self.instructions.initialize_dao.unhappy() + + # Proposal initializers: arbitrary plus every current typed kind. + + @flow( + weight=30, + max_times=3, + precondition=lambda self: self.instructions.initialize_proposal.can_happy(), + ) + def initialize_proposal_happy(self) -> None: + """Initialize a normal arbitrary proposal.""" + return self.instructions.initialize_proposal.happy() + + @flow( + weight=10, + max_times=2, + precondition=lambda self: self.instructions.initialize_proposal.can_unhappy(), + ) + def initialize_proposal_unhappy(self) -> None: + """Reject a conditional question with the wrong oracle.""" + return self.instructions.initialize_proposal.unhappy() + + @flow( + weight=35, + max_times=3, + precondition=lambda self: ( + self.instructions.initialize_large_spend_proposal.can_happy() + ), + ) + def initialize_large_spend_proposal_happy(self) -> None: + """Initialize a capped team-spend proposal.""" + return self.instructions.initialize_large_spend_proposal.happy() + + @flow( + weight=12, + max_times=2, + precondition=lambda self: ( + self.instructions.initialize_large_spend_proposal.can_unhappy() + ), + ) + def initialize_large_spend_proposal_unhappy(self) -> None: + """Reject a missing limit or spend above its three-month cap.""" + return self.instructions.initialize_large_spend_proposal.unhappy() + + @flow( + weight=35, + max_times=3, + precondition=lambda self: ( + self.instructions.initialize_mint_tokens_proposal.can_happy() + ), + ) + def initialize_mint_tokens_proposal_happy(self) -> None: + """Initialize a vault-authorized mint proposal.""" + return self.instructions.initialize_mint_tokens_proposal.happy() + + @flow( + weight=12, + max_times=2, + precondition=lambda self: ( + self.instructions.initialize_mint_tokens_proposal.can_unhappy() + ), + ) + def initialize_mint_tokens_proposal_unhappy(self) -> None: + """Reject a mint account that is not the DAO base mint.""" + return self.instructions.initialize_mint_tokens_proposal.unhappy() + + @flow( + weight=35, + max_times=3, + precondition=lambda self: ( + self.instructions.initialize_spending_limit_change_proposal.can_happy() + ), + ) + def initialize_spending_limit_change_proposal_happy(self) -> None: + """Initialize a spending-limit set/remove proposal.""" + return self.instructions.initialize_spending_limit_change_proposal.happy() + + @flow( + weight=12, + max_times=2, + precondition=lambda self: ( + self.instructions.initialize_spending_limit_change_proposal.can_unhappy() + ), + ) + def initialize_spending_limit_change_proposal_unhappy(self) -> None: + """Reject one invalid spending-limit shape.""" + return self.instructions.initialize_spending_limit_change_proposal.unhappy() + + @flow( + weight=35, + max_times=3, + precondition=lambda self: ( + self.instructions.initialize_hostile_takeover_proposal.can_happy() + ), + ) + def initialize_hostile_takeover_proposal_happy(self) -> None: + """Initialize a declared team-takeover proposal.""" + return self.instructions.initialize_hostile_takeover_proposal.happy() + + @flow( + weight=12, + max_times=2, + precondition=lambda self: ( + self.instructions.initialize_hostile_takeover_proposal.can_unhappy() + ), + ) + def initialize_hostile_takeover_proposal_unhappy(self) -> None: + """Reject the current team or an invalid takeover spending limit.""" + return self.instructions.initialize_hostile_takeover_proposal.unhappy() + + @flow( + weight=45, + max_times=3, + precondition=lambda self: ( + self.instructions.initialize_hostile_liquidate_proposal.can_happy() + ), + ) + def initialize_hostile_liquidate_proposal_happy(self) -> None: + """Initialize a hostile liquidation proposal and its IP-transfer memo.""" + return self.instructions.initialize_hostile_liquidate_proposal.happy() + + @flow( + weight=12, + max_times=2, + precondition=lambda self: ( + self.instructions.initialize_hostile_liquidate_proposal.can_unhappy() + ), + ) + def initialize_hostile_liquidate_proposal_unhappy(self) -> None: + """Reject a liquidation proposal backed by a non-binary question.""" + return self.instructions.initialize_hostile_liquidate_proposal.unhappy() + + @flow( + weight=35, + max_times=3, + precondition=lambda self: ( + self.instructions.initialize_buyback_token_proposal.can_happy() + ), + ) + def initialize_buyback_token_proposal_happy(self) -> None: + """Initialize a valid staged token-buyback mandate.""" + return self.instructions.initialize_buyback_token_proposal.happy() + + @flow( + weight=12, + max_times=2, + precondition=lambda self: ( + self.instructions.initialize_buyback_token_proposal.can_unhappy() + ), + ) + def initialize_buyback_token_proposal_unhappy(self) -> None: + """Reject one simple invalid buyback bound.""" + return self.instructions.initialize_buyback_token_proposal.unhappy() + + # Proposal lifecycle. + + @flow( + weight=250, + precondition=lambda self: self.instructions.stake_to_proposal.can_happy(), + ) + def stake_to_proposal_happy(self) -> None: + """Stake base tokens into one Draft.""" + return self.instructions.stake_to_proposal.happy() + + @flow( + weight=55, + precondition=lambda self: self.instructions.stake_to_proposal.can_unhappy(), + ) + def stake_to_proposal_unhappy(self) -> None: + """Reject zero, excessive, or post-liquidation stake.""" + return self.instructions.stake_to_proposal.unhappy() + + @flow( + weight=100, + precondition=lambda self: self.instructions.unstake_from_proposal.can_happy(), + ) + def unstake_from_proposal_happy(self) -> None: + """Return a random portion of recorded stake.""" + return self.instructions.unstake_from_proposal.happy() + + @flow( + weight=40, + precondition=lambda self: self.instructions.unstake_from_proposal.can_unhappy(), + ) + def unstake_from_proposal_unhappy(self) -> None: + """Reject early, zero, or excessive unstaking.""" + return self.instructions.unstake_from_proposal.unhappy() + + @flow( + weight=180, + precondition=lambda self: self.instructions.sponsor_proposal.can_happy(), + ) + def sponsor_proposal_happy(self) -> None: + """Sponsor a Draft with the current team signer.""" + return self.instructions.sponsor_proposal.happy() + + @flow( + weight=50, + precondition=lambda self: self.instructions.sponsor_proposal.can_unhappy(), + ) + def sponsor_proposal_unhappy(self) -> None: + """Reject duplicate or unauthorized sponsorship.""" + return self.instructions.sponsor_proposal.unhappy() + + @flow( + weight=300, + precondition=lambda self: self.instructions.launch_proposal.can_happy(), + ) + def launch_proposal_happy(self) -> None: + """Launch an eligible Draft into conditional markets.""" + return self.instructions.launch_proposal.happy() + + @flow( + weight=80, + precondition=lambda self: self.instructions.launch_proposal.can_unhappy(), + ) + def launch_proposal_unhappy(self) -> None: + """Reject one unmet launch gate atomically.""" + return self.instructions.launch_proposal.unhappy() + + @flow( + weight=250, + precondition=lambda self: self.instructions.finalize_proposal.can_happy(), + ) + def finalize_proposal_happy(self) -> None: + """Finalize a mature market from independent TWAP snapshots.""" + return self.instructions.finalize_proposal.happy() + + @flow( + weight=100, + precondition=lambda self: self.instructions.finalize_proposal.can_unhappy(), + ) + def finalize_proposal_unhappy(self) -> None: + """Reject a young or uncranked proposal market.""" + return self.instructions.finalize_proposal.unhappy() + + # AMM and liquidity. + + @flow( + weight=500, + precondition=lambda self: self.instructions.provide_liquidity.can_happy(), + ) + def provide_liquidity_happy(self) -> None: + """Provide initial or proportional spot liquidity.""" + return self.instructions.provide_liquidity.happy() + + @flow( + weight=100, + precondition=lambda self: self.instructions.provide_liquidity.can_unhappy(), + ) + def provide_liquidity_unhappy(self) -> None: + """Reject one state-appropriate invalid LP deposit.""" + return self.instructions.provide_liquidity.unhappy() + + @flow( + weight=350, + precondition=lambda self: self.instructions.spot_swap.can_happy(), + ) + def spot_swap_happy(self) -> None: + """Trade the spot pool in either state/direction.""" + return self.instructions.spot_swap.happy() + + @flow( + weight=70, + precondition=lambda self: self.instructions.spot_swap.can_unhappy(), + ) + def spot_swap_unhappy(self) -> None: + """Reject insufficient input or impossible positive slippage.""" + return self.instructions.spot_swap.unhappy() + + @flow( + weight=350, + precondition=lambda self: self.instructions.conditional_swap.can_happy(), + ) + def conditional_swap_happy(self) -> None: + """Trade pass or fail claims while a proposal is live.""" + return self.instructions.conditional_swap.happy() + + @flow( + weight=70, + precondition=lambda self: self.instructions.conditional_swap.can_unhappy(), + ) + def conditional_swap_unhappy(self) -> None: + """Reject Spot as a conditional-market selector.""" + return self.instructions.conditional_swap.unhappy() + + @flow( + weight=150, + precondition=lambda self: self.instructions.withdraw_liquidity.can_happy(), + ) + def withdraw_liquidity_happy(self) -> None: + """Withdraw a random share, including after liquidation.""" + return self.instructions.withdraw_liquidity.happy() + + @flow( + weight=60, + precondition=lambda self: self.instructions.withdraw_liquidity.can_unhappy(), + ) + def withdraw_liquidity_unhappy(self) -> None: + """Reject zero, excessive, or mid-market LP withdrawal.""" + return self.instructions.withdraw_liquidity.unhappy() + + @flow( + weight=80, + precondition=lambda self: self.instructions.collect_fees.can_happy(), + ) + def collect_fees_happy(self) -> None: + """Collect exact protocol fees, including after liquidation.""" + return self.instructions.collect_fees.happy() + + @flow( + weight=30, + precondition=lambda self: self.instructions.collect_fees.can_unhappy(), + ) + def collect_fees_unhappy(self) -> None: + """Reject fee collection while a proposal market is live.""" + return self.instructions.collect_fees.unhappy() + + # DAO configuration and spending-limit projection. + + @flow( + weight=40, + precondition=lambda self: self.instructions.update_dao.can_happy(), + ) + def update_dao_happy(self) -> None: + """Execute one valid vault-signed DAO update.""" + return self.instructions.update_dao.happy() + + @flow( + weight=20, + precondition=lambda self: self.instructions.update_dao.can_unhappy(), + ) + def update_dao_unhappy(self) -> None: + """Reject an update that violates DAO bounds.""" + return self.instructions.update_dao.unhappy() + + @flow( + weight=40, + precondition=lambda self: self.instructions.set_spending_limit.can_happy(), + ) + def set_spending_limit_happy(self) -> None: + """Set/remove the authoritative spending-limit record.""" + return self.instructions.set_spending_limit.happy() + + @flow( + weight=20, + precondition=lambda self: self.instructions.set_spending_limit.can_unhappy(), + ) + def set_spending_limit_unhappy(self) -> None: + """Reject one invalid spending-limit shape.""" + return self.instructions.set_spending_limit.unhappy() + + @flow( + weight=100, + precondition=lambda self: self.instructions.sync_spending_limit.can_happy(), + ) + def sync_spending_limit_happy(self) -> None: + """Project a dirty DAO record into Squads.""" + return self.instructions.sync_spending_limit.happy() + + @flow( + weight=30, + precondition=lambda self: self.instructions.sync_spending_limit.can_unhappy(), + ) + def sync_spending_limit_unhappy(self) -> None: + """Reject a sync when no authoritative change is pending.""" + return self.instructions.sync_spending_limit.unhappy() + + # Squads administration. + + @flow( + weight=60, + precondition=lambda self: ( + self.instructions.admin_enqueue_multisig_proposal_approval.can_happy() + ), + ) + def admin_enqueue_multisig_proposal_approval_happy(self) -> None: + """Enqueue approval with the admin or terminal liquidator.""" + return self.instructions.admin_enqueue_multisig_proposal_approval.happy() + + @flow( + weight=25, + precondition=lambda self: ( + self.instructions.admin_enqueue_multisig_proposal_approval.can_unhappy() + ), + ) + def admin_enqueue_multisig_proposal_approval_unhappy(self) -> None: + """Reject a state/status/authority-invalid enqueue.""" + return self.instructions.admin_enqueue_multisig_proposal_approval.unhappy() + + @flow( + weight=70, + precondition=lambda self: ( + self.instructions.execute_multisig_proposal_approval.can_happy() + ), + ) + def execute_multisig_proposal_approval_happy(self) -> None: + """Permissionlessly consume an enqueued approval.""" + return self.instructions.execute_multisig_proposal_approval.happy() + + @flow( + weight=25, + precondition=lambda self: ( + self.instructions.execute_multisig_proposal_approval.can_unhappy() + ), + ) + def execute_multisig_proposal_approval_unhappy(self) -> None: + """Reject a mismatched Squads proposal PDA.""" + return self.instructions.execute_multisig_proposal_approval.unhappy() + + @flow( + weight=60, + precondition=lambda self: ( + self.instructions.admin_enqueue_multisig_proposal_cancellation.can_happy() + ), + ) + def admin_enqueue_multisig_proposal_cancellation_happy(self) -> None: + """Enqueue cancellation of an approved Squads proposal.""" + return self.instructions.admin_enqueue_multisig_proposal_cancellation.happy() + + @flow( + weight=25, + precondition=lambda self: ( + self.instructions.admin_enqueue_multisig_proposal_cancellation.can_unhappy() + ), + ) + def admin_enqueue_multisig_proposal_cancellation_unhappy(self) -> None: + """Reject cancellation enqueue before Squads approval.""" + return self.instructions.admin_enqueue_multisig_proposal_cancellation.unhappy() + + @flow( + weight=70, + precondition=lambda self: ( + self.instructions.execute_multisig_proposal_cancellation.can_happy() + ), + ) + def execute_multisig_proposal_cancellation_happy(self) -> None: + """Permissionlessly consume an enqueued cancellation.""" + return self.instructions.execute_multisig_proposal_cancellation.happy() + + @flow( + weight=25, + precondition=lambda self: ( + self.instructions.execute_multisig_proposal_cancellation.can_unhappy() + ), + ) + def execute_multisig_proposal_cancellation_unhappy(self) -> None: + """Reject execution without a cancellation authorization.""" + return self.instructions.execute_multisig_proposal_cancellation.unhappy() + + @flow( + weight=35, + precondition=lambda self: ( + self.instructions.admin_execute_multisig_proposal.can_happy() + ), + ) + def admin_execute_multisig_proposal_happy(self) -> None: + """Execute an approved external Squads payload through Futarchy.""" + return self.instructions.admin_execute_multisig_proposal.happy() + + @flow( + weight=20, + precondition=lambda self: ( + self.instructions.admin_execute_multisig_proposal.can_unhappy() + ), + ) + def admin_execute_multisig_proposal_unhappy(self) -> None: + """Reject administrative execution before Squads approval.""" + return self.instructions.admin_execute_multisig_proposal.unhappy() + + # Direct proposal administration. + + @flow( + weight=20, + precondition=lambda self: self.instructions.admin_cancel_proposal.can_happy(), + ) + def admin_cancel_proposal_happy(self) -> None: + """Cancel one live blockable proposal into Fail.""" + return self.instructions.admin_cancel_proposal.happy() + + @flow( + weight=30, + precondition=lambda self: self.instructions.admin_cancel_proposal.can_unhappy(), + ) + def admin_cancel_proposal_unhappy(self) -> None: + """Reject cancellation before launch.""" + return self.instructions.admin_cancel_proposal.unhappy() + + @flow( + weight=20, + precondition=lambda self: self.instructions.admin_remove_proposal.can_happy(), + ) + def admin_remove_proposal_happy(self) -> None: + """Remove one Draft while preserving withdrawals.""" + return self.instructions.admin_remove_proposal.happy() + + @flow( + weight=30, + precondition=lambda self: self.instructions.admin_remove_proposal.can_unhappy(), + ) + def admin_remove_proposal_unhappy(self) -> None: + """Reject removing a non-Draft proposal.""" + return self.instructions.admin_remove_proposal.unhappy() + + @flow( + weight=50, + precondition=lambda self: ( + self.instructions.admin_update_proposal_params.can_happy() + ), + ) + def admin_update_proposal_params_happy(self) -> None: + """Update valid arbitrary-Draft terms.""" + return self.instructions.admin_update_proposal_params.happy() + + @flow( + weight=30, + precondition=lambda self: ( + self.instructions.admin_update_proposal_params.can_unhappy() + ), + ) + def admin_update_proposal_params_unhappy(self) -> None: + """Reject live, typed, or empty Draft parameter updates.""" + return self.instructions.admin_update_proposal_params.unhappy() + + +def test_futarchy_stateful() -> None: + """Run the coverage-tuning campaign configured in ``constants.py``.""" + FutarchyFuzz.run( + sequences_count=SEQUENCES_COUNT, + flows_count=FLOWS_COUNT, + ) diff --git a/fuzz/futarchy/utils/__init__.py b/fuzz/futarchy/utils/__init__.py new file mode 100644 index 00000000..dbba7cf0 --- /dev/null +++ b/fuzz/futarchy/utils/__init__.py @@ -0,0 +1 @@ +"""Setup, parameter, token, PDA, and failure helpers for Futarchy fuzzing.""" diff --git a/fuzz/futarchy/utils/accounts.py b/fuzz/futarchy/utils/accounts.py new file mode 100644 index 00000000..2f2580b2 --- /dev/null +++ b/fuzz/futarchy/utils/accounts.py @@ -0,0 +1,211 @@ +"""Canonical PDA derivations used by the Futarchy lifecycle.""" + +from __future__ import annotations + +from wake_sol import Account, Pubkey + +from ..constants import ( + AMM_POSITION_SEED, + CONDITIONAL_TOKEN_SEED, + CONDITIONAL_VAULT_SEED, + DAO_SEED, + ENQUEUED_APPROVAL_SEED, + ENQUEUED_CANCELLATION_SEED, + EVENT_AUTHORITY_SEED, + PROPOSAL_SEED, + QUESTION_SEED, + SQUADS_MULTISIG_SEED, + SQUADS_PREFIX_SEED, + SQUADS_PROPOSAL_SEED, + SQUADS_SPENDING_LIMIT_SEED, + SQUADS_TRANSACTION_SEED, + SQUADS_VAULT_SEED, + STAKE_SEED, +) + + +def derive_event_authority(program_id: Pubkey) -> tuple[Account, int]: + """Derive Anchor's event-CPI authority for one program.""" + return Account.find_program_address([EVENT_AUTHORITY_SEED], program_id) + + +def derive_dao( + creator: Account, nonce: int, program_id: Pubkey +) -> tuple[Account, int]: + """Derive a DAO PDA using its little-endian u64 nonce.""" + return Account.find_program_address( + [DAO_SEED, creator, nonce.to_bytes(8, "little")], program_id + ) + + +def derive_amm_position( + dao: Account, authority: Account, program_id: Pubkey +) -> tuple[Account, int]: + """Derive the immutable-authority AMM position PDA.""" + return Account.find_program_address( + [AMM_POSITION_SEED, dao, authority], program_id + ) + + +def derive_proposal( + squads_proposal: Account, program_id: Pubkey +) -> tuple[Account, int]: + """Derive a proposal PDA from its otherwise opaque Squads proposal key.""" + return Account.find_program_address( + [PROPOSAL_SEED, squads_proposal], program_id + ) + + +def derive_stake_account( + proposal: Account, staker: Account, program_id: Pubkey +) -> tuple[Account, int]: + """Derive the per-proposal, per-staker stake record PDA.""" + return Account.find_program_address( + [STAKE_SEED, proposal, staker], program_id + ) + + +def derive_enqueued_approval( + dao: Account | Pubkey, + transaction_index: int, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive Futarchy's temporary authorization for a Squads proposal.""" + return Account.find_program_address( + [ + ENQUEUED_APPROVAL_SEED, + dao, + transaction_index.to_bytes(8, "little"), + ], + program_id, + ) + + +def derive_enqueued_cancellation( + dao: Account | Pubkey, + transaction_index: int, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive Futarchy's temporary authorization for Squads cancellation.""" + return Account.find_program_address( + [ + ENQUEUED_CANCELLATION_SEED, + dao, + transaction_index.to_bytes(8, "little"), + ], + program_id, + ) + + +def derive_squads_multisig( + create_key: Account | Pubkey, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive the Squads multisig created with a DAO as its create key.""" + return Account.find_program_address( + [SQUADS_PREFIX_SEED, SQUADS_MULTISIG_SEED, create_key], + program_id, + ) + + +def derive_squads_vault( + multisig: Account | Pubkey, + program_id: Pubkey, + index: int = 0, +) -> tuple[Account, int]: + """Derive one Squads vault authority by its u8 vault index.""" + return Account.find_program_address( + [SQUADS_PREFIX_SEED, multisig, SQUADS_VAULT_SEED, bytes([index])], + program_id, + ) + + +def derive_squads_transaction( + multisig: Account | Pubkey, + transaction_index: int, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive a Squads vault-transaction PDA.""" + return Account.find_program_address( + [ + SQUADS_PREFIX_SEED, + multisig, + SQUADS_TRANSACTION_SEED, + transaction_index.to_bytes(8, "little"), + ], + program_id, + ) + + +def derive_squads_proposal( + multisig: Account | Pubkey, + transaction_index: int, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive the proposal attached to a Squads vault transaction.""" + return Account.find_program_address( + [ + SQUADS_PREFIX_SEED, + multisig, + SQUADS_TRANSACTION_SEED, + transaction_index.to_bytes(8, "little"), + SQUADS_PROPOSAL_SEED, + ], + program_id, + ) + + +def derive_squads_spending_limit( + multisig: Account | Pubkey, + create_key: Account | Pubkey, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive the Squads spending-limit record created by initialize_dao.""" + return Account.find_program_address( + [ + SQUADS_PREFIX_SEED, + multisig, + SQUADS_SPENDING_LIMIT_SEED, + create_key, + ], + program_id, + ) + + +def derive_question( + question_id: bytes, + oracle: Account | Pubkey, + outcomes: int, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive a Conditional Vault question PDA.""" + if len(question_id) != 32: + raise ValueError("question_id must contain exactly 32 bytes") + return Account.find_program_address( + [QUESTION_SEED, question_id, oracle, bytes([outcomes])], + program_id, + ) + + +def derive_conditional_vault( + question: Account | Pubkey, + underlying_mint: Account | Pubkey, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive a Conditional Vault for one question and underlying mint.""" + return Account.find_program_address( + [CONDITIONAL_VAULT_SEED, question, underlying_mint], + program_id, + ) + + +def derive_conditional_mint( + vault: Account | Pubkey, + outcome: int, + program_id: Pubkey, +) -> tuple[Account, int]: + """Derive one outcome mint created by a Conditional Vault.""" + return Account.find_program_address( + [CONDITIONAL_TOKEN_SEED, vault, bytes([outcome])], + program_id, + ) diff --git a/fuzz/futarchy/utils/assertions.py b/fuzz/futarchy/utils/assertions.py new file mode 100644 index 00000000..a9175a74 --- /dev/null +++ b/fuzz/futarchy/utils/assertions.py @@ -0,0 +1,164 @@ +"""Readable account snapshots for Futarchy transition and rollback checks.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass, fields, replace +from typing import TypeVar + +from wake_sol import Account, Instruction, Pubkey, must_fail + + +T = TypeVar("T") + + +def assert_changed_only(before: T, after: T, **changes: object) -> None: + """Compare a complete decoded snapshot with its permitted field changes.""" + expected = replace(before, **changes) + assert type(after) is type(expected), "account/state type changed" + for field in fields(expected): + assert getattr(after, field.name) == getattr(expected, field.name), ( + f"{type(before).__name__}.{field.name} differs from the expected snapshot" + ) + + +@dataclass(frozen=True, slots=True) +class AccountState: + """One account at one point in time, including non-existence.""" + + pubkey: Pubkey + label: str + exists: bool + lamports: int + owner: Pubkey | None + data: bytes + + def decode(self, account_type: type[T]) -> T: + """Decode this snapshot with a generated account type.""" + if not self.exists: + raise AssertionError(f"{self.label} does not exist") + return account_type.decode(self.data) + + def token_balance(self) -> int: + """Read the classic SPL Token amount from captured account bytes.""" + if not self.exists or len(self.data) != 165: + raise AssertionError(f"{self.label} is not a classic token account") + return int.from_bytes(self.data[64:72], "little") + + def mint_supply(self) -> int: + """Read a classic SPL Mint supply from captured account bytes.""" + assert self.exists and len(self.data) == 82, f"{self.label} is not a mint" + return int.from_bytes(self.data[36:44], "little") + + +@dataclass(frozen=True, slots=True) +class AccountSnapshot: + """An ordered, address-keyed collection with useful failure messages.""" + + states: dict[Pubkey, AccountState] + + @classmethod + def take(cls, accounts: Iterable[Account]) -> "AccountSnapshot": + states: dict[Pubkey, AccountState] = {} + for account in accounts: + if account.pubkey in states: + continue + label = account.label or str(account.pubkey) + if account.exists: + states[account.pubkey] = AccountState( + pubkey=account.pubkey, + label=label, + exists=True, + lamports=account.lamports, + owner=account.owner, + data=bytes(account.data), + ) + else: + states[account.pubkey] = AccountState( + pubkey=account.pubkey, + label=label, + exists=False, + lamports=0, + owner=None, + data=b"", + ) + return cls(states) + + def account(self, account: Account | Pubkey) -> AccountState: + """Return a captured account by handle or address.""" + key = account.pubkey if isinstance(account, Account) else account + try: + return self.states[key] + except KeyError as exc: + raise AssertionError(f"account {key} was not snapshotted") from exc + + def decode(self, account: Account | Pubkey, account_type: type[T]) -> T: + """Decode a captured generated account.""" + return self.account(account).decode(account_type) + + def assert_unchanged( + self, + after: "AccountSnapshot", + *, + ignore_lamports: Iterable[Pubkey] = (), + ) -> None: + """Report the first account-level difference after a failed tx.""" + ignored = set(ignore_lamports) + assert self.states.keys() == after.states.keys(), "snapshot keys changed" + for key, before_state in self.states.items(): + after_state = after.states[key] + if before_state.exists != after_state.exists: + raise AssertionError( + f"atomicity violation: {before_state.label} existence changed " + f"from {before_state.exists} to {after_state.exists}" + ) + if before_state.owner != after_state.owner: + raise AssertionError( + f"atomicity violation: {before_state.label} owner changed" + ) + if before_state.data != after_state.data: + raise AssertionError( + f"atomicity violation: {before_state.label} data changed" + ) + if key not in ignored and before_state.lamports != after_state.lamports: + raise AssertionError( + f"atomicity violation: {before_state.label} lamports changed " + f"from {before_state.lamports} to {after_state.lamports}" + ) + + +def writable_accounts( + instructions: Iterable[Instruction], + extra: Iterable[Account] = (), +) -> tuple[Account, ...]: + """Collect every top-level writable account, including future accounts.""" + accounts: list[Account] = list(extra) + seen = {account.pubkey for account in accounts} + for instruction in instructions: + for meta in instruction.accounts: + if meta.is_writable and meta.pubkey not in seen: + accounts.append(Account(meta.pubkey)) + seen.add(meta.pubkey) + return tuple(accounts) + + +def assert_atomic_failure( + action: Callable[[], object], + expected: object, + instructions: Iterable[Instruction], + *, + fee_payer: Account, + extra_accounts: Iterable[Account] = (), +) -> None: + """Require a typed failure and prove all writable accounts rolled back. + + The transaction fee is intentionally ignored on the fee payer; its data, + owner, and existence are still compared. All other lamport changes are + part of atomicity, including attempted account creation and closure. + """ + accounts = writable_accounts(instructions, extra_accounts) + before = AccountSnapshot.take(accounts) + with must_fail(expected): + action() + after = AccountSnapshot.take(accounts) + before.assert_unchanged(after, ignore_lamports=(fee_payer.pubkey,)) diff --git a/fuzz/futarchy/utils/builders.py b/fuzz/futarchy/utils/builders.py new file mode 100644 index 00000000..df0fafad --- /dev/null +++ b/fuzz/futarchy/utils/builders.py @@ -0,0 +1,40 @@ +"""Small builders for external instructions embedded in Squads payloads.""" + +from __future__ import annotations + +from wake_sol import Account, Instruction, Pubkey, signer, svm, writable + +from ..constants import SPL_MEMO_PROGRAM_ID + + +def memo_instruction(message: str) -> Instruction: + """Build an SPL Memo instruction with no required signers.""" + return Instruction(SPL_MEMO_PROGRAM_ID, [], message.encode()) + + +def token_transfer_instruction( + source: Account, + destination: Account, + authority: Account | Pubkey, + amount: int, +) -> Instruction: + """Build a classic SPL Token ``Transfer`` instruction.""" + return Instruction( + svm.token.program_id, + [writable(source), writable(destination), signer(authority)], + bytes([3]) + amount.to_bytes(8, "little"), + ) + + +def token_mint_to_instruction( + mint: Account, + destination: Account, + authority: Account | Pubkey, + amount: int, +) -> Instruction: + """Build a classic SPL Token ``MintTo`` instruction.""" + return Instruction( + svm.token.program_id, + [writable(mint), writable(destination), signer(authority)], + bytes([7]) + amount.to_bytes(8, "little"), + ) diff --git a/fuzz/futarchy/utils/harness.py b/fuzz/futarchy/utils/harness.py new file mode 100644 index 00000000..e3336328 --- /dev/null +++ b/fuzz/futarchy/utils/harness.py @@ -0,0 +1,195 @@ +"""Read-only selectors over the real accounts created during a fuzz sequence.""" + +from __future__ import annotations + +from wake_sol import Account, Pubkey, svm + +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import ( + AmmPosition, + Dao, + PoolState, + Proposal, + ProposalAction, + ProposalState, + StakeAccount, +) +from .accounts import derive_stake_account +from .state import ProposalAccounts +from .tokens import create_ata, token_balance + + +class FutarchyScenarioHelpers: + """Query on-chain state; retain only account handles as Python bookkeeping.""" + + def register_token_account(self, mint: Account, account: Account) -> None: + """Include an underlying-token account in supply conservation checks.""" + registry = ( + self.base_token_accounts + if mint.pubkey == self.base_mint.pubkey + else self.quote_token_accounts + ) + registry[account.pubkey] = account + + def register_proposal(self, proposal: ProposalAccounts) -> None: + """Record a normally initialized proposal and its stake custody ATA.""" + self.market_support.ensure_stake_account(proposal) + self.proposals.append(proposal) + + def ensure_underlying_ata( + self, owner: Account | Pubkey, mint: Account + ) -> Account: + """Create/register a base or quote ATA used by a proposal payload.""" + account = create_ata(self.payer, owner, mint) + self.register_token_account(mint, account) + return account + + def council_quote_balance(self) -> int: + """Read the treasury quote balance used by executable payloads.""" + return token_balance(self.council_quote_account) + + def dao_state(self) -> Dao: + """Decode the main DAO directly from SVM state.""" + return Dao.decode(self.dao.data) + + def proposal_state(self, proposal: ProposalAccounts) -> Proposal: + """Decode one Futarchy proposal directly from SVM state.""" + return Proposal.decode(proposal.proposal.data) + + def enqueue_authority(self) -> Account: + """Return the signer authorized to enqueue before/after liquidation.""" + liquidator = self.dao_state().liquidator + if liquidator is None: + return self.ops_admin + return self.signers_by_pubkey[liquidator] + + def is_liquidated(self) -> bool: + """Return whether terminal liquidation has landed.""" + return self.dao.exists and self.dao_state().liquidator is not None + + def is_spot(self) -> bool: + """Return whether no proposal market currently occupies the AMM.""" + return isinstance(self.dao_state().amm.state, PoolState.Spot) + + def pool_has_liquidity(self) -> bool: + """Return whether the physical spot pool can support swaps/launches.""" + state = self.dao_state().amm.state + spot = state.spot + return spot.baseReserves > 1 and spot.quoteReserves > 1 + + def proposals_with_state(self, state_type: type) -> list[ProposalAccounts]: + """Select initialized proposals by their decoded variant type.""" + return [ + proposal + for proposal in self.proposals + if proposal.proposal.exists + and isinstance(self.proposal_state(proposal).state, state_type) + ] + + def draft_proposals(self) -> list[ProposalAccounts]: + """Return all non-stale Futarchy drafts.""" + return self.proposals_with_state(ProposalState.Draft) + + def pending_proposals(self) -> list[ProposalAccounts]: + """Return the sole live proposal, if one exists.""" + return self.proposals_with_state(ProposalState.Pending) + + def hostile_liquidation_market_active(self) -> bool: + """Return whether the active market is the delayed terminal lane.""" + return any( + isinstance( + self.proposal_state(proposal).action, + ProposalAction.HostileLiquidate, + ) + for proposal in self.pending_proposals() + ) + + def passed_proposals(self) -> list[ProposalAccounts]: + """Return passed proposals whose Squads payload may still execute.""" + return self.proposals_with_state(ProposalState.Passed) + + def failed_or_passed_proposals(self) -> list[ProposalAccounts]: + """Return proposals that are no longer live or editable.""" + return [ + proposal + for proposal in self.proposals + if isinstance( + self.proposal_state(proposal).state, + (ProposalState.Passed, ProposalState.Failed), + ) + ] + + def finalizable_proposals(self) -> list[ProposalAccounts]: + """Select mature markets whose two TWAPs have begun accumulating.""" + if self.is_spot(): + return [] + now = svm.clock.unix_timestamp + result: list[ProposalAccounts] = [] + for proposal in self.pending_proposals(): + decoded = self.proposal_state(proposal) + if now < decoded.timestampEnqueued + decoded.durationInSeconds: + continue + state = self.dao_state().amm.state + assert isinstance(state, PoolState.Futarchy) + if all( + pool.oracle.lastUpdatedTimestamp + > pool.oracle.createdAtTimestamp + pool.oracle.startDelaySeconds + and pool.oracle.aggregator != 0 + for pool in (state.pass_, state.fail) + ): + result.append(proposal) + return result + + def stake_account(self, proposal: ProposalAccounts, staker: Account) -> Account: + """Return and register the canonical per-proposal stake PDA.""" + key = (proposal.proposal.pubkey, staker.pubkey) + account = self.stake_accounts.get(key) + if account is None: + account, _ = derive_stake_account( + proposal.proposal, staker, FUTARCHY_PROGRAM_ID + ) + account.label = f"stake: {proposal.proposal.label} / {staker.label}" + self.stake_accounts[key] = account + return account + + def positive_stakes(self) -> list[tuple[ProposalAccounts, Account, Account]]: + """Return proposal/staker/stake triples with a withdrawable balance.""" + proposals = {item.proposal.pubkey: item for item in self.proposals} + result: list[tuple[ProposalAccounts, Account, Account]] = [] + for (proposal_key, staker_key), stake in self.stake_accounts.items(): + if not stake.exists: + continue + if StakeAccount.decode(stake.data).amount > 0: + result.append( + (proposals[proposal_key], self.signers_by_pubkey[staker_key], stake) + ) + return result + + def live_positions(self) -> list[tuple[Account, Account, AmmPosition]]: + """Return signable authorities with positive LP positions.""" + result: list[tuple[Account, Account, AmmPosition]] = [] + for authority_key, position in self.position_accounts.items(): + authority = self.signers_by_pubkey.get(authority_key) + if authority is None or not position.exists: + continue + decoded = AmmPosition.decode(position.data) + if decoded.liquidity > 0: + result.append((authority, position, decoded)) + return result + + def affordable_providers(self, quote_cap: int) -> list[tuple[Account, int]]: + """Find actors able to add proportional liquidity to a spot pool.""" + if not self.is_spot() or self.dao_state().amm.totalLiquidity == 0: + return [] + spot = self.dao_state().amm.state.spot + result: list[tuple[Account, int]] = [] + for actor in self.actors: + base_balance = token_balance(self.base_atas_by_owner[actor.pubkey]) + quote_balance = token_balance(self.quote_atas_by_owner[actor.pubkey]) + max_from_base = ( + base_balance * spot.quoteReserves // spot.baseReserves + ) + maximum = min(quote_cap, quote_balance, max_from_base) + if maximum > 0: + result.append((actor, maximum)) + return result diff --git a/fuzz/futarchy/utils/markets.py b/fuzz/futarchy/utils/markets.py new file mode 100644 index 00000000..a382ef9a --- /dev/null +++ b/fuzz/futarchy/utils/markets.py @@ -0,0 +1,267 @@ +"""Create real Conditional Vault market plumbing for Futarchy proposals.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from wake_sol import ( + ASSOCIATED_TOKEN_PROGRAM_ID, + Account, + Instruction, + SYSTEM_PROGRAM_ID, + TOKEN_PROGRAM_ID, + signer, + svm, + writable, + writable_signer, +) + +from ..constants import ( + BINARY_QUESTION_OUTCOMES, + CONDITIONAL_VAULT_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + MARKET_TRADER_BASE_FUNDING, + MARKET_TRADER_QUOTE_FUNDING, +) +from .accounts import ( + derive_conditional_mint, + derive_conditional_vault, + derive_proposal, + derive_question, +) +from .state import MarketAccounts, ProposalAccounts, SquadsTransaction +from .tokens import create_ata, token_balance + + +class ConditionalMarketSupport: + """Own external question/vault setup without pretending it is Futarchy.""" + + INITIALIZE_QUESTION = hashlib.sha256( + b"global:initialize_question" + ).digest()[:8] + INITIALIZE_VAULT = hashlib.sha256( + b"global:initialize_conditional_vault" + ).digest()[:8] + SPLIT_TOKENS = hashlib.sha256(b"global:split_tokens").digest()[:8] + + def __init__(self, context: Any) -> None: + self.context = context + + def create( + self, + proposal: Account, + *, + outcomes: int = BINARY_QUESTION_OUTCOMES, + salt: bytes = b"", + ) -> MarketAccounts: + """Initialize a question plus base/quote vaults through real CPIs.""" + context = self.context + question_id = hashlib.sha256( + b"futarchy-fuzz-market" + bytes(proposal.pubkey) + salt + ).digest() + question, _ = derive_question( + question_id, + proposal, + outcomes, + CONDITIONAL_VAULT_PROGRAM_ID, + ) + question.label = f"question for {proposal.label} ({outcomes} outcomes)" + init_question = Instruction( + CONDITIONAL_VAULT_PROGRAM_ID, + [ + writable(question), + writable_signer(context.payer), + SYSTEM_PROGRAM_ID, + context.vault_event_authority, + CONDITIONAL_VAULT_PROGRAM_ID, + ], + self.INITIALIZE_QUESTION + + question_id + + bytes(proposal.pubkey) + + bytes([outcomes]), + ) + context.payer.tx(init_question) + + base = self._create_vault( + question, context.base_mint, outcomes, "base" + ) + quote = self._create_vault( + question, context.quote_mint, outcomes, "quote" + ) + context.register_token_account(context.base_mint, base[1]) + context.register_token_account(context.quote_mint, quote[1]) + return MarketAccounts( + question=question, + base_vault=base[0], + quote_vault=quote[0], + base_vault_underlying=base[1], + quote_vault_underlying=quote[1], + base_mints=base[2], + quote_mints=quote[2], + ) + + def _create_vault( + self, + question: Account, + underlying_mint: Account, + outcomes: int, + label: str, + ) -> tuple[Account, Account, tuple[Account, ...]]: + context = self.context + vault, _ = derive_conditional_vault( + question, underlying_mint, CONDITIONAL_VAULT_PROGRAM_ID + ) + vault.label = f"{label} conditional vault" + underlying = Account(svm.token.ata_address(vault, underlying_mint)) + underlying.label = f"{label} conditional-vault underlying ATA" + mints = tuple( + derive_conditional_mint( + vault, outcome, CONDITIONAL_VAULT_PROGRAM_ID + )[0] + for outcome in range(outcomes) + ) + for index, mint in enumerate(mints): + mint.label = f"{label} outcome mint {index}" + instruction = Instruction( + CONDITIONAL_VAULT_PROGRAM_ID, + [ + writable(vault), + question, + underlying_mint, + writable(underlying), + writable_signer(context.payer), + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + SYSTEM_PROGRAM_ID, + context.vault_event_authority, + CONDITIONAL_VAULT_PROGRAM_ID, + *(writable(mint) for mint in mints), + ], + self.INITIALIZE_VAULT, + ) + context.payer.tx(context.instructions.compute_limit(), instruction) + return vault, underlying, mints + + def proposal_accounts( + self, + prepared: SquadsTransaction, + *, + salt: bytes = b"", + ) -> ProposalAccounts: + """Derive a proposal and initialize its canonical binary markets.""" + proposal, _ = derive_proposal(prepared.proposal, FUTARCHY_PROGRAM_ID) + proposal.label = f"Futarchy proposal {prepared.index}: {prepared.purpose}" + market = self.create(proposal, salt=salt) + return ProposalAccounts( + proposal=proposal, + squads=prepared, + question=market.question, + base_vault=market.base_vault, + quote_vault=market.quote_vault, + base_vault_underlying=market.base_vault_underlying, + quote_vault_underlying=market.quote_vault_underlying, + fail_base_mint=market.base_mints[0], + pass_base_mint=market.base_mints[1], + fail_quote_mint=market.quote_mints[0], + pass_quote_mint=market.quote_mints[1], + ) + + def ensure_stake_account(self, proposal: ProposalAccounts) -> Account: + """Create the proposal-owned base ATA used as stake custody.""" + if proposal.proposal_base_account is None: + account = create_ata( + self.context.payer, + proposal.proposal, + self.context.base_mint, + ) + account.label = f"stake custody for {proposal.proposal.label}" + proposal.proposal_base_account = account + self.context.register_token_account(self.context.base_mint, account) + return proposal.proposal_base_account + + def ensure_launch_accounts(self, proposal: ProposalAccounts) -> None: + """Derive the four DAO-owned conditional-token ATAs used when live.""" + context = self.context + fields = ( + ("amm_fail_base_vault", proposal.fail_base_mint), + ("amm_pass_base_vault", proposal.pass_base_mint), + ("amm_fail_quote_vault", proposal.fail_quote_mint), + ("amm_pass_quote_vault", proposal.pass_quote_mint), + ) + for field, mint in fields: + if getattr(proposal, field) is None: + account = Account(svm.token.ata_address(context.dao, mint)) + account.label = f"DAO {mint.label} ATA" + setattr(proposal, field, account) + + def ensure_trader_tokens( + self, proposal: ProposalAccounts, trader: Account + ) -> None: + """Split a modest amount of underlying into both market outcomes.""" + context = self.context + self._ensure_split( + proposal, + trader, + context.base_mint, + context.base_atas_by_owner[trader.pubkey], + proposal.base_vault, + proposal.base_vault_underlying, + (proposal.fail_base_mint, proposal.pass_base_mint), + MARKET_TRADER_BASE_FUNDING, + ) + self._ensure_split( + proposal, + trader, + context.quote_mint, + context.quote_atas_by_owner[trader.pubkey], + proposal.quote_vault, + proposal.quote_vault_underlying, + (proposal.fail_quote_mint, proposal.pass_quote_mint), + MARKET_TRADER_QUOTE_FUNDING, + ) + + def _ensure_split( + self, + proposal: ProposalAccounts, + trader: Account, + underlying_mint: Account, + user_underlying: Account, + vault: Account, + vault_underlying: Account, + conditional_mints: tuple[Account, Account], + target: int, + ) -> None: + context = self.context + conditional_accounts: list[Account] = [] + for mint in conditional_mints: + key = (trader.pubkey, mint.pubkey) + account = proposal.conditional_accounts.get(key) + if account is None: + account = create_ata(context.payer, trader, mint) + account.label = f"{trader.label} {mint.label} ATA" + proposal.conditional_accounts[key] = account + conditional_accounts.append(account) + minimum = min(token_balance(account) for account in conditional_accounts) + if minimum >= target: + return + amount = min(target - minimum, token_balance(user_underlying)) + if amount <= 0: + return + instruction = Instruction( + CONDITIONAL_VAULT_PROGRAM_ID, + [ + proposal.question, + writable(vault), + writable(vault_underlying), + signer(trader), + writable(user_underlying), + TOKEN_PROGRAM_ID, + context.vault_event_authority, + CONDITIONAL_VAULT_PROGRAM_ID, + *(writable(mint) for mint in conditional_mints), + *(writable(account) for account in conditional_accounts), + ], + self.SPLIT_TOKENS + amount.to_bytes(8, "little"), + ) + trader.tx(context.instructions.compute_limit(), instruction) diff --git a/fuzz/futarchy/utils/oracle.py b/fuzz/futarchy/utils/oracle.py new file mode 100644 index 00000000..73d615ae --- /dev/null +++ b/fuzz/futarchy/utils/oracle.py @@ -0,0 +1,95 @@ +"""Small, stateless reference calculations for Futarchy TWAP assertions.""" + +from __future__ import annotations + +from ..constants import PRICE_SCALE, TWAP_UPDATE_INTERVAL_SECONDS, U128_MAX +from ..pytypes.futarchy import Pool, TwapOracle + + +U128_MODULUS = 2**128 + + +def assert_initial_oracle(oracle: TwapOracle, dao, timestamp: int, delay: int) -> None: + """Validate a fresh market independently of its stored oracle values.""" + assert oracle == TwapOracle( + aggregator=0, + lastUpdatedTimestamp=timestamp, + createdAtTimestamp=timestamp, + lastPrice=0, + lastObservation=dao.twapInitialObservation, + maxObservationChangePerUpdate=dao.twapMaxObservationChangePerUpdate, + initialObservation=dao.twapInitialObservation, + startDelaySeconds=delay, + ) + + +def expected_oracle_after_update(pool: Pool, timestamp: int) -> TwapOracle: + """Reproduce ``Pool::update_twap`` from a pre-swap pool snapshot.""" + oracle = pool.oracle + if ( + timestamp + < oracle.lastUpdatedTimestamp + TWAP_UPDATE_INTERVAL_SECONDS + or pool.baseReserves == 0 + or pool.quoteReserves == 0 + ): + return oracle + + price = pool.quoteReserves * PRICE_SCALE // pool.baseReserves + if price > oracle.lastObservation: + maximum = min( + U128_MAX, + oracle.lastObservation + oracle.maxObservationChangePerUpdate, + ) + observation = min(price, maximum) + else: + minimum = max( + 0, + oracle.lastObservation - oracle.maxObservationChangePerUpdate, + ) + observation = max(price, minimum) + + start = oracle.createdAtTimestamp + oracle.startDelaySeconds + if timestamp <= start: + aggregator = oracle.aggregator + else: + effective_last_update = max(oracle.lastUpdatedTimestamp, start) + elapsed = timestamp - effective_last_update + aggregator = ( + oracle.aggregator + oracle.lastObservation * elapsed + ) % U128_MODULUS + + return TwapOracle( + aggregator=aggregator, + lastUpdatedTimestamp=timestamp, + createdAtTimestamp=oracle.createdAtTimestamp, + lastPrice=price, + lastObservation=observation, + maxObservationChangePerUpdate=( + oracle.maxObservationChangePerUpdate + ), + initialObservation=oracle.initialObservation, + startDelaySeconds=oracle.startDelaySeconds, + ) + + +def assert_twap_update(before: Pool, after: Pool, timestamp: int) -> None: + """Assert that a swap applied the exact pre-reserve oracle transition.""" + assert after.oracle == expected_oracle_after_update(before, timestamp) + + +def calculate_twap(oracle: TwapOracle, timestamp: int) -> int: + """Mirror ``Pool::get_twap`` from ``state/futarchy_amm.rs``. + + This is a stateless reference calculation over the pre-instruction + snapshot. + """ + start = oracle.createdAtTimestamp + oracle.startDelaySeconds + assert oracle.lastUpdatedTimestamp > start + duration = timestamp - start + assert duration > 0 and oracle.aggregator != 0 + final_interval = timestamp - oracle.lastUpdatedTimestamp + final_contribution = ( + oracle.lastObservation * final_interval + ) % U128_MODULUS + total = (oracle.aggregator + final_contribution) % U128_MODULUS + return total // duration diff --git a/fuzz/futarchy/utils/parameters.py b/fuzz/futarchy/utils/parameters.py new file mode 100644 index 00000000..f4902639 --- /dev/null +++ b/fuzz/futarchy/utils/parameters.py @@ -0,0 +1,210 @@ +"""Boundary-biased generators for valid Futarchy configuration values.""" + +from __future__ import annotations + +from wake_sol import random + +from ..pytypes.futarchy import InitialSpendingLimit +from ..constants import ( + EXECUTE_ARBITRARY_DURATION_SECONDS, + MAX_PASS_THRESHOLD_BPS, + MAX_PROPOSAL_PASS_THRESHOLD_BPS, + MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + MAX_SPENDING_LIMIT_MEMBERS, + MIN_PROPOSAL_DURATION_SECONDS, + MIN_PROPOSAL_PASS_THRESHOLD_BPS, + MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + U32_MAX, + U64_MAX, + U128_MAX, + V08_BASE_TO_STAKE, + V08_LAUNCH_PRICE, + V08_MIN_BASE_FUTARCHIC_LIQUIDITY, + V08_MIN_QUOTE_FUTARCHIC_LIQUIDITY, + V08_PASS_THRESHOLD_BPS, + V08_SECONDS_PER_PROPOSAL, + V08_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + V08_TWAP_MAX_CHANGE, + V08_TWAP_START_DELAY_SECONDS, +) + + +def boundary_biased_integer( + minimum: int, + maximum: int, + *interesting: int, +) -> int: + """Choose a boundary/production value often and a full-range value otherwise.""" + candidates = tuple( + dict.fromkeys( + value + for value in (minimum, minimum + 1, *interesting, maximum - 1, maximum) + if minimum <= value <= maximum + ) + ) + if random.choice((True, True, False)): + return random.choice(candidates) + return random.randint(minimum, maximum) + + +def valid_pass_threshold_bps() -> int: + """Generate the complete valid DAO pass-threshold range.""" + return boundary_biased_integer( + 0, + MAX_PASS_THRESHOLD_BPS, + V08_PASS_THRESHOLD_BPS, + ) + + +def valid_base_to_stake() -> int: + """Generate a valid u64 stake requirement, including production and edges.""" + return boundary_biased_integer(0, U64_MAX, V08_BASE_TO_STAKE) + + +def valid_spending_limit_amount() -> int: + """Generate any nonzero monthly allowance accepted by Squads.""" + return boundary_biased_integer(1, U64_MAX) + + +def valid_spending_limit_member_count(maximum: int) -> int: + """Generate nonempty member-vector lengths accepted by Squads.""" + if not 1 <= maximum <= MAX_SPENDING_LIMIT_MEMBERS: + raise ValueError("maximum must be within the protocol member limit") + return boundary_biased_integer(1, maximum) + + +def valid_spending_limit(member_candidates) -> InitialSpendingLimit: + """Build a valid nonempty, unique spending-limit configuration.""" + maximum = min(MAX_SPENDING_LIMIT_MEMBERS, len(member_candidates)) + count = valid_spending_limit_member_count(maximum) + return InitialSpendingLimit( + amountPerMonth=valid_spending_limit_amount(), + members=[item.pubkey for item in random.sample(member_candidates, count)], + ) + + +def invalid_spending_limit( + member_candidates, +) -> tuple[InitialSpendingLimit, str]: + """Rotate through each validation guard and return its error name.""" + candidates = list(member_candidates) + assert len(candidates) >= MAX_SPENDING_LIMIT_MEMBERS + 1 + choice = random.randint(0, 3) + if choice == 0: + return ( + InitialSpendingLimit( + amountPerMonth=0, + members=[candidates[0].pubkey], + ), + "InvalidSpendingLimitAmount", + ) + if choice == 1: + return ( + InitialSpendingLimit(amountPerMonth=1, members=[]), + "EmptySpendingLimitMembers", + ) + if choice == 2: + member = candidates[0].pubkey + return ( + InitialSpendingLimit(amountPerMonth=1, members=[member, member]), + "DuplicateSpendingLimitMember", + ) + return ( + InitialSpendingLimit( + amountPerMonth=1, + members=[ + item.pubkey + for item in candidates[: MAX_SPENDING_LIMIT_MEMBERS + 1] + ], + ), + "TooManySpendingLimitMembers", + ) + + +def valid_proposal_duration() -> int: + """Generate any duration accepted for ExecuteArbitrary proposals.""" + return boundary_biased_integer( + MIN_PROPOSAL_DURATION_SECONDS + 1, + U32_MAX, + EXECUTE_ARBITRARY_DURATION_SECONDS, + ) + + +def valid_proposal_pass_threshold_bps() -> int: + """Generate the full admin-configurable proposal-threshold range.""" + return boundary_biased_integer( + MIN_PROPOSAL_PASS_THRESHOLD_BPS, + MAX_PROPOSAL_PASS_THRESHOLD_BPS, + V08_PASS_THRESHOLD_BPS, + ) + + +def valid_dao_config() -> dict[str, int]: + """Generate valid DAO fields over every serialized range. + + The delay is capped at ``u32::MAX // 2`` so its protocol-required doubled + duration is representable as a u32. Duration is then generated from that + relational lower bound through ``u32::MAX``. + """ + delay = boundary_biased_integer( + 0, + U32_MAX // 2, + V08_TWAP_START_DELAY_SECONDS, + ) + minimum_duration = max( + MIN_PROPOSAL_DURATION_SECONDS, + 2 * delay, + ) + duration = boundary_biased_integer( + minimum_duration, + U32_MAX, + V08_SECONDS_PER_PROPOSAL, + ) + return dict( + pass_threshold_bps=valid_pass_threshold_bps(), + seconds_per_proposal=duration, + twap_initial_observation=boundary_biased_integer( + 0, + U128_MAX, + V08_LAUNCH_PRICE, + ), + twap_max_observation_change_per_update=boundary_biased_integer( + 1, + U128_MAX, + V08_TWAP_MAX_CHANGE, + ), + twap_start_delay_seconds=delay, + min_quote_futarchic_liquidity=boundary_biased_integer( + 1, + U64_MAX, + V08_MIN_QUOTE_FUTARCHIC_LIQUIDITY, + ), + min_base_futarchic_liquidity=boundary_biased_integer( + 1, + U64_MAX, + V08_MIN_BASE_FUTARCHIC_LIQUIDITY, + ), + base_to_stake=valid_base_to_stake(), + team_sponsored_pass_threshold_bps=boundary_biased_integer( + MIN_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + MAX_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + V08_TEAM_SPONSORED_PASS_THRESHOLD_BPS, + ), + ) + + +def production_dao_config() -> dict[str, int]: + """Return the documented v0.8 launchpad-style baseline.""" + return dict( + pass_threshold_bps=V08_PASS_THRESHOLD_BPS, + seconds_per_proposal=V08_SECONDS_PER_PROPOSAL, + twap_initial_observation=V08_LAUNCH_PRICE, + twap_max_observation_change_per_update=V08_TWAP_MAX_CHANGE, + twap_start_delay_seconds=V08_TWAP_START_DELAY_SECONDS, + min_quote_futarchic_liquidity=V08_MIN_QUOTE_FUTARCHIC_LIQUIDITY, + min_base_futarchic_liquidity=V08_MIN_BASE_FUTARCHIC_LIQUIDITY, + base_to_stake=V08_BASE_TO_STAKE, + team_sponsored_pass_threshold_bps=( + V08_TEAM_SPONSORED_PASS_THRESHOLD_BPS + ), + ) diff --git a/fuzz/futarchy/utils/payloads.py b/fuzz/futarchy/utils/payloads.py new file mode 100644 index 00000000..6b87f415 --- /dev/null +++ b/fuzz/futarchy/utils/payloads.py @@ -0,0 +1,130 @@ +"""Check declared Squads payload effects from one transaction's snapshots.""" + +import hashlib +from dataclasses import fields, replace + +from wake_sol import Account, svm + +from ..constants import FUTARCHY_PROGRAM_ID, SPL_MEMO_PROGRAM_ID +from ..pytypes.futarchy import ( + AmmPosition, + Dao, + PoolState, + SetSpendingLimitArgs, + UpdateDaoParams, + WithdrawLiquidityParams, +) +from .assertions import assert_changed_only + + +def _discriminator(name: str) -> bytes: + return hashlib.sha256(f"global:{name}".encode()).digest()[:8] + + +UPDATE_DAO = _discriminator("update_dao") +SET_SPENDING_LIMIT = _discriminator("set_spending_limit") +WITHDRAW_LIQUIDITY = _discriminator("withdraw_liquidity") + + +def payload_state_allows_execution(context, transaction) -> bool: + """Liquidation freezes DAO configuration, not approved SPL or memo payloads.""" + for instruction in transaction.instructions: + if instruction.program_id != FUTARCHY_PROGRAM_ID: + continue + if not context.is_spot(): + return False + if context.is_liquidated() and instruction.data[:8] != WITHDRAW_LIQUIDITY: + return False + return True + + +def assert_payload_effects(context, transaction, before) -> None: + """Apply only the declared transfer/mint/config deltas; retain no shadow state.""" + expected_dao = before.decode(context.dao, Dao) + balance_deltas, supply_deltas = {}, {} + + def change(deltas, key, amount): + deltas[key] = deltas.get(key, 0) + amount + + for instruction in transaction.instructions: + keys = [meta.pubkey for meta in instruction.accounts] + data = bytes(instruction.data) + if instruction.program_id == svm.token.program_id: + assert len(data) == 9 and data[0] in (3, 7), "unsupported token payload" + amount = int.from_bytes(data[1:9], "little") + if data[0] == 3: # Transfer + change(balance_deltas, keys[0], -amount) + else: # MintTo + change(supply_deltas, keys[0], amount) + change(balance_deltas, keys[1], amount) + elif instruction.program_id == FUTARCHY_PROGRAM_ID: + if data[:8] == UPDATE_DAO: + params = UpdateDaoParams.decode(data[8:]) + updates = { + field.name: getattr(params, field.name) + for field in fields(params) + if getattr(params, field.name) is not None + } + expected_dao = replace( + expected_dao, **updates, seqNum=expected_dao.seqNum + 1 + ) + elif data[:8] == SET_SPENDING_LIMIT: + params = SetSpendingLimitArgs.decode(data[8:]) + expected_dao = replace( + expected_dao, + initialSpendingLimit=params.config, + spendingLimitDirty=True, + seqNum=expected_dao.seqNum + 1, + ) + elif data[:8] == WITHDRAW_LIQUIDITY: + params = WithdrawLiquidityParams.decode(data[8:]) + old_position = before.decode(keys[6], AmmPosition) + liquidity = params.liquidityToWithdraw + spot = expected_dao.amm.state.spot + base = liquidity * spot.baseReserves // expected_dao.amm.totalLiquidity + quote = liquidity * spot.quoteReserves // expected_dao.amm.totalLiquidity + for source, destination, amount in ( + (keys[4], keys[2], base), (keys[5], keys[3], quote) + ): + change(balance_deltas, source, -amount) + change(balance_deltas, destination, amount) + assert_changed_only( + old_position, + AmmPosition.decode(Account(keys[6]).data), + liquidity=old_position.liquidity - liquidity, + ) + pool = replace( + spot, + baseReserves=spot.baseReserves - base, + quoteReserves=spot.quoteReserves - quote, + ) + expected_dao = replace( + expected_dao, + seqNum=expected_dao.seqNum + 1, + amm=replace( + expected_dao.amm, + totalLiquidity=expected_dao.amm.totalLiquidity - liquidity, + state=PoolState.Spot(pool), + ), + ) + else: + raise AssertionError("add an effect assertion for this Futarchy payload") + else: + assert instruction.program_id == SPL_MEMO_PROGRAM_ID, "unsupported payload" + + assert_changed_only(expected_dao, context.dao_state()) + for key, old in before.states.items(): + if old.owner != svm.token.program_id: + continue + account = Account(key) + expected_data = bytearray(old.data) + if len(old.data) == 82: + offset, amount = 36, old.mint_supply() + supply_deltas.get(key, 0) + else: + offset, amount = 64, old.token_balance() + balance_deltas.get(key, 0) + expected_data[offset : offset + 8] = amount.to_bytes(8, "little") + assert account.exists and account.owner == old.owner + assert account.lamports == old.lamports + assert bytes(account.data) == bytes(expected_data), ( + f"unexpected payload effect on {old.label}" + ) diff --git a/fuzz/futarchy/utils/proposals.py b/fuzz/futarchy/utils/proposals.py new file mode 100644 index 00000000..18e15986 --- /dev/null +++ b/fuzz/futarchy/utils/proposals.py @@ -0,0 +1,134 @@ +"""Small independent expectations shared by proposal lifecycle wrappers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from ..constants import FUTARCHY_PROGRAM_ID +from ..pytypes.futarchy import Dao, Proposal, ProposalAction, ProposalState +from .accounts import derive_proposal +from .state import ProposalAccounts +from .assertions import assert_changed_only + + +DAY_SECONDS = 24 * 60 * 60 + + +@dataclass(frozen=True, slots=True) +class ProposalKindExpectation: + """Protocol constants duplicated intentionally as a test oracle.""" + + duration_seconds: int + pass_threshold_bps: int + sponsorship: str + council_can_block: bool = True + cooldown_seconds: int = 0 + twap_start_delay_seconds: int = DAY_SECONDS + + +_EXPECTATIONS: tuple[tuple[type, ProposalKindExpectation], ...] = ( + ( + ProposalAction.LargeSpend, + ProposalKindExpectation( + DAY_SECONDS * 3 // 2, + -1_000, + "required", + twap_start_delay_seconds=DAY_SECONDS // 2, + ), + ), + ( + ProposalAction.MintTokens, + ProposalKindExpectation(DAY_SECONDS * 5, 500, "optional"), + ), + ( + ProposalAction.SpendingLimitChange, + ProposalKindExpectation(DAY_SECONDS * 5, 500, "required"), + ), + ( + ProposalAction.ExecuteArbitrary, + ProposalKindExpectation(DAY_SECONDS * 10, 1_000, "optional"), + ), + ( + ProposalAction.HostileTakeover, + ProposalKindExpectation( + DAY_SECONDS * 20, + 1_000, + "forbidden", + cooldown_seconds=DAY_SECONDS * 20, + ), + ), + ( + ProposalAction.HostileLiquidate, + ProposalKindExpectation( + DAY_SECONDS * 10, + 2_500, + "forbidden", + cooldown_seconds=DAY_SECONDS * 10, + ), + ), + ( + ProposalAction.BuybackToken, + ProposalKindExpectation( + DAY_SECONDS * 10, + 1_000, + "optional", + cooldown_seconds=DAY_SECONDS * 90, + ), + ), +) + + +def proposal_kind_expectation(action: Any) -> ProposalKindExpectation: + """Return independent constants for the decoded proposal action.""" + for action_type, expectation in _EXPECTATIONS: + if isinstance(action, action_type): + return expectation + raise AssertionError(f"unsupported proposal action: {type(action)!r}") + + +def is_currently_sponsored(proposal: Proposal, dao: Dao) -> bool: + """A historical sponsor only counts while it is still the DAO team.""" + return proposal.sponsoredBy == dao.teamAddress + + +def assert_initialized_proposal( + context: Any, + accounts: ProposalAccounts, + before_dao: Dao, + assert_action: Callable[[Any], None], +) -> Proposal: + """Check the complete fresh Proposal snapshot shared by every initializer.""" + proposal = Proposal.decode(accounts.proposal.data) + after_dao = context.dao_state() + assert_changed_only( + before_dao, after_dao, + seqNum=before_dao.seqNum + 1, proposalCount=before_dao.proposalCount + 1, + ) + expected_address, bump = derive_proposal( + accounts.squads.proposal, FUTARCHY_PROGRAM_ID + ) + assert accounts.proposal.pubkey == expected_address.pubkey + assert accounts.proposal.owner == FUTARCHY_PROGRAM_ID + assert proposal.pdaBump == bump + assert proposal.number == after_dao.proposalCount + assert proposal.proposer == context.proposal_proposer.pubkey + assert proposal.timestampEnqueued == 0 + assert isinstance(proposal.state, ProposalState.Draft) + assert proposal.state.amountStaked == 0 + assert proposal.dao == context.dao.pubkey + assert proposal.squadsProposal == accounts.squads.proposal.pubkey + assert proposal.question == accounts.question.pubkey + assert proposal.baseVault == accounts.base_vault.pubkey + assert proposal.quoteVault == accounts.quote_vault.pubkey + assert proposal.failBaseMint == accounts.fail_base_mint.pubkey + assert proposal.passBaseMint == accounts.pass_base_mint.pubkey + assert proposal.failQuoteMint == accounts.fail_quote_mint.pubkey + assert proposal.passQuoteMint == accounts.pass_quote_mint.pubkey + assert proposal.sponsoredBy is None + expectation = proposal_kind_expectation(proposal.action) + assert proposal.durationInSeconds == expectation.duration_seconds + assert proposal.passThresholdBps == expectation.pass_threshold_bps + assert proposal.councilCanBlock == expectation.council_can_block + assert_action(proposal.action) + return proposal diff --git a/fuzz/futarchy/utils/settlement.py b/fuzz/futarchy/utils/settlement.py new file mode 100644 index 00000000..5f5ef632 --- /dev/null +++ b/fuzz/futarchy/utils/settlement.py @@ -0,0 +1,70 @@ +"""Snapshot assertions shared by market finalization and administrative cancellation.""" + +from dataclasses import replace + +from ..pytypes.futarchy import Dao, PoolState, Proposal, ProposalState +from .assertions import AccountSnapshot, assert_changed_only +from .tokens import mint_supply, token_balance + + +def assert_market_settled(context, accounts, before: AccountSnapshot, passed: bool) -> None: + """Check binary resolution, the winning reserve merge, and both redemptions.""" + old_dao = before.decode(context.dao, Dao) + old_proposal = before.decode(accounts.proposal, Proposal) + assert isinstance(old_dao.amm.state, PoolState.Futarchy) + new_dao = context.dao_state() + assert isinstance(new_dao.amm.state, PoolState.Spot) + assert_changed_only( + old_proposal, + context.proposal_state(accounts), + state=ProposalState.Passed() if passed else ProposalState.Failed(), + ) + + # Question layout: discriminator, id[32], oracle[32], Vec, denominator. + old_question = before.account(accounts.question).data + question = bytes(accounts.question.data) + assert len(old_question) == len(question) == 88 + assert question[:72] == old_question[:72] + assert int.from_bytes(old_question[72:76], "little") == 2 + assert old_question[76:88] == bytes(12), "question was already resolved" + assert question[72:76] == old_question[72:76] + payouts = [int.from_bytes(question[i : i + 4], "little") for i in (76, 80)] + assert payouts == ([0, 1] if passed else [1, 0]) + assert int.from_bytes(question[84:88], "little") == 1 + + state = old_dao.amm.state + winner = state.pass_ if passed else state.fail + merged = replace( + state.spot, + **{ + field: getattr(state.spot, field) + getattr(winner, field) + for field in ( + "baseReserves", "quoteReserves", + "baseProtocolFeeBalance", "quoteProtocolFeeBalance", + ) + }, + ) + assert_changed_only(old_dao.amm, new_dao.amm, state=PoolState.Spot(merged)) + + for underlying, custody, mints, vaults in ( + ( + context.amm_base_vault, accounts.base_vault_underlying, + (accounts.fail_base_mint, accounts.pass_base_mint), + (accounts.amm_fail_base_vault, accounts.amm_pass_base_vault), + ), + ( + context.amm_quote_vault, accounts.quote_vault_underlying, + (accounts.fail_quote_mint, accounts.pass_quote_mint), + (accounts.amm_fail_quote_vault, accounts.amm_pass_quote_vault), + ), + ): + payout = before.account(vaults[int(passed)]).token_balance() + assert token_balance(underlying) == ( + before.account(underlying).token_balance() + payout + ) + assert token_balance(custody) == before.account(custody).token_balance() - payout + for mint, vault in zip(mints, vaults): + assert token_balance(vault) == 0 + assert mint_supply(mint) == ( + before.account(mint).mint_supply() - before.account(vault).token_balance() + ) diff --git a/fuzz/futarchy/utils/setup.py b/fuzz/futarchy/utils/setup.py new file mode 100644 index 00000000..c1c3a9f2 --- /dev/null +++ b/fuzz/futarchy/utils/setup.py @@ -0,0 +1,248 @@ +"""Short dependency-ordered setup phases for each Futarchy fuzz sequence.""" + +from __future__ import annotations + +from typing import Any + +from wake_sol import Account, svm + +from ..constants import ( + ACTOR_COUNT, + ACTOR_LAMPORTS, + BASE_SLOT, + BASE_TIMESTAMP, + CONDITIONAL_VAULT_PROGRAM_ID, + CONDITIONAL_VAULT_SO, + FUTARCHY_PROGRAM_ID, + FUTARCHY_SO, + INITIAL_ACTOR_BASE_BALANCE, + INITIAL_ACTOR_QUOTE_BALANCE, + METADAO_MULTISIG_VAULT, + PAYER_LAMPORTS, + PERMISSIONLESS_ACCOUNT_SECRET, + SQUADS_PROGRAM_CONFIG, + SQUADS_PROGRAM_CONFIG_DATA, + SQUADS_PROGRAM_CONFIG_TREASURY, + SQUADS_PROGRAM_ID, + SQUADS_PERMISSIONLESS_MEMBER, + SQUADS_SO, + V08_DAO_NONCE, + V08_MONTHLY_SPENDING_LIMIT, +) +from .accounts import ( + derive_dao, + derive_event_authority, + derive_squads_multisig, + derive_squads_spending_limit, + derive_squads_vault, +) +from .tokens import create_ata, create_mint, mint_to, set_mint_authority + + +class FutarchySequenceSetup: + """Populate only real programs, signers, token accounts, and normal state.""" + + def __init__(self, context: Any) -> None: + self.context = context + + def install_programs_and_clock(self) -> None: + """Install Futarchy dependencies and deterministic Squads genesis state.""" + svm.add_program(FUTARCHY_PROGRAM_ID, FUTARCHY_SO.read_bytes()) + svm.add_program( + CONDITIONAL_VAULT_PROGRAM_ID, + CONDITIONAL_VAULT_SO.read_bytes(), + ) + svm.add_program(SQUADS_PROGRAM_ID, SQUADS_SO.read_bytes()) + config = SQUADS_PROGRAM_CONFIG_DATA.read_bytes() + svm.set_account( + SQUADS_PROGRAM_CONFIG, + lamports=svm.minimum_balance_for_rent_exemption(len(config)), + data=config, + owner=SQUADS_PROGRAM_ID, + ) + svm.airdrop(SQUADS_PROGRAM_CONFIG_TREASURY, ACTOR_LAMPORTS) + svm.set_clock(unix_timestamp=BASE_TIMESTAMP, slot=BASE_SLOT) + + def create_signers_and_addresses(self) -> None: + """Create funded identities and derive the primary DAO/Squads graph.""" + context = self.context + context.actor_lamports = ACTOR_LAMPORTS + context.program_id = FUTARCHY_PROGRAM_ID + context.squads_program_id = SQUADS_PROGRAM_ID + context.payer = Account.new() + context.payer.label = "transaction / rent payer" + svm.airdrop(context.payer, PAYER_LAMPORTS) + context.ops_admin = Account.new() + context.ops_admin.label = "development ops admin (enqueue / proposal params)" + context.proposal_admin = Account.new() + context.proposal_admin.label = "development proposal admin (cancel / remove / execute)" + context.fee_admin = Account.new() + context.fee_admin.label = "development fee collector" + admins = (context.ops_admin, context.proposal_admin, context.fee_admin) + context.dao_creator = Account.new() + context.dao_creator.label = "primary DAO creator" + context.team_candidates = [Account.new(), Account.new()] + context.team_candidates[0].label = "team A" + context.team_candidates[1].label = "team B" + context.team_address = context.team_candidates[0] + context.outsider = Account.new() + context.outsider.label = "outsider / liquidator" + context.actors = [Account.new() for _ in range(ACTOR_COUNT)] + for index, actor in enumerate(context.actors): + actor.label = f"actor {index}" + extras = [Account.new() for _ in range(8)] + for index, account in enumerate(extras): + account.label = f"member candidate {index}" + context.member_candidates = [ + *context.actors, + *context.team_candidates, + context.outsider, + *extras, + ] + context.liquidator_candidates = [context.outsider, *context.actors] + funded = { + account.pubkey: account + for account in ( + context.dao_creator, + *admins, + *context.member_candidates, + ) + } + for account in funded.values(): + svm.airdrop(account, ACTOR_LAMPORTS) + + context.permissionless_account = Account.from_secret( + PERMISSIONLESS_ACCOUNT_SECRET + ) + context.permissionless_account.label = "Squads permissionless member" + assert context.permissionless_account.pubkey == SQUADS_PERMISSIONLESS_MEMBER + svm.airdrop(context.permissionless_account, ACTOR_LAMPORTS) + context.signers_by_pubkey = { + account.pubkey: account + for account in ( + context.payer, + context.dao_creator, + context.permissionless_account, + *admins, + *context.member_candidates, + ) + } + context.proposal_proposer = context.actors[0] + + context.dao_nonce = V08_DAO_NONCE + context.dao, _ = derive_dao( + context.dao_creator, context.dao_nonce, FUTARCHY_PROGRAM_ID + ) + context.dao.label = "primary Futarchy DAO" + context.squads_multisig, _ = derive_squads_multisig( + context.dao, SQUADS_PROGRAM_ID + ) + context.squads_multisig.label = "DAO Squads multisig" + context.council, _ = derive_squads_vault( + context.squads_multisig, SQUADS_PROGRAM_ID + ) + context.council.label = "DAO Squads vault" + context.squads_spending_limit, _ = derive_squads_spending_limit( + context.squads_multisig, context.dao, SQUADS_PROGRAM_ID + ) + context.squads_spending_limit.label = "DAO Squads spending limit" + context.squads_program_config = Account(SQUADS_PROGRAM_CONFIG) + context.squads_program_config_treasury = Account( + SQUADS_PROGRAM_CONFIG_TREASURY + ) + context.event_authority, _ = derive_event_authority(FUTARCHY_PROGRAM_ID) + context.vault_event_authority, _ = derive_event_authority( + CONDITIONAL_VAULT_PROGRAM_ID + ) + + def create_tokens_and_bookkeeping(self) -> None: + """Create realistic token ledgers and reset lightweight resource registries.""" + context = self.context + context.base_token_accounts = {} + context.quote_token_accounts = {} + context.proposals = [] + context.squads_transactions = [] + context.position_accounts = {} + context.stake_accounts = {} + context.auxiliary_daos = [] + context.squads_transaction_index = 0 + context.typed_market_nonce = 0 + context.next_aux_nonce = 10_000 + context.initial_spending_limit_amount = V08_MONTHLY_SPENDING_LIMIT + + context.base_mint = Account.new() + context.base_mint.label = "base mint" + context.quote_mint = Account.new() + context.quote_mint.label = "quote mint" + create_mint(context.payer, context.base_mint, context.payer) + create_mint(context.payer, context.quote_mint, context.payer) + context.base_atas_by_owner = {} + context.quote_atas_by_owner = {} + for actor in context.actors: + base = create_ata(context.payer, actor, context.base_mint) + quote = create_ata(context.payer, actor, context.quote_mint) + base.label = f"{actor.label} base ATA" + quote.label = f"{actor.label} quote ATA" + context.base_atas_by_owner[actor.pubkey] = base + context.quote_atas_by_owner[actor.pubkey] = quote + context.register_token_account(context.base_mint, base) + context.register_token_account(context.quote_mint, quote) + mint_to( + context.payer, + context.base_mint, + base, + context.payer, + INITIAL_ACTOR_BASE_BALANCE, + ) + mint_to( + context.payer, + context.quote_mint, + quote, + context.payer, + INITIAL_ACTOR_QUOTE_BALANCE, + ) + + context.amm_base_vault = Account( + svm.token.ata_address(context.dao, context.base_mint) + ) + context.amm_base_vault.label = "DAO spot base vault" + context.amm_quote_vault = Account( + svm.token.ata_address(context.dao, context.quote_mint) + ) + context.amm_quote_vault.label = "DAO spot quote vault" + context.fee_base_account = context.ensure_underlying_ata( + METADAO_MULTISIG_VAULT, context.base_mint + ) + context.fee_quote_account = context.ensure_underlying_ata( + METADAO_MULTISIG_VAULT, context.quote_mint + ) + context.council_base_account = context.ensure_underlying_ata( + context.council, context.base_mint + ) + context.council_quote_account = context.ensure_underlying_ata( + context.council, context.quote_mint + ) + for team in context.team_candidates: + context.ensure_underlying_ata(team, context.base_mint) + context.ensure_underlying_ata(team, context.quote_mint) + mint_to( + context.payer, + context.quote_mint, + context.council_quote_account, + context.payer, + 500_000_000_000, + ) + set_mint_authority( + context.payer, + context.base_mint, + context.payer, + context.council, + ) + + def initialize_normal_state(self) -> None: + """Use public instructions for the primary DAO and one baseline Draft.""" + context = self.context + context.instructions.initialize_dao.initialize_main() + context.register_token_account(context.base_mint, context.amm_base_vault) + context.register_token_account(context.quote_mint, context.amm_quote_vault) + context.instructions.initialize_proposal.create_baseline() diff --git a/fuzz/futarchy/utils/squads.py b/fuzz/futarchy/utils/squads.py new file mode 100644 index 00000000..880c5580 --- /dev/null +++ b/fuzz/futarchy/utils/squads.py @@ -0,0 +1,706 @@ +"""Normal Squads transaction setup used by Futarchy instruction wrappers.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any + +from wake_sol import ( + Account, + AccountMeta, + Instruction, + Pubkey, + SYSTEM_PROGRAM_ID, + signer, + writable, + writable_signer, +) + +from ..constants import ( + COMPUTE_BUDGET_PROGRAM_ID, + FUTARCHY_PROGRAM_ID, + SQUADS_PROGRAM_ID, + TRANSACTION_COMPUTE_UNIT_LIMIT, +) +from ..pytypes.futarchy import ( + AdminEnqueueMultisigProposalApprovalArgs, + AdminEnqueueMultisigProposalCancellationArgs, + Futarchy as FutarchyProgram, +) +from .accounts import ( + derive_enqueued_approval, + derive_enqueued_cancellation, + derive_squads_proposal, + derive_squads_spending_limit, + derive_squads_transaction, + derive_squads_vault, +) +from .state import SquadsTransaction + + +@dataclass(frozen=True, slots=True) +class SquadsCompiledInstruction: + """The execution-relevant portion of one stored Squads instruction.""" + + program_id_index: int + account_indexes: tuple[int, ...] + data: bytes + + +@dataclass(frozen=True, slots=True) +class SquadsAddressTableLookup: + account_key: Pubkey + writable_indexes: tuple[int, ...] + readonly_indexes: tuple[int, ...] + + +@dataclass(frozen=True, slots=True) +class SquadsTransactionMessage: + num_signers: int + num_writable_signers: int + num_writable_non_signers: int + account_keys: tuple[Pubkey, ...] + instructions: tuple[SquadsCompiledInstruction, ...] + address_table_lookups: tuple[SquadsAddressTableLookup, ...] + + +@dataclass(frozen=True, slots=True) +class SquadsVaultTransaction: + multisig: Pubkey + creator: Pubkey + index: int + bump: int + vault_index: int + vault_bump: int + ephemeral_signer_bumps: tuple[int, ...] + message: SquadsTransactionMessage + + +@dataclass(frozen=True, slots=True) +class SquadsProposal: + multisig: Pubkey + transaction_index: int + status: str + status_timestamp: int | None + bump: int + approved: tuple[Pubkey, ...] + rejected: tuple[Pubkey, ...] + cancelled: tuple[Pubkey, ...] + + +@dataclass(frozen=True, slots=True) +class SquadsSpendingLimit: + multisig: Pubkey + create_key: Pubkey + vault_index: int + mint: Pubkey + amount: int + period: str + remaining_amount: int + last_reset: int + bump: int + members: tuple[Pubkey, ...] + destinations: tuple[Pubkey, ...] + + +class _BorshReader: + """Tiny bounded reader for the three pinned Squads account layouts.""" + + def __init__(self, data: bytes) -> None: + self.data = memoryview(data) + self.offset = 0 + + def take(self, size: int) -> bytes: + end = self.offset + size + if size < 0 or end > len(self.data): + raise ValueError("truncated Squads account") + value = bytes(self.data[self.offset:end]) + self.offset = end + return value + + def uint(self, size: int) -> int: + return int.from_bytes(self.take(size), "little") + + def i64(self) -> int: + return int.from_bytes(self.take(8), "little", signed=True) + + def pubkey(self) -> Pubkey: + return Pubkey(self.take(32)) + + def byte_vec(self, length_size: int = 4) -> tuple[int, ...]: + return tuple(self.take(self.uint(length_size))) + + def pubkey_vec(self) -> tuple[Pubkey, ...]: + return tuple(self.pubkey() for _ in range(self.uint(4))) + + def finish_account(self) -> None: + if any(self.take(len(self.data) - self.offset)): + raise ValueError("non-zero bytes after Squads account payload") + + +def _account_reader(data: bytes, name: str) -> _BorshReader: + reader = _BorshReader(data) + discriminator = hashlib.sha256(f"account:{name}".encode()).digest()[:8] + if reader.take(8) != discriminator: + raise ValueError(f"invalid Squads {name} discriminator") + return reader + + +def _read_compiled_instruction( + reader: _BorshReader, length_size: int +) -> SquadsCompiledInstruction: + return SquadsCompiledInstruction( + program_id_index=reader.uint(1), + account_indexes=reader.byte_vec(length_size), + data=bytes(reader.byte_vec(2 if length_size == 1 else 4)), + ) + + +def _read_lookup( + reader: _BorshReader, length_size: int +) -> SquadsAddressTableLookup: + return SquadsAddressTableLookup( + account_key=reader.pubkey(), + writable_indexes=reader.byte_vec(length_size), + readonly_indexes=reader.byte_vec(length_size), + ) + + +def _read_message( + reader: _BorshReader, length_size: int +) -> SquadsTransactionMessage: + num_signers = reader.uint(1) + num_writable_signers = reader.uint(1) + num_writable_non_signers = reader.uint(1) + account_keys = tuple(reader.pubkey() for _ in range(reader.uint(length_size))) + instructions = tuple( + _read_compiled_instruction(reader, length_size) + for _ in range(reader.uint(length_size)) + ) + address_table_lookups = tuple( + _read_lookup(reader, length_size) + for _ in range(reader.uint(length_size)) + ) + return SquadsTransactionMessage( + num_signers=num_signers, + num_writable_signers=num_writable_signers, + num_writable_non_signers=num_writable_non_signers, + account_keys=account_keys, + instructions=instructions, + address_table_lookups=address_table_lookups, + ) + + +def decode_squads_vault_transaction(data: bytes) -> SquadsVaultTransaction: + """Decode the pinned Squads v4 ``VaultTransaction`` account.""" + reader = _account_reader(data, "VaultTransaction") + value = SquadsVaultTransaction( + multisig=reader.pubkey(), + creator=reader.pubkey(), + index=reader.uint(8), + bump=reader.uint(1), + vault_index=reader.uint(1), + vault_bump=reader.uint(1), + ephemeral_signer_bumps=reader.byte_vec(), + message=_read_message(reader, 4), + ) + reader.finish_account() + return value + + +def decode_squads_proposal(data: bytes) -> SquadsProposal: + """Decode the pinned Squads v4 ``Proposal`` account.""" + reader = _account_reader(data, "Proposal") + multisig = reader.pubkey() + transaction_index = reader.uint(8) + status_tag = reader.uint(1) + statuses = ( + "Draft", + "Active", + "Rejected", + "Approved", + "Executing", + "Executed", + "Cancelled", + ) + if status_tag >= len(statuses): + raise ValueError(f"invalid Squads proposal status {status_tag}") + status_timestamp = None if status_tag == 4 else reader.i64() + value = SquadsProposal( + multisig=multisig, + transaction_index=transaction_index, + status=statuses[status_tag], + status_timestamp=status_timestamp, + bump=reader.uint(1), + approved=reader.pubkey_vec(), + rejected=reader.pubkey_vec(), + cancelled=reader.pubkey_vec(), + ) + reader.finish_account() + return value + + +def decode_squads_spending_limit(data: bytes) -> SquadsSpendingLimit: + """Decode the pinned Squads v4 ``SpendingLimit`` account.""" + reader = _account_reader(data, "SpendingLimit") + multisig = reader.pubkey() + create_key = reader.pubkey() + vault_index = reader.uint(1) + mint = reader.pubkey() + amount = reader.uint(8) + period_tag = reader.uint(1) + periods = ("OneTime", "Day", "Week", "Month") + if period_tag >= len(periods): + raise ValueError(f"invalid Squads spending-limit period {period_tag}") + value = SquadsSpendingLimit( + multisig=multisig, + create_key=create_key, + vault_index=vault_index, + mint=mint, + amount=amount, + period=periods[period_tag], + remaining_amount=reader.uint(8), + last_reset=reader.i64(), + bump=reader.uint(1), + members=reader.pubkey_vec(), + destinations=reader.pubkey_vec(), + ) + reader.finish_account() + return value + + +def _decode_compact_message(data: bytes) -> SquadsTransactionMessage: + """Decode the compact message supplied to Squads' create instruction.""" + reader = _BorshReader(data) + value = _read_message(reader, 1) + if reader.offset != len(reader.data): + raise ValueError("trailing compact Squads transaction-message bytes") + return value + + +class SquadsSupport: + """Create, approve, and execute real Squads vault transactions.""" + + VAULT_TRANSACTION_CREATE = hashlib.sha256( + b"global:vault_transaction_create" + ).digest()[:8] + PROPOSAL_CREATE = hashlib.sha256(b"global:proposal_create").digest()[:8] + VAULT_TRANSACTION_EXECUTE = hashlib.sha256( + b"global:vault_transaction_execute" + ).digest()[:8] + + def __init__(self, context: Any) -> None: + self.context = context + + @staticmethod + def compute_unit_limit( + units: int = TRANSACTION_COMPUTE_UNIT_LIMIT, + ) -> Instruction: + """Build Solana's SetComputeUnitLimit instruction.""" + return Instruction( + COMPUTE_BUDGET_PROGRAM_ID, + [], + bytes([2]) + units.to_bytes(4, "little"), + ) + + @staticmethod + def compile_message( + vault: Account, + instructions: tuple[Instruction, ...], + ) -> tuple[bytes, tuple[AccountMeta, ...]]: + """Compile the Squads transaction-message format used on chain.""" + metadata: dict[Any, tuple[bool, bool]] = {vault.pubkey: (True, False)} + for instruction in instructions: + metadata.setdefault(instruction.program_id, (False, False)) + for account in instruction.accounts: + old_signer, old_writable = metadata.get( + account.pubkey, (False, False) + ) + metadata[account.pubkey] = ( + old_signer or account.is_signer, + old_writable or account.is_writable, + ) + + groups: list[list[Any]] = [[], [], [], []] + for key in sorted(metadata, key=bytes): + is_signer, is_writable = metadata[key] + if is_signer and is_writable: + groups[0].append(key) + elif is_signer: + if key == vault.pubkey: + groups[1].insert(0, key) + else: + groups[1].append(key) + elif is_writable: + groups[2].append(key) + else: + groups[3].append(key) + + keys = tuple(key for group in groups for key in group) + num_signers = len(groups[0]) + len(groups[1]) + indexes = {key: index for index, key in enumerate(keys)} + message = bytearray( + [num_signers, len(groups[0]), len(groups[2]), len(keys)] + ) + for key in keys: + message.extend(bytes(key)) + message.append(len(instructions)) + for instruction in instructions: + account_indexes = [indexes[meta.pubkey] for meta in instruction.accounts] + message.append(indexes[instruction.program_id]) + message.append(len(account_indexes)) + message.extend(account_indexes) + message.extend(len(instruction.data).to_bytes(2, "little")) + message.extend(instruction.data) + message.append(0) + + metas: list[AccountMeta] = [] + for index, key in enumerate(keys): + is_signer = index < num_signers and key != vault.pubkey + is_writable = ( + index < len(groups[0]) + or num_signers <= index < num_signers + len(groups[2]) + ) + metas.append(AccountMeta(key, is_signer, is_writable)) + return bytes(message), tuple(metas) + + def assert_proposal_status( + self, + prepared: SquadsTransaction, + expected: str, + ) -> SquadsProposal: + """Assert a Squads proposal's canonical identity and exact status.""" + expected_account, bump = derive_squads_proposal( + self.context.squads_multisig, + prepared.index, + SQUADS_PROGRAM_ID, + ) + assert prepared.proposal.pubkey == expected_account.pubkey + assert prepared.proposal.owner == SQUADS_PROGRAM_ID + proposal = decode_squads_proposal(prepared.proposal.data) + assert proposal.multisig == self.context.squads_multisig.pubkey + assert proposal.transaction_index == prepared.index + assert proposal.bump == bump + assert proposal.status == expected + dao_vote = (self.context.dao.pubkey,) + if expected in ("Approved", "Executed"): + assert proposal.approved == dao_vote + assert proposal.rejected == () + assert proposal.cancelled == () + elif expected == "Rejected": + assert proposal.approved == () + assert proposal.rejected == dao_vote + assert proposal.cancelled == () + elif expected == "Cancelled": + assert proposal.approved == dao_vote + assert proposal.rejected == () + assert proposal.cancelled == dao_vote + elif expected == "Active": + assert proposal.approved == () + assert proposal.rejected == () + assert proposal.cancelled == () + return proposal + + def assert_prepared_transaction( + self, + prepared: SquadsTransaction, + proposal_status: str = "Active", + ) -> SquadsVaultTransaction: + """Compare a stored transaction with the independently built payload.""" + context = self.context + expected_account, bump = derive_squads_transaction( + context.squads_multisig, + prepared.index, + SQUADS_PROGRAM_ID, + ) + vault, vault_bump = derive_squads_vault( + context.squads_multisig, + SQUADS_PROGRAM_ID, + ) + assert prepared.transaction.pubkey == expected_account.pubkey + assert prepared.transaction.owner == SQUADS_PROGRAM_ID + assert vault.pubkey == context.council.pubkey + + transaction = decode_squads_vault_transaction(prepared.transaction.data) + expected_message, _ = self.compile_message( + context.council, prepared.instructions + ) + assert transaction.multisig == context.squads_multisig.pubkey + assert transaction.creator == context.permissionless_account.pubkey + assert transaction.index == prepared.index + assert transaction.bump == bump + assert transaction.vault_index == 0 + assert transaction.vault_bump == vault_bump + assert transaction.ephemeral_signer_bumps == () + assert transaction.message == _decode_compact_message(expected_message) + assert transaction.message.address_table_lookups == () + self.assert_proposal_status(prepared, proposal_status) + return transaction + + def assert_spending_limit( + self, + config: Any | None, + *, + account: Account | None = None, + multisig: Account | None = None, + dao: Account | None = None, + quote_mint: Account | None = None, + ) -> None: + """Assert the complete canonical Squads projection of a DAO limit.""" + context = self.context + if account is None: + account = context.squads_spending_limit + if multisig is None: + multisig = context.squads_multisig + if dao is None: + dao = context.dao + if quote_mint is None: + quote_mint = context.quote_mint + if config is None: + assert not account.exists + return + + expected, bump = derive_squads_spending_limit( + multisig, + dao, + SQUADS_PROGRAM_ID, + ) + assert account.exists + assert account.pubkey == expected.pubkey + assert account.owner == SQUADS_PROGRAM_ID + limit = decode_squads_spending_limit(account.data) + assert limit.multisig == multisig.pubkey + assert limit.create_key == dao.pubkey + assert limit.vault_index == 0 + assert limit.mint == quote_mint.pubkey + assert limit.amount == config.amountPerMonth + assert limit.period == "Month" + assert limit.remaining_amount == config.amountPerMonth + assert limit.bump == bump + assert limit.members == tuple(sorted(config.members, key=bytes)) + assert limit.destinations == () + + def preview_next( + self, + *instructions: Instruction, + purpose: str, + allow_admin_execute: bool = False, + ) -> SquadsTransaction: + """Derive the next transaction without mutating Squads.""" + context = self.context + index = context.squads_transaction_index + 1 + transaction, _ = derive_squads_transaction( + context.squads_multisig, index, SQUADS_PROGRAM_ID + ) + proposal, _ = derive_squads_proposal( + context.squads_multisig, index, SQUADS_PROGRAM_ID + ) + _, metas = self.compile_message(context.council, tuple(instructions)) + transaction.label = f"Squads transaction {index}: {purpose}" + proposal.label = f"Squads proposal {index}: {purpose}" + return SquadsTransaction( + index=index, + transaction=transaction, + proposal=proposal, + instructions=tuple(instructions), + message_accounts=metas, + purpose=purpose, + allow_admin_execute=allow_admin_execute, + ) + + def build_create_instructions( + self, prepared: SquadsTransaction + ) -> tuple[Instruction, Instruction]: + """Build Squads vault-transaction-create and proposal-create.""" + context = self.context + message, _ = self.compile_message( + context.council, prepared.instructions + ) + create_transaction = Instruction( + SQUADS_PROGRAM_ID, + [ + writable(context.squads_multisig), + writable(prepared.transaction), + signer(context.permissionless_account), + writable_signer(context.payer), + SYSTEM_PROGRAM_ID, + ], + self.VAULT_TRANSACTION_CREATE + + bytes([0, 0]) + + len(message).to_bytes(4, "little") + + message + + bytes([0]), + ) + create_proposal = Instruction( + SQUADS_PROGRAM_ID, + [ + context.squads_multisig, + writable(prepared.proposal), + signer(context.permissionless_account), + writable_signer(context.payer), + SYSTEM_PROGRAM_ID, + ], + self.PROPOSAL_CREATE + + prepared.index.to_bytes(8, "little") + + bytes([False]), + ) + return create_transaction, create_proposal + + def commit(self, prepared: SquadsTransaction) -> SquadsTransaction: + """Register a transaction that was created by Squads or a typed CPI.""" + assert prepared.transaction.exists + assert prepared.proposal.exists + self.assert_prepared_transaction(prepared) + self.context.squads_transaction_index = prepared.index + self.context.squads_transactions.append(prepared) + return prepared + + def prepare( + self, + *instructions: Instruction, + purpose: str, + allow_admin_execute: bool = False, + ) -> SquadsTransaction: + """Create one active Squads proposal through its normal instructions.""" + prepared = self.preview_next( + *instructions, + purpose=purpose, + allow_admin_execute=allow_admin_execute, + ) + create_transaction, create_proposal = self.build_create_instructions( + prepared + ) + self.context.payer.tx( + create_transaction, + create_proposal, + signers=[self.context.permissionless_account], + ) + return self.commit(prepared) + + def build_enqueue( + self, prepared: SquadsTransaction, admin: Account + ) -> tuple[Instruction, Account]: + """Build Futarchy's admin-gated approval enqueue instruction.""" + enqueued, _ = derive_enqueued_approval( + self.context.dao, prepared.index, FUTARCHY_PROGRAM_ID + ) + enqueued.label = f"enqueued approval {prepared.index}" + instruction = FutarchyProgram.adminEnqueueMultisigProposalApproval( + AdminEnqueueMultisigProposalApprovalArgs( + transactionIndex=prepared.index + ), + dao=self.context.dao, + admin=admin, + squadsMultisig=self.context.squads_multisig, + squadsMultisigProposal=prepared.proposal, + enqueuedApproval=enqueued, + ) + return instruction, enqueued + + def build_approve( + self, + prepared: SquadsTransaction, + enqueued: Account, + rent_receiver: Account, + ) -> Instruction: + """Build the permissionless Futarchy-to-Squads approval CPI.""" + return FutarchyProgram.executeMultisigProposalApproval( + dao=self.context.dao, + rentReceiver=rent_receiver, + squadsMultisig=self.context.squads_multisig, + squadsMultisigProposal=prepared.proposal, + enqueuedApproval=enqueued, + squadsMultisigProgram=SQUADS_PROGRAM_ID, + ) + + def build_enqueue_cancellation( + self, prepared: SquadsTransaction, admin: Account + ) -> tuple[Instruction, Account]: + """Build the admin/liquidator-gated cancellation enqueue.""" + enqueued, _ = derive_enqueued_cancellation( + self.context.dao, prepared.index, FUTARCHY_PROGRAM_ID + ) + enqueued.label = f"enqueued cancellation {prepared.index}" + instruction = FutarchyProgram.adminEnqueueMultisigProposalCancellation( + AdminEnqueueMultisigProposalCancellationArgs( + transactionIndex=prepared.index + ), + dao=self.context.dao, + admin=admin, + squadsMultisig=self.context.squads_multisig, + squadsMultisigProposal=prepared.proposal, + enqueuedCancellation=enqueued, + ) + return instruction, enqueued + + def build_cancel( + self, + prepared: SquadsTransaction, + enqueued: Account, + rent_receiver: Account, + ) -> Instruction: + """Build permissionless execution of an enqueued cancellation.""" + return FutarchyProgram.executeMultisigProposalCancellation( + dao=self.context.dao, + rentReceiver=rent_receiver, + squadsMultisig=self.context.squads_multisig, + squadsMultisigProposal=prepared.proposal, + enqueuedCancellation=enqueued, + squadsMultisigProgram=SQUADS_PROGRAM_ID, + ) + + def approve_for_dependency( + self, prepared: SquadsTransaction, admin: Account | None = None + ) -> None: + """Normally enqueue and approve a transaction needed by another flow.""" + authority = admin or self.context.enqueue_authority() + enqueue, enqueued = self.build_enqueue(prepared, authority) + authority.tx(enqueue) + prepared.enqueued_approval = enqueued + approve = self.build_approve(prepared, enqueued, self.context.payer) + self.context.payer.tx(approve) + assert not enqueued.exists + prepared.enqueued_approval = None + self.assert_proposal_status(prepared, "Approved") + prepared.approved = True + + def prepare_and_approve( + self, + *instructions: Instruction, + purpose: str, + allow_admin_execute: bool = False, + ) -> SquadsTransaction: + """Create and approve a prerequisite Squads transaction normally.""" + prepared = self.prepare( + *instructions, + purpose=purpose, + allow_admin_execute=allow_admin_execute, + ) + self.approve_for_dependency(prepared) + return prepared + + def build_execute(self, prepared: SquadsTransaction) -> Instruction: + """Build ordinary top-level Squads vault execution.""" + return Instruction( + SQUADS_PROGRAM_ID, + [ + self.context.squads_multisig, + writable(prepared.proposal), + prepared.transaction, + signer(self.context.permissionless_account), + *prepared.message_accounts, + ], + self.VAULT_TRANSACTION_EXECUTE, + ) + + def execute_top_level(self, prepared: SquadsTransaction) -> Any: + """Execute an approved payload directly through Squads.""" + result = self.context.payer.tx( + self.compute_unit_limit(), + self.build_execute(prepared), + signers=[self.context.permissionless_account], + ) + self.assert_proposal_status(prepared, "Executed") + prepared.executed = True + return result diff --git a/fuzz/futarchy/utils/state.py b/fuzz/futarchy/utils/state.py new file mode 100644 index 00000000..d9058a4b --- /dev/null +++ b/fuzz/futarchy/utils/state.py @@ -0,0 +1,76 @@ +"""Small account-handle records used to navigate the on-chain fuzz state.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from wake_sol import Account, AccountMeta, Instruction, Pubkey + + +@dataclass(slots=True) +class SquadsTransaction: + """Bookkeeping for a real Squads transaction created in this sequence.""" + + index: int + transaction: Account + proposal: Account + instructions: tuple[Instruction, ...] + message_accounts: tuple[AccountMeta, ...] + purpose: str + enqueued_approval: Account | None = None + enqueued_cancellation: Account | None = None + approved: bool = False + rejected: bool = False + executed: bool = False + cancelled: bool = False + disabled: bool = False + allow_admin_execute: bool = False + + +@dataclass(slots=True) +class MarketAccounts: + """A Conditional Vault question and the two underlying-token markets.""" + + question: Account + base_vault: Account + quote_vault: Account + base_vault_underlying: Account + quote_vault_underlying: Account + base_mints: tuple[Account, ...] + quote_mints: tuple[Account, ...] + + +@dataclass(slots=True) +class ProposalAccounts: + """Addresses belonging to one normally initialized Futarchy proposal.""" + + proposal: Account + squads: SquadsTransaction + question: Account + base_vault: Account + quote_vault: Account + base_vault_underlying: Account + quote_vault_underlying: Account + fail_base_mint: Account + pass_base_mint: Account + fail_quote_mint: Account + pass_quote_mint: Account + proposal_base_account: Account | None = None + amm_pass_base_vault: Account | None = None + amm_pass_quote_vault: Account | None = None + amm_fail_base_vault: Account | None = None + amm_fail_quote_vault: Account | None = None + conditional_accounts: dict[tuple[Pubkey, Pubkey], Account] = field( + default_factory=dict + ) + liquidator: Account | None = None + + @property + def conditional_mints(self) -> tuple[Account, Account, Account, Account]: + """Return outcome mints in base-fail/pass then quote-fail/pass order.""" + return ( + self.fail_base_mint, + self.pass_base_mint, + self.fail_quote_mint, + self.pass_quote_mint, + ) diff --git a/fuzz/futarchy/utils/swaps.py b/fuzz/futarchy/utils/swaps.py new file mode 100644 index 00000000..ac8c78ae --- /dev/null +++ b/fuzz/futarchy/utils/swaps.py @@ -0,0 +1,50 @@ +"""Stateless swap bounds and fee checks without reproducing arbitrage.""" + +from ..constants import MAX_BPS +from ..pytypes.futarchy import Market, PoolState, SwapType +from .assertions import assert_changed_only +from .oracle import assert_twap_update + + +PROTOCOL_TAKER_FEE_BPS = 50 + + +def assert_swap_transition(before, after, market, direction, amount, output, timestamp): + """Check the direct quote lower bound, exact taker fee, and arb fee direction.""" + assert_changed_only(before, after, amm=after.amm, seqNum=before.seqNum + 1) + assert_changed_only(before.amm, after.amm, state=after.amm.state) + assert type(after.amm.state) is type(before.amm.state) + names = ( + ("spot",) + if isinstance(before.amm.state, PoolState.Spot) + else ("spot", "pass_", "fail") + ) + selected = { + Market.Spot: "spot", Market.Pass: "pass_", Market.Fail: "fail" + }[market] + old_selected = getattr(before.amm.state, selected) + buying = direction == SwapType.Buy + input_side, output_side = ("quote", "base") if buying else ("base", "quote") + net_input = amount * (MAX_BPS - PROTOCOL_TAKER_FEE_BPS) // MAX_BPS + fee = amount - net_input + input_reserve = getattr(old_selected, input_side + "Reserves") + output_reserve = getattr(old_selected, output_side + "Reserves") + direct_quote = net_input * output_reserve // (input_reserve + net_input) + assert output >= direct_quote, "arbitrage must not reduce the direct swap output" + + for name in names: + old = getattr(before.amm.state, name) + new = getattr(after.amm.state, name) + assert_twap_update(old, new, timestamp) + assert ( + new.baseReserves * new.quoteReserves >= old.baseReserves * old.quoteReserves + ) + for side in ("base", "quote"): + field = side + "ProtocolFeeBalance" + expected = getattr(old, field) + ( + fee if name == selected and side == input_side else 0 + ) + if name != "spot" and name != selected and side == output_side: + assert getattr(new, field) >= expected + else: + assert getattr(new, field) == expected diff --git a/fuzz/futarchy/utils/tokens.py b/fuzz/futarchy/utils/tokens.py new file mode 100644 index 00000000..a84bb360 --- /dev/null +++ b/fuzz/futarchy/utils/tokens.py @@ -0,0 +1,104 @@ +"""Classic SPL mint/ATA setup and independent packed-account readers.""" + +from __future__ import annotations + +from wake_sol import Account, Instruction, Pubkey, signer, svm, writable + +from ..constants import MINT_ACCOUNT_SIZE, TOKEN_ACCOUNT_SIZE, TOKEN_DECIMALS + + +def create_mint(payer: Account, mint: Account, authority: Account) -> None: + """Create a classic SPL mint controlled by a signer account.""" + payer.tx( + svm.system.create_account( + svm.minimum_balance_for_rent_exemption(MINT_ACCOUNT_SIZE), + MINT_ACCOUNT_SIZE, + svm.token.program_id, + from_=payer, + to=mint, + ) + ) + payer.tx( + svm.token.initialize_mint2( + TOKEN_DECIMALS, + authority, + mint=mint, + ) + ) + + +def create_ata( + payer: Account, owner: Account | Pubkey, mint: Account +) -> Account: + """Create and return the canonical ATA for an account or off-curve PDA.""" + address = Account(svm.token.ata_address(owner, mint)) + if not address.exists: + payer.tx(svm.token.create_ata(payer, owner, mint)) + return address + + +def mint_to( + payer: Account, + mint: Account, + destination: Account, + authority: Account, + amount: int, +) -> None: + """Mint an exact native-unit amount to an initialized token account.""" + payer.tx( + svm.token.mint_to_checked( + amount, + TOKEN_DECIMALS, + mint=mint, + account=destination, + authority=authority, + ) + ) + + +def set_mint_authority( + payer: Account, + mint: Account, + current_authority: Account, + new_authority: Account | Pubkey | None, +) -> None: + """Transfer or burn the classic SPL mint authority.""" + option = bytes([0]) + authority_bytes = bytes(32) + if new_authority is not None: + option = bytes([1]) + authority_key = ( + new_authority.pubkey + if isinstance(new_authority, Account) + else new_authority + ) + authority_bytes = bytes(authority_key) + instruction = Instruction( + svm.token.program_id, + [writable(mint), signer(current_authority)], + bytes([6, 0]) + option + authority_bytes, + ) + payer.tx(instruction) + + +def mint_supply(mint: Account) -> int: + """Read a classic SPL Mint supply independently from its packed bytes.""" + data = mint.data + assert len(data) == MINT_ACCOUNT_SIZE + return int.from_bytes(data[36:44], "little") + + +def token_account_fields(token_account: Account) -> tuple[Pubkey, Pubkey, int]: + """Read mint, owner, and amount from a classic SPL Token account.""" + data = token_account.data + assert len(data) == TOKEN_ACCOUNT_SIZE + return ( + Pubkey(data[0:32]), + Pubkey(data[32:64]), + int.from_bytes(data[64:72], "little"), + ) + + +def token_balance(token_account: Account) -> int: + """Read only the native-unit balance of a classic SPL token account.""" + return token_account_fields(token_account)[2] From ffe1428d4ee449f12e0860c344cc278adabe8608 Mon Sep 17 00:00:00 2001 From: hyckomat Date: Mon, 14 Sep 2026 13:43:04 +0200 Subject: [PATCH 2/2] Update cu limit --- fuzz/README.md | 6 +++--- fuzz/futarchy/constants.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fuzz/README.md b/fuzz/README.md index b3bd4b54..8dc29ced 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -1,7 +1,7 @@ # Futarchy Fuzz Test This directory contains the Wake.sol stateful fuzz test for the Futarchy -program at commit `05f8a5c8efc22f4cf157e313d6d768475526a004`. It has 29 +program at commit `658c342c2f1ea11917d5fed10539d078e9e1a3b7`. It has 29 instruction-level happy paths, 29 unhappy paths, two support flows, and twelve global invariants. Each instruction wrapper also checks its own postconditions or atomic rollback behavior. @@ -46,8 +46,8 @@ tests/fixtures/squads-program-config python -m pytest -q -s fuzz/futarchy/test_fuzz.py ``` -Use `FUTARCHY_FUZZ_SEQUENCES` and `FUTARCHY_FUZZ_FLOWS` to change the number of -sequences and flows for local or pipeline runs. +Use `FUZZ_SEQUENCES` and `FUZZ_FLOWS` to change the number of sequences and +flows for local or pipeline runs. The test prints a base seed. Reproduce that run with: diff --git a/fuzz/futarchy/constants.py b/fuzz/futarchy/constants.py index a4277489..c960e3dd 100644 --- a/fuzz/futarchy/constants.py +++ b/fuzz/futarchy/constants.py @@ -135,7 +135,7 @@ PAYER_LAMPORTS = 100_000_000_000 INITIAL_ACTOR_BASE_BALANCE = 2_000_000 * TOKEN_SCALE INITIAL_ACTOR_QUOTE_BALANCE = 250_000 * TOKEN_SCALE -TRANSACTION_COMPUTE_UNIT_LIMIT = 400_000 +TRANSACTION_COMPUTE_UNIT_LIMIT = 1_400_000 SQUADS_INVALID_PROPOSAL_STATUS_ERROR_CODE = 6008 MARKET_TRADER_BASE_FUNDING = 1_000 * TOKEN_SCALE MARKET_TRADER_QUOTE_FUNDING = 1_000 * TOKEN_SCALE @@ -147,6 +147,6 @@ # Synthetic fuzz campaign size -SEQUENCES_COUNT = int(os.environ.get("FUTARCHY_FUZZ_SEQUENCES", "100")) -FLOWS_COUNT = int(os.environ.get("FUTARCHY_FUZZ_FLOWS", "500")) +SEQUENCES_COUNT = int(os.environ.get("FUZZ_SEQUENCES", "100")) +FLOWS_COUNT = int(os.environ.get("FUZZ_FLOWS", "500")) LIQUIDATION_FLOW_FRACTION = 0.60