diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24c5656..4da55ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,38 @@ jobs: working-directory: solana run: cargo fmt --all -- --check + # Guards against the committed source silently building for the wrong network. `declare_id!` + # is baked into the binary and checked at runtime against the deployment address, so a + # mainnet upgrade built from a source whose `declare_id` is not the mainnet program ID would + # reject every instruction with DeclaredProgramIdMismatch. The committed value must be the + # mainnet ID; the ephemeral-align step below rewrites it for localnet tests, and a devnet + # rehearsal patches it to [programs.devnet] (see solana/README.md). + - name: Verify declare_id matches the mainnet program ID + working-directory: solana + run: | + set -euo pipefail + DECLARED=$(sed -nE 's/^declare_id!\("([^"]+)"\).*/\1/p' \ + programs/stable-swapper/src/lib.rs) + MAINNET=$(awk ' + /^\[programs\.mainnet\]/ { f = 1; next } + /^\[/ { f = 0 } + f && /^stable_swapper/ { + gsub(/["[:space:]]/, "", $0); sub(/stable_swapper=/, "", $0); print; exit + } + ' Anchor.toml) + if [ -z "$DECLARED" ] || [ -z "$MAINNET" ]; then + echo "Could not read declare_id ('$DECLARED') or [programs.mainnet] ('$MAINNET')" >&2 + exit 1 + fi + if [ "$DECLARED" != "$MAINNET" ]; then + echo "declare_id ($DECLARED) != [programs.mainnet] ($MAINNET) in Anchor.toml." >&2 + echo "A mainnet build from this source would deploy the wrong program ID and brick" >&2 + echo "with DeclaredProgramIdMismatch on every instruction. Set declare_id to the" >&2 + echo "mainnet program ID before merging." >&2 + exit 1 + fi + echo "declare_id matches [programs.mainnet]: $DECLARED" + - name: Install JS dependencies run: yarn install --frozen-lockfile working-directory: solana @@ -141,4 +173,31 @@ jobs: - name: Run Anchor tests working-directory: solana - run: anchor test --provider.cluster localnet --skip-build + run: | + set -euo pipefail + + WALLET="$(solana address)" + + # `anchor test`'s built-in deploy leaves the program's upgrade authority set to a key + # other than the provider wallet, which trips initialize's NotUpgradeAuthority guard. + # Load the program into a validator we control -- upgradeable, with the provider wallet + # as the upgrade authority -- and have anchor run only the test script against it. + solana-test-validator --reset --quiet \ + --upgradeable-program target/deploy/stable_swapper-keypair.json \ + target/deploy/stable_swapper.so "$WALLET" & + VALIDATOR_PID=$! + trap 'kill "$VALIDATOR_PID" 2>/dev/null || true' EXIT + + # Wait for the validator RPC to accept requests before deploying/airdropping. + for _ in $(seq 1 60); do + if solana cluster-version --url http://127.0.0.1:8899 >/dev/null 2>&1; then + break + fi + sleep 1 + done + + # Fund the provider wallet so it can pay for test transactions. + solana airdrop 100 "$WALLET" --url http://127.0.0.1:8899 >/dev/null + + anchor test --provider.cluster localnet \ + --skip-build --skip-deploy --skip-local-validator diff --git a/audits/cantina_coinbase_stable_swapper_solana_aug2026.pdf b/audits/cantina_coinbase_stable_swapper_solana_aug2026.pdf new file mode 100644 index 0000000..893a072 Binary files /dev/null and b/audits/cantina_coinbase_stable_swapper_solana_aug2026.pdf differ diff --git a/solana/.gitignore b/solana/.gitignore index 01342bf..79f8358 100644 --- a/solana/.gitignore +++ b/solana/.gitignore @@ -15,3 +15,4 @@ test-ledger **/*-keypair.json **/id.json package-lock.json +.migration-verify diff --git a/solana/README.md b/solana/README.md index a7d20cc..077342d 100644 --- a/solana/README.md +++ b/solana/README.md @@ -1,83 +1,94 @@ # StableSwapper -- Solana -Solana / Anchor implementation of StableSwapper: a 1:1 stablecoin swap program that lets a custom stablecoin be paired with USDC (or other listed stablecoins) at a fixed rate with configurable fees, slippage protection, and administrative controls. +A production-ready Solana-based liquidity management system designed for secure, efficient 1:1 stablecoin swapping with configurable fees and comprehensive admin controls. -## Features +## 🏗️ Key Features -- **1:1 Token Swaps** -- Fixed-rate swapping between any listed stablecoins, with automatic decimal normalization -- **Dual Authority Model** -- Separate operations and pause authorities, both compatible with multisigs -- **Slippage Protection** -- User-supplied minimum output amount per swap -- **Granular Pause Controls** -- Independent pause flags for swaps and liquidity management -- **Configurable Fees** -- Admin-controlled fee rate (0--10%, capped at 1000 basis points) with a dedicated fee recipient -- **Multi-token Support** -- Add up to 50 supported tokens, each with its own vault. Token mints must have between 6 and 9 decimals. -- **Access Controls** -- Authority validation enforced via `has_one` constraints; the pool itself is a PDA +- ✅ **1:1 Token Swaps**: Guaranteed parity swapping between supported stablecoins +- ✅ **Role-Based Authority Model**: Four in-program roles split between cold and hot keys (Pause, Unpause, Treasury, Configure) +- ✅ **Withdraw Recipient Allowlist**: `withdraw_liquidity` can only target a token account owned by an allowlisted address; the allowlist is managed by the cold-key Configure Authority +- ✅ **Slippage Protection**: User-defined minimum output amounts prevent unexpected losses +- ✅ **Granular Pause Controls**: Independent pause flags for swaps, withdraws, and per-token; pausing is hot, unpausing is cold +- ✅ **Configurable Fees**: Cold-key controlled fee rates (0-10% max) with separate fee recipient +- ✅ **Multi-token Support**: Dynamic token addition with vault creation (up to 50 tokens) -## Project Structure +## 📁 Project Structure ``` -solana/ ├── programs/stable-swapper/ # Solana program (Rust/Anchor) -│ └── src/ -│ ├── lib.rs # Main program logic -│ ├── state.rs -│ ├── constants.rs -│ ├── errors.rs -│ └── utils.rs -├── tests/ # Program tests (Anchor / Mocha) -├── Anchor.toml # Anchor configuration -└── Cargo.toml # Workspace configuration +│ ├── src/ +│ │ ├── lib.rs # Instructions + account constraints +│ │ ├── state.rs # Pool / vault account layouts +│ │ ├── utils.rs # Decimal normalization (round-down) +│ │ ├── constants.rs +│ │ └── errors.rs +│ └── Cargo.toml +├── tests/ # Anchor / bankrun program tests +│ ├── stable-swapper.ts # RBAC roles, allowlist, swaps, pauses +│ ├── initialize-guards.ts # initialize role guards (bankrun) +│ └── migration.ts # Legacy → role-layout migrate_authorities +├── Anchor.toml # Anchor configuration +├── Cargo.toml # Workspace configuration +└── package.json # JS test / tooling deps ``` -## Prerequisites +Production deploy and one-shot `migrate_authorities` execution are handled outside +this package (internal tooling). This repo carries the on-chain program and its tests. -- **Rust** 1.70+ -- **Solana CLI** (Agave) 3.0+ +## 🚀 Getting Started + +### Prerequisites + +- **Rust** 1.70.0+ +- **Node.js** 18.0.0+ - **Anchor CLI** 0.31.1+ -- **Node.js** 18+ with Yarn +- **Solana CLI** 1.18.0+ -## Getting Started +### Installation 1. **Clone the repository** - -```bash -git clone https://github.com/coinbase/stable-swapper.git -cd stable-swapper/solana -``` + ```bash + git clone https://github.com/coinbase/stable-swapper.git + cd stable-swapper/solana + ``` 2. **Install dependencies** - -```bash -cargo build -yarn install --frozen-lockfile -``` + ```bash + # Install Rust dependencies + cargo build + ``` 3. **Configure Solana for development** + ```bash + # Set to devnet + solana config set --url devnet -```bash -# Set to devnet -solana config set --url devnet + # Create a keypair (if needed) + solana-keygen new --outfile ~/.config/solana/id.json -# Create a keypair (if needed) -solana-keygen new --outfile ~/.config/solana/id.json + # Airdrop SOL for testing + solana airdrop 2 + ``` -# Airdrop SOL for testing -solana airdrop 2 -``` - -## Development Workflow +## 🔧 Development Workflow -### Building the program +### Building the Solana Program ```bash +# Build the program anchor build + +# Deploy to devnet +anchor deploy --provider.cluster devnet ``` -### Running the test suite +### Running the Test Suite -The committed `declare_id!` and `[programs.devnet]` / `[programs.mainnet]` -entries in `Anchor.toml` point at the real deployed programs. To run the -Anchor / Mocha suite against a local validator, generate a throwaway keypair -and align all three references to it before building: +The committed `declare_id!` is the **mainnet** program ID (`pqgqK…`, matching +`[programs.mainnet]` and `[programs.localnet]`); `[programs.devnet]` points at +the separate devnet deployment. To run the Anchor / Mocha suite against a local +validator, generate a throwaway keypair and align all three references to it +before building: ```bash # Mint an ephemeral test keypair and align the program ID everywhere @@ -104,85 +115,114 @@ anchor test --provider.cluster localnet --skip-build git checkout -- programs/stable-swapper/src/lib.rs Anchor.toml ``` -### Deploying - -```bash -# Build verifiably -anchor build --verifiable - -# Deploy -anchor deploy --provider.cluster - -# Verify deployment -solana program show -``` - -Deployments, pool initialization, and authority management are performed via -out-of-repo tooling; the contract under `programs/` is the source of truth -for the on-chain behavior. - -## Program Architecture - -### Pool design - -The program uses a single centralized pool for all users and tokens: -- Pool PDA: `[b"liquidity_pool"]` (no authority in the seeds) -- Exactly one pool exists per program deployment -- All users interact with the same pool -- The operations authority controls the pool but does not "own" separate instances - -### Fee model - -Fees are charged on the **input token** (the token being swapped from). The user provides the full swap amount, which is split: - -- **Net amount** (after fee) → goes to the destination vault as liquidity -- **Fee amount** → goes to the fee recipient as protocol revenue - -Example: swap 100 USDC → custom stablecoin with a 1% fee: -- User transfers: 100 USDC total -- Vault receives: 99 USDC (liquidity) -- Fee recipient receives: 1 USDC (protocol fee) -- User receives: 99 custom-stablecoin (1:1 with the net amount) - -### Swap permissions - -- Swaps are permissionless when `swaps_paused` is `false` and both tokens are enabled. -- `user_from_token_account` does not need to be owned by `user`; the SPL Token program enforces that `user` is either the owner or a valid delegate. -- `to_token_account` may be any valid token account for the output mint, so delegated swaps can route output to a recipient chosen by the delegate. - -### Core instructions - -- **`initialize`** — Creates the pool with operations + pause authorities and fee configuration -- **`add_supported_token`** — Adds a token with its dedicated vault (operations authority). Mints with fewer than 6 or more than 9 decimals are rejected. -- **`remove_supported_token`** — Removes a token from the pool, closes its vault and vault token account, and refunds rent to the operations authority. Requires the token to be disabled and the vault to be empty. -- **`swap`** — Executes a 1:1 swap with slippage protection (`min_amount_out`) -- **`withdraw_liquidity`** — Removes liquidity from a vault (operations authority, gated by `liquidity_paused`) -- **`update_fee_config`** — Updates the fee rate and/or recipient (operations authority) -- **`update_pause_config`** — Controls `swaps_paused` and `liquidity_paused` (pause authority) -- **`update_token_status`** — Disables or enables a specific token for swaps (pause authority) -- **`update_operations_authority`** — Operations authority rotates itself -- **`update_pause_authority`** — Pause authority rotates itself +CI runs the equivalent of these steps in `.github/workflows/test.yml`. + +### Network Configuration + +The system is configured for **Solana Devnet** by default. To change networks: + +1. Point the Anchor provider at the target cluster in `Anchor.toml` (`[provider] cluster`), + or pass `--provider.cluster` on the command line. +2. Update your Solana CLI configuration: + ```bash + solana config set --url mainnet-beta # or devnet + ``` + +## 🏛️ Program Architecture + +### Design Philosophy + +**StableSwapper uses a single centralized pool** for all users and tokens: +- Pool PDA: `[b"liquidity_pool"]` (no authority in seeds) +- Only ONE pool exists per program deployment +- All users interact with the same global pool +- Authority controls the pool but doesn't "own" separate instances + +**Fee Model**: +- Fees are charged on the **input token** (the token being swapped FROM) +- User provides the full swap amount, which is split: + - **Net amount** (after fee) → goes to vault as liquidity + - **Fee amount** → goes to fee_recipient as protocol revenue +- Example: Swap 100 USDC → AppStable with 1% fee: + - User transfers: 100 USDC total + - Vault receives: 99 USDC (liquidity) + - Fee recipient receives: 1 USDC (protocol fee) + - User receives: 99 AppStable (1:1 with net amount) + +**Swap Account Model**: +- Swaps are permissionless when `swaps_paused` is false and both tokens are enabled +- `user_from_token_account` does not need to be owned by `user`; the SPL Token program enforces that `user` is either the owner or a valid delegate +- `to_token_account` may be any valid token account for the output mint, so delegated swaps can route output to a recipient chosen by the delegate + +### Roles + +| Role | Key class | Permissions | +| --- | --- | --- | +| Pause Authority | hot | `pause_swaps`, `pause_withdraws`, `pause_token` | +| Unpause Authority | cold | `unpause_swaps`, `unpause_withdraws`, `unpause_token` | +| Treasury Authority | hot | `withdraw_liquidity` (recipient must be on `withdraw_recipients` allowlist) | +| Configure Authority | cold | `add_supported_token`, `remove_supported_token`, `update_fee_rate`, `update_fee_recipient`, `add_withdraw_recipient`, `remove_withdraw_recipient` | +| Each role | (self) | `update__authority` (strict self-rotation) | + +The on-chain program upgrade authority is held by the BPF loader (rotate via `solana program set-upgrade-authority`) and is independent from the in-program roles above. It cannot exercise any of them, but it is the only key that can run the two pool-lifecycle instructions: `initialize` and `migrate_authorities` both require the payer to be the current upgrade authority. This is not extra privilege — a key that can deploy new bytecode to this program ID can already rewrite the pool account however it likes — but it does mean the upgrade authority alone can seed or redistribute every role, so it must be held to the same standard as the cold keys it assigns. + +### Core Instructions + +- **`initialize`**: Creates pool with the four role authorities, fee recipient, and a withdraw allowlist seeded with one recipient. Restricted to the program's upgrade authority: takes the program's `ProgramData` account (the BPF upgradeable loader PDA seeded by the program ID) and requires `upgrade_authority_address == payer`, so deploy and initialize are performed by the same key +- **`migrate_authorities`**: One-shot migration of an existing legacy pool to the role-based layout; seeds the withdraw allowlist with the provided recipient. Restricted to the program's upgrade authority on the same `ProgramData` check as `initialize`; the legacy `operations_authority` and `pause_authority` stored in the pool are overwritten and are not consulted. Invoked by internal migration tooling, not by in-repo CLIs. +- **`add_supported_token` / `remove_supported_token`**: Configure Authority manages supported tokens +- **`swap`**: Executes 1:1 swaps with slippage protection (`min_amount_out`) +- **`withdraw_liquidity`**: Treasury Authority withdraws to a token account whose owner is on the `withdraw_recipients` allowlist +- **`update_fee_rate` / `update_fee_recipient`**: Configure Authority updates the fee rate (basis points) and the fee recipient independently. The fee recipient cannot be set to the default pubkey, at creation or afterwards, since it is the token-account authority every fee is transferred to +- **`add_withdraw_recipient` / `remove_withdraw_recipient`**: Configure Authority manages the withdraw allowlist (up to 10 entries) +- **`pause_swaps` / `pause_withdraws` / `pause_token`**: Pause Authority puts the corresponding flag in the paused state +- **`unpause_swaps` / `unpause_withdraws` / `unpause_token`**: Unpause Authority clears the flag +- **`update__authority`**: Each role self-rotates (no cross-role rotation) Liquidity is seeded by sending tokens directly to the vault token account via an SPL Token transfer; there is no dedicated deposit instruction. -## Security - -### Access controls -- **Dual authority model**: separate operations and pause authorities (both multisig-ready) -- **Self-updating authorities**: each authority can only update itself -- **Authority validation**: enforced via program-derived addresses -- **Granular pause controls**: independent `swaps_paused` and `liquidity_paused` flags -- **Fee rate cap**: maximum 10% (1000 basis points) enforced at program level - -### Liquidity safety -- **Slippage protection**: users supply `min_amount_out` to prevent TOCTOU attacks -- **PDA-based validation**: accounts validated using program-derived addresses -- **Balance validation**: ensures sufficient vault balance before swaps and withdrawals -- **Overflow protection**: checked arithmetic throughout +## 🔐 Security Features + +### Access Controls +- **Deploy-gated lifecycle**: `initialize` and `migrate_authorities` are restricted to the program upgrade authority. The pool PDA has a fixed seed and no instruction can close it, so the first successful `initialize` claims the only pool a deployment will ever have; gating it removes the griefing window between deploy and initialize, whose only other remedy is redeploying at a new program ID. Note both instructions stop working once the program is made immutable +- **Four-role model**: Pause/Unpause/Treasury/Configure split across cold and hot keys +- **Strict self-rotation**: Each role rotates only itself; no role can take over another +- **Withdraw recipient allowlist**: `withdraw_liquidity` recipient must be a token account whose owner is on `pool.withdraw_recipients`; only the cold-key Configure Authority can add or remove entries, so a compromised hot Treasury key cannot redirect funds to a new address +- **Pausing is hot, unpausing is cold**: A compromised hot key can pause but cannot resume operations +- **Granular pause controls**: Independent `swaps_paused`, `liquidity_paused`, and per-token `disabled` flags +- **Fee rate cap**: Maximum 10% (1000 basis points) enforced at program level + +### Liquidity Safety +- **Slippage protection**: Users specify `min_amount_out` to prevent TOCTOU attacks +- **PDA-based validation**: Accounts validated using program-derived addresses +- **Balance validation**: Ensures sufficient vault balance before swaps and withdrawals +- **Overflow protection**: Checked arithmetic throughout + +### Error Handling +- **Comprehensive error codes**: Detailed error messages for debugging +- **Input validation**: All parameters validated at program level +- **Account ownership verification**: Fee recipient token accounts verified to match pool configuration + +## 🚀 Deployment + +Program binaries are built with Anchor and deployed via your usual Solana release +process (upgrade authority is independent of in-program roles). There are no +in-repo ops CLIs or deployment runbooks; use internal deploy / migration tooling +for production upgrades and `migrate_authorities`. + +**Program ID must match the deployment.** `declare_id!` is compiled into the +binary and checked at runtime against the address the program is deployed at; a +mismatch makes the program reject every instruction with +`DeclaredProgramIdMismatch`. The committed `declare_id!` is the **mainnet** ID, +so a mainnet build uses the source as-is and CI enforces +`declare_id == [programs.mainnet]`. A **devnet** build must first patch +`declare_id!` (and `[programs.localnet]`) to `[programs.devnet]`; otherwise a +devnet upgrade bricks the same way. -### Error handling -- **Comprehensive error codes**: detailed messages for debugging -- **Input validation**: all parameters validated at the program level -- **Account ownership verification**: fee recipient token accounts verified against the pool configuration +```bash +# Local / CI build +anchor build -For vulnerability disclosure, see the repository-root [`SECURITY.md`](../SECURITY.md). +# Inspect a deployed program +solana program show +``` diff --git a/solana/package.json b/solana/package.json index 5297855..0dbfc84 100644 --- a/solana/package.json +++ b/solana/package.json @@ -14,9 +14,11 @@ "@types/chai": "^4.3.0", "@types/mocha": "^9.0.0", "@types/node": "^25.0.3", + "anchor-bankrun": "^0.5.0", "chai": "^4.3.4", "mocha": "^9.0.3", "prettier": "^2.6.2", + "solana-bankrun": "^0.4.0", "ts-mocha": "^10.0.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/solana/programs/stable-swapper/src/constants.rs b/solana/programs/stable-swapper/src/constants.rs index be948c2..cc9397b 100644 --- a/solana/programs/stable-swapper/src/constants.rs +++ b/solana/programs/stable-swapper/src/constants.rs @@ -7,6 +7,11 @@ pub const MAX_FEE_RATE: u64 = 1000; /// Maximum number of supported tokens per pool pub const MAX_SUPPORTED_TOKENS: usize = 50; +/// Maximum number of allowlisted withdraw recipients per pool. +/// The treasury authority may only withdraw to a token account owned by one of +/// these addresses; only the cold-key configure authority can add or remove them. +pub const MAX_WITHDRAW_RECIPIENTS: usize = 10; + /// Minimum allowed token decimals pub const MIN_TOKEN_DECIMALS: u8 = 6; diff --git a/solana/programs/stable-swapper/src/errors.rs b/solana/programs/stable-swapper/src/errors.rs index a6180e8..e2ce4f4 100644 --- a/solana/programs/stable-swapper/src/errors.rs +++ b/solana/programs/stable-swapper/src/errors.rs @@ -4,8 +4,8 @@ use anchor_lang::prelude::*; pub enum LiquidityError { #[msg("Swaps are paused")] SwapsPaused, - #[msg("Liquidity management is paused")] - LiquidityPaused, + #[msg("Withdrawals are paused")] + WithdrawalPaused, #[msg("Invalid amount")] InvalidAmount, #[msg("Token not supported")] @@ -48,4 +48,28 @@ pub enum LiquidityError { TokenMustBeDisabled, #[msg("Vault must be empty before removing token")] VaultNotEmpty, + #[msg("Pool has already been migrated to the role-based authority layout")] + AlreadyMigrated, + #[msg("Recipient key must not be the default pubkey")] + RecipientNotSet, + #[msg("Withdraw recipient is not on the allowlist")] + WithdrawRecipientNotAllowed, + #[msg("Withdraw recipient is already on the allowlist")] + WithdrawRecipientAlreadyAllowed, + #[msg("Maximum number of withdraw recipients reached")] + MaxWithdrawRecipientsReached, + #[msg("Legacy pool data length does not match the expected pre-migration size")] + LegacySizeMismatch, + #[msg("Legacy pool discriminator does not match LiquidityPool")] + LegacyDiscriminatorMismatch, + #[msg("Legacy supported_tokens length is invalid")] + LegacyVecLengthInvalid, + #[msg("Failed to serialize the new LiquidityPool layout during migration")] + MigrationSerializeFailed, + #[msg("Authority key must not be the default pubkey")] + AuthorityNotSet, + #[msg("Program data account does not belong to this program")] + InvalidProgramData, + #[msg("Payer is not the program upgrade authority")] + NotUpgradeAuthority, } diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index 6d1c558..2f08e5c 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -12,7 +12,7 @@ use errors::*; use state::*; use utils::*; -declare_id!("9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH"); +declare_id!("pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F"); // NOTE: The previously deployed whitelist PDA (seeded b"address_whitelist") is orphaned // on devnet/mainnet after whitelist removal. Its rent is intentionally forfeited; adding a @@ -24,11 +24,27 @@ pub mod stable_swapper { pub fn initialize(ctx: Context, fee_rate: u64) -> Result<()> { require!(fee_rate <= MAX_FEE_RATE, LiquidityError::InvalidFeeRate); + // Reject the default pubkey for every stored key so the pool starts fully usable; the + // require_*_set helpers explain why a zero key is rejected. + require_recipient_set("withdraw_recipient", &ctx.accounts.withdraw_recipient.key())?; + require_recipient_set("fee_recipient", &ctx.accounts.fee_recipient.key())?; + require_authority_set("pause_authority", &ctx.accounts.pause_authority.key())?; + require_authority_set("unpause_authority", &ctx.accounts.unpause_authority.key())?; + require_authority_set("treasury_authority", &ctx.accounts.treasury_authority.key())?; + require_authority_set( + "configure_authority", + &ctx.accounts.configure_authority.key(), + )?; let pool = &mut ctx.accounts.pool; - pool.operations_authority = ctx.accounts.operations_authority.key(); pool.pause_authority = ctx.accounts.pause_authority.key(); + pool.unpause_authority = ctx.accounts.unpause_authority.key(); + pool.treasury_authority = ctx.accounts.treasury_authority.key(); + pool.configure_authority = ctx.accounts.configure_authority.key(); pool.fee_recipient = ctx.accounts.fee_recipient.key(); + // Seed the allowlist with the initial recipient so withdraws are possible from day one. + // Further recipients are managed by `configure_authority` via add/remove. + pool.withdraw_recipients = vec![ctx.accounts.withdraw_recipient.key()]; pool.supported_tokens = Vec::new(); pool.fee_rate = fee_rate; pool.swaps_paused = false; @@ -39,6 +55,34 @@ pub mod stable_swapper { Ok(()) } + /// One-shot migration from the legacy `(operations_authority, pause_authority)` layout + /// to the new role-based layout. Gated on the program upgrade authority. + /// + /// The pool grows from the legacy layout to `LiquidityPool::INIT_SPACE` (extra role keys + /// plus the withdraw-recipient allowlist slot). The legacy account is opened as + /// `UncheckedAccount` because the on-chain bytes don't deserialize into the new + /// `LiquidityPool` struct; we parse the legacy fields manually, realloc, then serialize + /// the new layout. Re-runs are rejected by checking the on-chain data length. + pub fn migrate_authorities( + ctx: Context, + new_pause_authority: Pubkey, + new_unpause_authority: Pubkey, + new_treasury_authority: Pubkey, + new_configure_authority: Pubkey, + new_withdraw_recipient: Pubkey, + ) -> Result<()> { + do_migrate_authorities( + &ctx.accounts.pool.to_account_info(), + &ctx.accounts.payer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + new_pause_authority, + new_unpause_authority, + new_treasury_authority, + new_configure_authority, + new_withdraw_recipient, + ) + } + pub fn add_supported_token(ctx: Context) -> Result<()> { let pool = &mut ctx.accounts.pool; let mint = ctx.accounts.mint.key(); @@ -64,7 +108,7 @@ pub mod stable_swapper { let vault = &mut ctx.accounts.vault; vault.mint = mint; // reserved_amount is layout-only and stays zero because Anchor initializes account data with zeroes. - vault.disabled = false; + vault.disabled = true; vault.bump = ctx.bumps.vault; msg!("Added supported token: {}", mint); @@ -79,12 +123,12 @@ pub mod stable_swapper { /// /// This instruction will: /// 1. Verify vault is empty - /// 2. Close vault_token_account and reclaim rent to operations_authority - /// 3. Close vault account and reclaim rent to operations_authority + /// 2. Close vault_token_account and reclaim rent to configure_authority + /// 3. Close vault account and reclaim rent to configure_authority /// 4. Remove token from supported_tokens vector /// /// Note: Anyone can send tokens directly to vault_token_account via SPL transfers. - /// To prevent griefing, operations_authority can always withdraw() any balance first. + /// To prevent griefing, treasury_authority can always withdraw() any balance first. pub fn remove_supported_token(ctx: Context) -> Result<()> { let pool = &mut ctx.accounts.pool; let vault = &ctx.accounts.vault; @@ -111,7 +155,7 @@ pub mod stable_swapper { ctx.accounts.token_program.to_account_info(), anchor_spl::token::CloseAccount { account: ctx.accounts.vault_token_account.to_account_info(), - destination: ctx.accounts.operations_authority.to_account_info(), + destination: ctx.accounts.configure_authority.to_account_info(), authority: pool.to_account_info(), }, &[&[LIQUIDITY_POOL_SEED, &[pool.bump]]], @@ -127,6 +171,7 @@ pub mod stable_swapper { let pool = &ctx.accounts.pool; require!(!pool.swaps_paused, LiquidityError::SwapsPaused); require!(amount_in > 0, LiquidityError::InvalidAmount); + require!(min_amount_out > 0, LiquidityError::InvalidAmount); // Check that neither token is disabled require!( @@ -259,10 +304,20 @@ pub mod stable_swapper { pub fn withdraw_liquidity(ctx: Context, amount: u64) -> Result<()> { let pool = &ctx.accounts.pool; - require!(!pool.liquidity_paused, LiquidityError::LiquidityPaused); + require!(!pool.liquidity_paused, LiquidityError::WithdrawalPaused); require!(amount > 0, LiquidityError::InvalidAmount); - // Ensure the operations authority does not overdraw the vault balance. + // The treasury (hot) key selects a destination by passing its token account; the program + // enforces that the account's owner is on the cold-key-managed allowlist. This prevents + // the treasury key from redirecting funds to an address it controls on its own. An empty + // allowlist therefore blocks all withdraws. + require!( + pool.withdraw_recipients + .contains(&ctx.accounts.recipient_token_account.owner), + LiquidityError::WithdrawRecipientNotAllowed + ); + + // Ensure the treasury authority does not overdraw the vault balance. require!( amount <= ctx.accounts.vault_token_account.amount, LiquidityError::InsufficientLiquidity @@ -287,60 +342,94 @@ pub mod stable_swapper { Ok(()) } - pub fn update_fee_config( + pub fn update_fee_rate(ctx: Context, fee_rate: u64) -> Result<()> { + require!(fee_rate <= MAX_FEE_RATE, LiquidityError::InvalidFeeRate); + ctx.accounts.pool.fee_rate = fee_rate; + msg!("Updated fee rate to: {}", fee_rate); + Ok(()) + } + + /// Changing the fee recipient only affects future swaps. Fees already collected in the old + /// recipient's token accounts remain owned by the previous recipient. + pub fn update_fee_recipient( ctx: Context, - fee_rate: Option, - fee_recipient: Option, + fee_recipient: Pubkey, ) -> Result<()> { - let pool = &mut ctx.accounts.pool; - - if let Some(new_fee_rate) = fee_rate { - require!(new_fee_rate <= MAX_FEE_RATE, LiquidityError::InvalidFeeRate); - pool.fee_rate = new_fee_rate; - msg!("Updated fee rate to: {}", new_fee_rate); - } - - if let Some(new_fee_recipient) = fee_recipient { - // Note: Changing the fee recipient only affects future swaps. - // Fees already collected in the old recipient's token accounts - // remain owned by the previous recipient. - pool.fee_recipient = new_fee_recipient; - msg!("Updated fee recipient to: {}", new_fee_recipient); - } + require_recipient_set("fee_recipient", &fee_recipient)?; + ctx.accounts.pool.fee_recipient = fee_recipient; + msg!("Updated fee recipient to: {}", fee_recipient); + Ok(()) + } + /// Adds an owner to the withdraw-recipient allowlist. Only `configure_authority` (cold key). + pub fn add_withdraw_recipient( + ctx: Context, + recipient: Pubkey, + ) -> Result<()> { + require_recipient_set("withdraw_recipient", &recipient)?; + let pool = &mut ctx.accounts.pool; + require!( + !pool.withdraw_recipients.contains(&recipient), + LiquidityError::WithdrawRecipientAlreadyAllowed + ); + require!( + pool.withdraw_recipients.len() < MAX_WITHDRAW_RECIPIENTS, + LiquidityError::MaxWithdrawRecipientsReached + ); + pool.withdraw_recipients.push(recipient); + msg!("Added withdraw recipient: {}", recipient); Ok(()) } - pub fn update_pause_config( - ctx: Context, - swaps_paused: Option, - liquidity_paused: Option, + /// Removes an owner from the withdraw-recipient allowlist. Only `configure_authority` (cold key). + pub fn remove_withdraw_recipient( + ctx: Context, + recipient: Pubkey, ) -> Result<()> { let pool = &mut ctx.accounts.pool; + let position = pool + .withdraw_recipients + .iter() + .position(|&r| r == recipient) + .ok_or(LiquidityError::WithdrawRecipientNotAllowed)?; + pool.withdraw_recipients.swap_remove(position); + msg!("Removed withdraw recipient: {}", recipient); + Ok(()) + } - if let Some(new_swaps_paused) = swaps_paused { - pool.swaps_paused = new_swaps_paused; - msg!("Updated swaps_paused to: {}", new_swaps_paused); - } + pub fn pause_swaps(ctx: Context) -> Result<()> { + ctx.accounts.pool.swaps_paused = true; + msg!("Swaps paused"); + Ok(()) + } - if let Some(new_liquidity_paused) = liquidity_paused { - pool.liquidity_paused = new_liquidity_paused; - msg!("Updated liquidity_paused to: {}", new_liquidity_paused); - } + pub fn unpause_swaps(ctx: Context) -> Result<()> { + ctx.accounts.pool.swaps_paused = false; + msg!("Swaps unpaused"); + Ok(()) + } + pub fn pause_withdraws(ctx: Context) -> Result<()> { + ctx.accounts.pool.liquidity_paused = true; + msg!("Withdraws paused"); Ok(()) } - pub fn update_operations_authority( - ctx: Context, - new_operations_authority: Pubkey, - ) -> Result<()> { - let pool = &mut ctx.accounts.pool; - pool.operations_authority = new_operations_authority; - msg!( - "Updated operations_authority to: {}", - new_operations_authority - ); + pub fn unpause_withdraws(ctx: Context) -> Result<()> { + ctx.accounts.pool.liquidity_paused = false; + msg!("Withdraws unpaused"); + Ok(()) + } + + pub fn pause_token(ctx: Context) -> Result<()> { + ctx.accounts.vault.disabled = true; + msg!("Token {} paused", ctx.accounts.mint.key()); + Ok(()) + } + + pub fn unpause_token(ctx: Context) -> Result<()> { + ctx.accounts.vault.disabled = false; + msg!("Token {} unpaused", ctx.accounts.mint.key()); Ok(()) } @@ -348,27 +437,220 @@ pub mod stable_swapper { ctx: Context, new_pause_authority: Pubkey, ) -> Result<()> { + require_authority_set("pause_authority", &new_pause_authority)?; let pool = &mut ctx.accounts.pool; pool.pause_authority = new_pause_authority; msg!("Updated pause_authority to: {}", new_pause_authority); Ok(()) } - /// Disables or enables a token for swaps. - /// Useful for emergency response or to deprecate tokens for operational reasons. - pub fn update_token_status(ctx: Context, disabled: bool) -> Result<()> { - let vault = &mut ctx.accounts.vault; - vault.disabled = disabled; + pub fn update_unpause_authority( + ctx: Context, + new_unpause_authority: Pubkey, + ) -> Result<()> { + require_authority_set("unpause_authority", &new_unpause_authority)?; + let pool = &mut ctx.accounts.pool; + pool.unpause_authority = new_unpause_authority; + msg!("Updated unpause_authority to: {}", new_unpause_authority); + Ok(()) + } + pub fn update_treasury_authority( + ctx: Context, + new_treasury_authority: Pubkey, + ) -> Result<()> { + require_authority_set("treasury_authority", &new_treasury_authority)?; + let pool = &mut ctx.accounts.pool; + pool.treasury_authority = new_treasury_authority; + msg!("Updated treasury_authority to: {}", new_treasury_authority); + Ok(()) + } + + pub fn update_configure_authority( + ctx: Context, + new_configure_authority: Pubkey, + ) -> Result<()> { + require_authority_set("configure_authority", &new_configure_authority)?; + let pool = &mut ctx.accounts.pool; + pool.configure_authority = new_configure_authority; msg!( - "Updated token {} status to disabled: {}", - ctx.accounts.mint.key(), - disabled + "Updated configure_authority to: {}", + new_configure_authority ); Ok(()) } } +/// Rejects the default pubkey for a key stored on the pool, failing with `error_code`. `field` +/// names the offending key, since a bare comparison logs the same zero key on both sides. +fn require_key_set(field: &str, key: &Pubkey, error_code: LiquidityError) -> Result<()> { + if *key == Pubkey::default() { + msg!("{} must not be set to the default pubkey", field); + return Err(error_code.into()); + } + Ok(()) +} + +/// Rejects the default pubkey for a signing role. Roles can only be rotated by their current +/// holder, so a zero-key role is a one-way door: no signature exists for it. +fn require_authority_set(field: &str, key: &Pubkey) -> Result<()> { + require_key_set(field, key, LiquidityError::AuthorityNotSet) +} + +/// Rejects the default pubkey for a payout destination (fee or withdraw recipient). Unlike a +/// role, a recipient never signs; a zero key just routes funds to a token account nobody can +/// sign for. +fn require_recipient_set(field: &str, key: &Pubkey) -> Result<()> { + require_key_set(field, key, LiquidityError::RecipientNotSet) +} + +/// Shared body for `migrate_authorities`: legacy parse, realloc, rent top-up, re-serialize. +/// The Accounts struct on the calling instruction is responsible for verifying the pool address +/// (the canonical PDA) and that the caller is the program upgrade authority. +/// +/// Rent: the pool grows by the two extra role keys plus the withdraw-recipient allowlist, +/// and `payer_ai` pays the difference through a +/// `system_program::transfer` CPI. It must therefore be a system-owned account holding enough +/// lamports (~0.0027 SOL at the current rent rate). When the pool PDA already holds +/// `Rent::minimum_balance` for the new size the top-up is skipped entirely and no lamports are +/// needed, which is the way to migrate when the upgrade authority is a program-owned account +/// (a multisig PDA, say) that cannot be debited by the system program. +fn do_migrate_authorities<'info>( + pool_ai: &AccountInfo<'info>, + payer_ai: &AccountInfo<'info>, + system_program_ai: &AccountInfo<'info>, + new_pause_authority: Pubkey, + new_unpause_authority: Pubkey, + new_treasury_authority: Pubkey, + new_configure_authority: Pubkey, + new_withdraw_recipient: Pubkey, +) -> Result<()> { + require_recipient_set("withdraw_recipient", &new_withdraw_recipient)?; + require_authority_set("pause_authority", &new_pause_authority)?; + require_authority_set("unpause_authority", &new_unpause_authority)?; + require_authority_set("treasury_authority", &new_treasury_authority)?; + require_authority_set("configure_authority", &new_configure_authority)?; + + let legacy_total = 8 + LiquidityPool::LEGACY_INIT_SPACE; + let new_total = 8 + LiquidityPool::INIT_SPACE; + + // Defense-in-depth: `UncheckedAccount` does not enforce ownership. Reject any account not + // owned by this program before we start parsing its bytes. + require_keys_eq!( + *pool_ai.owner, + crate::ID, + ErrorCode::AccountOwnedByWrongProgram + ); + + // Re-run guard: after a successful migration the account is `new_total` bytes, so a second + // invocation is reported as already migrated. Any other unexpected size is a different + // failure and gets its own error. + require!( + pool_ai.data_len() != new_total, + LiquidityError::AlreadyMigrated + ); + require!( + pool_ai.data_len() == legacy_total, + LiquidityError::LegacySizeMismatch + ); + + // Snapshot legacy fields with a scoped borrow so we can drop it before realloc. + let (legacy_fee_recipient, supported_tokens, fee_rate, swaps_paused, liquidity_paused, bump) = { + let data = pool_ai.try_borrow_data()?; + require!( + &data[..8] == LiquidityPool::DISCRIMINATOR, + LiquidityError::LegacyDiscriminatorMismatch + ); + + // Bytes 8..72 hold the legacy operations and pause authorities. Both are superseded by + // the role keys passed to this instruction, so they are skipped rather than read. + // `Pubkey::try_from` on a 32-byte slice is infallible; the slice length is fixed + // here by construction, so unwrap is safe. + let legacy_fee_recipient = Pubkey::try_from(&data[72..104]).unwrap(); + + // supported_tokens vec: 4-byte length + 32-byte pubkeys, max-allocated to MAX_SUPPORTED_TOKENS + let len = u32::from_le_bytes(data[104..108].try_into().unwrap()) as usize; + require!( + len <= MAX_SUPPORTED_TOKENS, + LiquidityError::LegacyVecLengthInvalid + ); + let mut tokens = Vec::with_capacity(len); + for i in 0..len { + let off = 108 + i * 32; + tokens.push(Pubkey::try_from(&data[off..off + 32]).unwrap()); + } + + // Borsh serializes a `Vec` packed (4-byte len + len * element_size), NOT padded to its + // allocated capacity. The trailing fixed-size fields therefore sit immediately after the + // `len` actual token entries, not after the max-sized slot. + let trailing = 108 + len * 32; + let fee_rate = u64::from_le_bytes(data[trailing..trailing + 8].try_into().unwrap()); + let swaps_paused = data[trailing + 8] != 0; + let liquidity_paused = data[trailing + 9] != 0; + let bump = data[trailing + 10]; + + ( + legacy_fee_recipient, + tokens, + fee_rate, + swaps_paused, + liquidity_paused, + bump, + ) + }; + + require_recipient_set("fee_recipient", &legacy_fee_recipient)?; + + // Top up rent for the additional bytes, then grow the account. This is a no-op when the + // pool already holds the new minimum balance. + let rent = Rent::get()?; + let new_min_balance = rent.minimum_balance(new_total); + let lamports_diff = new_min_balance.saturating_sub(pool_ai.lamports()); + if lamports_diff > 0 { + anchor_lang::system_program::transfer( + CpiContext::new( + system_program_ai.clone(), + anchor_lang::system_program::Transfer { + from: payer_ai.clone(), + to: pool_ai.clone(), + }, + ), + lamports_diff, + )?; + } + pool_ai.resize(new_total)?; + + // Serialize the new layout over the entire account. + let new_pool = LiquidityPool { + pause_authority: new_pause_authority, + unpause_authority: new_unpause_authority, + treasury_authority: new_treasury_authority, + configure_authority: new_configure_authority, + fee_recipient: legacy_fee_recipient, + withdraw_recipients: vec![new_withdraw_recipient], + supported_tokens, + fee_rate, + swaps_paused, + liquidity_paused, + bump, + }; + + { + let mut data = pool_ai.try_borrow_mut_data()?; + data.fill(0); + // Discriminator is the same before and after migration; rewrite it explicitly + // and then borsh-serialize the struct body. + data[..8].copy_from_slice(LiquidityPool::DISCRIMINATOR); + let mut writer: &mut [u8] = &mut data[8..]; + new_pool + .serialize(&mut writer) + .map_err(|_| error!(LiquidityError::MigrationSerializeFailed))?; + } + + msg!("Migrated pool authorities to role-based layout"); + Ok(()) +} + // Instruction contexts #[derive(Accounts)] pub struct Initialize<'info> { @@ -384,15 +666,80 @@ pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'info>, - /// CHECK: Operations authority can be any account - pub operations_authority: UncheckedAccount<'info>, + /// This program's `ProgramData` account, which carries the BPF loader's upgrade authority. + /// + /// The pool is a PDA of a fixed seed and no instruction can close it, so the first successful + /// `initialize` claims the only pool this deployment will ever have. Left permissionless that + /// is a griefing vector whose only remedy is redeploying at a new program ID, so pool creation + /// is tied to the key that deploys the program. `Account` enforces the BPF + /// upgradeable loader as owner and rejects the other loader states; the address constraint + /// pins it to this program's record. + /// + /// Note this makes `initialize` unavailable once the program is made immutable + /// (`upgrade_authority_address == None`), which is intended: initialize and deploy belong to + /// the same operation. + #[account( + constraint = program_data.key() == program_data_address() + @ LiquidityError::InvalidProgramData, + constraint = program_data.upgrade_authority_address == Some(payer.key()) + @ LiquidityError::NotUpgradeAuthority, + )] + pub program_data: Account<'info, ProgramData>, /// CHECK: Pause authority can be any account pub pause_authority: UncheckedAccount<'info>, + /// CHECK: Unpause authority can be any account + pub unpause_authority: UncheckedAccount<'info>, + + /// CHECK: Treasury authority can be any account + pub treasury_authority: UncheckedAccount<'info>, + + /// CHECK: Configure authority can be any account + pub configure_authority: UncheckedAccount<'info>, + /// CHECK: Fee recipient can be any account pub fee_recipient: UncheckedAccount<'info>, + /// CHECK: Withdraw recipient can be any account; only its key matters. It seeds the withdraw + /// allowlist, which `configure_authority` manages via add/remove after initialization. + pub withdraw_recipient: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct MigrateAuthorities<'info> { + /// Pool is opened as `UncheckedAccount` because the on-chain legacy bytes don't fit the + /// new `LiquidityPool` struct. The instruction body verifies the discriminator + PDA + /// derivation, parses the legacy fields, reallocates, and rewrites the new layout. + /// CHECK: PDA + discriminator + legacy size verified inside `migrate_authorities`. + #[account( + mut, + seeds = [LIQUIDITY_POOL_SEED], + bump, + )] + pub pool: UncheckedAccount<'info>, + + /// Must be the program upgrade authority, as enforced against `program_data` below. Also + /// pays the additional rent for the realloc unless the pool is already funded to the new + /// minimum balance. See `do_migrate_authorities` for the rent details. + #[account(mut)] + pub payer: Signer<'info>, + + /// This program's `ProgramData` account. Same gate as `initialize`, for the same reason the + /// upgrade authority is the right key here: it can rewrite the pool wholesale by deploying + /// new code, so a migration it authorizes grants it nothing it did not already have. The + /// legacy authorities recorded in the pool are not consulted, which keeps hot keys out of + /// the operation. + #[account( + constraint = program_data.key() == program_data_address() + @ LiquidityError::InvalidProgramData, + constraint = program_data.upgrade_authority_address == Some(payer.key()) + @ LiquidityError::NotUpgradeAuthority, + )] + pub program_data: Account<'info, ProgramData>, + pub system_program: Program<'info, System>, } @@ -400,7 +747,7 @@ pub struct Initialize<'info> { pub struct AddSupportedToken<'info> { #[account( mut, - has_one = operations_authority, + has_one = configure_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] @@ -408,7 +755,7 @@ pub struct AddSupportedToken<'info> { #[account( init, - payer = operations_authority, + payer = configure_authority, space = 8 + TokenVault::INIT_SPACE, seeds = [TOKEN_VAULT_SEED, pool.key().as_ref(), mint.key().as_ref()], bump @@ -417,7 +764,7 @@ pub struct AddSupportedToken<'info> { #[account( init, - payer = operations_authority, + payer = configure_authority, token::mint = mint, token::authority = pool, seeds = [VAULT_TOKEN_ACCOUNT_SEED, vault.key().as_ref()], @@ -427,7 +774,7 @@ pub struct AddSupportedToken<'info> { #[account( init_if_needed, - payer = operations_authority, + payer = configure_authority, associated_token::mint = mint, associated_token::authority = fee_recipient )] @@ -440,7 +787,7 @@ pub struct AddSupportedToken<'info> { pub mint: Account<'info, Mint>, #[account(mut)] - pub operations_authority: Signer<'info>, + pub configure_authority: Signer<'info>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, @@ -452,7 +799,7 @@ pub struct AddSupportedToken<'info> { pub struct RemoveSupportedToken<'info> { #[account( mut, - has_one = operations_authority, + has_one = configure_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] @@ -460,7 +807,7 @@ pub struct RemoveSupportedToken<'info> { #[account( mut, - close = operations_authority, + close = configure_authority, seeds = [TOKEN_VAULT_SEED, pool.key().as_ref(), mint.key().as_ref()], bump = vault.bump )] @@ -476,7 +823,7 @@ pub struct RemoveSupportedToken<'info> { pub mint: Account<'info, Mint>, #[account(mut)] - pub operations_authority: Signer<'info>, + pub configure_authority: Signer<'info>, pub token_program: Program<'info, Token>, } @@ -568,7 +915,7 @@ pub struct Swap<'info> { #[derive(Accounts)] pub struct WithdrawLiquidity<'info> { #[account( - has_one = operations_authority, + has_one = treasury_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] @@ -587,6 +934,9 @@ pub struct WithdrawLiquidity<'info> { )] pub vault_token_account: Account<'info, TokenAccount>, + /// Destination token account. Its owner must be on `pool.withdraw_recipients` (enforced in + /// the instruction body). This prevents the treasury (hot) key from redirecting funds to an + /// attacker-controlled wallet on its own; only the cold configure authority manages the list. #[account( mut, token::mint = mint, @@ -595,7 +945,7 @@ pub struct WithdrawLiquidity<'info> { pub mint: Account<'info, Mint>, - pub operations_authority: Signer<'info>, + pub treasury_authority: Signer<'info>, pub token_program: Program<'info, Token>, } @@ -604,17 +954,30 @@ pub struct WithdrawLiquidity<'info> { pub struct UpdateFeeConfig<'info> { #[account( mut, - has_one = operations_authority, + has_one = configure_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] pub pool: Account<'info, LiquidityPool>, - pub operations_authority: Signer<'info>, + pub configure_authority: Signer<'info>, } #[derive(Accounts)] -pub struct UpdatePauseConfig<'info> { +pub struct ConfigureWithdrawRecipients<'info> { + #[account( + mut, + has_one = configure_authority, + seeds = [LIQUIDITY_POOL_SEED], + bump = pool.bump + )] + pub pool: Account<'info, LiquidityPool>, + + pub configure_authority: Signer<'info>, +} + +#[derive(Accounts)] +pub struct PauseAction<'info> { #[account( mut, has_one = pause_authority, @@ -627,35 +990,43 @@ pub struct UpdatePauseConfig<'info> { } #[derive(Accounts)] -pub struct UpdateOperationsAuthority<'info> { +pub struct UnpauseAction<'info> { #[account( mut, - has_one = operations_authority, + has_one = unpause_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] pub pool: Account<'info, LiquidityPool>, - pub operations_authority: Signer<'info>, + pub unpause_authority: Signer<'info>, } #[derive(Accounts)] -pub struct UpdatePauseAuthority<'info> { +pub struct PauseToken<'info> { #[account( - mut, has_one = pause_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] pub pool: Account<'info, LiquidityPool>, + #[account( + mut, + seeds = [TOKEN_VAULT_SEED, pool.key().as_ref(), mint.key().as_ref()], + bump = vault.bump + )] + pub vault: Account<'info, TokenVault>, + + pub mint: Account<'info, Mint>, + pub pause_authority: Signer<'info>, } #[derive(Accounts)] -pub struct UpdateTokenStatus<'info> { +pub struct UnpauseToken<'info> { #[account( - has_one = pause_authority, + has_one = unpause_authority, seeds = [LIQUIDITY_POOL_SEED], bump = pool.bump )] @@ -670,5 +1041,57 @@ pub struct UpdateTokenStatus<'info> { pub mint: Account<'info, Mint>, + pub unpause_authority: Signer<'info>, +} + +#[derive(Accounts)] +pub struct UpdatePauseAuthority<'info> { + #[account( + mut, + has_one = pause_authority, + seeds = [LIQUIDITY_POOL_SEED], + bump = pool.bump + )] + pub pool: Account<'info, LiquidityPool>, + pub pause_authority: Signer<'info>, } + +#[derive(Accounts)] +pub struct UpdateUnpauseAuthority<'info> { + #[account( + mut, + has_one = unpause_authority, + seeds = [LIQUIDITY_POOL_SEED], + bump = pool.bump + )] + pub pool: Account<'info, LiquidityPool>, + + pub unpause_authority: Signer<'info>, +} + +#[derive(Accounts)] +pub struct UpdateTreasuryAuthority<'info> { + #[account( + mut, + has_one = treasury_authority, + seeds = [LIQUIDITY_POOL_SEED], + bump = pool.bump + )] + pub pool: Account<'info, LiquidityPool>, + + pub treasury_authority: Signer<'info>, +} + +#[derive(Accounts)] +pub struct UpdateConfigureAuthority<'info> { + #[account( + mut, + has_one = configure_authority, + seeds = [LIQUIDITY_POOL_SEED], + bump = pool.bump + )] + pub pool: Account<'info, LiquidityPool>, + + pub configure_authority: Signer<'info>, +} diff --git a/solana/programs/stable-swapper/src/state.rs b/solana/programs/stable-swapper/src/state.rs index 3b17a5f..a78ed74 100644 --- a/solana/programs/stable-swapper/src/state.rs +++ b/solana/programs/stable-swapper/src/state.rs @@ -1,11 +1,27 @@ -use crate::constants::MAX_SUPPORTED_TOKENS; +use crate::constants::{MAX_SUPPORTED_TOKENS, MAX_WITHDRAW_RECIPIENTS}; use anchor_lang::prelude::*; +// Discriminator-stability invariant: Anchor derives the 8-byte account discriminator from +// the struct *name*, not its field layout. The `migrate_authorities` instruction relies on +// the discriminator being identical before and after migration, which means this struct +// MUST stay named `LiquidityPool`. Renaming it would break re-deserialization of every +// existing pool on devnet/mainnet and break the migration's discriminator check. #[account] pub struct LiquidityPool { - pub operations_authority: Pubkey, + /// Hot key allowed to pause swaps, withdraws, and individual tokens. pub pause_authority: Pubkey, + /// Cold key allowed to unpause swaps, withdraws, and individual tokens. + pub unpause_authority: Pubkey, + /// Hot key allowed to withdraw liquidity (only to an allowlisted owner in `withdraw_recipients`). + pub treasury_authority: Pubkey, + /// Cold key allowed to list/unlist tokens, update fee config, and manage the withdraw_recipients allowlist. + pub configure_authority: Pubkey, + /// Recipient of swap fees (token transfers go to its ATA per mint). pub fee_recipient: Pubkey, + /// Allowlist of owners whose token accounts may receive `withdraw_liquidity` outputs. + /// The treasury authority selects any one of these per withdraw; only `configure_authority` + /// can add or remove entries. + pub withdraw_recipients: Vec, pub supported_tokens: Vec, pub fee_rate: u64, // in basis points pub swaps_paused: bool, @@ -16,7 +32,21 @@ pub struct LiquidityPool { } impl LiquidityPool { - pub const INIT_SPACE: usize = 32 + 32 + 32 + (4 + 32 * MAX_SUPPORTED_TOKENS) + 8 + 1 + 1 + 1; // operations_authority + pause_authority + fee_recipient + supported_tokens + fee_rate + swaps_paused + liquidity_paused + bump + // 5 Pubkeys (pause/unpause/treasury/configure/fee_recipient) + // + withdraw_recipients vec header + cap + // + supported_tokens vec header + cap + // + fee_rate + 2 bools + bump + pub const INIT_SPACE: usize = 32 * 5 + + (4 + 32 * MAX_WITHDRAW_RECIPIENTS) + + (4 + 32 * MAX_SUPPORTED_TOKENS) + + 8 + + 1 + + 1 + + 1; + + /// Pre-migration on-chain layout: ops + pause + fee_recipient + supported_tokens + fee_rate + 2 bools + bump. + /// Used by `migrate_authorities` to size the pre-realloc account before expanding to `INIT_SPACE`. + pub const LEGACY_INIT_SPACE: usize = 32 * 3 + (4 + 32 * MAX_SUPPORTED_TOKENS) + 8 + 1 + 1 + 1; } #[account] diff --git a/solana/programs/stable-swapper/src/utils.rs b/solana/programs/stable-swapper/src/utils.rs index 6856381..1b47245 100644 --- a/solana/programs/stable-swapper/src/utils.rs +++ b/solana/programs/stable-swapper/src/utils.rs @@ -1,6 +1,15 @@ use crate::errors::LiquidityError; use anchor_lang::prelude::*; +/// Address of this program's `ProgramData` account under the BPF upgradeable loader. +/// +/// Deriving it here rather than taking the program account as an extra input keeps the +/// caller's account list minimal: the seed is `crate::ID`, which Anchor already pins to the +/// executing program, so there is no way to point the check at another program's authority. +pub fn program_data_address() -> Pubkey { + Pubkey::find_program_address(&[crate::ID.as_ref()], &ProgramData::owner()).0 +} + /// Normalizes an amount from source decimals to destination decimals. /// Uses round-down (floor) strategy to favor the protocol. /// diff --git a/solana/tests/initialize-guards.ts b/solana/tests/initialize-guards.ts new file mode 100644 index 0000000..79de105 --- /dev/null +++ b/solana/tests/initialize-guards.ts @@ -0,0 +1,229 @@ +/** + * Guards on `initialize` that cannot be exercised against the shared localnet pool in + * stable-swapper.ts: the pool is a PDA of a fixed seed, so it can only be initialized once per + * program. Each test here gets a fresh `solana-bankrun` context with no pool account, which + * lets us assert that a bad role key is rejected at creation time rather than baked into a + * pool that can never be repaired. + */ +import * as path from "path"; +import { Program, BN } from "@coral-xyz/anchor"; +import { PublicKey, Keypair, SystemProgram } from "@solana/web3.js"; +import { start, ProgramTestContext } from "solana-bankrun"; +import { BankrunProvider } from "anchor-bankrun"; +import { assert } from "chai"; +import { StableSwapper } from "../target/types/stable_swapper"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const IDL = require("../target/idl/stable_swapper.json"); + +const LIQUIDITY_POOL_SEED = Buffer.from("liquidity_pool"); +const BPF_LOADER_UPGRADEABLE = new PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111" +); + +// `solana-bankrun` registers programs under the non-upgradeable loader, so no ProgramData +// account exists in the test SVM. `initialize` reads one to find the upgrade authority, so +// each test installs it by hand, exactly as the BPF upgradeable loader would lay it out: +// bincode-encoded `UpgradeableLoaderState::ProgramData` -- a u32 variant tag (3), a u64 slot, +// then `Option` as a one-byte tag plus the key. A real account carries the program ELF +// after this header; the extra bytes are ignored either way. +function programDataBytes(upgradeAuthority: PublicKey | null): Buffer { + const buf = Buffer.alloc(45); + buf.writeUInt32LE(3, 0); + buf.writeBigUInt64LE(BigInt(0), 4); + if (upgradeAuthority) { + buf.writeUInt8(1, 12); + upgradeAuthority.toBuffer().copy(buf, 13); + } + return buf; +} + +interface InitializeRoles { + pauseAuthority: PublicKey; + unpauseAuthority: PublicKey; + treasuryAuthority: PublicKey; + configureAuthority: PublicKey; + feeRecipient: PublicKey; + withdrawRecipient: PublicKey; +} + +function errText(error: any): string { + const logs = Array.isArray(error?.logs) ? error.logs.join("\n") : ""; + return `${logs}\n${error?.transactionMessage ?? ""}\n${error}`.toLowerCase(); +} + +describe("initialize role guards (bankrun)", () => { + let context: ProgramTestContext; + let program: Program; + let programId: PublicKey; + let pool: PublicKey; + let programData: PublicKey; + let payer: PublicKey; + + beforeEach(async () => { + // A fresh SVM per test: `initialize` only succeeds while the pool PDA is empty. + const deployDir = path.resolve(__dirname, "..", "target", "deploy"); + process.env.SBF_OUT_DIR = deployDir; + process.env.BPF_OUT_DIR = deployDir; + + programId = new PublicKey(IDL.address); + context = await start([{ name: "stable_swapper", programId }], []); + const provider = new BankrunProvider(context); + program = new Program(IDL as StableSwapper, provider); + payer = provider.wallet.publicKey; + [pool] = PublicKey.findProgramAddressSync([LIQUIDITY_POOL_SEED], programId); + [programData] = PublicKey.findProgramAddressSync( + [programId.toBuffer()], + BPF_LOADER_UPGRADEABLE + ); + + // Happy path: the provider wallet, which pays for every test here, is the upgrade authority. + setProgramData(programData, payer); + }); + + function setProgramData(address: PublicKey, authority: PublicKey | null) { + context.setAccount(address, { + lamports: 1_000_000_000, + data: programDataBytes(authority), + owner: BPF_LOADER_UPGRADEABLE, + executable: false, + rentEpoch: 0, + }); + } + + function fundSystemAccount(key: PublicKey) { + context.setAccount(key, { + lamports: 1_000_000_000, + data: Buffer.alloc(0), + owner: SystemProgram.programId, + executable: false, + rentEpoch: 0, + }); + } + + function defaultRoles(): InitializeRoles { + return { + pauseAuthority: Keypair.generate().publicKey, + unpauseAuthority: Keypair.generate().publicKey, + treasuryAuthority: Keypair.generate().publicKey, + configureAuthority: Keypair.generate().publicKey, + feeRecipient: Keypair.generate().publicKey, + withdrawRecipient: Keypair.generate().publicKey, + }; + } + + interface InitializeOverrides { + payer?: PublicKey; + programData?: PublicKey; + signers?: Keypair[]; + } + + function initialize( + roles: InitializeRoles, + overrides: InitializeOverrides = {} + ) { + return program.methods + .initialize(new BN(0)) + .accounts({ + pool, + payer: overrides.payer ?? payer, + programData: overrides.programData ?? programData, + ...roles, + systemProgram: SystemProgram.programId, + }) + .signers(overrides.signers ?? []) + .rpc(); + } + + async function assertInitializeFails( + expected: string, + roles: InitializeRoles, + overrides: InitializeOverrides = {} + ) { + // Tracked with a flag rather than `assert.fail` inside the `try`: the assertion message + // would contain `expected` and the `catch` below would then match against itself. + let succeeded = false; + try { + await initialize(roles, overrides); + succeeded = true; + } catch (error) { + assert.include(errText(error), expected); + } + assert.isFalse(succeeded, `initialize succeeded, expected ${expected}`); + + const acct = await context.banksClient.getAccount(pool); + assert.isNull(acct, "pool must not be created by a rejected initialize"); + } + + it("initializes with distinct role keys", async () => { + const roles = defaultRoles(); + await initialize(roles); + + const acct = await program.account.liquidityPool.fetch(pool); + assert.equal( + acct.pauseAuthority.toBase58(), + roles.pauseAuthority.toBase58() + ); + assert.equal( + acct.configureAuthority.toBase58(), + roles.configureAuthority.toBase58() + ); + assert.equal(acct.withdrawRecipients.length, 1); + assert.equal( + acct.withdrawRecipients[0].toBase58(), + roles.withdrawRecipient.toBase58() + ); + }); + + const zeroKeyCases: [keyof InitializeRoles, string][] = [ + ["pauseAuthority", "authoritynotset"], + ["unpauseAuthority", "authoritynotset"], + ["treasuryAuthority", "authoritynotset"], + ["configureAuthority", "authoritynotset"], + ["feeRecipient", "recipientnotset"], + ["withdrawRecipient", "recipientnotset"], + ]; + + for (const [role, expected] of zeroKeyCases) { + it(`rejects the default pubkey for ${role}`, async () => { + await assertInitializeFails(expected, { + ...defaultRoles(), + [role]: PublicKey.default, + }); + }); + } + + // The pool PDA has a fixed seed and no instruction can close it, so an unguarded + // `initialize` would let the first caller squat the only pool the program can ever have. + describe("upgrade authority gate", () => { + it("rejects a payer that is not the upgrade authority", async () => { + const squatter = Keypair.generate(); + fundSystemAccount(squatter.publicKey); + + await assertInitializeFails("notupgradeauthority", defaultRoles(), { + payer: squatter.publicKey, + signers: [squatter], + }); + }); + + it("rejects an immutable program", async () => { + setProgramData(programData, null); + + await assertInitializeFails("notupgradeauthority", defaultRoles()); + }); + + it("rejects program data belonging to another program", async () => { + // A well-formed ProgramData account naming the payer as upgrade authority, but recording + // it for a different program. Only the address constraint separates it from the real one. + const [foreign] = PublicKey.findProgramAddressSync( + [Keypair.generate().publicKey.toBuffer()], + BPF_LOADER_UPGRADEABLE + ); + setProgramData(foreign, payer); + + await assertInitializeFails("invalidprogramdata", defaultRoles(), { + programData: foreign, + }); + }); + }); +}); diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts new file mode 100644 index 0000000..3c1b4dd --- /dev/null +++ b/solana/tests/migration.ts @@ -0,0 +1,445 @@ +/** + * End-to-end test for the one-shot `migrate_authorities` instruction. + * + * The pool is a PDA, so a legacy-layout pool can't be produced by the new program's + * `initialize` (which only writes the new layout) and can't be created by an external + * keypair. We use `solana-bankrun` to deploy the program in an in-process SVM and + * `context.setAccount` to fabricate a legacy-layout pool at the canonical PDA, then run the + * real instruction (legacy parse + realloc + rent top-up + reserialize) and assert the + * migrated state. This is what catches the borsh packed-vs-padded parsing bug: the legacy + * `supported_tokens` vec is packed, so the trailing fixed fields (fee_rate, pause flags, + * bump) live at `108 + len * 32`, not `108 + MAX_SUPPORTED_TOKENS * 32`. + */ +import * as path from "path"; +import { Program, BN } from "@coral-xyz/anchor"; +import { PublicKey, Keypair, SystemProgram } from "@solana/web3.js"; +import { start, ProgramTestContext } from "solana-bankrun"; +import { BankrunProvider } from "anchor-bankrun"; +import { assert } from "chai"; +import { StableSwapper } from "../target/types/stable_swapper"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const IDL = require("../target/idl/stable_swapper.json"); + +const MAX_SUPPORTED_TOKENS = 50; +// Pre-migration on-chain size: disc + ops + pause + fee_recipient + supported_tokens cap +// + fee_rate + 2 bools + bump. Matches `LiquidityPool::LEGACY_INIT_SPACE` (+ 8 disc). +const LEGACY_TOTAL = + 8 + (32 * 3 + (4 + 32 * MAX_SUPPORTED_TOKENS) + 8 + 1 + 1 + 1); + +const LIQUIDITY_POOL_SEED = Buffer.from("liquidity_pool"); +const BPF_LOADER_UPGRADEABLE = new PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111" +); + +// `migrate_authorities` is gated on the program upgrade authority, which it reads from the +// ProgramData account. Bankrun registers programs under the non-upgradeable loader, so no such +// account exists and the test installs one: bincode-encoded `UpgradeableLoaderState::ProgramData` +// is a u32 variant tag (3), a u64 slot, then `Option` as a one-byte tag plus the key. +function programDataBytes(upgradeAuthority: PublicKey | null): Buffer { + const buf = Buffer.alloc(45); + buf.writeUInt32LE(3, 0); + buf.writeBigUInt64LE(BigInt(0), 4); + if (upgradeAuthority) { + buf.writeUInt8(1, 12); + upgradeAuthority.toBuffer().copy(buf, 13); + } + return buf; +} + +interface LegacyFields { + ops: PublicKey; + pause: PublicKey; + feeRecipient: PublicKey; + tokens: PublicKey[]; + feeRate: number; + swapsPaused: boolean; + liquidityPaused: boolean; + bump: number; +} + +// Serialize a legacy pool exactly as borsh/Anchor would have written it: the `supported_tokens` +// vec is packed (len + len*32), with the trailing fixed fields immediately after, and the +// remainder of the allocated account left as zero padding. +function buildLegacyPoolData(disc: Buffer, f: LegacyFields): Buffer { + const buf = Buffer.alloc(LEGACY_TOTAL); + disc.copy(buf, 0); + f.ops.toBuffer().copy(buf, 8); + f.pause.toBuffer().copy(buf, 40); + f.feeRecipient.toBuffer().copy(buf, 72); + buf.writeUInt32LE(f.tokens.length, 104); + f.tokens.forEach((t, i) => t.toBuffer().copy(buf, 108 + i * 32)); + const trailing = 108 + f.tokens.length * 32; + buf.writeBigUInt64LE(BigInt(f.feeRate), trailing); + buf.writeUInt8(f.swapsPaused ? 1 : 0, trailing + 8); + buf.writeUInt8(f.liquidityPaused ? 1 : 0, trailing + 9); + buf.writeUInt8(f.bump, trailing + 10); + return buf; +} + +/// Per-test substitutions for the accounts and role arguments the migration takes, so a test +/// can vary one input while leaving the rest at their happy-path values. +interface MigrateOverrides { + payer?: PublicKey; + programData?: PublicKey; + signers?: Keypair[]; + newPause?: PublicKey; + newUnpause?: PublicKey; + newTreasury?: PublicKey; + newConfigure?: PublicKey; + newWithdrawRecipient?: PublicKey; +} + +function errText(error: any): string { + const logs = Array.isArray(error?.logs) ? error.logs.join("\n") : ""; + return `${logs}\n${error?.transactionMessage ?? ""}\n${error}`.toLowerCase(); +} + +describe("migrate_authorities (bankrun)", () => { + let context: ProgramTestContext; + let provider: BankrunProvider; + let program: Program; + let programId: PublicKey; + let pool: PublicKey; + let poolBump: number; + let programData: PublicKey; + let payer: PublicKey; + let accountDiscriminator: Buffer; + + // Legacy authorities embedded in the fabricated pool. The migration overwrites both with the + // new role keys and never consults them, so they only need to be present in the legacy bytes. + const legacyOps = Keypair.generate().publicKey; + const legacyPause = Keypair.generate().publicKey; + const legacyFeeRecipient = Keypair.generate().publicKey; + + // New role keys supplied to the migration. + const newPause = Keypair.generate(); + const newUnpause = Keypair.generate(); + const newTreasury = Keypair.generate(); + const newConfigure = Keypair.generate(); + const newWithdrawRecipient = Keypair.generate(); + + const tokens = [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]; + + before(async () => { + // Let bankrun's `start` find the freshly built program binary in target/deploy. + const deployDir = path.resolve(__dirname, "..", "target", "deploy"); + process.env.SBF_OUT_DIR = deployDir; + process.env.BPF_OUT_DIR = deployDir; + + programId = new PublicKey(IDL.address); + context = await start([{ name: "stable_swapper", programId }], []); + provider = new BankrunProvider(context); + program = new Program(IDL as StableSwapper, provider); + payer = provider.wallet.publicKey; + + [pool, poolBump] = PublicKey.findProgramAddressSync( + [LIQUIDITY_POOL_SEED], + programId + ); + [programData] = PublicKey.findProgramAddressSync( + [programId.toBuffer()], + BPF_LOADER_UPGRADEABLE + ); + accountDiscriminator = Buffer.from( + IDL.accounts.find((a: any) => a.name === "LiquidityPool").discriminator + ); + + // The provider wallet, which pays for every migration here, is the upgrade authority. + setProgramData(programData, payer); + }); + + // A bankrun context holds a single bank whose blockhash only rolls when the slot moves, and + // several tests issue byte-identical `migrate` transactions. Sharing a blockhash across them + // would get the repeat rejected as already-processed before the guard under test ever runs. + let slot = 1; + beforeEach(() => { + context.warpToSlot(BigInt(++slot)); + }); + + function setProgramData(address: PublicKey, authority: PublicKey | null) { + context.setAccount(address, { + lamports: 1_000_000_000, + data: programDataBytes(authority), + owner: BPF_LOADER_UPGRADEABLE, + executable: false, + rentEpoch: 0, + }); + } + + async function seedLegacyPool(overrides: Partial = {}) { + const fields: LegacyFields = { + ops: legacyOps, + pause: legacyPause, + feeRecipient: legacyFeeRecipient, + tokens, + feeRate: 30, + swapsPaused: true, + liquidityPaused: false, + bump: poolBump, + ...overrides, + }; + const data = buildLegacyPoolData(accountDiscriminator, fields); + const rent = await context.banksClient.getRent(); + const lamports = Number(rent.minimumBalance(BigInt(data.length))); + context.setAccount(pool, { + lamports, + data, + owner: programId, + executable: false, + rentEpoch: 0, + }); + } + + function migrate(overrides: MigrateOverrides = {}) { + return program.methods + .migrateAuthorities( + overrides.newPause ?? newPause.publicKey, + overrides.newUnpause ?? newUnpause.publicKey, + overrides.newTreasury ?? newTreasury.publicKey, + overrides.newConfigure ?? newConfigure.publicKey, + overrides.newWithdrawRecipient ?? newWithdrawRecipient.publicKey + ) + .accounts({ + pool, + payer: overrides.payer ?? payer, + programData: overrides.programData ?? programData, + systemProgram: SystemProgram.programId, + }) + .signers(overrides.signers ?? []) + .rpc(); + } + + function fundSystemAccount(key: PublicKey) { + context.setAccount(key, { + lamports: 1_000_000_000, + data: Buffer.alloc(0), + owner: SystemProgram.programId, + executable: false, + rentEpoch: 0, + }); + } + + async function fetchPool(): Promise { + const raw = await context.banksClient.getAccount(pool); + assert.isNotNull(raw, "pool account missing"); + // Anchor's Program constructor camelCases IDL account names. + return program.coder.accounts.decode( + "liquidityPool", + Buffer.from(raw!.data) + ); + } + + it("migrates a legacy pool and preserves packed trailing state", async () => { + await seedLegacyPool(); + + await migrate(); + + const acct = await fetchPool(); + // New roles applied. + assert.equal(acct.pauseAuthority.toBase58(), newPause.publicKey.toBase58()); + assert.equal( + acct.unpauseAuthority.toBase58(), + newUnpause.publicKey.toBase58() + ); + assert.equal( + acct.treasuryAuthority.toBase58(), + newTreasury.publicKey.toBase58() + ); + assert.equal( + acct.configureAuthority.toBase58(), + newConfigure.publicKey.toBase58() + ); + // Preserved + new field seeded. + assert.equal( + acct.feeRecipient.toBase58(), + legacyFeeRecipient.toBase58(), + "fee_recipient must be carried over" + ); + assert.equal(acct.withdrawRecipients.length, 1); + assert.equal( + acct.withdrawRecipients[0].toBase58(), + newWithdrawRecipient.publicKey.toBase58() + ); + // Trailing fields read from the packed offset (the bug under test). + assert.deepEqual( + acct.supportedTokens.map((t: PublicKey) => t.toBase58()), + tokens.map((t) => t.toBase58()), + "packed supported_tokens must be parsed in full" + ); + assert.equal( + acct.feeRate.toNumber(), + 30, + "fee_rate must survive migration" + ); + assert.equal(acct.swapsPaused, true, "swaps_paused must survive migration"); + assert.equal(acct.liquidityPaused, false); + assert.equal( + acct.bump, + poolBump, + "bump must survive migration (zero bump would brick the PDA)" + ); + }); + + it("zeroes stale trailing bytes after migration", async () => { + const data = buildLegacyPoolData(accountDiscriminator, { + ops: legacyOps, + pause: legacyPause, + feeRecipient: legacyFeeRecipient, + tokens, + feeRate: 30, + swapsPaused: true, + liquidityPaused: false, + bump: poolBump, + }); + const legacyPackedEnd = 108 + tokens.length * 32 + 8 + 1 + 1 + 1; + data.fill(0xff, legacyPackedEnd); + const rent = await context.banksClient.getRent(); + context.setAccount(pool, { + lamports: Number(rent.minimumBalance(BigInt(data.length))), + data, + owner: programId, + executable: false, + rentEpoch: 0, + }); + + await migrate(); + + const raw = await context.banksClient.getAccount(pool); + assert.isNotNull(raw, "pool account missing"); + const body = Buffer.from(raw!.data); + const packed = await program.coder.accounts.encode( + "liquidityPool", + await fetchPool() + ); + const tail = body.subarray(packed.length); + assert.isTrue( + tail.length > 0, + "expected reserved capacity past the packed struct" + ); + assert.isTrue( + tail.every((b) => b === 0), + "bytes past the packed struct must be zeroed" + ); + }); + + it("rejects a second migration (AlreadyMigrated)", async () => { + await seedLegacyPool(); + await migrate(); + + // The retry must differ from the first transaction, otherwise the SVM rejects it as + // already-processed (same signers, args, and blockhash) and the guard never runs. A + // different role key is enough: AlreadyMigrated is checked on the account length, + // before any of the legacy fields are parsed. + try { + await migrate({ newPause: Keypair.generate().publicKey }); + assert.fail("expected AlreadyMigrated"); + } catch (error) { + assert.include(errText(error), "alreadymigrated"); + } + }); + + it("rejects a caller that is not the upgrade authority", async () => { + await seedLegacyPool(); + const stranger = Keypair.generate(); + fundSystemAccount(stranger.publicKey); + + try { + await migrate({ payer: stranger.publicKey, signers: [stranger] }); + assert.fail("expected NotUpgradeAuthority"); + } catch (error) { + assert.include(errText(error), "notupgradeauthority"); + } + + // The legacy layout must be left intact for a later, properly authorized attempt. + const raw = await context.banksClient.getAccount(pool); + assert.equal(raw!.data.length, LEGACY_TOTAL); + }); + + it("rejects program data belonging to another program", async () => { + await seedLegacyPool(); + // Well-formed and naming the payer as upgrade authority, but recorded for a different + // program. Only the address constraint separates it from this program's record. + const [foreign] = PublicKey.findProgramAddressSync( + [Keypair.generate().publicKey.toBuffer()], + BPF_LOADER_UPGRADEABLE + ); + setProgramData(foreign, payer); + + try { + await migrate({ programData: foreign }); + assert.fail("expected InvalidProgramData"); + } catch (error) { + assert.include(errText(error), "invalidprogramdata"); + } + }); + + it("rejects a pool whose size is neither the legacy nor the migrated layout", async () => { + // A wrong-sized account is not the same failure as a re-run, so it must not be reported + // as AlreadyMigrated. + const data = Buffer.alloc(LEGACY_TOTAL - 1); + accountDiscriminator.copy(data, 0); + const rent = await context.banksClient.getRent(); + context.setAccount(pool, { + lamports: Number(rent.minimumBalance(BigInt(data.length))), + data, + owner: programId, + executable: false, + rentEpoch: 0, + }); + + try { + await migrate(); + assert.fail("expected LegacySizeMismatch"); + } catch (error) { + assert.include(errText(error), "legacysizemismatch"); + } + }); + + it("rejects a migration that would assign a role to the default pubkey", async () => { + // Roles self-rotate, so a zero-key role could never be recovered. + const roles: (keyof MigrateOverrides)[] = [ + "newPause", + "newUnpause", + "newTreasury", + "newConfigure", + ]; + + for (const role of roles) { + await seedLegacyPool(); + try { + await migrate({ [role]: PublicKey.default }); + assert.fail(`expected AuthorityNotSet for ${role}`); + } catch (error) { + assert.include(errText(error), "authoritynotset", `role: ${role}`); + } + } + }); + + it("rejects a legacy pool with a default-pubkey fee_recipient", async () => { + await seedLegacyPool({ feeRecipient: PublicKey.default }); + try { + await migrate(); + assert.fail("expected RecipientNotSet"); + } catch (error) { + assert.include(errText(error), "recipientnotset"); + } + }); + + it("works with an empty legacy supported_tokens vec", async () => { + // Regression guard for the offset math when len = 0: trailing fields sit right after + // the 4-byte length prefix. + await seedLegacyPool({ tokens: [], feeRate: 7, swapsPaused: false }); + await migrate(); + + const acct = await fetchPool(); + assert.equal(acct.supportedTokens.length, 0); + assert.equal(acct.feeRate.toNumber(), 7); + assert.equal(acct.swapsPaused, false); + assert.equal(acct.bump, poolBump); + }); +}); diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index d9decc6..3a06e82 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -15,19 +15,28 @@ import { } from "@solana/spl-token"; import { assert } from "chai"; +const BPF_LOADER_UPGRADEABLE_PROGRAM_ID = new PublicKey( + "BPFLoaderUpgradeab1e11111111111111111111111" +); + describe("stable-swapper", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.stableSwapper as Program; const payer = provider.wallet as anchor.Wallet; - const operationsAuthority = payer; // In tests, same as payer - const pauseAuthority = payer; // In tests, same as payer + const operationsAuthority = payer; // legacy alias retained for migration tests + const pauseAuthority = payer; + const unpauseAuthority = payer; + const treasuryAuthority = payer; + const configureAuthority = payer; + const withdrawRecipient = payer; // Test keypairs let usdcMint: PublicKey; let customStableMint: PublicKey; let pool: PublicKey; + let programData: PublicKey; let usdcVault: PublicKey; let customStableVault: PublicKey; let usdcVaultTokenAccount: PublicKey; @@ -65,6 +74,13 @@ describe("stable-swapper", () => { program.programId ); + // `initialize` requires the payer to be the program's upgrade authority. Anchor deploys the + // program with the provider wallet as that authority, and the provider wallet is `payer`. + [programData] = PublicKey.findProgramAddressSync( + [program.programId.toBuffer()], + BPF_LOADER_UPGRADEABLE_PROGRAM_ID + ); + [usdcVault] = PublicKey.findProgramAddressSync( [Buffer.from("token_vault"), pool.toBuffer(), usdcMint.toBuffer()], program.programId @@ -143,9 +159,13 @@ describe("stable-swapper", () => { .accounts({ pool, payer: payer.publicKey, - operationsAuthority: operationsAuthority.publicKey, + programData, pauseAuthority: pauseAuthority.publicKey, + unpauseAuthority: unpauseAuthority.publicKey, + treasuryAuthority: treasuryAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, feeRecipient: payer.publicKey, + withdrawRecipient: withdrawRecipient.publicKey, systemProgram: SystemProgram.programId, }) .signers([payer.payer]) @@ -153,14 +173,31 @@ describe("stable-swapper", () => { // Verify pool state const poolAccount = await program.account.liquidityPool.fetch(pool); - assert.equal( - poolAccount.operationsAuthority.toString(), - operationsAuthority.publicKey.toString() - ); assert.equal( poolAccount.pauseAuthority.toString(), pauseAuthority.publicKey.toString() ); + assert.equal( + poolAccount.unpauseAuthority.toString(), + unpauseAuthority.publicKey.toString() + ); + assert.equal( + poolAccount.treasuryAuthority.toString(), + treasuryAuthority.publicKey.toString() + ); + assert.equal( + poolAccount.configureAuthority.toString(), + configureAuthority.publicKey.toString() + ); + assert.equal( + poolAccount.feeRecipient.toString(), + payer.publicKey.toString() + ); + assert.equal(poolAccount.withdrawRecipients.length, 1); + assert.equal( + poolAccount.withdrawRecipients[0].toString(), + withdrawRecipient.publicKey.toString() + ); assert.equal(poolAccount.feeRate.toNumber(), feeRate); assert.equal(poolAccount.swapsPaused, false); assert.equal(poolAccount.liquidityPaused, false); @@ -177,13 +214,25 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, mint: usdcMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + + // Newly listed tokens start disabled; enable swapping. + await program.methods + .unpauseToken() + .accounts({ + pool, + vault: usdcVault, + mint: usdcMint, + unpauseAuthority: unpauseAuthority.publicKey, + }) + .signers([unpauseAuthority.payer]) .rpc(); // Verify vault creation @@ -209,13 +258,25 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: feeRecipientCustomStableAccount, feeRecipient: payer.publicKey, mint: customStableMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + + // Newly listed tokens start disabled; enable swapping. + await program.methods + .unpauseToken() + .accounts({ + pool, + vault: customStableVault, + mint: customStableMint, + unpauseAuthority: unpauseAuthority.publicKey, + }) + .signers([unpauseAuthority.payer]) .rpc(); // Verify pool now has both tokens @@ -270,10 +331,10 @@ describe("stable-swapper", () => { vaultTokenAccount: usdcVaultTokenAccount, recipientTokenAccount: userUsdcAccount, mint: usdcMint, - operationsAuthority: operationsAuthority.publicKey, + treasuryAuthority: treasuryAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([treasuryAuthority.payer]) .rpc(); // Verify liquidity was withdrawn @@ -301,7 +362,7 @@ describe("stable-swapper", () => { it("Fails to withdraw when liquidity is paused", async () => { // First pause liquidity await program.methods - .updatePauseConfig(null, true) // swapsPaused=null, liquidityPaused=true + .pauseWithdraws() // Sets liquidityPaused=true; swapsPaused is unchanged .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -320,25 +381,25 @@ describe("stable-swapper", () => { vaultTokenAccount: usdcVaultTokenAccount, recipientTokenAccount: userUsdcAccount, mint: usdcMint, - operationsAuthority: operationsAuthority.publicKey, + treasuryAuthority: treasuryAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([treasuryAuthority.payer]) .rpc(); assert.fail("Expected liquidity paused error"); } catch (error) { - assert.include(error.toString(), "LiquidityPaused"); + assert.include(error.toString(), "WithdrawalPaused"); } // Unpause liquidity for other tests await program.methods - .updatePauseConfig(null, false) + .unpauseWithdraws() .accounts({ pool, - pauseAuthority: pauseAuthority.publicKey, + unpauseAuthority: unpauseAuthority.publicKey, }) - .signers([pauseAuthority.payer]) + .signers([unpauseAuthority.payer]) .rpc(); }); }); @@ -727,12 +788,12 @@ describe("stable-swapper", () => { it("Fails to swap when slippage protection is triggered", async () => { // First, set a 5% fee rate await program.methods - .updateFeeConfig(new anchor.BN(500), null) // 5% fee + .updateFeeRate(new anchor.BN(500)) // 5% fee .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); const swapAmount = new anchor.BN(100 * 10 ** 6); // 100 USDC @@ -770,24 +831,24 @@ describe("stable-swapper", () => { // Reset fee rate to 0 await program.methods - .updateFeeConfig(new anchor.BN(0), null) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); it("Fails when swap amount results in zero output (fee consumes entire input)", async () => { // Set a 1% fee rate (100 basis points) await program.methods - .updateFeeConfig(new anchor.BN(100), null) // 1% fee + .updateFeeRate(new anchor.BN(100)) // 1% fee .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Try to swap only 1 unit @@ -795,7 +856,7 @@ describe("stable-swapper", () => { // amount_after_fee = 1 - 1 = 0 // amount_out = 0 (should fail) const tinySwapAmount = new anchor.BN(1); - const minAmountOut = new anchor.BN(0); // User doesn't care about slippage + const minAmountOut = new anchor.BN(1); // Nonzero; the zero-output check below is what's under test try { await program.methods @@ -829,12 +890,12 @@ describe("stable-swapper", () => { // Reset fee rate to 0 await program.methods - .updateFeeConfig(new anchor.BN(0), null) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); }); @@ -843,7 +904,7 @@ describe("stable-swapper", () => { it("Disables a token and prevents swaps", async () => { // Disable USDC await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: usdcVault, @@ -927,14 +988,14 @@ describe("stable-swapper", () => { it("Re-enables a token and allows swaps again", async () => { // Re-enable USDC await program.methods - .updateTokenStatus(false) + .unpauseToken() .accounts({ pool, vault: usdcVault, mint: usdcMint, - pauseAuthority: pauseAuthority.publicKey, + unpauseAuthority: unpauseAuthority.publicKey, }) - .signers([pauseAuthority.payer]) + .signers([unpauseAuthority.payer]) .rpc(); // Verify vault is enabled @@ -986,7 +1047,7 @@ describe("stable-swapper", () => { try { await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: usdcVault, @@ -1065,13 +1126,25 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: feeRecipientTestTokenAccount, feeRecipient: payer.publicKey, mint: testTokenMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + + // Newly listed tokens start disabled; enable swapping. + await program.methods + .unpauseToken() + .accounts({ + pool, + vault: testTokenVault, + mint: testTokenMint, + unpauseAuthority: unpauseAuthority.publicKey, + }) + .signers([unpauseAuthority.payer]) .rpc(); const poolAccount = await program.account.liquidityPool.fetch(pool); @@ -1092,10 +1165,10 @@ describe("stable-swapper", () => { vault: testTokenVault, vaultTokenAccount: testTokenVaultTokenAccount, mint: testTokenMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Should have failed - token not disabled"); @@ -1123,7 +1196,7 @@ describe("stable-swapper", () => { it("Disables the test token", async () => { await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: testTokenVault, @@ -1148,10 +1221,10 @@ describe("stable-swapper", () => { vault: testTokenVault, vaultTokenAccount: testTokenVaultTokenAccount, mint: testTokenMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Should have failed - vault not empty"); @@ -1175,10 +1248,10 @@ describe("stable-swapper", () => { vaultTokenAccount: testTokenVaultTokenAccount, recipientTokenAccount: userTestTokenAccount, mint: testTokenMint, - operationsAuthority: operationsAuthority.publicKey, + treasuryAuthority: treasuryAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([treasuryAuthority.payer]) .rpc(); // Verify vault is empty @@ -1196,10 +1269,10 @@ describe("stable-swapper", () => { vault: testTokenVault, vaultTokenAccount: testTokenVaultTokenAccount, mint: testTokenMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Verify token removed from pool @@ -1274,18 +1347,18 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: newFeeRecipientTestTokenAccount, feeRecipient: payer.publicKey, mint: newTestTokenMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Disable it await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: newTestTokenVault, @@ -1304,7 +1377,7 @@ describe("stable-swapper", () => { vault: newTestTokenVault, vaultTokenAccount: newTestTokenVaultTokenAccount, mint: newTestTokenMint, - operationsAuthority: unauthorizedUser.publicKey, + configureAuthority: unauthorizedUser.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) .signers([unauthorizedUser]) @@ -1323,10 +1396,10 @@ describe("stable-swapper", () => { vault: newTestTokenVault, vaultTokenAccount: newTestTokenVaultTokenAccount, mint: newTestTokenMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); }); @@ -1336,12 +1409,12 @@ describe("stable-swapper", () => { const newFeeRate = 25; // 0.25% await program.methods - .updateFeeConfig(new anchor.BN(newFeeRate), null) // feeRate, feeRecipient + .updateFeeRate(new anchor.BN(newFeeRate)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Verify fee rate was updated @@ -1350,18 +1423,18 @@ describe("stable-swapper", () => { // Reset fee back to 0% for other tests await program.methods - .updateFeeConfig(new anchor.BN(0), null) // feeRate, feeRecipient + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); it("Pauses swaps", async () => { await program.methods - .updatePauseConfig(true, null) // swapsPaused, liquidityPaused + .pauseSwaps() // Sets swapsPaused=true; liquidityPaused is unchanged .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -1410,12 +1483,12 @@ describe("stable-swapper", () => { it("Unpauses swaps", async () => { await program.methods - .updatePauseConfig(false, null) // swapsPaused, liquidityPaused + .unpauseSwaps() // Sets swapsPaused=false; liquidityPaused is unchanged .accounts({ pool, - pauseAuthority: pauseAuthority.publicKey, + unpauseAuthority: unpauseAuthority.publicKey, }) - .signers([pauseAuthority.payer]) + .signers([unpauseAuthority.payer]) .rpc(); // Verify swaps are unpaused @@ -1479,12 +1552,20 @@ describe("stable-swapper", () => { // Update pool to use new fee recipient and set 1% fee await program.methods - .updateFeeConfig(new anchor.BN(100), feeRecipient.publicKey) + .updateFeeRate(new anchor.BN(100)) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(feeRecipient.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); const swapAmount = new anchor.BN(100 * 10 ** 6); // 100 USDC @@ -1589,12 +1670,20 @@ describe("stable-swapper", () => { // Reset fee rate and fee recipient back to original await program.methods - .updateFeeConfig(new anchor.BN(0), payer.publicKey) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(payer.publicKey) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) .rpc(); }); @@ -1612,12 +1701,20 @@ describe("stable-swapper", () => { // Update pool to use new fee recipient and set 1% fee await program.methods - .updateFeeConfig(new anchor.BN(100), newFeeRecipient.publicKey) + .updateFeeRate(new anchor.BN(100)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(newFeeRecipient.publicKey) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) .rpc(); const swapAmount = new anchor.BN(100 * 10 ** 6); // 100 USDC @@ -1667,12 +1764,20 @@ describe("stable-swapper", () => { // Reset fee rate and fee recipient back to original await program.methods - .updateFeeConfig(new anchor.BN(0), payer.publicKey) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(payer.publicKey) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) .rpc(); }); @@ -1748,12 +1853,12 @@ describe("stable-swapper", () => { it("Rounds up fees to prevent protocol loss on fractional amounts", async () => { // Set 1% fee (100 basis points) await program.methods - .updateFeeConfig(new anchor.BN(100), null) + .updateFeeRate(new anchor.BN(100)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Create a separate fee recipient to track fees @@ -1766,12 +1871,12 @@ describe("stable-swapper", () => { ); await program.methods - .updateFeeConfig(null, feeRecipient.publicKey) + .updateFeeRecipient(feeRecipient.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Test case 1: Amount that creates fractional fee in basis points @@ -1872,24 +1977,32 @@ describe("stable-swapper", () => { // Reset fee rate and recipient await program.methods - .updateFeeConfig(new anchor.BN(0), payer.publicKey) + .updateFeeRate(new anchor.BN(0)) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(payer.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); it("Does not over-charge on perfect fee amounts (no rounding needed)", async () => { // Set 1% fee (100 basis points) await program.methods - .updateFeeConfig(new anchor.BN(100), null) + .updateFeeRate(new anchor.BN(100)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Create a separate fee recipient to track fees @@ -1902,12 +2015,12 @@ describe("stable-swapper", () => { ); await program.methods - .updateFeeConfig(null, feeRecipient.publicKey) + .updateFeeRecipient(feeRecipient.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Test: 100 tokens with 1% fee = exactly 1 token @@ -1958,12 +2071,20 @@ describe("stable-swapper", () => { // Reset fee rate and recipient await program.methods - .updateFeeConfig(new anchor.BN(0), payer.publicKey) + .updateFeeRate(new anchor.BN(0)) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(payer.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); }); @@ -1974,12 +2095,12 @@ describe("stable-swapper", () => { try { await program.methods - .updateFeeConfig(new anchor.BN(excessiveFeeRate), null) + .updateFeeRate(new anchor.BN(excessiveFeeRate)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Expected invalid fee rate error"); @@ -1992,12 +2113,12 @@ describe("stable-swapper", () => { const maxFeeRate = 1000; // 10% await program.methods - .updateFeeConfig(new anchor.BN(maxFeeRate), null) + .updateFeeRate(new anchor.BN(maxFeeRate)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); const poolAccount = await program.account.liquidityPool.fetch(pool); @@ -2005,14 +2126,39 @@ describe("stable-swapper", () => { // Reset fee rate await program.methods - .updateFeeConfig(new anchor.BN(0), null) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); + + it("Fails to set the fee recipient to the default pubkey", async () => { + // The recipient is the authority on the token account every fee lands in, so the zero + // key would make collected fees permanently unspendable. + try { + await program.methods + .updateFeeRecipient(PublicKey.default) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + assert.fail("Expected RecipientNotSet error"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "recipientnotset"); + } + + const poolAccount = await program.account.liquidityPool.fetch(pool); + assert.notEqual( + poolAccount.feeRecipient.toBase58(), + PublicKey.default.toBase58(), + "fee recipient must be unchanged by a rejected update" + ); + }); }); describe("Decimal Normalization", () => { @@ -2076,13 +2222,25 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: feeRecipient9DecAccount, feeRecipient: payer.publicKey, mint: token9DecMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) + .rpc(); + + // Newly listed tokens start disabled; enable swapping. + await program.methods + .unpauseToken() + .accounts({ + pool, + vault: token9DecVault, + mint: token9DecMint, + unpauseAuthority: unpauseAuthority.publicKey, + }) + .signers([unpauseAuthority.payer]) .rpc(); // Seed liquidity for 9-decimal token via direct SPL transfer @@ -2298,13 +2456,13 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: invalidFeeRecipientAccount, feeRecipient: payer.publicKey, mint: invalidMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Should have rejected token with 5 decimals"); @@ -2348,13 +2506,13 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: invalidFeeRecipientAccount, feeRecipient: payer.publicKey, mint: invalidMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Should have rejected token with 12 decimals"); @@ -2470,13 +2628,13 @@ describe("stable-swapper", () => { feeRecipientTokenAccount, feeRecipient: payer.publicKey, mint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); } @@ -2509,13 +2667,13 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: extraFeeRecipientTokenAccount, feeRecipient: payer.publicKey, mint: extraMint, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, rent: anchor.web3.SYSVAR_RENT_PUBKEY, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Should have failed - max tokens reached"); @@ -2533,7 +2691,7 @@ describe("stable-swapper", () => { describe("Token Validation", () => { it("Fails to swap same token (from == to)", async () => { const swapAmount = new anchor.BN(10 * 10 ** 6); - const minAmountOut = new anchor.BN(0); + const minAmountOut = new anchor.BN(1); // Nonzero; the same-token check below is what's under test try { await program.methods @@ -2576,10 +2734,10 @@ describe("stable-swapper", () => { vaultTokenAccount: usdcVaultTokenAccount, recipientTokenAccount: userUsdcAccount, mint: usdcMint, - operationsAuthority: operationsAuthority.publicKey, + treasuryAuthority: treasuryAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([treasuryAuthority.payer]) .rpc(); assert.fail("Should have failed - zero amount"); @@ -2606,10 +2764,10 @@ describe("stable-swapper", () => { vaultTokenAccount: usdcVaultTokenAccount, recipientTokenAccount: userUsdcAccount, mint: usdcMint, - operationsAuthority: operationsAuthority.publicKey, + treasuryAuthority: treasuryAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) - .signers([operationsAuthority.payer]) + .signers([treasuryAuthority.payer]) .rpc(); assert.fail("Should have failed - insufficient liquidity"); @@ -2622,7 +2780,7 @@ describe("stable-swapper", () => { describe("Token Validation", () => { it("Fails to swap same token (from == to)", async () => { const swapAmount = new anchor.BN(10 * 10 ** 6); - const minAmountOut = new anchor.BN(0); + const minAmountOut = new anchor.BN(1); // Nonzero; the same-token check below is what's under test try { await program.methods @@ -2655,34 +2813,32 @@ describe("stable-swapper", () => { }); describe("Authority Management", () => { - it("Updates operations authority successfully", async () => { - // Create a new operations authority - const newOpsAuthority = anchor.web3.Keypair.generate(); + it("Updates configure authority successfully", async () => { + const newConfigure = anchor.web3.Keypair.generate(); await program.methods - .updateOperationsAuthority(newOpsAuthority.publicKey) + .updateConfigureAuthority(newConfigure.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); - // Verify the authority was updated const poolAccount = await program.account.liquidityPool.fetch(pool); assert.equal( - poolAccount.operationsAuthority.toString(), - newOpsAuthority.publicKey.toString() + poolAccount.configureAuthority.toString(), + newConfigure.publicKey.toString() ); - // Change it back to the original for other tests + // Change it back to the original for other tests. await program.methods - .updateOperationsAuthority(operationsAuthority.publicKey) + .updateConfigureAuthority(configureAuthority.publicKey) .accounts({ pool, - operationsAuthority: newOpsAuthority.publicKey, + configureAuthority: newConfigure.publicKey, }) - .signers([newOpsAuthority]) + .signers([newConfigure]) .rpc(); }); @@ -2717,45 +2873,77 @@ describe("stable-swapper", () => { .rpc(); }); - it("Fails when pause authority tries to update operations authority", async () => { - const newAuthority = anchor.web3.Keypair.generate(); - - try { - await program.methods - .updateOperationsAuthority(newAuthority.publicKey) - .accounts({ - pool, - operationsAuthority: pauseAuthority.publicKey, // Wrong authority - }) - .signers([pauseAuthority.payer]) - .rpc(); - - assert.fail("Expected constraint violation"); - } catch (error) { - // Should fail due to has_one constraint - assert.include(error.toString().toLowerCase(), "constraint"); - } - }); - - it("Fails when operations authority tries to update pause authority", async () => { - const newAuthority = anchor.web3.Keypair.generate(); - - try { - await program.methods - .updatePauseAuthority(newAuthority.publicKey) - .accounts({ - pool, - pauseAuthority: operationsAuthority.publicKey, // Wrong authority + // Parameterized cross-role rotation matrix: every `update__authority` call must + // reject any signer that does not currently hold that exact role. The test fixture sets + // all four roles to the same payer at init, so a `has_one` violation can only be + // surfaced by signing with a foreign keypair (not the payer). We exercise all four + // target roles; the "wrong signer" stands in for any of the other three roles + // (functionally equivalent because `has_one` reduces to a pubkey equality check). + type RoleSpec = { + label: string; + method: + | "updatePauseAuthority" + | "updateUnpauseAuthority" + | "updateTreasuryAuthority" + | "updateConfigureAuthority"; + accountField: + | "pauseAuthority" + | "unpauseAuthority" + | "treasuryAuthority" + | "configureAuthority"; + }; + + const roles: RoleSpec[] = [ + { + label: "pause", + method: "updatePauseAuthority", + accountField: "pauseAuthority", + }, + { + label: "unpause", + method: "updateUnpauseAuthority", + accountField: "unpauseAuthority", + }, + { + label: "treasury", + method: "updateTreasuryAuthority", + accountField: "treasuryAuthority", + }, + { + label: "configure", + method: "updateConfigureAuthority", + accountField: "configureAuthority", + }, + ]; + + for (const role of roles) { + it(`Fails when a non-${role.label} signer tries to rotate the ${role.label} authority`, async () => { + const stranger = anchor.web3.Keypair.generate(); + const transferTx = new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: stranger.publicKey, + lamports: 0.05 * anchor.web3.LAMPORTS_PER_SOL, }) - .signers([operationsAuthority.payer]) - .rpc(); - - assert.fail("Expected constraint violation"); - } catch (error) { - // Should fail due to has_one constraint - assert.include(error.toString().toLowerCase(), "constraint"); - } - }); + ); + await provider.sendAndConfirm(transferTx, [payer.payer]); + + const newAuthority = anchor.web3.Keypair.generate(); + try { + await (program.methods as any) + [role.method](newAuthority.publicKey) + .accounts({ + pool, + [role.accountField]: stranger.publicKey, + }) + .signers([stranger]) + .rpc(); + assert.fail(`Expected constraint violation rotating ${role.label}`); + } catch (error) { + assert.include(error.toString().toLowerCase(), "constraint"); + } + }); + } }); describe("Authority Access Control", () => { @@ -2829,7 +3017,7 @@ describe("stable-swapper", () => { feeRecipientTokenAccount: newFeeRecipientAccount, feeRecipient: payer.publicKey, mint: newMint, - operationsAuthority: unauthorizedUser.publicKey, // Wrong authority + configureAuthority: unauthorizedUser.publicKey, // Wrong authority tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: anchor.web3.SystemProgram.programId, @@ -2854,7 +3042,7 @@ describe("stable-swapper", () => { vaultTokenAccount: usdcVaultTokenAccount, recipientTokenAccount: unauthorizedUserUsdcAccount, mint: usdcMint, - operationsAuthority: unauthorizedUser.publicKey, // Wrong authority + treasuryAuthority: unauthorizedUser.publicKey, // Wrong authority tokenProgram: TOKEN_PROGRAM_ID, }) .signers([unauthorizedUser]) @@ -2869,10 +3057,10 @@ describe("stable-swapper", () => { it("Fails when unauthorized user tries to update fee config", async () => { try { await program.methods - .updateFeeConfig(new anchor.BN(50), null) + .updateFeeRate(new anchor.BN(50)) .accounts({ pool, - operationsAuthority: unauthorizedUser.publicKey, // Wrong authority + configureAuthority: unauthorizedUser.publicKey, // Wrong authority }) .signers([unauthorizedUser]) .rpc(); @@ -2886,7 +3074,7 @@ describe("stable-swapper", () => { it("Fails when unauthorized user tries to update pause config", async () => { try { await program.methods - .updatePauseConfig(true, null) + .pauseSwaps() .accounts({ pool, pauseAuthority: unauthorizedUser.publicKey, // Wrong authority @@ -2899,5 +3087,506 @@ describe("stable-swapper", () => { assert.include(error.toString().toLowerCase(), "constraint"); } }); + + it("Fails when pause authority tries to unpause swaps", async () => { + // First put swaps into a paused state. + await program.methods + .pauseSwaps() + .accounts({ pool, pauseAuthority: pauseAuthority.publicKey }) + .signers([pauseAuthority.payer]) + .rpc(); + + try { + await program.methods + .unpauseSwaps() + .accounts({ + pool, + unpauseAuthority: unauthorizedUser.publicKey, // Wrong authority + }) + .signers([unauthorizedUser]) + .rpc(); + assert.fail("Expected constraint violation"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "constraint"); + } finally { + // Restore unpaused state for subsequent tests. + await program.methods + .unpauseSwaps() + .accounts({ pool, unpauseAuthority: unpauseAuthority.publicKey }) + .signers([unpauseAuthority.payer]) + .rpc(); + } + }); + + it("Fails when treasury tries to list a token (configure-only)", async () => { + const newMint = await createMint( + provider.connection, + payer.payer, + payer.publicKey, + null, + 6 + ); + const [newVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), pool.toBuffer(), newMint.toBuffer()], + program.programId + ); + const [newVaultTokenAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("vault_token_account"), newVault.toBuffer()], + program.programId + ); + const newFeeRecipientAccount = await getAssociatedTokenAddress( + newMint, + payer.publicKey + ); + + try { + await program.methods + .addSupportedToken() + .accounts({ + pool, + vault: newVault, + vaultTokenAccount: newVaultTokenAccount, + feeRecipientTokenAccount: newFeeRecipientAccount, + feeRecipient: payer.publicKey, + mint: newMint, + configureAuthority: unauthorizedUser.publicKey, // wrong role + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }) + .signers([unauthorizedUser]) + .rpc(); + assert.fail("Expected constraint violation"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "constraint"); + } + }); + }); + + describe("Withdraw Recipient Allowlist", () => { + let foreignOwner: anchor.web3.Keypair; + let foreignUsdcAccount: PublicKey; + + before(async () => { + foreignOwner = anchor.web3.Keypair.generate(); + foreignUsdcAccount = await createAccount( + provider.connection, + payer.payer, + usdcMint, + foreignOwner.publicKey + ); + }); + + async function fundStranger(stranger: anchor.web3.Keypair) { + const tx = new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: stranger.publicKey, + lamports: 0.05 * anchor.web3.LAMPORTS_PER_SOL, + }) + ); + await provider.sendAndConfirm(tx, [payer.payer]); + } + + it("Rejects withdraw to an owner not on the allowlist", async () => { + try { + await program.methods + .withdrawLiquidity(new anchor.BN(1)) + .accounts({ + pool, + vault: usdcVault, + vaultTokenAccount: usdcVaultTokenAccount, + recipientTokenAccount: foreignUsdcAccount, // owner not allowlisted + mint: usdcMint, + treasuryAuthority: treasuryAuthority.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([treasuryAuthority.payer]) + .rpc(); + assert.fail("Expected WithdrawRecipientNotAllowed"); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "withdrawrecipientnotallowed" + ); + } + }); + + it("Lets configure_authority add a recipient, unlocking withdraws to it", async () => { + await program.methods + .addWithdrawRecipient(foreignOwner.publicKey) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + + const poolAccount = await program.account.liquidityPool.fetch(pool); + assert.isTrue( + poolAccount.withdrawRecipients.some((r) => + r.equals(foreignOwner.publicKey) + ) + ); + + const before = await getAccount(provider.connection, foreignUsdcAccount); + await program.methods + .withdrawLiquidity(new anchor.BN(1)) + .accounts({ + pool, + vault: usdcVault, + vaultTokenAccount: usdcVaultTokenAccount, + recipientTokenAccount: foreignUsdcAccount, + mint: usdcMint, + treasuryAuthority: treasuryAuthority.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([treasuryAuthority.payer]) + .rpc(); + const after = await getAccount(provider.connection, foreignUsdcAccount); + assert.equal(after.amount - before.amount, BigInt(1)); + + // The original seed recipient (payer) is still allowed simultaneously. + const beforeUser = await getAccount(provider.connection, userUsdcAccount); + await program.methods + .withdrawLiquidity(new anchor.BN(1)) + .accounts({ + pool, + vault: usdcVault, + vaultTokenAccount: usdcVaultTokenAccount, + recipientTokenAccount: userUsdcAccount, + mint: usdcMint, + treasuryAuthority: treasuryAuthority.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([treasuryAuthority.payer]) + .rpc(); + const afterUser = await getAccount(provider.connection, userUsdcAccount); + assert.equal(afterUser.amount - beforeUser.amount, BigInt(1)); + }); + + it("Rejects adding a duplicate recipient", async () => { + try { + await program.methods + .addWithdrawRecipient(foreignOwner.publicKey) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + assert.fail("Expected WithdrawRecipientAlreadyAllowed"); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "withdrawrecipientalreadyallowed" + ); + } + }); + + it("Rejects adding the default pubkey", async () => { + try { + await program.methods + .addWithdrawRecipient(PublicKey.default) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + assert.fail("Expected RecipientNotSet"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "recipientnotset"); + } + }); + + it("Rejects add_withdraw_recipient from a non-configure signer", async () => { + const stranger = anchor.web3.Keypair.generate(); + await fundStranger(stranger); + try { + await program.methods + .addWithdrawRecipient(stranger.publicKey) + .accounts({ pool, configureAuthority: stranger.publicKey }) + .signers([stranger]) + .rpc(); + assert.fail("Expected constraint violation"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "constraint"); + } + }); + + it("Rejects remove_withdraw_recipient from a non-configure signer", async () => { + const stranger = anchor.web3.Keypair.generate(); + await fundStranger(stranger); + try { + await program.methods + .removeWithdrawRecipient(foreignOwner.publicKey) + .accounts({ pool, configureAuthority: stranger.publicKey }) + .signers([stranger]) + .rpc(); + assert.fail("Expected constraint violation"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "constraint"); + } + }); + + it("Rejects removing a recipient that is not on the allowlist", async () => { + try { + await program.methods + .removeWithdrawRecipient(anchor.web3.Keypair.generate().publicKey) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + assert.fail("Expected WithdrawRecipientNotAllowed"); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "withdrawrecipientnotallowed" + ); + } + }); + + it("Lets configure_authority remove a recipient, re-locking withdraws to it", async () => { + await program.methods + .removeWithdrawRecipient(foreignOwner.publicKey) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + + const poolAccount = await program.account.liquidityPool.fetch(pool); + assert.isFalse( + poolAccount.withdrawRecipients.some((r) => + r.equals(foreignOwner.publicKey) + ) + ); + + try { + await program.methods + .withdrawLiquidity(new anchor.BN(1)) + .accounts({ + pool, + vault: usdcVault, + vaultTokenAccount: usdcVaultTokenAccount, + recipientTokenAccount: foreignUsdcAccount, + mint: usdcMint, + treasuryAuthority: treasuryAuthority.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([treasuryAuthority.payer]) + .rpc(); + assert.fail("Expected WithdrawRecipientNotAllowed"); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "withdrawrecipientnotallowed" + ); + } + }); + + it("Enforces the maximum number of withdraw recipients", async () => { + // Seed already holds one entry (payer). Fill up to the on-chain cap. + const MAX_WITHDRAW_RECIPIENTS = 10; + let current = (await program.account.liquidityPool.fetch(pool)) + .withdrawRecipients.length; + const added: PublicKey[] = []; + while (current < MAX_WITHDRAW_RECIPIENTS) { + const r = anchor.web3.Keypair.generate().publicKey; + await program.methods + .addWithdrawRecipient(r) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + added.push(r); + current += 1; + } + + try { + await program.methods + .addWithdrawRecipient(anchor.web3.Keypair.generate().publicKey) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + assert.fail("Expected MaxWithdrawRecipientsReached"); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "maxwithdrawrecipientsreached" + ); + } + + // Clean up so later suites see only the seed recipient. + for (const r of added) { + await program.methods + .removeWithdrawRecipient(r) + .accounts({ pool, configureAuthority: configureAuthority.publicKey }) + .signers([configureAuthority.payer]) + .rpc(); + } + }); + }); + + describe("Self-Rotation of New Roles", () => { + async function fund(stranger: anchor.web3.Keypair) { + const tx = new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: stranger.publicKey, + lamports: 0.05 * anchor.web3.LAMPORTS_PER_SOL, + }) + ); + await provider.sendAndConfirm(tx, [payer.payer]); + } + + it("treasury rotates itself", async () => { + const next = anchor.web3.Keypair.generate(); + await program.methods + .updateTreasuryAuthority(next.publicKey) + .accounts({ pool, treasuryAuthority: treasuryAuthority.publicKey }) + .signers([treasuryAuthority.payer]) + .rpc(); + let p = await program.account.liquidityPool.fetch(pool); + assert.equal(p.treasuryAuthority.toString(), next.publicKey.toString()); + + await fund(next); + await program.methods + .updateTreasuryAuthority(treasuryAuthority.publicKey) + .accounts({ pool, treasuryAuthority: next.publicKey }) + .signers([next]) + .rpc(); + p = await program.account.liquidityPool.fetch(pool); + assert.equal( + p.treasuryAuthority.toString(), + treasuryAuthority.publicKey.toString() + ); + }); + + it("unpause rotates itself; pause cannot rotate it", async () => { + const next = anchor.web3.Keypair.generate(); + await program.methods + .updateUnpauseAuthority(next.publicKey) + .accounts({ pool, unpauseAuthority: unpauseAuthority.publicKey }) + .signers([unpauseAuthority.payer]) + .rpc(); + let p = await program.account.liquidityPool.fetch(pool); + assert.equal(p.unpauseAuthority.toString(), next.publicKey.toString()); + + // Pause cannot rotate unpause. + const stranger = anchor.web3.Keypair.generate(); + await fund(stranger); + try { + await program.methods + .updateUnpauseAuthority(stranger.publicKey) + .accounts({ pool, unpauseAuthority: stranger.publicKey }) + .signers([stranger]) + .rpc(); + assert.fail("Expected constraint violation"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "constraint"); + } + + // Restore. + await fund(next); + await program.methods + .updateUnpauseAuthority(unpauseAuthority.publicKey) + .accounts({ pool, unpauseAuthority: next.publicKey }) + .signers([next]) + .rpc(); + }); + + it("Rejects rotating any role to the default pubkey", async () => { + // Rotation requires the current holder to sign, so a role handed to the zero key + // could never be recovered. + const rotations: [string, () => Promise][] = [ + [ + "pause", + () => + program.methods + .updatePauseAuthority(PublicKey.default) + .accounts({ pool, pauseAuthority: pauseAuthority.publicKey }) + .signers([pauseAuthority.payer]) + .rpc(), + ], + [ + "unpause", + () => + program.methods + .updateUnpauseAuthority(PublicKey.default) + .accounts({ pool, unpauseAuthority: unpauseAuthority.publicKey }) + .signers([unpauseAuthority.payer]) + .rpc(), + ], + [ + "treasury", + () => + program.methods + .updateTreasuryAuthority(PublicKey.default) + .accounts({ + pool, + treasuryAuthority: treasuryAuthority.publicKey, + }) + .signers([treasuryAuthority.payer]) + .rpc(), + ], + [ + "configure", + () => + program.methods + .updateConfigureAuthority(PublicKey.default) + .accounts({ + pool, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(), + ], + ]; + + for (const [role, rotate] of rotations) { + try { + await rotate(); + assert.fail(`Expected AuthorityNotSet for ${role}`); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "authoritynotset", + `role: ${role}` + ); + } + } + + // The pool must be untouched by the rejected rotations. + const p = await program.account.liquidityPool.fetch(pool); + assert.equal( + p.pauseAuthority.toString(), + pauseAuthority.publicKey.toString() + ); + assert.equal( + p.configureAuthority.toString(), + configureAuthority.publicKey.toString() + ); + }); + }); + + describe("Migration guard", () => { + it("Rejects migrate_authorities on a pool already in the new layout", async () => { + // The live test pool was initialized with the new layout, so migrate must + // refuse it via the AlreadyMigrated size check. + try { + await program.methods + .migrateAuthorities( + pauseAuthority.publicKey, + unpauseAuthority.publicKey, + treasuryAuthority.publicKey, + configureAuthority.publicKey, + withdrawRecipient.publicKey + ) + .accounts({ + pool, + payer: payer.publicKey, + programData, + systemProgram: SystemProgram.programId, + }) + .signers([payer.payer]) + .rpc(); + assert.fail("Expected AlreadyMigrated error"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "alreadymigrated"); + } + }); }); }); diff --git a/solana/yarn.lock b/solana/yarn.lock index c1ced3d..08d8a6f 100644 --- a/solana/yarn.lock +++ b/solana/yarn.lock @@ -204,7 +204,7 @@ "@solana/spl-token-metadata" "^0.1.6" buffer "^6.0.3" -"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.69.0", "@solana/web3.js@^1.98.4": +"@solana/web3.js@^1.32.0", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.69.0", "@solana/web3.js@^1.98.4": version "1.98.4" resolved "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz" integrity sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw== @@ -343,6 +343,11 @@ agentkeepalive@^4.5.0: dependencies: humanize-ms "^1.2.1" +anchor-bankrun@^0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/anchor-bankrun/-/anchor-bankrun-0.5.0.tgz#62b5905f6f0ed3799d4a37e6be045887c13d4f33" + integrity sha512-cNTRv7pN9dy+kiyJ3UlNVTg9hAXhY2HtNVNXJbP/2BkS9nOdLV0qKWhgW8UR9Go0gYuEOLKuPzrGL4HFAZPsVw== + ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz" @@ -1090,6 +1095,45 @@ serialize-javascript@6.0.0: dependencies: randombytes "^2.1.0" +solana-bankrun-darwin-arm64@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/solana-bankrun-darwin-arm64/-/solana-bankrun-darwin-arm64-0.4.0.tgz#eb0f3dfffb1675f6329a1e026b12d09222b33986" + integrity sha512-6dz78Teoz7ez/3lpRLDjktYLJb79FcmJk2me4/YaB8WiO6W43OdExU4h+d2FyuAryO2DgBPXaBoBNY/8J1HJmw== + +solana-bankrun-darwin-universal@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/solana-bankrun-darwin-universal/-/solana-bankrun-darwin-universal-0.4.0.tgz#0ac13ec7637b334b1030e6f51abecc50a254b5de" + integrity sha512-zSSw/Jx3KNU42pPMmrEWABd0nOwGJfsj7nm9chVZ3ae7WQg3Uty0hHAkn5NSDCj3OOiN0py9Dr1l9vmRJpOOxg== + +solana-bankrun-darwin-x64@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/solana-bankrun-darwin-x64/-/solana-bankrun-darwin-x64-0.4.0.tgz#f863c5a668858b7c44be51376bd05fb077c11c99" + integrity sha512-LWjs5fsgHFtyr7YdJR6r0Ho5zrtzI6CY4wvwPXr8H2m3b4pZe6RLIZjQtabCav4cguc14G0K8yQB2PTMuGub8w== + +solana-bankrun-linux-x64-gnu@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/solana-bankrun-linux-x64-gnu/-/solana-bankrun-linux-x64-gnu-0.4.0.tgz#30fd7edaf3ff6585468138d3bed6eaed37878d9e" + integrity sha512-SrlVrb82UIxt21Zr/XZFHVV/h9zd2/nP25PMpLJVLD7Pgl2yhkhfi82xj3OjxoQqWe+zkBJ+uszA0EEKr67yNw== + +solana-bankrun-linux-x64-musl@0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/solana-bankrun-linux-x64-musl/-/solana-bankrun-linux-x64-musl-0.4.0.tgz#3c870218140b1307dc44b51d2282697c99f2e1e4" + integrity sha512-Nv328ZanmURdYfcLL+jwB1oMzX4ZzK57NwIcuJjGlf0XSNLq96EoaO5buEiUTo4Ls7MqqMyLbClHcrPE7/aKyA== + +solana-bankrun@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/solana-bankrun/-/solana-bankrun-0.4.0.tgz#a48a7a74ce6c56be4ec7e200336026f65e90b8dc" + integrity sha512-NMmXUipPBkt8NgnyNO3SCnPERP6xT/AMNMBooljGA3+rG6NN8lmXJsKeLqQTiFsDeWD74U++QM/DgcueSWvrIg== + dependencies: + "@solana/web3.js" "^1.68.0" + bs58 "^4.0.1" + optionalDependencies: + solana-bankrun-darwin-arm64 "0.4.0" + solana-bankrun-darwin-universal "0.4.0" + solana-bankrun-darwin-x64 "0.4.0" + solana-bankrun-linux-x64-gnu "0.4.0" + solana-bankrun-linux-x64-musl "0.4.0" + source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz"