From f097b93b8167b6d317e91f2e443f27fa0b5821ba Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 4 Aug 2026 09:02:28 -0400 Subject: [PATCH 01/20] feat(solana): fine-grained pool RBAC and legacy migrate_authorities Split pause/unpause/treasury/configure roles, add withdraw-recipient allowlist, and support one-shot migration from the legacy layout with Anchor and bankrun test coverage. --- solana/.gitignore | 2 +- solana/Anchor.toml | 6 +- solana/Cargo.lock | 16 +- solana/README.md | 262 ++-- solana/migrations/deploy.ts | 12 + solana/package.json | 4 +- .../Cargo.toml | 6 +- .../Xargo.toml | 0 .../src/constants.rs | 5 + .../src/errors.rs | 18 + .../src/lib.rs | 495 +++++++- solana/programs/scaas-liquidity/src/state.rs | 66 + .../src/utils.rs | 0 solana/programs/stable-swapper/src/state.rs | 36 - solana/tests/migration.ts | 273 ++++ .../{stable-swapper.ts => scaas-liquidity.ts} | 1131 ++++++++++++----- solana/yarn.lock | 46 +- 17 files changed, 1819 insertions(+), 559 deletions(-) create mode 100644 solana/migrations/deploy.ts rename solana/programs/{stable-swapper => scaas-liquidity}/Cargo.toml (77%) rename solana/programs/{stable-swapper => scaas-liquidity}/Xargo.toml (100%) rename solana/programs/{stable-swapper => scaas-liquidity}/src/constants.rs (73%) rename solana/programs/{stable-swapper => scaas-liquidity}/src/errors.rs (68%) rename solana/programs/{stable-swapper => scaas-liquidity}/src/lib.rs (54%) create mode 100644 solana/programs/scaas-liquidity/src/state.rs rename solana/programs/{stable-swapper => scaas-liquidity}/src/utils.rs (100%) delete mode 100644 solana/programs/stable-swapper/src/state.rs create mode 100644 solana/tests/migration.ts rename solana/tests/{stable-swapper.ts => scaas-liquidity.ts} (72%) diff --git a/solana/.gitignore b/solana/.gitignore index 01342bf..bfef655 100644 --- a/solana/.gitignore +++ b/solana/.gitignore @@ -14,4 +14,4 @@ test-ledger **/deploy/*.json **/*-keypair.json **/id.json -package-lock.json +.migration-verify diff --git a/solana/Anchor.toml b/solana/Anchor.toml index 1aaa989..9c90b35 100644 --- a/solana/Anchor.toml +++ b/solana/Anchor.toml @@ -6,13 +6,13 @@ resolution = true skip-lint = false [programs.localnet] -stable_swapper = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" +scaas_liquidity = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" [programs.devnet] -stable_swapper = "9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH" +scaas_liquidity = "9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH" [programs.mainnet] -stable_swapper = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" +scaas_liquidity = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" [registry] url = "https://api.apr.dev" diff --git a/solana/Cargo.lock b/solana/Cargo.lock index 54dd782..c861f4a 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1206,6 +1206,14 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scaas-liquidity" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2546,14 +2554,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "stable-swapper" -version = "0.1.0" -dependencies = [ - "anchor-lang", - "anchor-spl", -] - [[package]] name = "subtle" version = "2.6.1" diff --git a/solana/README.md b/solana/README.md index a7d20cc..f1f090b 100644 --- a/solana/README.md +++ b/solana/README.md @@ -1,96 +1,103 @@ -# StableSwapper -- Solana +# SCaaS - Stablecoin-as-a-Service Liquidity Management -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 SCM cold and CCS 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 +├── programs/scaas-liquidity/ # Solana program (Rust/Anchor) +│ ├── src/ +│ │ ├── lib.rs # Instructions + account constraints +│ │ ├── state.rs # Pool / vault account layouts +│ │ ├── constants.rs +│ │ └── errors.rs +│ └── Cargo.toml +├── tests/ # Anchor / bankrun program tests +│ ├── scaas-liquidity.ts # RBAC roles, allowlist, swaps, pauses +│ └── 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. +## 🚀 Getting Started -- **Rust** 1.70+ -- **Solana CLI** (Agave) 3.0+ +### 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: +entries 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: ```bash # Mint an ephemeral test keypair and align the program ID everywhere mkdir -p target/deploy solana-keygen new --no-bip39-passphrase --silent --force \ - --outfile target/deploy/stable_swapper-keypair.json -TEST_ID=$(solana address -k target/deploy/stable_swapper-keypair.json) + --outfile target/deploy/scaas_liquidity-keypair.json +TEST_ID=$(solana address -k target/deploy/scaas_liquidity-keypair.json) perl -pi -e "s/declare_id!\\(\"[^\"]+\"\\)/declare_id!(\"$TEST_ID\")/" \ - programs/stable-swapper/src/lib.rs + programs/scaas-liquidity/src/lib.rs awk -v id="$TEST_ID" ' /^\[/ { in_localnet = ($0 ~ /^\[programs\.localnet\]$/) } - in_localnet && /^stable_swapper[[:space:]]*=/ { - print "stable_swapper = \"" id "\""; next + in_localnet && /^scaas_liquidity[[:space:]]*=/ { + print "scaas_liquidity = \"" id "\""; next } { print } ' Anchor.toml > Anchor.toml.tmp && mv Anchor.toml.tmp Anchor.toml @@ -101,88 +108,105 @@ anchor build anchor test --provider.cluster localnet --skip-build # Restore the committed IDs when done -git checkout -- programs/stable-swapper/src/lib.rs Anchor.toml +git checkout -- programs/scaas-liquidity/src/lib.rs Anchor.toml ``` -### Deploying +CI runs the equivalent of these steps in `.github/workflows/test.yml`. -```bash -# Build verifiably -anchor build --verifiable +### Network Configuration -# Deploy -anchor deploy --provider.cluster +The system is configured for **Solana Devnet** by default. To change networks: -# Verify deployment -solana program show -``` +2. Update your Solana CLI configuration: + ```bash + solana config set --url mainnet-beta # or devnet + ``` -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 -## Program Architecture +### Design Philosophy -### Pool design +**SCaaS 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 -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) +- 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) -### Fee model +**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 -Fees are charged on the **input token** (the token being swapped from). The user provides the full swap amount, which is split: +### Roles -- **Net amount** (after fee) → goes to the destination vault as liquidity -- **Fee amount** → goes to the fee recipient as protocol revenue +| Role | Key class | Permissions | +| --- | --- | --- | +| Pause Authority | CCS hot | `pause_swaps`, `pause_withdraws`, `pause_token` | +| Unpause Authority | SCM cold | `unpause_swaps`, `unpause_withdraws`, `unpause_token` | +| Treasury Authority | CCS hot | `withdraw_liquidity` (recipient must be on `withdraw_recipients` allowlist) | +| Configure Authority | SCM cold | `add_supported_token`, `remove_supported_token`, `update_fee_config`, `add_withdraw_recipient`, `remove_withdraw_recipient` | +| Each role | (self) | `update__authority` (strict self-rotation) | -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) +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. -### Swap permissions +### Core Instructions -- 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. +- **`initialize`**: Creates pool with the four role authorities, fee recipient, and a withdraw allowlist seeded with one recipient +- **`migrate_authorities`**: One-shot migration of an existing legacy pool to the role-based layout (co-signed by current `operations_authority` + `pause_authority`); seeds the withdraw allowlist with the provided recipient. 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_config`**: Configure Authority updates fee rate and recipient +- **`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) -### Core instructions +Liquidity is seeded by sending tokens directly to the vault token account via an SPL Token transfer; there is no dedicated deposit instruction. -- **`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 +## 🔐 Security Features -Liquidity is seeded by sending tokens directly to the vault token account via an SPL Token transfer; there is no dedicated deposit instruction. +### Access Controls +- **Four-role model**: Pause/Unpause/Treasury/Configure split across SCM cold and CCS 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 -## Security +### 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 -### 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 +### 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 -### 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 +## 🚀 Deployment -### 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 +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`. -For vulnerability disclosure, see the repository-root [`SECURITY.md`](../SECURITY.md). +```bash +# Local / CI build +anchor build + +# Inspect a deployed program +solana program show +``` diff --git a/solana/migrations/deploy.ts b/solana/migrations/deploy.ts new file mode 100644 index 0000000..439431e --- /dev/null +++ b/solana/migrations/deploy.ts @@ -0,0 +1,12 @@ +// Migrations are an early feature. Currently, they're nothing more than this +// single deploy script that's invoked from the CLI, injecting a provider +// configured from the workspace's Anchor.toml. + +import * as anchor from "@coral-xyz/anchor"; + +module.exports = async function (provider: anchor.AnchorProvider) { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Add your deploy script here. +}; diff --git a/solana/package.json b/solana/package.json index 5297855..ab910d1 100644 --- a/solana/package.json +++ b/solana/package.json @@ -1,5 +1,5 @@ { - "license": "Apache-2.0", + "license": "ISC", "scripts": { "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" @@ -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/Cargo.toml b/solana/programs/scaas-liquidity/Cargo.toml similarity index 77% rename from solana/programs/stable-swapper/Cargo.toml rename to solana/programs/scaas-liquidity/Cargo.toml index 03725a0..ae7ddd1 100644 --- a/solana/programs/stable-swapper/Cargo.toml +++ b/solana/programs/scaas-liquidity/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "stable-swapper" +name = "scaas-liquidity" version = "0.1.0" -description = "StableSwapper liquidity pool program" +description = "Created with Anchor" edition = "2021" [lib] crate-type = ["cdylib", "lib"] -name = "stable_swapper" +name = "scaas_liquidity" [features] default = [] diff --git a/solana/programs/stable-swapper/Xargo.toml b/solana/programs/scaas-liquidity/Xargo.toml similarity index 100% rename from solana/programs/stable-swapper/Xargo.toml rename to solana/programs/scaas-liquidity/Xargo.toml diff --git a/solana/programs/stable-swapper/src/constants.rs b/solana/programs/scaas-liquidity/src/constants.rs similarity index 73% rename from solana/programs/stable-swapper/src/constants.rs rename to solana/programs/scaas-liquidity/src/constants.rs index be948c2..cc9397b 100644 --- a/solana/programs/stable-swapper/src/constants.rs +++ b/solana/programs/scaas-liquidity/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/scaas-liquidity/src/errors.rs similarity index 68% rename from solana/programs/stable-swapper/src/errors.rs rename to solana/programs/scaas-liquidity/src/errors.rs index a6180e8..d542014 100644 --- a/solana/programs/stable-swapper/src/errors.rs +++ b/solana/programs/scaas-liquidity/src/errors.rs @@ -48,4 +48,22 @@ 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("Withdraw recipient is not configured")] + WithdrawRecipientNotSet, + #[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, } diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/scaas-liquidity/src/lib.rs similarity index 54% rename from solana/programs/stable-swapper/src/lib.rs rename to solana/programs/scaas-liquidity/src/lib.rs index 6d1c558..5ba29af 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/scaas-liquidity/src/lib.rs @@ -19,16 +19,27 @@ declare_id!("9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH"); // close_whitelist instruction is not worth the complexity for the small amount involved. #[program] -pub mod stable_swapper { +pub mod scaas_liquidity { use super::*; pub fn initialize(ctx: Context, fee_rate: u64) -> Result<()> { require!(fee_rate <= MAX_FEE_RATE, LiquidityError::InvalidFeeRate); + // Refuse to stand up a pool whose withdraws would be unconditionally blocked by + // `withdraw_liquidity`'s zero-key guard. Mirrors `update_withdraw_recipient`. + require!( + ctx.accounts.withdraw_recipient.key() != Pubkey::default(), + LiquidityError::WithdrawRecipientNotSet + ); 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 +50,36 @@ pub mod stable_swapper { Ok(()) } + /// One-shot migration from the legacy `(operations_authority, pause_authority)` layout + /// to the new role-based layout. Co-signed by both legacy authorities so neither key alone + /// can unilaterally redistribute roles. + /// + /// 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.legacy_operations_authority.to_account_info(), + &ctx.accounts.legacy_pause_authority.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(); @@ -79,12 +120,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 +152,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]]], @@ -262,7 +303,17 @@ pub mod stable_swapper { require!(!pool.liquidity_paused, LiquidityError::LiquidityPaused); 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 @@ -311,36 +362,78 @@ pub mod stable_swapper { Ok(()) } - pub fn update_pause_config( - ctx: Context, - swaps_paused: Option, - liquidity_paused: Option, + /// 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 != Pubkey::default(), + LiquidityError::WithdrawRecipientNotSet + ); 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(()) + } - if let Some(new_swaps_paused) = swaps_paused { - pool.swaps_paused = new_swaps_paused; - msg!("Updated swaps_paused to: {}", new_swaps_paused); - } + /// 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_liquidity_paused) = liquidity_paused { - pool.liquidity_paused = new_liquidity_paused; - msg!("Updated liquidity_paused to: {}", new_liquidity_paused); - } + pub fn pause_swaps(ctx: Context) -> Result<()> { + ctx.accounts.pool.swaps_paused = true; + msg!("Swaps paused"); + Ok(()) + } + pub fn unpause_swaps(ctx: Context) -> Result<()> { + ctx.accounts.pool.swaps_paused = false; + msg!("Swaps unpaused"); 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 pause_withdraws(ctx: Context) -> Result<()> { + ctx.accounts.pool.liquidity_paused = true; + msg!("Withdraws paused"); + Ok(()) + } + + 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(()) } @@ -354,21 +447,193 @@ pub mod stable_swapper { 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<()> { + 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<()> { + 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<()> { + 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(()) } } +/// Shared body for `migrate_authorities`: legacy parse, signer match, realloc, rent top-up, +/// re-serialize. The Accounts struct on the calling instruction is responsible for verifying +/// the pool address (the canonical PDA). +fn do_migrate_authorities<'info>( + pool_ai: &AccountInfo<'info>, + legacy_operations_authority_ai: &AccountInfo<'info>, + legacy_pause_authority_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!( + new_withdraw_recipient != Pubkey::default(), + LiquidityError::WithdrawRecipientNotSet + ); + + 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, + LiquidityError::LegacyDiscriminatorMismatch + ); + + // Re-run guard: only legacy-sized accounts are migratable. After a successful migration + // the account is `new_total` bytes, so a second invocation lands here. + require!( + pool_ai.data_len() == legacy_total, + LiquidityError::AlreadyMigrated + ); + + // Snapshot legacy fields with a scoped borrow so we can drop it before realloc. + let ( + legacy_ops, + legacy_pause, + 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 + ); + + // `Pubkey::try_from` on a 32-byte slice is infallible; the slice length is fixed + // here by construction, so unwrap is safe. + let legacy_ops = Pubkey::try_from(&data[8..40]).unwrap(); + let legacy_pause = Pubkey::try_from(&data[40..72]).unwrap(); + 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_ops, + legacy_pause, + legacy_fee_recipient, + tokens, + fee_rate, + swaps_paused, + liquidity_paused, + bump, + ) + }; + + // Verify both legacy signers match the on-chain values. + require_keys_eq!( + *legacy_operations_authority_ai.key, + legacy_ops, + LiquidityError::LegacyDiscriminatorMismatch + ); + require_keys_eq!( + *legacy_pause_authority_ai.key, + legacy_pause, + LiquidityError::LegacyDiscriminatorMismatch + ); + + // Top up rent for the additional 96 bytes, then grow the account. + 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: legacy_operations_authority_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()?; + // 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 +649,49 @@ pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'info>, - /// CHECK: Operations authority can be any account - pub operations_authority: UncheckedAccount<'info>, - /// 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>, + + /// Legacy operations authority. Verified inside the instruction against the legacy on-chain + /// bytes; pays the additional rent for the 96-byte realloc. + #[account(mut)] + pub legacy_operations_authority: Signer<'info>, + + /// Legacy pause authority. Verified inside the instruction against the legacy on-chain bytes. + pub legacy_pause_authority: Signer<'info>, + pub system_program: Program<'info, System>, } @@ -400,7 +699,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 +707,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 +716,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 +726,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 +739,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 +751,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 +759,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 +775,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 +867,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 +886,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 +897,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 +906,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 +942,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 +993,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/scaas-liquidity/src/state.rs b/solana/programs/scaas-liquidity/src/state.rs new file mode 100644 index 0000000..8ac2bfb --- /dev/null +++ b/solana/programs/scaas-liquidity/src/state.rs @@ -0,0 +1,66 @@ +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 { + /// 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 `withdraw_recipient`). + pub treasury_authority: Pubkey, + /// Cold key allowed to list/unlist tokens, update fee config, and rotate the withdraw recipient. + 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, + /// Controls the withdraw_liquidity instruction only. + /// Note: Deposits go directly to vault_token_account via SPL Token transfers and are not gated. + pub liquidity_paused: bool, + pub bump: u8, +} + +impl LiquidityPool { + // 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] +pub struct TokenVault { + pub mint: Pubkey, + /// Deprecated: liquidity reservation was removed in STBLE-2811. + #[deprecated( + note = "Liquidity reservation was removed in STBLE-2811; field retained for layout compatibility and is always zero on new vaults." + )] + pub reserved_amount: u64, + pub disabled: bool, // If true, this token cannot be used in swaps + pub bump: u8, +} + +impl TokenVault { + pub const INIT_SPACE: usize = 32 + 8 + 1 + 1; // mint + reserved_amount (layout-only) + disabled + bump +} diff --git a/solana/programs/stable-swapper/src/utils.rs b/solana/programs/scaas-liquidity/src/utils.rs similarity index 100% rename from solana/programs/stable-swapper/src/utils.rs rename to solana/programs/scaas-liquidity/src/utils.rs diff --git a/solana/programs/stable-swapper/src/state.rs b/solana/programs/stable-swapper/src/state.rs deleted file mode 100644 index 3b17a5f..0000000 --- a/solana/programs/stable-swapper/src/state.rs +++ /dev/null @@ -1,36 +0,0 @@ -use crate::constants::MAX_SUPPORTED_TOKENS; -use anchor_lang::prelude::*; - -#[account] -pub struct LiquidityPool { - pub operations_authority: Pubkey, - pub pause_authority: Pubkey, - pub fee_recipient: Pubkey, - pub supported_tokens: Vec, - pub fee_rate: u64, // in basis points - pub swaps_paused: bool, - /// Controls the withdraw_liquidity instruction only. - /// Note: Deposits go directly to vault_token_account via SPL Token transfers and are not gated. - pub liquidity_paused: bool, - pub bump: u8, -} - -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 -} - -#[account] -pub struct TokenVault { - pub mint: Pubkey, - /// Deprecated: liquidity reservation was removed. - #[deprecated( - note = "Liquidity reservation was removed; field retained for layout compatibility and is always zero on new vaults." - )] - pub reserved_amount: u64, - pub disabled: bool, // If true, this token cannot be used in swaps - pub bump: u8, -} - -impl TokenVault { - pub const INIT_SPACE: usize = 32 + 8 + 1 + 1; // mint + reserved_amount (layout-only) + disabled + bump -} diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts new file mode 100644 index 0000000..ea9c2fe --- /dev/null +++ b/solana/tests/migration.ts @@ -0,0 +1,273 @@ +/** + * 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 { ScaasLiquidity } from "../target/types/scaas_liquidity"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const IDL = require("../target/idl/scaas_liquidity.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"); + +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; +} + +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 accountDiscriminator: Buffer; + + // Legacy authorities embedded in the fabricated pool. The migration verifies the signers + // against these on-chain values. + const legacyOps = Keypair.generate(); + const legacyPause = Keypair.generate(); + 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: "scaas_liquidity", programId }], []); + provider = new BankrunProvider(context); + program = new Program(IDL as ScaasLiquidity, provider); + + [pool, poolBump] = PublicKey.findProgramAddressSync( + [LIQUIDITY_POOL_SEED], + programId + ); + accountDiscriminator = Buffer.from( + IDL.accounts.find((a: any) => a.name === "LiquidityPool").discriminator + ); + + // Fund the legacy signers as system accounts; legacyOps pays the realloc rent top-up. + for (const kp of [legacyOps, legacyPause]) { + context.setAccount(kp.publicKey, { + lamports: 1_000 * 1_000_000_000, + data: Buffer.alloc(0), + owner: SystemProgram.programId, + executable: false, + rentEpoch: 0, + }); + } + }); + + async function seedLegacyPool(overrides: Partial = {}) { + const fields: LegacyFields = { + ops: legacyOps.publicKey, + pause: legacyPause.publicKey, + 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(signers: Keypair[], pauseAuthorityAccount?: PublicKey) { + return program.methods + .migrateAuthorities( + newPause.publicKey, + newUnpause.publicKey, + newTreasury.publicKey, + newConfigure.publicKey, + newWithdrawRecipient.publicKey + ) + .accounts({ + pool, + legacyOperationsAuthority: legacyOps.publicKey, + legacyPauseAuthority: pauseAuthorityAccount ?? legacyPause.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers(signers) + .rpc(); + } + + 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([legacyOps, legacyPause]); + + 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("rejects a second migration (AlreadyMigrated)", async () => { + await seedLegacyPool(); + await migrate([legacyOps, legacyPause]); + + try { + await migrate([legacyOps, legacyPause]); + assert.fail("expected AlreadyMigrated"); + } catch (error) { + assert.include(errText(error), "alreadymigrated"); + } + }); + + it("rejects a migration when a legacy signer does not match", async () => { + await seedLegacyPool(); + const stranger = Keypair.generate(); + context.setAccount(stranger.publicKey, { + lamports: 1_000_000_000, + data: Buffer.alloc(0), + owner: SystemProgram.programId, + executable: false, + rentEpoch: 0, + }); + + try { + await migrate([legacyOps, stranger], stranger.publicKey); + assert.fail("expected legacy signer mismatch"); + } catch (error) { + assert.include(errText(error), "legacydiscriminatormismatch"); + } + }); + + 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([legacyOps, legacyPause]); + + 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/scaas-liquidity.ts similarity index 72% rename from solana/tests/stable-swapper.ts rename to solana/tests/scaas-liquidity.ts index d9decc6..2a81f74 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/scaas-liquidity.ts @@ -1,6 +1,6 @@ import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; -import { StableSwapper } from "../target/types/stable_swapper"; +import { ScaasLiquidity } from "../target/types/scaas_liquidity"; import { PublicKey, SystemProgram } from "@solana/web3.js"; import { TOKEN_PROGRAM_ID, @@ -15,34 +15,38 @@ import { } from "@solana/spl-token"; import { assert } from "chai"; -describe("stable-swapper", () => { +describe("scaas-liquidity", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); - const program = anchor.workspace.stableSwapper as Program; + const program = anchor.workspace.scaasLiquidity 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 appStableMint: PublicKey; let pool: PublicKey; let usdcVault: PublicKey; - let customStableVault: PublicKey; + let appStableVault: PublicKey; let usdcVaultTokenAccount: PublicKey; - let customStableVaultTokenAccount: PublicKey; + let appStableVaultTokenAccount: PublicKey; // User accounts (also used for fee collection since authority is the fee recipient in tests) let userUsdcAccount: PublicKey; - let userCustomStableAccount: PublicKey; + let userAppStableAccount: PublicKey; // Fee recipient token accounts (created when tokens are added) let feeRecipientUsdcAccount: PublicKey; - let feeRecipientCustomStableAccount: PublicKey; + let feeRecipientAppStableAccount: PublicKey; before(async () => { - // Create USDC and CustomStable mints + // Create USDC and AppStable mints usdcMint = await createMint( provider.connection, payer.payer, @@ -51,12 +55,12 @@ describe("stable-swapper", () => { 6 // USDC decimals ); - customStableMint = await createMint( + appStableMint = await createMint( provider.connection, payer.payer, payer.publicKey, null, - 6 // CustomStable decimals + 6 // AppStable decimals ); // Derive PDAs (pool is now a single centralized pool, no authority in seed) @@ -70,12 +74,8 @@ describe("stable-swapper", () => { program.programId ); - [customStableVault] = PublicKey.findProgramAddressSync( - [ - Buffer.from("token_vault"), - pool.toBuffer(), - customStableMint.toBuffer(), - ], + [appStableVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), pool.toBuffer(), appStableMint.toBuffer()], program.programId ); @@ -84,8 +84,8 @@ describe("stable-swapper", () => { program.programId ); - [customStableVaultTokenAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("vault_token_account"), customStableVault.toBuffer()], + [appStableVaultTokenAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("vault_token_account"), appStableVault.toBuffer()], program.programId ); @@ -94,8 +94,8 @@ describe("stable-swapper", () => { usdcMint, payer.publicKey ); - feeRecipientCustomStableAccount = await getAssociatedTokenAddress( - customStableMint, + feeRecipientAppStableAccount = await getAssociatedTokenAddress( + appStableMint, payer.publicKey ); @@ -107,10 +107,10 @@ describe("stable-swapper", () => { payer.publicKey ); - userCustomStableAccount = await createAccount( + userAppStableAccount = await createAccount( provider.connection, payer.payer, - customStableMint, + appStableMint, payer.publicKey ); @@ -127,10 +127,10 @@ describe("stable-swapper", () => { await mintTo( provider.connection, payer.payer, - customStableMint, - userCustomStableAccount, + appStableMint, + userAppStableAccount, payer.payer, - 1000 * 10 ** 6 // 1000 CustomStable + 1000 * 10 ** 6 // 1000 AppStable ); }); @@ -143,9 +143,12 @@ describe("stable-swapper", () => { .accounts({ pool, payer: payer.publicKey, - operationsAuthority: operationsAuthority.publicKey, 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 +156,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 +197,13 @@ 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(); // Verify vault creation @@ -199,23 +219,23 @@ describe("stable-swapper", () => { ); }); - it("Adds CustomStable as supported token", async () => { + it("Adds AppStable as supported token", async () => { await program.methods .addSupportedToken() .accounts({ pool, - vault: customStableVault, - vaultTokenAccount: customStableVaultTokenAccount, - feeRecipientTokenAccount: feeRecipientCustomStableAccount, + vault: appStableVault, + vaultTokenAccount: appStableVaultTokenAccount, + feeRecipientTokenAccount: feeRecipientAppStableAccount, feeRecipient: payer.publicKey, - mint: customStableMint, - operationsAuthority: operationsAuthority.publicKey, + mint: appStableMint, + 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(); // Verify pool now has both tokens @@ -242,8 +262,8 @@ describe("stable-swapper", () => { await transfer( provider.connection, payer.payer, - userCustomStableAccount, - customStableVaultTokenAccount, + userAppStableAccount, + appStableVaultTokenAccount, payer.payer, seedAmount ); @@ -270,10 +290,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 +321,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() // swapsPaused=null, liquidityPaused=true .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -320,10 +340,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("Expected liquidity paused error"); @@ -333,29 +353,29 @@ describe("stable-swapper", () => { // 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(); }); }); describe("Swapping", () => { - it("Swaps USDC for CustomStable (1:1)", async () => { + it("Swaps USDC for AppStable (1:1)", async () => { const swapAmount = new anchor.BN(100 * 10 ** 6); // 100 USDC - const minAmountOut = new anchor.BN(100 * 10 ** 6); // Expect 100 CustomStable (0% fee) + const minAmountOut = new anchor.BN(100 * 10 ** 6); // Expect 100 AppStable (0% fee) // Get initial balances const initialUserUsdcBalance = await getAccount( provider.connection, userUsdcAccount ); - const initialUserCustomStableBalance = await getAccount( + const initialUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); await program.methods @@ -363,15 +383,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, // Fee collected in input token (USDC) feeRecipient: payer.publicKey, // Fee recipient authority fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -385,24 +405,23 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserCustomStableBalance = await getAccount( + const finalUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); // Verify balances changed correctly (1:1 swap, 0% fee) const usdcDiff = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; - const customStableDiff = - finalUserCustomStableBalance.amount - - initialUserCustomStableBalance.amount; + const appStableDiff = + finalUserAppStableBalance.amount - initialUserAppStableBalance.amount; assert.equal(usdcDiff.toString(), swapAmount.toString()); - assert.equal(customStableDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee + assert.equal(appStableDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee }); - it("Swaps CustomStable for USDC (1:1)", async () => { - const swapAmount = new anchor.BN(50 * 10 ** 6); // 50 CustomStable + it("Swaps AppStable for USDC (1:1)", async () => { + const swapAmount = new anchor.BN(50 * 10 ** 6); // 50 AppStable const minAmountOut = new anchor.BN(50 * 10 ** 6); // Expect 50 USDC (0% fee) // Get initial balances @@ -410,24 +429,24 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const initialUserCustomStableBalance = await getAccount( + const initialUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); await program.methods .swap(swapAmount, minAmountOut) .accounts({ pool, - inVault: customStableVault, + inVault: appStableVault, outVault: usdcVault, - inVaultTokenAccount: customStableVaultTokenAccount, + inVaultTokenAccount: appStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, - userFromTokenAccount: userCustomStableAccount, + userFromTokenAccount: userAppStableAccount, toTokenAccount: userUsdcAccount, - feeRecipientTokenAccount: userCustomStableAccount, // Fee collected in input token (CustomStable) + feeRecipientTokenAccount: userAppStableAccount, // Fee collected in input token (AppStable) feeRecipient: payer.publicKey, // Fee recipient authority - fromMint: customStableMint, + fromMint: appStableMint, toMint: usdcMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, @@ -442,19 +461,18 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserCustomStableBalance = await getAccount( + const finalUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); // Verify balances changed correctly (1:1 swap, 0% fee) const usdcDiff = finalUserUsdcBalance.amount - initialUserUsdcBalance.amount; - const customStableDiff = - initialUserCustomStableBalance.amount - - finalUserCustomStableBalance.amount; + const appStableDiff = + initialUserAppStableBalance.amount - finalUserAppStableBalance.amount; - assert.equal(customStableDiff.toString(), swapAmount.toString()); + assert.equal(appStableDiff.toString(), swapAmount.toString()); assert.equal(usdcDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee }); @@ -466,10 +484,10 @@ describe("stable-swapper", () => { usdcMint, swapper.publicKey ); - const swapperCustomStableAccount = await createAccount( + const swapperAppStableAccount = await createAccount( provider.connection, payer.payer, - customStableMint, + appStableMint, swapper.publicKey ); @@ -486,7 +504,7 @@ describe("stable-swapper", () => { const minAmountOut = new anchor.BN(10 * 10 ** 6); const beforeBalance = await getAccount( provider.connection, - swapperCustomStableAccount + swapperAppStableAccount ); await program.methods @@ -494,15 +512,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: swapperUsdcAccount, - toTokenAccount: swapperCustomStableAccount, + toTokenAccount: swapperAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: swapper.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -513,7 +531,7 @@ describe("stable-swapper", () => { const afterBalance = await getAccount( provider.connection, - swapperCustomStableAccount + swapperAppStableAccount ); assert.equal( (afterBalance.amount - beforeBalance.amount).toString(), @@ -533,15 +551,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: unauthorizedUser.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -573,10 +591,10 @@ describe("stable-swapper", () => { usdcMint, owner.publicKey ); - const delegateCustomStableAccount = await createAccount( + const delegateAppStableAccount = await createAccount( provider.connection, payer.payer, - customStableMint, + appStableMint, delegate.publicKey ); @@ -600,7 +618,7 @@ describe("stable-swapper", () => { const beforeBalance = await getAccount( provider.connection, - delegateCustomStableAccount + delegateAppStableAccount ); await program.methods @@ -608,15 +626,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: ownerUsdcAccount, - toTokenAccount: delegateCustomStableAccount, + toTokenAccount: delegateAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: delegate.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -627,7 +645,7 @@ describe("stable-swapper", () => { const afterBalance = await getAccount( provider.connection, - delegateCustomStableAccount + delegateAppStableAccount ); assert.equal( (afterBalance.amount - beforeBalance.amount).toString(), @@ -638,7 +656,7 @@ describe("stable-swapper", () => { it("Allows swapping exactly the full destination vault balance", async () => { const destinationVaultBefore = await getAccount( provider.connection, - customStableVaultTokenAccount + appStableVaultTokenAccount ); const fullDrainAmount = new anchor.BN( destinationVaultBefore.amount.toString() @@ -658,15 +676,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -677,15 +695,15 @@ describe("stable-swapper", () => { const drainedVault = await getAccount( provider.connection, - customStableVaultTokenAccount + appStableVaultTokenAccount ); assert.equal(drainedVault.amount.toString(), "0"); await transfer( provider.connection, payer.payer, - userCustomStableAccount, - customStableVaultTokenAccount, + userAppStableAccount, + appStableVaultTokenAccount, payer.payer, BigInt(fullDrainAmount.toString()) ); @@ -701,15 +719,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -730,9 +748,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(500), null) // 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 @@ -746,15 +764,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -773,9 +791,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); @@ -785,9 +803,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(100), null) // 1% fee .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Try to swap only 1 unit @@ -803,15 +821,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -832,9 +850,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); }); @@ -843,7 +861,7 @@ describe("stable-swapper", () => { it("Disables a token and prevents swaps", async () => { // Disable USDC await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: usdcVault, @@ -857,7 +875,7 @@ describe("stable-swapper", () => { const vaultAccount = await program.account.tokenVault.fetch(usdcVault); assert.equal(vaultAccount.disabled, true, "Vault should be disabled"); - // Try to swap USDC for CustomStable (should fail) + // Try to swap USDC for AppStable (should fail) const swapAmount = new anchor.BN(10 * 10 ** 6); const minAmountOut = new anchor.BN(10 * 10 ** 6); @@ -867,15 +885,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -891,7 +909,7 @@ describe("stable-swapper", () => { }); it("Prevents swaps when output token is disabled", async () => { - // Try to swap CustomStable for USDC (USDC is disabled from previous test) + // Try to swap AppStable for USDC (USDC is disabled from previous test) const swapAmount = new anchor.BN(10 * 10 ** 6); const minAmountOut = new anchor.BN(10 * 10 ** 6); @@ -900,15 +918,15 @@ describe("stable-swapper", () => { .swap(swapAmount, minAmountOut) .accounts({ pool, - inVault: customStableVault, + inVault: appStableVault, outVault: usdcVault, - inVaultTokenAccount: customStableVaultTokenAccount, + inVaultTokenAccount: appStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, - userFromTokenAccount: userCustomStableAccount, + userFromTokenAccount: userAppStableAccount, toTokenAccount: userUsdcAccount, - feeRecipientTokenAccount: userCustomStableAccount, + feeRecipientTokenAccount: userAppStableAccount, feeRecipient: payer.publicKey, - fromMint: customStableMint, + fromMint: appStableMint, toMint: usdcMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, @@ -927,14 +945,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 @@ -950,15 +968,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -986,7 +1004,7 @@ describe("stable-swapper", () => { try { await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: usdcVault, @@ -1065,13 +1083,13 @@ 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(); const poolAccount = await program.account.liquidityPool.fetch(pool); @@ -1092,10 +1110,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 +1141,7 @@ describe("stable-swapper", () => { it("Disables the test token", async () => { await program.methods - .updateTokenStatus(true) + .pauseToken() .accounts({ pool, vault: testTokenVault, @@ -1148,10 +1166,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 +1193,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 +1214,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 +1292,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 +1322,7 @@ describe("stable-swapper", () => { vault: newTestTokenVault, vaultTokenAccount: newTestTokenVaultTokenAccount, mint: newTestTokenMint, - operationsAuthority: unauthorizedUser.publicKey, + configureAuthority: unauthorizedUser.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) .signers([unauthorizedUser]) @@ -1323,10 +1341,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(); }); }); @@ -1339,9 +1357,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(newFeeRate), null) // feeRate, feeRecipient .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Verify fee rate was updated @@ -1353,15 +1371,15 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), null) // feeRate, feeRecipient .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() // swapsPaused, liquidityPaused .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -1385,15 +1403,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1410,7 +1428,7 @@ describe("stable-swapper", () => { it("Unpauses swaps", async () => { await program.methods - .updatePauseConfig(false, null) // swapsPaused, liquidityPaused + .unpauseSwaps() // swapsPaused, liquidityPaused .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -1441,15 +1459,15 @@ describe("stable-swapper", () => { .swap(excessiveAmount, minAmountOut) .accounts({ pool, - inVault: customStableVault, + inVault: appStableVault, outVault: usdcVault, - inVaultTokenAccount: customStableVaultTokenAccount, + inVaultTokenAccount: appStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, - userFromTokenAccount: userCustomStableAccount, + userFromTokenAccount: userAppStableAccount, toTokenAccount: userUsdcAccount, - feeRecipientTokenAccount: userCustomStableAccount, + feeRecipientTokenAccount: userAppStableAccount, feeRecipient: payer.publicKey, - fromMint: customStableMint, + fromMint: appStableMint, toMint: usdcMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, @@ -1482,9 +1500,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(100), 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 @@ -1497,9 +1515,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const initialUserCustomStableBalance = await getAccount( + const initialUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); const initialVaultUsdcBalance = await getAccount( provider.connection, @@ -1515,15 +1533,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1537,9 +1555,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserCustomStableBalance = await getAccount( + const finalUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); const finalVaultUsdcBalance = await getAccount( provider.connection, @@ -1560,11 +1578,10 @@ describe("stable-swapper", () => { ); // Verify user received net amount (after fee deduction) - const userCustomStableReceived = - finalUserCustomStableBalance.amount - - initialUserCustomStableBalance.amount; + const userAppStableReceived = + finalUserAppStableBalance.amount - initialUserAppStableBalance.amount; assert.equal( - userCustomStableReceived.toString(), + userAppStableReceived.toString(), expectedNetAmount.toString(), "User should receive net amount after fees" ); @@ -1592,9 +1609,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), payer.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); @@ -1615,9 +1632,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(100), newFeeRecipient.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 @@ -1635,15 +1652,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: newFeeRecipientUsdcAccount, // New fee recipient feeRecipient: newFeeRecipient.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1670,9 +1687,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), payer.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); @@ -1687,9 +1704,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const initialUserCustomStableBalance = await getAccount( + const initialUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); await program.methods @@ -1697,15 +1714,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1719,17 +1736,16 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserCustomStableBalance = await getAccount( + const finalUserAppStableBalance = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); // Verify 1:1 swap with no fees const usdcSpent = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; - const customStableReceived = - finalUserCustomStableBalance.amount - - initialUserCustomStableBalance.amount; + const appStableReceived = + finalUserAppStableBalance.amount - initialUserAppStableBalance.amount; assert.equal( usdcSpent.toString(), @@ -1737,7 +1753,7 @@ describe("stable-swapper", () => { "Should spend exact swap amount" ); assert.equal( - customStableReceived.toString(), + appStableReceived.toString(), swapAmount.toString(), "Should receive exact swap amount (1:1, no fees)" ); @@ -1751,9 +1767,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(100), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Create a separate fee recipient to track fees @@ -1769,9 +1785,9 @@ describe("stable-swapper", () => { .updateFeeConfig(null, 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 @@ -1792,15 +1808,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1840,15 +1856,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1875,9 +1891,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), payer.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); @@ -1887,9 +1903,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(100), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); // Create a separate fee recipient to track fees @@ -1905,9 +1921,9 @@ describe("stable-swapper", () => { .updateFeeConfig(null, 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 @@ -1926,15 +1942,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1961,9 +1977,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), payer.publicKey) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); }); @@ -1977,9 +1993,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(excessiveFeeRate), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); assert.fail("Expected invalid fee rate error"); @@ -1995,9 +2011,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(maxFeeRate), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); const poolAccount = await program.account.liquidityPool.fetch(pool); @@ -2008,9 +2024,9 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(0), null) .accounts({ pool, - operationsAuthority: operationsAuthority.publicKey, + configureAuthority: configureAuthority.publicKey, }) - .signers([operationsAuthority.payer]) + .signers([configureAuthority.payer]) .rpc(); }); }); @@ -2076,13 +2092,13 @@ 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(); // Seed liquidity for 9-decimal token via direct SPL transfer @@ -2298,13 +2314,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 +2364,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"); @@ -2366,15 +2382,15 @@ describe("stable-swapper", () => { it("Swaps with same decimals (6 to 6) work", async () => { // This tests backward compatibility - swaps between tokens with same decimals const swapAmount = new anchor.BN(50 * 10 ** 6); // 50 USDC - const minAmountOut = new anchor.BN(50 * 10 ** 6); // Expect 50 CustomStable + const minAmountOut = new anchor.BN(50 * 10 ** 6); // Expect 50 AppStable const userUsdcBefore = await getAccount( provider.connection, userUsdcAccount ); - const userCustomStableBefore = await getAccount( + const userAppStableBefore = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); await program.methods @@ -2382,15 +2398,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: customStableVault, + outVault: appStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: customStableVaultTokenAccount, + outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userCustomStableAccount, + toTokenAccount: userAppStableAccount, feeRecipient: payer.publicKey, feeRecipientTokenAccount: userUsdcAccount, fromMint: usdcMint, - toMint: customStableMint, + toMint: appStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -2403,9 +2419,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const userCustomStableAfter = await getAccount( + const userAppStableAfter = await getAccount( provider.connection, - userCustomStableAccount + userAppStableAccount ); // Should still be 1:1 when decimals are the same @@ -2415,9 +2431,9 @@ describe("stable-swapper", () => { "USDC deducted incorrectly" ); assert.equal( - userCustomStableAfter.amount - userCustomStableBefore.amount, + userAppStableAfter.amount - userAppStableBefore.amount, BigInt(50 * 10 ** 6), - "CustomStable received incorrectly" + "AppStable received incorrectly" ); }); }); @@ -2470,13 +2486,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 +2525,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"); @@ -2576,10 +2592,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 +2622,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"); @@ -2655,34 +2671,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 +2731,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 + // 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([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 - }) - .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 +2875,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 +2900,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]) @@ -2872,7 +2918,7 @@ describe("stable-swapper", () => { .updateFeeConfig(new anchor.BN(50), null) .accounts({ pool, - operationsAuthority: unauthorizedUser.publicKey, // Wrong authority + configureAuthority: unauthorizedUser.publicKey, // Wrong authority }) .signers([unauthorizedUser]) .rpc(); @@ -2886,7 +2932,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 +2945,436 @@ 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 WithdrawRecipientNotSet"); + } catch (error) { + assert.include( + error.toString().toLowerCase(), + "withdrawrecipientnotset" + ); + } + }); + + 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(); + }); + }); + + 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, + legacyOperationsAuthority: payer.publicKey, + legacyPauseAuthority: payer.publicKey, + 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" From 973e715001a7b56545260faa0741ecaaae227d49 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 4 Aug 2026 09:26:11 -0400 Subject: [PATCH 02/20] Remove extra stable-swapper diff --- solana/Anchor.toml | 6 +++--- solana/Cargo.lock | 2 +- solana/README.md | 20 +++++++++---------- solana/migrations/deploy.ts | 12 ----------- .../Cargo.toml | 6 +++--- .../Xargo.toml | 0 .../src/constants.rs | 0 .../src/errors.rs | 0 .../src/lib.rs | 2 +- .../src/state.rs | 0 .../src/utils.rs | 0 solana/tests/migration.ts | 10 +++++----- .../{scaas-liquidity.ts => stable-swapper.ts} | 6 +++--- 13 files changed, 26 insertions(+), 38 deletions(-) delete mode 100644 solana/migrations/deploy.ts rename solana/programs/{scaas-liquidity => stable-swapper}/Cargo.toml (77%) rename solana/programs/{scaas-liquidity => stable-swapper}/Xargo.toml (100%) rename solana/programs/{scaas-liquidity => stable-swapper}/src/constants.rs (100%) rename solana/programs/{scaas-liquidity => stable-swapper}/src/errors.rs (100%) rename solana/programs/{scaas-liquidity => stable-swapper}/src/lib.rs (99%) rename solana/programs/{scaas-liquidity => stable-swapper}/src/state.rs (100%) rename solana/programs/{scaas-liquidity => stable-swapper}/src/utils.rs (100%) rename solana/tests/{scaas-liquidity.ts => stable-swapper.ts} (99%) diff --git a/solana/Anchor.toml b/solana/Anchor.toml index 9c90b35..1aaa989 100644 --- a/solana/Anchor.toml +++ b/solana/Anchor.toml @@ -6,13 +6,13 @@ resolution = true skip-lint = false [programs.localnet] -scaas_liquidity = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" +stable_swapper = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" [programs.devnet] -scaas_liquidity = "9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH" +stable_swapper = "9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH" [programs.mainnet] -scaas_liquidity = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" +stable_swapper = "pqgqKahpG1y2wsgxFhzaAnkV1cL9vk8MSg9qm4q646F" [registry] url = "https://api.apr.dev" diff --git a/solana/Cargo.lock b/solana/Cargo.lock index c861f4a..259676e 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1207,7 +1207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "scaas-liquidity" +name = "stable-swapper" version = "0.1.0" dependencies = [ "anchor-lang", diff --git a/solana/README.md b/solana/README.md index f1f090b..92c6dc3 100644 --- a/solana/README.md +++ b/solana/README.md @@ -1,4 +1,4 @@ -# SCaaS - Stablecoin-as-a-Service Liquidity Management +# StableSwapper -- Solana A production-ready Solana-based liquidity management system designed for secure, efficient 1:1 stablecoin swapping with configurable fees and comprehensive admin controls. @@ -15,7 +15,7 @@ A production-ready Solana-based liquidity management system designed for secure, ## 📁 Project Structure ``` -├── programs/scaas-liquidity/ # Solana program (Rust/Anchor) +├── programs/stable-swapper/ # Solana program (Rust/Anchor) │ ├── src/ │ │ ├── lib.rs # Instructions + account constraints │ │ ├── state.rs # Pool / vault account layouts @@ -23,7 +23,7 @@ A production-ready Solana-based liquidity management system designed for secure, │ │ └── errors.rs │ └── Cargo.toml ├── tests/ # Anchor / bankrun program tests -│ ├── scaas-liquidity.ts # RBAC roles, allowlist, swaps, pauses +│ ├── stable-swapper.ts # RBAC roles, allowlist, swaps, pauses │ └── migration.ts # Legacy → role-layout migrate_authorities ├── Anchor.toml # Anchor configuration ├── Cargo.toml # Workspace configuration @@ -90,14 +90,14 @@ references to it before building: # Mint an ephemeral test keypair and align the program ID everywhere mkdir -p target/deploy solana-keygen new --no-bip39-passphrase --silent --force \ - --outfile target/deploy/scaas_liquidity-keypair.json -TEST_ID=$(solana address -k target/deploy/scaas_liquidity-keypair.json) + --outfile target/deploy/stable_swapper-keypair.json +TEST_ID=$(solana address -k target/deploy/stable_swapper-keypair.json) perl -pi -e "s/declare_id!\\(\"[^\"]+\"\\)/declare_id!(\"$TEST_ID\")/" \ - programs/scaas-liquidity/src/lib.rs + programs/stable-swapper/src/lib.rs awk -v id="$TEST_ID" ' /^\[/ { in_localnet = ($0 ~ /^\[programs\.localnet\]$/) } - in_localnet && /^scaas_liquidity[[:space:]]*=/ { - print "scaas_liquidity = \"" id "\""; next + in_localnet && /^stable_swapper[[:space:]]*=/ { + print "stable_swapper = \"" id "\""; next } { print } ' Anchor.toml > Anchor.toml.tmp && mv Anchor.toml.tmp Anchor.toml @@ -108,7 +108,7 @@ anchor build anchor test --provider.cluster localnet --skip-build # Restore the committed IDs when done -git checkout -- programs/scaas-liquidity/src/lib.rs Anchor.toml +git checkout -- programs/stable-swapper/src/lib.rs Anchor.toml ``` CI runs the equivalent of these steps in `.github/workflows/test.yml`. @@ -126,7 +126,7 @@ The system is configured for **Solana Devnet** by default. To change networks: ### Design Philosophy -**SCaaS uses a single centralized pool** for all users and tokens: +**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 diff --git a/solana/migrations/deploy.ts b/solana/migrations/deploy.ts deleted file mode 100644 index 439431e..0000000 --- a/solana/migrations/deploy.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Migrations are an early feature. Currently, they're nothing more than this -// single deploy script that's invoked from the CLI, injecting a provider -// configured from the workspace's Anchor.toml. - -import * as anchor from "@coral-xyz/anchor"; - -module.exports = async function (provider: anchor.AnchorProvider) { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/solana/programs/scaas-liquidity/Cargo.toml b/solana/programs/stable-swapper/Cargo.toml similarity index 77% rename from solana/programs/scaas-liquidity/Cargo.toml rename to solana/programs/stable-swapper/Cargo.toml index ae7ddd1..03725a0 100644 --- a/solana/programs/scaas-liquidity/Cargo.toml +++ b/solana/programs/stable-swapper/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "scaas-liquidity" +name = "stable-swapper" version = "0.1.0" -description = "Created with Anchor" +description = "StableSwapper liquidity pool program" edition = "2021" [lib] crate-type = ["cdylib", "lib"] -name = "scaas_liquidity" +name = "stable_swapper" [features] default = [] diff --git a/solana/programs/scaas-liquidity/Xargo.toml b/solana/programs/stable-swapper/Xargo.toml similarity index 100% rename from solana/programs/scaas-liquidity/Xargo.toml rename to solana/programs/stable-swapper/Xargo.toml diff --git a/solana/programs/scaas-liquidity/src/constants.rs b/solana/programs/stable-swapper/src/constants.rs similarity index 100% rename from solana/programs/scaas-liquidity/src/constants.rs rename to solana/programs/stable-swapper/src/constants.rs diff --git a/solana/programs/scaas-liquidity/src/errors.rs b/solana/programs/stable-swapper/src/errors.rs similarity index 100% rename from solana/programs/scaas-liquidity/src/errors.rs rename to solana/programs/stable-swapper/src/errors.rs diff --git a/solana/programs/scaas-liquidity/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs similarity index 99% rename from solana/programs/scaas-liquidity/src/lib.rs rename to solana/programs/stable-swapper/src/lib.rs index 5ba29af..caef41e 100644 --- a/solana/programs/scaas-liquidity/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -19,7 +19,7 @@ declare_id!("9vDwZVJXw5nxymWmUcgmNpemDH5EBcJwLNhtsznrgJDH"); // close_whitelist instruction is not worth the complexity for the small amount involved. #[program] -pub mod scaas_liquidity { +pub mod stable_swapper { use super::*; pub fn initialize(ctx: Context, fee_rate: u64) -> Result<()> { diff --git a/solana/programs/scaas-liquidity/src/state.rs b/solana/programs/stable-swapper/src/state.rs similarity index 100% rename from solana/programs/scaas-liquidity/src/state.rs rename to solana/programs/stable-swapper/src/state.rs diff --git a/solana/programs/scaas-liquidity/src/utils.rs b/solana/programs/stable-swapper/src/utils.rs similarity index 100% rename from solana/programs/scaas-liquidity/src/utils.rs rename to solana/programs/stable-swapper/src/utils.rs diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts index ea9c2fe..ab46781 100644 --- a/solana/tests/migration.ts +++ b/solana/tests/migration.ts @@ -16,10 +16,10 @@ import { PublicKey, Keypair, SystemProgram } from "@solana/web3.js"; import { start, ProgramTestContext } from "solana-bankrun"; import { BankrunProvider } from "anchor-bankrun"; import { assert } from "chai"; -import { ScaasLiquidity } from "../target/types/scaas_liquidity"; +import { StableSwapper } from "../target/types/stable_swapper"; // eslint-disable-next-line @typescript-eslint/no-var-requires -const IDL = require("../target/idl/scaas_liquidity.json"); +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 @@ -67,7 +67,7 @@ function errText(error: any): string { describe("migrate_authorities (bankrun)", () => { let context: ProgramTestContext; let provider: BankrunProvider; - let program: Program; + let program: Program; let programId: PublicKey; let pool: PublicKey; let poolBump: number; @@ -99,9 +99,9 @@ describe("migrate_authorities (bankrun)", () => { process.env.BPF_OUT_DIR = deployDir; programId = new PublicKey(IDL.address); - context = await start([{ name: "scaas_liquidity", programId }], []); + context = await start([{ name: "stable_swapper", programId }], []); provider = new BankrunProvider(context); - program = new Program(IDL as ScaasLiquidity, provider); + program = new Program(IDL as StableSwapper, provider); [pool, poolBump] = PublicKey.findProgramAddressSync( [LIQUIDITY_POOL_SEED], diff --git a/solana/tests/scaas-liquidity.ts b/solana/tests/stable-swapper.ts similarity index 99% rename from solana/tests/scaas-liquidity.ts rename to solana/tests/stable-swapper.ts index 2a81f74..59e8ae7 100644 --- a/solana/tests/scaas-liquidity.ts +++ b/solana/tests/stable-swapper.ts @@ -1,6 +1,6 @@ import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; -import { ScaasLiquidity } from "../target/types/scaas_liquidity"; +import { StableSwapper } from "../target/types/stable_swapper"; import { PublicKey, SystemProgram } from "@solana/web3.js"; import { TOKEN_PROGRAM_ID, @@ -15,11 +15,11 @@ import { } from "@solana/spl-token"; import { assert } from "chai"; -describe("scaas-liquidity", () => { +describe("stable-swapper", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); - const program = anchor.workspace.scaasLiquidity as Program; + const program = anchor.workspace.stableSwapper as Program; const payer = provider.wallet as anchor.Wallet; const operationsAuthority = payer; // legacy alias retained for migration tests const pauseAuthority = payer; From 0ecf61e9d5ca4b2ab5ddd1b7eabfffa565036dfb Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 4 Aug 2026 09:30:48 -0400 Subject: [PATCH 03/20] revert unecessary name change --- solana/tests/stable-swapper.ts | 154 ++++++++++++++++----------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 59e8ae7..8b782d1 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -39,14 +39,14 @@ describe("stable-swapper", () => { // User accounts (also used for fee collection since authority is the fee recipient in tests) let userUsdcAccount: PublicKey; - let userAppStableAccount: PublicKey; + let userCustomStableAccount: PublicKey; // Fee recipient token accounts (created when tokens are added) let feeRecipientUsdcAccount: PublicKey; - let feeRecipientAppStableAccount: PublicKey; + let feeRecipientCustomStableAccount: PublicKey; before(async () => { - // Create USDC and AppStable mints + // Create USDC and CustomStable mints usdcMint = await createMint( provider.connection, payer.payer, @@ -60,7 +60,7 @@ describe("stable-swapper", () => { payer.payer, payer.publicKey, null, - 6 // AppStable decimals + 6 // CustomStable decimals ); // Derive PDAs (pool is now a single centralized pool, no authority in seed) @@ -94,7 +94,7 @@ describe("stable-swapper", () => { usdcMint, payer.publicKey ); - feeRecipientAppStableAccount = await getAssociatedTokenAddress( + feeRecipientCustomStableAccount = await getAssociatedTokenAddress( appStableMint, payer.publicKey ); @@ -107,7 +107,7 @@ describe("stable-swapper", () => { payer.publicKey ); - userAppStableAccount = await createAccount( + userCustomStableAccount = await createAccount( provider.connection, payer.payer, appStableMint, @@ -128,9 +128,9 @@ describe("stable-swapper", () => { provider.connection, payer.payer, appStableMint, - userAppStableAccount, + userCustomStableAccount, payer.payer, - 1000 * 10 ** 6 // 1000 AppStable + 1000 * 10 ** 6 // 1000 CustomStable ); }); @@ -219,14 +219,14 @@ describe("stable-swapper", () => { ); }); - it("Adds AppStable as supported token", async () => { + it("Adds CustomStable as supported token", async () => { await program.methods .addSupportedToken() .accounts({ pool, vault: appStableVault, vaultTokenAccount: appStableVaultTokenAccount, - feeRecipientTokenAccount: feeRecipientAppStableAccount, + feeRecipientTokenAccount: feeRecipientCustomStableAccount, feeRecipient: payer.publicKey, mint: appStableMint, configureAuthority: configureAuthority.publicKey, @@ -262,7 +262,7 @@ describe("stable-swapper", () => { await transfer( provider.connection, payer.payer, - userAppStableAccount, + userCustomStableAccount, appStableVaultTokenAccount, payer.payer, seedAmount @@ -364,18 +364,18 @@ describe("stable-swapper", () => { }); describe("Swapping", () => { - it("Swaps USDC for AppStable (1:1)", async () => { + it("Swaps USDC for CustomStable (1:1)", async () => { const swapAmount = new anchor.BN(100 * 10 ** 6); // 100 USDC - const minAmountOut = new anchor.BN(100 * 10 ** 6); // Expect 100 AppStable (0% fee) + const minAmountOut = new anchor.BN(100 * 10 ** 6); // Expect 100 CustomStable (0% fee) // Get initial balances const initialUserUsdcBalance = await getAccount( provider.connection, userUsdcAccount ); - const initialUserAppStableBalance = await getAccount( + const initialUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); await program.methods @@ -387,7 +387,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, // Fee collected in input token (USDC) feeRecipient: payer.publicKey, // Fee recipient authority fromMint: usdcMint, @@ -405,23 +405,23 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserAppStableBalance = await getAccount( + const finalUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); // Verify balances changed correctly (1:1 swap, 0% fee) const usdcDiff = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; const appStableDiff = - finalUserAppStableBalance.amount - initialUserAppStableBalance.amount; + finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; assert.equal(usdcDiff.toString(), swapAmount.toString()); assert.equal(appStableDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee }); - it("Swaps AppStable for USDC (1:1)", async () => { - const swapAmount = new anchor.BN(50 * 10 ** 6); // 50 AppStable + it("Swaps CustomStable for USDC (1:1)", async () => { + const swapAmount = new anchor.BN(50 * 10 ** 6); // 50 CustomStable const minAmountOut = new anchor.BN(50 * 10 ** 6); // Expect 50 USDC (0% fee) // Get initial balances @@ -429,9 +429,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const initialUserAppStableBalance = await getAccount( + const initialUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); await program.methods @@ -442,9 +442,9 @@ describe("stable-swapper", () => { outVault: usdcVault, inVaultTokenAccount: appStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, - userFromTokenAccount: userAppStableAccount, + userFromTokenAccount: userCustomStableAccount, toTokenAccount: userUsdcAccount, - feeRecipientTokenAccount: userAppStableAccount, // Fee collected in input token (AppStable) + feeRecipientTokenAccount: userCustomStableAccount, // Fee collected in input token (CustomStable) feeRecipient: payer.publicKey, // Fee recipient authority fromMint: appStableMint, toMint: usdcMint, @@ -461,16 +461,16 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserAppStableBalance = await getAccount( + const finalUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); // Verify balances changed correctly (1:1 swap, 0% fee) const usdcDiff = finalUserUsdcBalance.amount - initialUserUsdcBalance.amount; const appStableDiff = - initialUserAppStableBalance.amount - finalUserAppStableBalance.amount; + initialUserCustomStableBalance.amount - finalUserCustomStableBalance.amount; assert.equal(appStableDiff.toString(), swapAmount.toString()); assert.equal(usdcDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee @@ -484,7 +484,7 @@ describe("stable-swapper", () => { usdcMint, swapper.publicKey ); - const swapperAppStableAccount = await createAccount( + const swapperCustomStableAccount = await createAccount( provider.connection, payer.payer, appStableMint, @@ -504,7 +504,7 @@ describe("stable-swapper", () => { const minAmountOut = new anchor.BN(10 * 10 ** 6); const beforeBalance = await getAccount( provider.connection, - swapperAppStableAccount + swapperCustomStableAccount ); await program.methods @@ -516,7 +516,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: swapperUsdcAccount, - toTokenAccount: swapperAppStableAccount, + toTokenAccount: swapperCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -531,7 +531,7 @@ describe("stable-swapper", () => { const afterBalance = await getAccount( provider.connection, - swapperAppStableAccount + swapperCustomStableAccount ); assert.equal( (afterBalance.amount - beforeBalance.amount).toString(), @@ -555,7 +555,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -591,7 +591,7 @@ describe("stable-swapper", () => { usdcMint, owner.publicKey ); - const delegateAppStableAccount = await createAccount( + const delegateCustomStableAccount = await createAccount( provider.connection, payer.payer, appStableMint, @@ -618,7 +618,7 @@ describe("stable-swapper", () => { const beforeBalance = await getAccount( provider.connection, - delegateAppStableAccount + delegateCustomStableAccount ); await program.methods @@ -630,7 +630,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: ownerUsdcAccount, - toTokenAccount: delegateAppStableAccount, + toTokenAccount: delegateCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -645,7 +645,7 @@ describe("stable-swapper", () => { const afterBalance = await getAccount( provider.connection, - delegateAppStableAccount + delegateCustomStableAccount ); assert.equal( (afterBalance.amount - beforeBalance.amount).toString(), @@ -680,7 +680,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -702,7 +702,7 @@ describe("stable-swapper", () => { await transfer( provider.connection, payer.payer, - userAppStableAccount, + userCustomStableAccount, appStableVaultTokenAccount, payer.payer, BigInt(fullDrainAmount.toString()) @@ -723,7 +723,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -768,7 +768,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -825,7 +825,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -875,7 +875,7 @@ describe("stable-swapper", () => { const vaultAccount = await program.account.tokenVault.fetch(usdcVault); assert.equal(vaultAccount.disabled, true, "Vault should be disabled"); - // Try to swap USDC for AppStable (should fail) + // Try to swap USDC for CustomStable (should fail) const swapAmount = new anchor.BN(10 * 10 ** 6); const minAmountOut = new anchor.BN(10 * 10 ** 6); @@ -889,7 +889,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -909,7 +909,7 @@ describe("stable-swapper", () => { }); it("Prevents swaps when output token is disabled", async () => { - // Try to swap AppStable for USDC (USDC is disabled from previous test) + // Try to swap CustomStable for USDC (USDC is disabled from previous test) const swapAmount = new anchor.BN(10 * 10 ** 6); const minAmountOut = new anchor.BN(10 * 10 ** 6); @@ -922,9 +922,9 @@ describe("stable-swapper", () => { outVault: usdcVault, inVaultTokenAccount: appStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, - userFromTokenAccount: userAppStableAccount, + userFromTokenAccount: userCustomStableAccount, toTokenAccount: userUsdcAccount, - feeRecipientTokenAccount: userAppStableAccount, + feeRecipientTokenAccount: userCustomStableAccount, feeRecipient: payer.publicKey, fromMint: appStableMint, toMint: usdcMint, @@ -972,7 +972,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -1407,7 +1407,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -1463,9 +1463,9 @@ describe("stable-swapper", () => { outVault: usdcVault, inVaultTokenAccount: appStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, - userFromTokenAccount: userAppStableAccount, + userFromTokenAccount: userCustomStableAccount, toTokenAccount: userUsdcAccount, - feeRecipientTokenAccount: userAppStableAccount, + feeRecipientTokenAccount: userCustomStableAccount, feeRecipient: payer.publicKey, fromMint: appStableMint, toMint: usdcMint, @@ -1515,9 +1515,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const initialUserAppStableBalance = await getAccount( + const initialUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); const initialVaultUsdcBalance = await getAccount( provider.connection, @@ -1537,7 +1537,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, @@ -1555,9 +1555,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserAppStableBalance = await getAccount( + const finalUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); const finalVaultUsdcBalance = await getAccount( provider.connection, @@ -1578,10 +1578,10 @@ describe("stable-swapper", () => { ); // Verify user received net amount (after fee deduction) - const userAppStableReceived = - finalUserAppStableBalance.amount - initialUserAppStableBalance.amount; + const userCustomStableReceived = + finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; assert.equal( - userAppStableReceived.toString(), + userCustomStableReceived.toString(), expectedNetAmount.toString(), "User should receive net amount after fees" ); @@ -1656,7 +1656,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: newFeeRecipientUsdcAccount, // New fee recipient feeRecipient: newFeeRecipient.publicKey, fromMint: usdcMint, @@ -1704,9 +1704,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const initialUserAppStableBalance = await getAccount( + const initialUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); await program.methods @@ -1718,7 +1718,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, @@ -1736,16 +1736,16 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const finalUserAppStableBalance = await getAccount( + const finalUserCustomStableBalance = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); // Verify 1:1 swap with no fees const usdcSpent = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; const appStableReceived = - finalUserAppStableBalance.amount - initialUserAppStableBalance.amount; + finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; assert.equal( usdcSpent.toString(), @@ -1812,7 +1812,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, @@ -1860,7 +1860,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, @@ -1946,7 +1946,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, @@ -2382,15 +2382,15 @@ describe("stable-swapper", () => { it("Swaps with same decimals (6 to 6) work", async () => { // This tests backward compatibility - swaps between tokens with same decimals const swapAmount = new anchor.BN(50 * 10 ** 6); // 50 USDC - const minAmountOut = new anchor.BN(50 * 10 ** 6); // Expect 50 AppStable + const minAmountOut = new anchor.BN(50 * 10 ** 6); // Expect 50 CustomStable const userUsdcBefore = await getAccount( provider.connection, userUsdcAccount ); - const userAppStableBefore = await getAccount( + const userCustomStableBefore = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); await program.methods @@ -2402,7 +2402,7 @@ describe("stable-swapper", () => { inVaultTokenAccount: usdcVaultTokenAccount, outVaultTokenAccount: appStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, - toTokenAccount: userAppStableAccount, + toTokenAccount: userCustomStableAccount, feeRecipient: payer.publicKey, feeRecipientTokenAccount: userUsdcAccount, fromMint: usdcMint, @@ -2419,9 +2419,9 @@ describe("stable-swapper", () => { provider.connection, userUsdcAccount ); - const userAppStableAfter = await getAccount( + const userCustomStableAfter = await getAccount( provider.connection, - userAppStableAccount + userCustomStableAccount ); // Should still be 1:1 when decimals are the same @@ -2431,9 +2431,9 @@ describe("stable-swapper", () => { "USDC deducted incorrectly" ); assert.equal( - userAppStableAfter.amount - userAppStableBefore.amount, + userCustomStableAfter.amount - userCustomStableBefore.amount, BigInt(50 * 10 ** 6), - "AppStable received incorrectly" + "CustomStable received incorrectly" ); }); }); From eca9e513ebb3dc69a819f09cb1f0c91f35540d9c Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 4 Aug 2026 09:31:55 -0400 Subject: [PATCH 04/20] Revert cosmetic AppStable rename in Solana tests Restore CustomStable naming to shrink the RBAC PR diff; no behavioral change. Co-authored-by: Cursor --- solana/tests/stable-swapper.ts | 178 ++++++++++++++++----------------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 8b782d1..fd7aade 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -30,12 +30,12 @@ describe("stable-swapper", () => { // Test keypairs let usdcMint: PublicKey; - let appStableMint: PublicKey; + let customStableMint: PublicKey; let pool: PublicKey; let usdcVault: PublicKey; - let appStableVault: PublicKey; + let customStableVault: PublicKey; let usdcVaultTokenAccount: PublicKey; - let appStableVaultTokenAccount: PublicKey; + let customStableVaultTokenAccount: PublicKey; // User accounts (also used for fee collection since authority is the fee recipient in tests) let userUsdcAccount: PublicKey; @@ -55,7 +55,7 @@ describe("stable-swapper", () => { 6 // USDC decimals ); - appStableMint = await createMint( + customStableMint = await createMint( provider.connection, payer.payer, payer.publicKey, @@ -74,8 +74,8 @@ describe("stable-swapper", () => { program.programId ); - [appStableVault] = PublicKey.findProgramAddressSync( - [Buffer.from("token_vault"), pool.toBuffer(), appStableMint.toBuffer()], + [customStableVault] = PublicKey.findProgramAddressSync( + [Buffer.from("token_vault"), pool.toBuffer(), customStableMint.toBuffer()], program.programId ); @@ -84,8 +84,8 @@ describe("stable-swapper", () => { program.programId ); - [appStableVaultTokenAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("vault_token_account"), appStableVault.toBuffer()], + [customStableVaultTokenAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("vault_token_account"), customStableVault.toBuffer()], program.programId ); @@ -95,7 +95,7 @@ describe("stable-swapper", () => { payer.publicKey ); feeRecipientCustomStableAccount = await getAssociatedTokenAddress( - appStableMint, + customStableMint, payer.publicKey ); @@ -110,7 +110,7 @@ describe("stable-swapper", () => { userCustomStableAccount = await createAccount( provider.connection, payer.payer, - appStableMint, + customStableMint, payer.publicKey ); @@ -127,7 +127,7 @@ describe("stable-swapper", () => { await mintTo( provider.connection, payer.payer, - appStableMint, + customStableMint, userCustomStableAccount, payer.payer, 1000 * 10 ** 6 // 1000 CustomStable @@ -224,11 +224,11 @@ describe("stable-swapper", () => { .addSupportedToken() .accounts({ pool, - vault: appStableVault, - vaultTokenAccount: appStableVaultTokenAccount, + vault: customStableVault, + vaultTokenAccount: customStableVaultTokenAccount, feeRecipientTokenAccount: feeRecipientCustomStableAccount, feeRecipient: payer.publicKey, - mint: appStableMint, + mint: customStableMint, configureAuthority: configureAuthority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -263,7 +263,7 @@ describe("stable-swapper", () => { provider.connection, payer.payer, userCustomStableAccount, - appStableVaultTokenAccount, + customStableVaultTokenAccount, payer.payer, seedAmount ); @@ -383,15 +383,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, // Fee collected in input token (USDC) feeRecipient: payer.publicKey, // Fee recipient authority fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -413,11 +413,11 @@ describe("stable-swapper", () => { // Verify balances changed correctly (1:1 swap, 0% fee) const usdcDiff = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; - const appStableDiff = + const customStableDiff = finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; assert.equal(usdcDiff.toString(), swapAmount.toString()); - assert.equal(appStableDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee + assert.equal(customStableDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee }); it("Swaps CustomStable for USDC (1:1)", async () => { @@ -438,15 +438,15 @@ describe("stable-swapper", () => { .swap(swapAmount, minAmountOut) .accounts({ pool, - inVault: appStableVault, + inVault: customStableVault, outVault: usdcVault, - inVaultTokenAccount: appStableVaultTokenAccount, + inVaultTokenAccount: customStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, userFromTokenAccount: userCustomStableAccount, toTokenAccount: userUsdcAccount, feeRecipientTokenAccount: userCustomStableAccount, // Fee collected in input token (CustomStable) feeRecipient: payer.publicKey, // Fee recipient authority - fromMint: appStableMint, + fromMint: customStableMint, toMint: usdcMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, @@ -469,10 +469,10 @@ describe("stable-swapper", () => { // Verify balances changed correctly (1:1 swap, 0% fee) const usdcDiff = finalUserUsdcBalance.amount - initialUserUsdcBalance.amount; - const appStableDiff = + const customStableDiff = initialUserCustomStableBalance.amount - finalUserCustomStableBalance.amount; - assert.equal(appStableDiff.toString(), swapAmount.toString()); + assert.equal(customStableDiff.toString(), swapAmount.toString()); assert.equal(usdcDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee }); @@ -487,7 +487,7 @@ describe("stable-swapper", () => { const swapperCustomStableAccount = await createAccount( provider.connection, payer.payer, - appStableMint, + customStableMint, swapper.publicKey ); @@ -512,15 +512,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: swapperUsdcAccount, toTokenAccount: swapperCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: swapper.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -551,15 +551,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: unauthorizedUser.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -594,7 +594,7 @@ describe("stable-swapper", () => { const delegateCustomStableAccount = await createAccount( provider.connection, payer.payer, - appStableMint, + customStableMint, delegate.publicKey ); @@ -626,15 +626,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: ownerUsdcAccount, toTokenAccount: delegateCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: delegate.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -656,7 +656,7 @@ describe("stable-swapper", () => { it("Allows swapping exactly the full destination vault balance", async () => { const destinationVaultBefore = await getAccount( provider.connection, - appStableVaultTokenAccount + customStableVaultTokenAccount ); const fullDrainAmount = new anchor.BN( destinationVaultBefore.amount.toString() @@ -676,15 +676,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -695,7 +695,7 @@ describe("stable-swapper", () => { const drainedVault = await getAccount( provider.connection, - appStableVaultTokenAccount + customStableVaultTokenAccount ); assert.equal(drainedVault.amount.toString(), "0"); @@ -703,7 +703,7 @@ describe("stable-swapper", () => { provider.connection, payer.payer, userCustomStableAccount, - appStableVaultTokenAccount, + customStableVaultTokenAccount, payer.payer, BigInt(fullDrainAmount.toString()) ); @@ -719,15 +719,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -764,15 +764,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -821,15 +821,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -885,15 +885,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -918,15 +918,15 @@ describe("stable-swapper", () => { .swap(swapAmount, minAmountOut) .accounts({ pool, - inVault: appStableVault, + inVault: customStableVault, outVault: usdcVault, - inVaultTokenAccount: appStableVaultTokenAccount, + inVaultTokenAccount: customStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, userFromTokenAccount: userCustomStableAccount, toTokenAccount: userUsdcAccount, feeRecipientTokenAccount: userCustomStableAccount, feeRecipient: payer.publicKey, - fromMint: appStableMint, + fromMint: customStableMint, toMint: usdcMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, @@ -968,15 +968,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1403,15 +1403,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1459,15 +1459,15 @@ describe("stable-swapper", () => { .swap(excessiveAmount, minAmountOut) .accounts({ pool, - inVault: appStableVault, + inVault: customStableVault, outVault: usdcVault, - inVaultTokenAccount: appStableVaultTokenAccount, + inVaultTokenAccount: customStableVaultTokenAccount, outVaultTokenAccount: usdcVaultTokenAccount, userFromTokenAccount: userCustomStableAccount, toTokenAccount: userUsdcAccount, feeRecipientTokenAccount: userCustomStableAccount, feeRecipient: payer.publicKey, - fromMint: appStableMint, + fromMint: customStableMint, toMint: usdcMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, @@ -1533,15 +1533,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccount, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1652,15 +1652,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: newFeeRecipientUsdcAccount, // New fee recipient feeRecipient: newFeeRecipient.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1714,15 +1714,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: userUsdcAccount, feeRecipient: payer.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1744,7 +1744,7 @@ describe("stable-swapper", () => { // Verify 1:1 swap with no fees const usdcSpent = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; - const appStableReceived = + const customStableReceived = finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; assert.equal( @@ -1753,7 +1753,7 @@ describe("stable-swapper", () => { "Should spend exact swap amount" ); assert.equal( - appStableReceived.toString(), + customStableReceived.toString(), swapAmount.toString(), "Should receive exact swap amount (1:1, no fees)" ); @@ -1808,15 +1808,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1856,15 +1856,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -1942,15 +1942,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipientTokenAccount: feeRecipientUsdcAccountForTest, feeRecipient: feeRecipient.publicKey, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, @@ -2398,15 +2398,15 @@ describe("stable-swapper", () => { .accounts({ pool, inVault: usdcVault, - outVault: appStableVault, + outVault: customStableVault, inVaultTokenAccount: usdcVaultTokenAccount, - outVaultTokenAccount: appStableVaultTokenAccount, + outVaultTokenAccount: customStableVaultTokenAccount, userFromTokenAccount: userUsdcAccount, toTokenAccount: userCustomStableAccount, feeRecipient: payer.publicKey, feeRecipientTokenAccount: userUsdcAccount, fromMint: usdcMint, - toMint: appStableMint, + toMint: customStableMint, user: payer.publicKey, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, From d51f753f38840ac3926ff98d50fd5136c51bbcb0 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 4 Aug 2026 09:35:40 -0400 Subject: [PATCH 05/20] Fix Prettier formatting in stable-swapper tests Co-authored-by: Cursor --- solana/tests/stable-swapper.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index fd7aade..211fcc8 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -75,7 +75,11 @@ describe("stable-swapper", () => { ); [customStableVault] = PublicKey.findProgramAddressSync( - [Buffer.from("token_vault"), pool.toBuffer(), customStableMint.toBuffer()], + [ + Buffer.from("token_vault"), + pool.toBuffer(), + customStableMint.toBuffer(), + ], program.programId ); @@ -414,7 +418,8 @@ describe("stable-swapper", () => { const usdcDiff = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; const customStableDiff = - finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; + finalUserCustomStableBalance.amount - + initialUserCustomStableBalance.amount; assert.equal(usdcDiff.toString(), swapAmount.toString()); assert.equal(customStableDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee @@ -470,7 +475,8 @@ describe("stable-swapper", () => { const usdcDiff = finalUserUsdcBalance.amount - initialUserUsdcBalance.amount; const customStableDiff = - initialUserCustomStableBalance.amount - finalUserCustomStableBalance.amount; + initialUserCustomStableBalance.amount - + finalUserCustomStableBalance.amount; assert.equal(customStableDiff.toString(), swapAmount.toString()); assert.equal(usdcDiff.toString(), swapAmount.toString()); // 1:1 with 0% fee @@ -1579,7 +1585,8 @@ describe("stable-swapper", () => { // Verify user received net amount (after fee deduction) const userCustomStableReceived = - finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; + finalUserCustomStableBalance.amount - + initialUserCustomStableBalance.amount; assert.equal( userCustomStableReceived.toString(), expectedNetAmount.toString(), @@ -1745,7 +1752,8 @@ describe("stable-swapper", () => { const usdcSpent = initialUserUsdcBalance.amount - finalUserUsdcBalance.amount; const customStableReceived = - finalUserCustomStableBalance.amount - initialUserCustomStableBalance.amount; + finalUserCustomStableBalance.amount - + initialUserCustomStableBalance.amount; assert.equal( usdcSpent.toString(), From ca9ab3b844373de499e12fbac8af28b91568792a Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Wed, 5 Aug 2026 16:32:41 -0400 Subject: [PATCH 06/20] Address RBAC review: zero-key guards and precise migration errors Roles can only be rotated by their current holder, so a role assigned the default pubkey is unrecoverable. Reject it in initialize, in each rotation instruction, and in migrate_authorities. Give the migration's checks their own errors instead of reporting everything as LegacyDiscriminatorMismatch: wrong owner, wrong account size, and each legacy co-signer now fail distinctly, which also puts the previously unused LegacySizeMismatch to work. Document that the legacy operations authority funds the realloc, and derive the growth from the layout constants so the byte count cannot go stale. --- solana/programs/stable-swapper/src/errors.rs | 6 + solana/programs/stable-swapper/src/lib.rs | 61 ++++++++-- solana/programs/stable-swapper/src/state.rs | 4 + solana/tests/initialize-guards.ts | 119 +++++++++++++++++++ solana/tests/migration.ts | 110 ++++++++++++++--- solana/tests/stable-swapper.ts | 73 ++++++++++++ 6 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 solana/tests/initialize-guards.ts diff --git a/solana/programs/stable-swapper/src/errors.rs b/solana/programs/stable-swapper/src/errors.rs index d542014..16bdd8e 100644 --- a/solana/programs/stable-swapper/src/errors.rs +++ b/solana/programs/stable-swapper/src/errors.rs @@ -66,4 +66,10 @@ pub enum LiquidityError { LegacyVecLengthInvalid, #[msg("Failed to serialize the new LiquidityPool layout during migration")] MigrationSerializeFailed, + #[msg("Authority key must not be the default pubkey")] + AuthorityNotSet, + #[msg("Signer does not match the legacy operations authority stored in the pool")] + LegacyOperationsAuthorityMismatch, + #[msg("Signer does not match the legacy pause authority stored in the pool")] + LegacyPauseAuthorityMismatch, } diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index caef41e..97ec4ae 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -30,6 +30,15 @@ pub mod stable_swapper { ctx.accounts.withdraw_recipient.key() != Pubkey::default(), LiquidityError::WithdrawRecipientNotSet ); + // A role assigned to the default pubkey is unrecoverable: every rotation instruction + // requires the current holder to sign, and nothing can sign for the zero 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.pause_authority = ctx.accounts.pause_authority.key(); @@ -441,6 +450,7 @@ 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); @@ -451,6 +461,7 @@ pub mod stable_swapper { 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); @@ -461,6 +472,7 @@ pub mod stable_swapper { 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); @@ -471,6 +483,7 @@ pub mod stable_swapper { 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!( @@ -481,9 +494,28 @@ pub mod stable_swapper { } } +/// Rejects the default pubkey as a role holder. Roles can only be rotated by their current +/// holder, so the zero key is a one-way door: no signature exists for it. `role` names the +/// offending field, since a bare key comparison logs the same zero key on both sides. +fn require_authority_set(role: &str, authority: &Pubkey) -> Result<()> { + if *authority == Pubkey::default() { + msg!("Role {} must not be set to the default pubkey", role); + return err!(LiquidityError::AuthorityNotSet); + } + Ok(()) +} + /// Shared body for `migrate_authorities`: legacy parse, signer match, realloc, rent top-up, /// re-serialize. The Accounts struct on the calling instruction is responsible for verifying /// the pool address (the canonical PDA). +/// +/// Rent: the pool grows by `LiquidityPool::MIGRATION_GROWTH` bytes (the two extra role keys +/// plus the withdraw-recipient allowlist), and `legacy_operations_authority_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 legacy operations authority is a +/// program-owned account that cannot be debited by the system program. fn do_migrate_authorities<'info>( pool_ai: &AccountInfo<'info>, legacy_operations_authority_ai: &AccountInfo<'info>, @@ -499,6 +531,12 @@ fn do_migrate_authorities<'info>( new_withdraw_recipient != Pubkey::default(), LiquidityError::WithdrawRecipientNotSet ); + // A role assigned to the default pubkey is unrecoverable: every rotation instruction + // requires the current holder to sign, and nothing can sign for the zero key. + 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; @@ -508,15 +546,20 @@ fn do_migrate_authorities<'info>( require_keys_eq!( *pool_ai.owner, crate::ID, - LiquidityError::LegacyDiscriminatorMismatch + ErrorCode::AccountOwnedByWrongProgram ); - // Re-run guard: only legacy-sized accounts are migratable. After a successful migration - // the account is `new_total` bytes, so a second invocation lands here. + // 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() == legacy_total, + 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 ( @@ -578,15 +621,16 @@ fn do_migrate_authorities<'info>( require_keys_eq!( *legacy_operations_authority_ai.key, legacy_ops, - LiquidityError::LegacyDiscriminatorMismatch + LiquidityError::LegacyOperationsAuthorityMismatch ); require_keys_eq!( *legacy_pause_authority_ai.key, legacy_pause, - LiquidityError::LegacyDiscriminatorMismatch + LiquidityError::LegacyPauseAuthorityMismatch ); - // Top up rent for the additional 96 bytes, then grow the account. + // Top up rent for the additional `LiquidityPool::MIGRATION_GROWTH` 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()); @@ -685,7 +729,8 @@ pub struct MigrateAuthorities<'info> { pub pool: UncheckedAccount<'info>, /// Legacy operations authority. Verified inside the instruction against the legacy on-chain - /// bytes; pays the additional rent for the 96-byte realloc. + /// bytes; 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 legacy_operations_authority: Signer<'info>, diff --git a/solana/programs/stable-swapper/src/state.rs b/solana/programs/stable-swapper/src/state.rs index 8ac2bfb..d9eabbc 100644 --- a/solana/programs/stable-swapper/src/state.rs +++ b/solana/programs/stable-swapper/src/state.rs @@ -47,6 +47,10 @@ impl LiquidityPool { /// 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; + + /// Bytes added by `migrate_authorities`: two extra role keys plus the withdraw-recipient + /// allowlist slot. Derived from the two space constants so it cannot drift from them. + pub const MIGRATION_GROWTH: usize = Self::INIT_SPACE - Self::LEGACY_INIT_SPACE; } #[account] diff --git a/solana/tests/initialize-guards.ts b/solana/tests/initialize-guards.ts new file mode 100644 index 0000000..cae88b8 --- /dev/null +++ b/solana/tests/initialize-guards.ts @@ -0,0 +1,119 @@ +/** + * 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"); + +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 pool: 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; + + const 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); + }); + + 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, + }; + } + + function initialize(roles: InitializeRoles) { + return program.methods + .initialize(new BN(0)) + .accounts({ + pool, + payer, + ...roles, + systemProgram: SystemProgram.programId, + }) + .rpc(); + } + + 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"], + ["withdrawRecipient", "withdrawrecipientnotset"], + ]; + + for (const [role, expected] of zeroKeyCases) { + it(`rejects the default pubkey for ${role}`, async () => { + try { + await initialize({ ...defaultRoles(), [role]: PublicKey.default }); + assert.fail(`expected ${expected} for ${role}`); + } catch (error) { + assert.include(errText(error), expected); + } + + const acct = await context.banksClient.getAccount(pool); + assert.isNull(acct, "pool must not be created by a rejected initialize"); + }); + } +}); diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts index ab46781..bc42eb5 100644 --- a/solana/tests/migration.ts +++ b/solana/tests/migration.ts @@ -59,6 +59,18 @@ function buildLegacyPoolData(disc: Buffer, f: LegacyFields): Buffer { 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 { + opsAuthority?: PublicKey; + pauseAuthority?: PublicKey; + 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(); @@ -147,25 +159,36 @@ describe("migrate_authorities (bankrun)", () => { }); } - function migrate(signers: Keypair[], pauseAuthorityAccount?: PublicKey) { + function migrate(signers: Keypair[], overrides: MigrateOverrides = {}) { return program.methods .migrateAuthorities( - newPause.publicKey, - newUnpause.publicKey, - newTreasury.publicKey, - newConfigure.publicKey, - newWithdrawRecipient.publicKey + overrides.newPause ?? newPause.publicKey, + overrides.newUnpause ?? newUnpause.publicKey, + overrides.newTreasury ?? newTreasury.publicKey, + overrides.newConfigure ?? newConfigure.publicKey, + overrides.newWithdrawRecipient ?? newWithdrawRecipient.publicKey ) .accounts({ pool, - legacyOperationsAuthority: legacyOps.publicKey, - legacyPauseAuthority: pauseAuthorityAccount ?? legacyPause.publicKey, + legacyOperationsAuthority: + overrides.opsAuthority ?? legacyOps.publicKey, + legacyPauseAuthority: overrides.pauseAuthority ?? legacyPause.publicKey, systemProgram: SystemProgram.programId, }) .signers(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"); @@ -239,22 +262,77 @@ describe("migrate_authorities (bankrun)", () => { } }); - it("rejects a migration when a legacy signer does not match", async () => { + it("rejects a migration when the legacy pause signer does not match", async () => { await seedLegacyPool(); const stranger = Keypair.generate(); - context.setAccount(stranger.publicKey, { - lamports: 1_000_000_000, - data: Buffer.alloc(0), - owner: SystemProgram.programId, + fundSystemAccount(stranger.publicKey); + + try { + await migrate([legacyOps, stranger], { + pauseAuthority: stranger.publicKey, + }); + assert.fail("expected legacy pause authority mismatch"); + } catch (error) { + assert.include(errText(error), "legacypauseauthoritymismatch"); + } + }); + + it("rejects a migration when the legacy operations signer does not match", async () => { + await seedLegacyPool(); + const stranger = Keypair.generate(); + fundSystemAccount(stranger.publicKey); + + try { + await migrate([stranger, legacyPause], { + opsAuthority: stranger.publicKey, + }); + assert.fail("expected legacy operations authority mismatch"); + } catch (error) { + assert.include(errText(error), "legacyoperationsauthoritymismatch"); + } + }); + + 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([legacyOps, stranger], stranger.publicKey); - assert.fail("expected legacy signer mismatch"); + await migrate([legacyOps, legacyPause]); + assert.fail("expected LegacySizeMismatch"); } catch (error) { - assert.include(errText(error), "legacydiscriminatormismatch"); + 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([legacyOps, legacyPause], { + [role]: PublicKey.default, + }); + assert.fail(`expected AuthorityNotSet for ${role}`); + } catch (error) { + assert.include(errText(error), "authoritynotset", `role: ${role}`); + } } }); diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 211fcc8..310975e 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -3356,6 +3356,79 @@ describe("stable-swapper", () => { .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", () => { From 2794e3f3f1a5e56bc457e3ff3285cc120dd61513 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Thu, 6 Aug 2026 11:31:04 -0400 Subject: [PATCH 07/20] Address some comments and split fee configuration setting --- solana/.gitignore | 1 + solana/Cargo.lock | 16 ++-- solana/README.md | 9 ++- solana/package.json | 2 +- solana/programs/stable-swapper/src/lib.rs | 32 +++----- solana/programs/stable-swapper/src/state.rs | 4 +- solana/tests/migration.ts | 8 +- solana/tests/stable-swapper.ts | 88 ++++++++++++++++----- 8 files changed, 107 insertions(+), 53 deletions(-) diff --git a/solana/.gitignore b/solana/.gitignore index bfef655..79f8358 100644 --- a/solana/.gitignore +++ b/solana/.gitignore @@ -14,4 +14,5 @@ test-ledger **/deploy/*.json **/*-keypair.json **/id.json +package-lock.json .migration-verify diff --git a/solana/Cargo.lock b/solana/Cargo.lock index 259676e..54dd782 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1206,14 +1206,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "stable-swapper" -version = "0.1.0" -dependencies = [ - "anchor-lang", - "anchor-spl", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2554,6 +2546,14 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "stable-swapper" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/solana/README.md b/solana/README.md index 92c6dc3..0740ab9 100644 --- a/solana/README.md +++ b/solana/README.md @@ -19,11 +19,13 @@ A production-ready Solana-based liquidity management system designed for secure, │ ├── 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 @@ -32,6 +34,7 @@ A production-ready Solana-based liquidity management system designed for secure, 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. + ## 🚀 Getting Started ### Prerequisites @@ -117,6 +120,8 @@ CI runs the equivalent of these steps in `.github/workflows/test.yml`. 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 @@ -155,7 +160,7 @@ The system is configured for **Solana Devnet** by default. To change networks: | Pause Authority | CCS hot | `pause_swaps`, `pause_withdraws`, `pause_token` | | Unpause Authority | SCM cold | `unpause_swaps`, `unpause_withdraws`, `unpause_token` | | Treasury Authority | CCS hot | `withdraw_liquidity` (recipient must be on `withdraw_recipients` allowlist) | -| Configure Authority | SCM cold | `add_supported_token`, `remove_supported_token`, `update_fee_config`, `add_withdraw_recipient`, `remove_withdraw_recipient` | +| Configure Authority | SCM 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. @@ -167,7 +172,7 @@ The on-chain program upgrade authority is held by the BPF loader (rotate via `so - **`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_config`**: Configure Authority updates fee rate and recipient +- **`update_fee_rate` / `update_fee_recipient`**: Configure Authority updates the fee rate (basis points) and the fee recipient independently - **`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 diff --git a/solana/package.json b/solana/package.json index ab910d1..0dbfc84 100644 --- a/solana/package.json +++ b/solana/package.json @@ -1,5 +1,5 @@ { - "license": "ISC", + "license": "Apache-2.0", "scripts": { "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index 97ec4ae..bd3c273 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -347,27 +347,21 @@ 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); - } - + ctx.accounts.pool.fee_recipient = fee_recipient; + msg!("Updated fee recipient to: {}", fee_recipient); Ok(()) } diff --git a/solana/programs/stable-swapper/src/state.rs b/solana/programs/stable-swapper/src/state.rs index d9eabbc..fe08c9d 100644 --- a/solana/programs/stable-swapper/src/state.rs +++ b/solana/programs/stable-swapper/src/state.rs @@ -56,9 +56,9 @@ impl LiquidityPool { #[account] pub struct TokenVault { pub mint: Pubkey, - /// Deprecated: liquidity reservation was removed in STBLE-2811. + /// Deprecated: liquidity reservation was removed. #[deprecated( - note = "Liquidity reservation was removed in STBLE-2811; field retained for layout compatibility and is always zero on new vaults." + note = "Liquidity reservation was removed; field retained for layout compatibility and is always zero on new vaults." )] pub reserved_amount: u64, pub disabled: bool, // If true, this token cannot be used in swaps diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts index bc42eb5..ffc7769 100644 --- a/solana/tests/migration.ts +++ b/solana/tests/migration.ts @@ -254,8 +254,14 @@ describe("migrate_authorities (bankrun)", () => { await seedLegacyPool(); await migrate([legacyOps, legacyPause]); + // 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([legacyOps, legacyPause]); + await migrate([legacyOps, legacyPause], { + newPause: Keypair.generate().publicKey, + }); assert.fail("expected AlreadyMigrated"); } catch (error) { assert.include(errText(error), "alreadymigrated"); diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 310975e..f6f6875 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -751,7 +751,7 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -794,7 +794,7 @@ describe("stable-swapper", () => { // Reset fee rate to 0 await program.methods - .updateFeeConfig(new anchor.BN(0), null) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -806,7 +806,7 @@ describe("stable-swapper", () => { 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, configureAuthority: configureAuthority.publicKey, @@ -853,7 +853,7 @@ describe("stable-swapper", () => { // Reset fee rate to 0 await program.methods - .updateFeeConfig(new anchor.BN(0), null) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -1360,7 +1360,7 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -1374,7 +1374,7 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -1503,7 +1503,15 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -1613,7 +1621,15 @@ 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, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(payer.publicKey) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -1636,7 +1652,15 @@ 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, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(newFeeRecipient.publicKey) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -1691,7 +1715,15 @@ 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, + configureAuthority: configureAuthority.publicKey, + }) + .signers([configureAuthority.payer]) + .rpc(); + await program.methods + .updateFeeRecipient(payer.publicKey) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -1772,7 +1804,7 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -1790,7 +1822,7 @@ describe("stable-swapper", () => { ); await program.methods - .updateFeeConfig(null, feeRecipient.publicKey) + .updateFeeRecipient(feeRecipient.publicKey) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -1896,7 +1928,15 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -1908,7 +1948,7 @@ describe("stable-swapper", () => { 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, configureAuthority: configureAuthority.publicKey, @@ -1926,7 +1966,7 @@ describe("stable-swapper", () => { ); await program.methods - .updateFeeConfig(null, feeRecipient.publicKey) + .updateFeeRecipient(feeRecipient.publicKey) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -1982,7 +2022,15 @@ 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, configureAuthority: configureAuthority.publicKey, @@ -1998,7 +2046,7 @@ describe("stable-swapper", () => { try { await program.methods - .updateFeeConfig(new anchor.BN(excessiveFeeRate), null) + .updateFeeRate(new anchor.BN(excessiveFeeRate)) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -2016,7 +2064,7 @@ describe("stable-swapper", () => { const maxFeeRate = 1000; // 10% await program.methods - .updateFeeConfig(new anchor.BN(maxFeeRate), null) + .updateFeeRate(new anchor.BN(maxFeeRate)) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -2029,7 +2077,7 @@ describe("stable-swapper", () => { // Reset fee rate await program.methods - .updateFeeConfig(new anchor.BN(0), null) + .updateFeeRate(new anchor.BN(0)) .accounts({ pool, configureAuthority: configureAuthority.publicKey, @@ -2923,7 +2971,7 @@ 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, configureAuthority: unauthorizedUser.publicKey, // Wrong authority From 46acbead03639090afc64acc77871d5c48a6569e Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Thu, 6 Aug 2026 13:07:48 -0400 Subject: [PATCH 08/20] Replace cosign guard with upgrader guard --- solana/README.md | 7 +- solana/programs/stable-swapper/src/errors.rs | 8 +- solana/programs/stable-swapper/src/lib.rs | 103 +++++++------- solana/programs/stable-swapper/src/utils.rs | 9 ++ solana/tests/initialize-guards.ts | 133 +++++++++++++++++-- solana/tests/migration.ts | 130 +++++++++++------- solana/tests/stable-swapper.ts | 17 ++- 7 files changed, 291 insertions(+), 116 deletions(-) diff --git a/solana/README.md b/solana/README.md index 0740ab9..6337afb 100644 --- a/solana/README.md +++ b/solana/README.md @@ -163,12 +163,12 @@ The system is configured for **Solana Devnet** by default. To change networks: | Configure Authority | SCM 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. +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 -- **`migrate_authorities`**: One-shot migration of an existing legacy pool to the role-based layout (co-signed by current `operations_authority` + `pause_authority`); seeds the withdraw allowlist with the provided recipient. Invoked by internal migration tooling, not by in-repo CLIs. +- **`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 @@ -183,6 +183,7 @@ Liquidity is seeded by sending tokens directly to the vault token account via an ## 🔐 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 SCM cold and CCS 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 diff --git a/solana/programs/stable-swapper/src/errors.rs b/solana/programs/stable-swapper/src/errors.rs index 16bdd8e..eb6fe47 100644 --- a/solana/programs/stable-swapper/src/errors.rs +++ b/solana/programs/stable-swapper/src/errors.rs @@ -68,8 +68,8 @@ pub enum LiquidityError { MigrationSerializeFailed, #[msg("Authority key must not be the default pubkey")] AuthorityNotSet, - #[msg("Signer does not match the legacy operations authority stored in the pool")] - LegacyOperationsAuthorityMismatch, - #[msg("Signer does not match the legacy pause authority stored in the pool")] - LegacyPauseAuthorityMismatch, + #[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 bd3c273..685eb34 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -60,8 +60,10 @@ pub mod stable_swapper { } /// One-shot migration from the legacy `(operations_authority, pause_authority)` layout - /// to the new role-based layout. Co-signed by both legacy authorities so neither key alone - /// can unilaterally redistribute roles. + /// to the new role-based layout. Gated on the program upgrade authority rather than on the + /// legacy authorities stored in the pool: the upgrade authority can already rewrite this + /// account by deploying new code, so it is the key that ultimately governs the migration, + /// and routing through it keeps the legacy hot keys out of the operation. /// /// 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 @@ -78,8 +80,7 @@ pub mod stable_swapper { ) -> Result<()> { do_migrate_authorities( &ctx.accounts.pool.to_account_info(), - &ctx.accounts.legacy_operations_authority.to_account_info(), - &ctx.accounts.legacy_pause_authority.to_account_info(), + &ctx.accounts.payer.to_account_info(), &ctx.accounts.system_program.to_account_info(), new_pause_authority, new_unpause_authority, @@ -499,21 +500,20 @@ fn require_authority_set(role: &str, authority: &Pubkey) -> Result<()> { Ok(()) } -/// Shared body for `migrate_authorities`: legacy parse, signer match, realloc, rent top-up, -/// re-serialize. The Accounts struct on the calling instruction is responsible for verifying -/// the pool address (the canonical PDA). +/// 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 `LiquidityPool::MIGRATION_GROWTH` bytes (the two extra role keys -/// plus the withdraw-recipient allowlist), and `legacy_operations_authority_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 legacy operations authority is a -/// program-owned account that cannot be debited by the system program. +/// 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>, - legacy_operations_authority_ai: &AccountInfo<'info>, - legacy_pause_authority_ai: &AccountInfo<'info>, + payer_ai: &AccountInfo<'info>, system_program_ai: &AccountInfo<'info>, new_pause_authority: Pubkey, new_unpause_authority: Pubkey, @@ -556,26 +556,17 @@ fn do_migrate_authorities<'info>( ); // Snapshot legacy fields with a scoped borrow so we can drop it before realloc. - let ( - legacy_ops, - legacy_pause, - legacy_fee_recipient, - supported_tokens, - fee_rate, - swaps_paused, - liquidity_paused, - bump, - ) = { + 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_ops = Pubkey::try_from(&data[8..40]).unwrap(); - let legacy_pause = Pubkey::try_from(&data[40..72]).unwrap(); 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 @@ -600,8 +591,6 @@ fn do_migrate_authorities<'info>( let bump = data[trailing + 10]; ( - legacy_ops, - legacy_pause, legacy_fee_recipient, tokens, fee_rate, @@ -611,18 +600,6 @@ fn do_migrate_authorities<'info>( ) }; - // Verify both legacy signers match the on-chain values. - require_keys_eq!( - *legacy_operations_authority_ai.key, - legacy_ops, - LiquidityError::LegacyOperationsAuthorityMismatch - ); - require_keys_eq!( - *legacy_pause_authority_ai.key, - legacy_pause, - LiquidityError::LegacyPauseAuthorityMismatch - ); - // Top up rent for the additional `LiquidityPool::MIGRATION_GROWTH` bytes, then grow the // account. This is a no-op when the pool already holds the new minimum balance. let rent = Rent::get()?; @@ -633,7 +610,7 @@ fn do_migrate_authorities<'info>( CpiContext::new( system_program_ai.clone(), anchor_lang::system_program::Transfer { - from: legacy_operations_authority_ai.clone(), + from: payer_ai.clone(), to: pool_ai.clone(), }, ), @@ -687,6 +664,26 @@ pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'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>, @@ -722,14 +719,24 @@ pub struct MigrateAuthorities<'info> { )] pub pool: UncheckedAccount<'info>, - /// Legacy operations authority. Verified inside the instruction against the legacy on-chain - /// bytes; 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. + /// 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 legacy_operations_authority: Signer<'info>, + pub payer: Signer<'info>, - /// Legacy pause authority. Verified inside the instruction against the legacy on-chain bytes. - pub legacy_pause_authority: 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>, } 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 index cae88b8..c437174 100644 --- a/solana/tests/initialize-guards.ts +++ b/solana/tests/initialize-guards.ts @@ -17,6 +17,26 @@ import { StableSwapper } from "../target/types/stable_swapper"; 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; @@ -35,7 +55,9 @@ function errText(error: any): string { 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 () => { @@ -44,14 +66,41 @@ describe("initialize role guards (bankrun)", () => { process.env.SBF_OUT_DIR = deployDir; process.env.BPF_OUT_DIR = deployDir; - const programId = new PublicKey(IDL.address); + 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, @@ -63,18 +112,49 @@ describe("initialize role guards (bankrun)", () => { }; } - function initialize(roles: InitializeRoles) { + interface InitializeOverrides { + payer?: PublicKey; + programData?: PublicKey; + signers?: Keypair[]; + } + + function initialize( + roles: InitializeRoles, + overrides: InitializeOverrides = {} + ) { return program.methods .initialize(new BN(0)) .accounts({ pool, - payer, + 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); @@ -105,15 +185,44 @@ describe("initialize role guards (bankrun)", () => { for (const [role, expected] of zeroKeyCases) { it(`rejects the default pubkey for ${role}`, async () => { - try { - await initialize({ ...defaultRoles(), [role]: PublicKey.default }); - assert.fail(`expected ${expected} for ${role}`); - } catch (error) { - assert.include(errText(error), expected); - } - - const acct = await context.banksClient.getAccount(pool); - assert.isNull(acct, "pool must not be created by a rejected initialize"); + 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 index ffc7769..17bc7ad 100644 --- a/solana/tests/migration.ts +++ b/solana/tests/migration.ts @@ -28,6 +28,24 @@ 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; @@ -62,8 +80,9 @@ function buildLegacyPoolData(disc: Buffer, f: LegacyFields): Buffer { /// 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 { - opsAuthority?: PublicKey; - pauseAuthority?: PublicKey; + payer?: PublicKey; + programData?: PublicKey; + signers?: Keypair[]; newPause?: PublicKey; newUnpause?: PublicKey; newTreasury?: PublicKey; @@ -83,12 +102,14 @@ describe("migrate_authorities (bankrun)", () => { 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 verifies the signers - // against these on-chain values. - const legacyOps = Keypair.generate(); - const legacyPause = Keypair.generate(); + // 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. @@ -114,31 +135,46 @@ describe("migrate_authorities (bankrun)", () => { 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 ); - // Fund the legacy signers as system accounts; legacyOps pays the realloc rent top-up. - for (const kp of [legacyOps, legacyPause]) { - context.setAccount(kp.publicKey, { - lamports: 1_000 * 1_000_000_000, - data: Buffer.alloc(0), - owner: SystemProgram.programId, - executable: false, - rentEpoch: 0, - }); - } + // 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.publicKey, - pause: legacyPause.publicKey, + ops: legacyOps, + pause: legacyPause, feeRecipient: legacyFeeRecipient, tokens, feeRate: 30, @@ -159,7 +195,7 @@ describe("migrate_authorities (bankrun)", () => { }); } - function migrate(signers: Keypair[], overrides: MigrateOverrides = {}) { + function migrate(overrides: MigrateOverrides = {}) { return program.methods .migrateAuthorities( overrides.newPause ?? newPause.publicKey, @@ -170,12 +206,11 @@ describe("migrate_authorities (bankrun)", () => { ) .accounts({ pool, - legacyOperationsAuthority: - overrides.opsAuthority ?? legacyOps.publicKey, - legacyPauseAuthority: overrides.pauseAuthority ?? legacyPause.publicKey, + payer: overrides.payer ?? payer, + programData: overrides.programData ?? programData, systemProgram: SystemProgram.programId, }) - .signers(signers) + .signers(overrides.signers ?? []) .rpc(); } @@ -202,7 +237,7 @@ describe("migrate_authorities (bankrun)", () => { it("migrates a legacy pool and preserves packed trailing state", async () => { await seedLegacyPool(); - await migrate([legacyOps, legacyPause]); + await migrate(); const acct = await fetchPool(); // New roles applied. @@ -252,49 +287,52 @@ describe("migrate_authorities (bankrun)", () => { it("rejects a second migration (AlreadyMigrated)", async () => { await seedLegacyPool(); - await migrate([legacyOps, legacyPause]); + 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([legacyOps, legacyPause], { - newPause: Keypair.generate().publicKey, - }); + await migrate({ newPause: Keypair.generate().publicKey }); assert.fail("expected AlreadyMigrated"); } catch (error) { assert.include(errText(error), "alreadymigrated"); } }); - it("rejects a migration when the legacy pause signer does not match", async () => { + it("rejects a caller that is not the upgrade authority", async () => { await seedLegacyPool(); const stranger = Keypair.generate(); fundSystemAccount(stranger.publicKey); try { - await migrate([legacyOps, stranger], { - pauseAuthority: stranger.publicKey, - }); - assert.fail("expected legacy pause authority mismatch"); + await migrate({ payer: stranger.publicKey, signers: [stranger] }); + assert.fail("expected NotUpgradeAuthority"); } catch (error) { - assert.include(errText(error), "legacypauseauthoritymismatch"); + 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 a migration when the legacy operations signer does not match", async () => { + it("rejects program data belonging to another program", async () => { await seedLegacyPool(); - const stranger = Keypair.generate(); - fundSystemAccount(stranger.publicKey); + // 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([stranger, legacyPause], { - opsAuthority: stranger.publicKey, - }); - assert.fail("expected legacy operations authority mismatch"); + await migrate({ programData: foreign }); + assert.fail("expected InvalidProgramData"); } catch (error) { - assert.include(errText(error), "legacyoperationsauthoritymismatch"); + assert.include(errText(error), "invalidprogramdata"); } }); @@ -313,7 +351,7 @@ describe("migrate_authorities (bankrun)", () => { }); try { - await migrate([legacyOps, legacyPause]); + await migrate(); assert.fail("expected LegacySizeMismatch"); } catch (error) { assert.include(errText(error), "legacysizemismatch"); @@ -332,9 +370,7 @@ describe("migrate_authorities (bankrun)", () => { for (const role of roles) { await seedLegacyPool(); try { - await migrate([legacyOps, legacyPause], { - [role]: PublicKey.default, - }); + await migrate({ [role]: PublicKey.default }); assert.fail(`expected AuthorityNotSet for ${role}`); } catch (error) { assert.include(errText(error), "authoritynotset", `role: ${role}`); @@ -346,7 +382,7 @@ describe("migrate_authorities (bankrun)", () => { // 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([legacyOps, legacyPause]); + await migrate(); const acct = await fetchPool(); assert.equal(acct.supportedTokens.length, 0); diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index f6f6875..b53f747 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -15,6 +15,10 @@ 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); @@ -32,6 +36,7 @@ describe("stable-swapper", () => { let usdcMint: PublicKey; let customStableMint: PublicKey; let pool: PublicKey; + let programData: PublicKey; let usdcVault: PublicKey; let customStableVault: PublicKey; let usdcVaultTokenAccount: PublicKey; @@ -69,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 @@ -147,6 +159,7 @@ describe("stable-swapper", () => { .accounts({ pool, payer: payer.publicKey, + programData, pauseAuthority: pauseAuthority.publicKey, unpauseAuthority: unpauseAuthority.publicKey, treasuryAuthority: treasuryAuthority.publicKey, @@ -3494,8 +3507,8 @@ describe("stable-swapper", () => { ) .accounts({ pool, - legacyOperationsAuthority: payer.publicKey, - legacyPauseAuthority: payer.publicKey, + payer: payer.publicKey, + programData, systemProgram: SystemProgram.programId, }) .signers([payer.payer]) From 5a38770d89b23ac994f60ecd65507e5c27fc7488 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Thu, 6 Aug 2026 13:36:04 -0400 Subject: [PATCH 09/20] Remove cb internals and add additional guards on updating addresses --- solana/README.md | 14 ++++++------- solana/programs/stable-swapper/src/lib.rs | 20 ++++++++++++------ solana/tests/initialize-guards.ts | 1 + solana/tests/stable-swapper.ts | 25 +++++++++++++++++++++++ 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/solana/README.md b/solana/README.md index 6337afb..126a391 100644 --- a/solana/README.md +++ b/solana/README.md @@ -5,7 +5,7 @@ A production-ready Solana-based liquidity management system designed for secure, ## 🏗️ Key Features - ✅ **1:1 Token Swaps**: Guaranteed parity swapping between supported stablecoins -- ✅ **Role-Based Authority Model**: Four in-program roles split between SCM cold and CCS hot keys (Pause, Unpause, Treasury, Configure) +- ✅ **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 @@ -157,10 +157,10 @@ The system is configured for **Solana Devnet** by default. To change networks: | Role | Key class | Permissions | | --- | --- | --- | -| Pause Authority | CCS hot | `pause_swaps`, `pause_withdraws`, `pause_token` | -| Unpause Authority | SCM cold | `unpause_swaps`, `unpause_withdraws`, `unpause_token` | -| Treasury Authority | CCS hot | `withdraw_liquidity` (recipient must be on `withdraw_recipients` allowlist) | -| Configure Authority | SCM cold | `add_supported_token`, `remove_supported_token`, `update_fee_rate`, `update_fee_recipient`, `add_withdraw_recipient`, `remove_withdraw_recipient` | +| 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. @@ -172,7 +172,7 @@ The on-chain program upgrade authority is held by the BPF loader (rotate via `so - **`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 +- **`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 @@ -184,7 +184,7 @@ Liquidity is seeded by sending tokens directly to the vault token account via an ### 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 SCM cold and CCS hot keys +- **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 diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index 685eb34..f6b857a 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -39,6 +39,9 @@ pub mod stable_swapper { "configure_authority", &ctx.accounts.configure_authority.key(), )?; + // Same zero-key rule `update_fee_recipient` applies, so a pool cannot start out in a + // state that instruction would refuse to set. + require_authority_set("fee_recipient", &ctx.accounts.fee_recipient.key())?; let pool = &mut ctx.accounts.pool; pool.pause_authority = ctx.accounts.pause_authority.key(); @@ -361,6 +364,9 @@ pub mod stable_swapper { ctx: Context, fee_recipient: Pubkey, ) -> Result<()> { + // The recipient is the ATA authority for every fee transfer, so the default pubkey would + // silently send fees to a token account that nothing can sign for. + require_authority_set("fee_recipient", &fee_recipient)?; ctx.accounts.pool.fee_recipient = fee_recipient; msg!("Updated fee recipient to: {}", fee_recipient); Ok(()) @@ -489,12 +495,14 @@ pub mod stable_swapper { } } -/// Rejects the default pubkey as a role holder. Roles can only be rotated by their current -/// holder, so the zero key is a one-way door: no signature exists for it. `role` names the -/// offending field, since a bare key comparison logs the same zero key on both sides. -fn require_authority_set(role: &str, authority: &Pubkey) -> Result<()> { - if *authority == Pubkey::default() { - msg!("Role {} must not be set to the default pubkey", role); +/// Rejects the default pubkey for a key stored on the pool. Roles can only be rotated by their +/// current holder, so a zero-key role is a one-way door: no signature exists for it. The fee +/// recipient is recoverable by comparison, but a zero key there routes every fee to a token +/// account nobody can sign for. `field` names the offending key, since a bare comparison logs +/// the same zero key on both sides. +fn require_authority_set(field: &str, key: &Pubkey) -> Result<()> { + if *key == Pubkey::default() { + msg!("{} must not be set to the default pubkey", field); return err!(LiquidityError::AuthorityNotSet); } Ok(()) diff --git a/solana/tests/initialize-guards.ts b/solana/tests/initialize-guards.ts index c437174..62156ac 100644 --- a/solana/tests/initialize-guards.ts +++ b/solana/tests/initialize-guards.ts @@ -180,6 +180,7 @@ describe("initialize role guards (bankrun)", () => { ["unpauseAuthority", "authoritynotset"], ["treasuryAuthority", "authoritynotset"], ["configureAuthority", "authoritynotset"], + ["feeRecipient", "authoritynotset"], ["withdrawRecipient", "withdrawrecipientnotset"], ]; diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index b53f747..80151e0 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -2098,6 +2098,31 @@ describe("stable-swapper", () => { .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 AuthorityNotSet error"); + } catch (error) { + assert.include(error.toString().toLowerCase(), "authoritynotset"); + } + + 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", () => { From 0f9d487f324eaf7193c4daba16adb2532d26de70 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Mon, 10 Aug 2026 09:51:48 -0400 Subject: [PATCH 10/20] Squashed single verified commit --- .github/workflows/test.yml | 61 ++++++++++++++++++- solana/README.md | 18 ++++-- solana/programs/stable-swapper/src/errors.rs | 4 +- solana/programs/stable-swapper/src/lib.rs | 62 ++++++++------------ solana/tests/initialize-guards.ts | 4 +- solana/tests/stable-swapper.ts | 11 ++-- 6 files changed, 108 insertions(+), 52 deletions(-) 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/solana/README.md b/solana/README.md index 126a391..077342d 100644 --- a/solana/README.md +++ b/solana/README.md @@ -84,10 +84,11 @@ anchor deploy --provider.cluster devnet ### Running the Test Suite -The committed `declare_id!` and `[programs.devnet]` / `[programs.mainnet]` -entries 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 @@ -209,6 +210,15 @@ 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. + ```bash # Local / CI build anchor build diff --git a/solana/programs/stable-swapper/src/errors.rs b/solana/programs/stable-swapper/src/errors.rs index eb6fe47..090c541 100644 --- a/solana/programs/stable-swapper/src/errors.rs +++ b/solana/programs/stable-swapper/src/errors.rs @@ -50,8 +50,8 @@ pub enum LiquidityError { VaultNotEmpty, #[msg("Pool has already been migrated to the role-based authority layout")] AlreadyMigrated, - #[msg("Withdraw recipient is not configured")] - WithdrawRecipientNotSet, + #[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")] diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index f6b857a..100732d 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,14 +24,10 @@ pub mod stable_swapper { pub fn initialize(ctx: Context, fee_rate: u64) -> Result<()> { require!(fee_rate <= MAX_FEE_RATE, LiquidityError::InvalidFeeRate); - // Refuse to stand up a pool whose withdraws would be unconditionally blocked by - // `withdraw_liquidity`'s zero-key guard. Mirrors `update_withdraw_recipient`. - require!( - ctx.accounts.withdraw_recipient.key() != Pubkey::default(), - LiquidityError::WithdrawRecipientNotSet - ); - // A role assigned to the default pubkey is unrecoverable: every rotation instruction - // requires the current holder to sign, and nothing can sign for the zero key. + // 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())?; @@ -39,9 +35,6 @@ pub mod stable_swapper { "configure_authority", &ctx.accounts.configure_authority.key(), )?; - // Same zero-key rule `update_fee_recipient` applies, so a pool cannot start out in a - // state that instruction would refuse to set. - require_authority_set("fee_recipient", &ctx.accounts.fee_recipient.key())?; let pool = &mut ctx.accounts.pool; pool.pause_authority = ctx.accounts.pause_authority.key(); @@ -63,10 +56,7 @@ pub mod stable_swapper { } /// One-shot migration from the legacy `(operations_authority, pause_authority)` layout - /// to the new role-based layout. Gated on the program upgrade authority rather than on the - /// legacy authorities stored in the pool: the upgrade authority can already rewrite this - /// account by deploying new code, so it is the key that ultimately governs the migration, - /// and routing through it keeps the legacy hot keys out of the operation. + /// 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 @@ -364,9 +354,7 @@ pub mod stable_swapper { ctx: Context, fee_recipient: Pubkey, ) -> Result<()> { - // The recipient is the ATA authority for every fee transfer, so the default pubkey would - // silently send fees to a token account that nothing can sign for. - require_authority_set("fee_recipient", &fee_recipient)?; + require_recipient_set("fee_recipient", &fee_recipient)?; ctx.accounts.pool.fee_recipient = fee_recipient; msg!("Updated fee recipient to: {}", fee_recipient); Ok(()) @@ -377,10 +365,7 @@ pub mod stable_swapper { ctx: Context, recipient: Pubkey, ) -> Result<()> { - require!( - recipient != Pubkey::default(), - LiquidityError::WithdrawRecipientNotSet - ); + require_recipient_set("withdraw_recipient", &recipient)?; let pool = &mut ctx.accounts.pool; require!( !pool.withdraw_recipients.contains(&recipient), @@ -495,19 +480,29 @@ pub mod stable_swapper { } } -/// Rejects the default pubkey for a key stored on the pool. Roles can only be rotated by their -/// current holder, so a zero-key role is a one-way door: no signature exists for it. The fee -/// recipient is recoverable by comparison, but a zero key there routes every fee to a token -/// account nobody can sign for. `field` names the offending key, since a bare comparison logs -/// the same zero key on both sides. -fn require_authority_set(field: &str, key: &Pubkey) -> Result<()> { +/// 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!(LiquidityError::AuthorityNotSet); + 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. @@ -529,12 +524,7 @@ fn do_migrate_authorities<'info>( new_configure_authority: Pubkey, new_withdraw_recipient: Pubkey, ) -> Result<()> { - require!( - new_withdraw_recipient != Pubkey::default(), - LiquidityError::WithdrawRecipientNotSet - ); - // A role assigned to the default pubkey is unrecoverable: every rotation instruction - // requires the current holder to sign, and nothing can sign for the zero key. + 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)?; diff --git a/solana/tests/initialize-guards.ts b/solana/tests/initialize-guards.ts index 62156ac..79de105 100644 --- a/solana/tests/initialize-guards.ts +++ b/solana/tests/initialize-guards.ts @@ -180,8 +180,8 @@ describe("initialize role guards (bankrun)", () => { ["unpauseAuthority", "authoritynotset"], ["treasuryAuthority", "authoritynotset"], ["configureAuthority", "authoritynotset"], - ["feeRecipient", "authoritynotset"], - ["withdrawRecipient", "withdrawrecipientnotset"], + ["feeRecipient", "recipientnotset"], + ["withdrawRecipient", "recipientnotset"], ]; for (const [role, expected] of zeroKeyCases) { diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 80151e0..49605e3 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -2111,9 +2111,9 @@ describe("stable-swapper", () => { }) .signers([configureAuthority.payer]) .rpc(); - assert.fail("Expected AuthorityNotSet error"); + assert.fail("Expected RecipientNotSet error"); } catch (error) { - assert.include(error.toString().toLowerCase(), "authoritynotset"); + assert.include(error.toString().toLowerCase(), "recipientnotset"); } const poolAccount = await program.account.liquidityPool.fetch(pool); @@ -3241,12 +3241,9 @@ describe("stable-swapper", () => { .accounts({ pool, configureAuthority: configureAuthority.publicKey }) .signers([configureAuthority.payer]) .rpc(); - assert.fail("Expected WithdrawRecipientNotSet"); + assert.fail("Expected RecipientNotSet"); } catch (error) { - assert.include( - error.toString().toLowerCase(), - "withdrawrecipientnotset" - ); + assert.include(error.toString().toLowerCase(), "recipientnotset"); } }); From 397bfa360655a91dbd2be2bbf61c487943549389 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 11 Aug 2026 12:13:28 -0400 Subject: [PATCH 11/20] zero out account body before migrating --- solana/programs/stable-swapper/src/lib.rs | 1 + solana/tests/migration.ts | 42 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index 100732d..fcabc8f 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -634,6 +634,7 @@ fn do_migrate_authorities<'info>( { 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); diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts index 17bc7ad..ba8495b 100644 --- a/solana/tests/migration.ts +++ b/solana/tests/migration.ts @@ -285,6 +285,48 @@ describe("migrate_authorities (bankrun)", () => { ); }); + 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(); From 8cf8eb86deae9677c3ba7a4502c2264356b61f17 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 08:41:46 -0400 Subject: [PATCH 12/20] Perform a check on legacy fee --- solana/programs/stable-swapper/src/lib.rs | 2 ++ solana/tests/migration.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index fcabc8f..fd00fe6 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -598,6 +598,8 @@ fn do_migrate_authorities<'info>( ) }; + require_recipient_set("fee_recipient", &legacy_fee_recipient)?; + // Top up rent for the additional `LiquidityPool::MIGRATION_GROWTH` bytes, then grow the // account. This is a no-op when the pool already holds the new minimum balance. let rent = Rent::get()?; diff --git a/solana/tests/migration.ts b/solana/tests/migration.ts index ba8495b..3c1b4dd 100644 --- a/solana/tests/migration.ts +++ b/solana/tests/migration.ts @@ -420,6 +420,16 @@ describe("migrate_authorities (bankrun)", () => { } }); + 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. From 8b0fea96e80e2ab24b7c513f36b757029b044552 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 08:46:56 -0400 Subject: [PATCH 13/20] Remove MIGRATION_GROWTH --- solana/programs/stable-swapper/src/lib.rs | 8 ++++---- solana/programs/stable-swapper/src/state.rs | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index fd00fe6..56a70e6 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -507,8 +507,8 @@ fn require_recipient_set(field: &str, key: &Pubkey) -> Result<()> { /// 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 `LiquidityPool::MIGRATION_GROWTH` bytes (the two extra role keys -/// plus the withdraw-recipient allowlist), and `payer_ai` pays the difference through a +/// 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 @@ -600,8 +600,8 @@ fn do_migrate_authorities<'info>( require_recipient_set("fee_recipient", &legacy_fee_recipient)?; - // Top up rent for the additional `LiquidityPool::MIGRATION_GROWTH` bytes, then grow the - // account. This is a no-op when the pool already holds the new minimum balance. + // 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()); diff --git a/solana/programs/stable-swapper/src/state.rs b/solana/programs/stable-swapper/src/state.rs index fe08c9d..121b065 100644 --- a/solana/programs/stable-swapper/src/state.rs +++ b/solana/programs/stable-swapper/src/state.rs @@ -47,10 +47,6 @@ impl LiquidityPool { /// 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; - - /// Bytes added by `migrate_authorities`: two extra role keys plus the withdraw-recipient - /// allowlist slot. Derived from the two space constants so it cannot drift from them. - pub const MIGRATION_GROWTH: usize = Self::INIT_SPACE - Self::LEGACY_INIT_SPACE; } #[account] From 2cd0211127129f93f61b685ee3a2302e894d5056 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 08:57:23 -0400 Subject: [PATCH 14/20] Fix error naming --- solana/programs/stable-swapper/src/errors.rs | 4 ++-- solana/programs/stable-swapper/src/lib.rs | 2 +- solana/tests/stable-swapper.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/solana/programs/stable-swapper/src/errors.rs b/solana/programs/stable-swapper/src/errors.rs index 090c541..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")] diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index 56a70e6..d29e651 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -303,7 +303,7 @@ 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); // The treasury (hot) key selects a destination by passing its token account; the program diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 49605e3..6411e20 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -365,7 +365,7 @@ describe("stable-swapper", () => { assert.fail("Expected liquidity paused error"); } catch (error) { - assert.include(error.toString(), "LiquidityPaused"); + assert.include(error.toString(), "WithdrawalPaused"); } // Unpause liquidity for other tests From a861357665f4a95afe38bb84b2b5355ed66c3209 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 13:19:56 -0400 Subject: [PATCH 15/20] Fix unit test --- solana/tests/stable-swapper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 6411e20..3cefc15 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -1450,9 +1450,9 @@ describe("stable-swapper", () => { .unpauseSwaps() // swapsPaused, liquidityPaused .accounts({ pool, - pauseAuthority: pauseAuthority.publicKey, + unpauseAuthority: unpauseAuthority.publicKey, }) - .signers([pauseAuthority.payer]) + .signers([unpauseAuthority.payer]) .rpc(); // Verify swaps are unpaused From 1e9c39e4a15eadfe9065d96cf3b9ec36df531767 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 13:32:36 -0400 Subject: [PATCH 16/20] Change disabled defaults for newly provisioned vaults --- solana/programs/stable-swapper/src/lib.rs | 2 +- solana/tests/stable-swapper.ts | 48 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index d29e651..019a4e3 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -108,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); diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 3cefc15..760de72 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -223,6 +223,18 @@ describe("stable-swapper", () => { .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 const vaultAccount = await program.account.tokenVault.fetch(usdcVault); assert.equal(vaultAccount.mint.toString(), usdcMint.toString()); @@ -255,6 +267,18 @@ describe("stable-swapper", () => { .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 const poolAccount = await program.account.liquidityPool.fetch(pool); assert.equal(poolAccount.supportedTokens.length, 2); @@ -1111,6 +1135,18 @@ describe("stable-swapper", () => { .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); assert.equal(poolAccount.supportedTokens.length, 3); assert.ok( @@ -2195,6 +2231,18 @@ describe("stable-swapper", () => { .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 await transfer( provider.connection, From 53312dca81b13327d27a52f2bb55ff45dec2cdf7 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 13:42:44 -0400 Subject: [PATCH 17/20] Fix some comments --- solana/programs/stable-swapper/src/state.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solana/programs/stable-swapper/src/state.rs b/solana/programs/stable-swapper/src/state.rs index 121b065..a78ed74 100644 --- a/solana/programs/stable-swapper/src/state.rs +++ b/solana/programs/stable-swapper/src/state.rs @@ -12,9 +12,9 @@ pub struct LiquidityPool { 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 `withdraw_recipient`). + /// 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 rotate the withdraw recipient. + /// 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, From 4b0687db12b777b8e2e161c4c458fa8e5c42d9a9 Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 13:50:48 -0400 Subject: [PATCH 18/20] Adding guard, min_amount_out --- solana/programs/stable-swapper/src/lib.rs | 1 + solana/tests/stable-swapper.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/solana/programs/stable-swapper/src/lib.rs b/solana/programs/stable-swapper/src/lib.rs index 019a4e3..2f08e5c 100644 --- a/solana/programs/stable-swapper/src/lib.rs +++ b/solana/programs/stable-swapper/src/lib.rs @@ -171,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!( diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 760de72..40a55a2 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -856,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 @@ -2691,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 @@ -2780,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 From adebb76ba007e4fb68f9d0b5449706ea59d6c86e Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Tue, 18 Aug 2026 13:56:19 -0400 Subject: [PATCH 19/20] Fix obselete test comments --- solana/tests/stable-swapper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/solana/tests/stable-swapper.ts b/solana/tests/stable-swapper.ts index 40a55a2..3a06e82 100644 --- a/solana/tests/stable-swapper.ts +++ b/solana/tests/stable-swapper.ts @@ -362,7 +362,7 @@ describe("stable-swapper", () => { it("Fails to withdraw when liquidity is paused", async () => { // First pause liquidity await program.methods - .pauseWithdraws() // swapsPaused=null, liquidityPaused=true + .pauseWithdraws() // Sets liquidityPaused=true; swapsPaused is unchanged .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -1434,7 +1434,7 @@ describe("stable-swapper", () => { it("Pauses swaps", async () => { await program.methods - .pauseSwaps() // swapsPaused, liquidityPaused + .pauseSwaps() // Sets swapsPaused=true; liquidityPaused is unchanged .accounts({ pool, pauseAuthority: pauseAuthority.publicKey, @@ -1483,7 +1483,7 @@ describe("stable-swapper", () => { it("Unpauses swaps", async () => { await program.methods - .unpauseSwaps() // swapsPaused, liquidityPaused + .unpauseSwaps() // Sets swapsPaused=false; liquidityPaused is unchanged .accounts({ pool, unpauseAuthority: unpauseAuthority.publicKey, From 939cbc7c69be55ee6ab39129798a8b271a26906a Mon Sep 17 00:00:00 2001 From: Oliver Cai Date: Wed, 19 Aug 2026 10:57:57 -0400 Subject: [PATCH 20/20] Add security audit --- ..._coinbase_stable_swapper_solana_aug2026.pdf | Bin 0 -> 125133 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 audits/cantina_coinbase_stable_swapper_solana_aug2026.pdf 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 0000000000000000000000000000000000000000..893a072bea195ab6875d99e25d689909e72cee42 GIT binary patch literal 125133 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
  • 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

    U#G zL4}qy$xQ0QXn7d1NcLg**sQU;1Mp$>dOO2@<*VOnEgZVJ>$~=IZMAz{d3ON1*tJph zBF6s&Lnw|FYgCIQdX%?!B<=kgyS;1WhxGA|e>rk=-D=m8`CV~6KYN_H+x2_e?P*Z2 zc%${H(hG0*%U!#f*{;&nvAg!-3t?RtA@`%@n(e05XFD3&)cY}%OQ%)emQSE|&~FSZ zg>l8{7USBZu%ksG(4J?^uU^ki#q?fzmcEBjIcLXWg1gDr3US5#L2-5DMfTyyjC<+o z<=4#)ORrszZO;I_(O&d(gL+z4w=CCCS8LR*_(mNT8di--d}!V>i~3C`?-E;W?W&($ zkNcg`r^JJ3Z|_c3X63Q`u5a~^@7tiSU+0I*VpsRh&uvfR*Rk%RUCpX)XXUi*t}FYK zXusX)%W=$;A>&8Za%`4@b?E_E^pSo17Ws*v@fWhw! z>XO`Hi|#|{_$z=MZKhZgBo;LU%+bXGBOK>o5V#7cs^do|JD@7m_n6z9=tM)ThJ)Qa z>Wwla_CEICf~pPq;Y*^;8^>o-nS|uI=TT%lq2@8oV`K)@9-@P=|u&~(`0C6YIU$wQa-KBeROq+acDV*lF9Tknnc@HEC;V9 zAYdfNHu7Ux_H^ax*KKY&2bbX0jB}=VcryFOet0$*+E9 zZe?WG&9=WNQ}0 zdxDahVyhSKqjmmb84;FbvxW~OMyNo9Bm%h~|Kp?Y_L2L(a=oWKJI>G_zCV$GQ3RQl z6(P#&7Q!JsmikI_M!k=DiNp9t@0%{jMU_kCyNSmOo7Ph$q+O(|I)e}4A)Z|)fp2t~ zuHzB$DtZS$5WorZCZA4FoK9Gm0+Vh zg-V%D5#iRk`U%=99IM)jZ2taBLHqRA)%b$i*JvjbyKeN^xmAW|{8U_8x*|_dU6?u1 zuu0G8_+@;`*O`N_n5S4i+(bOawA2J`oMvp@pV*WKkHHe4wYhsT5m?*R>KN_<^lmzf z+;j`TPIo6nEL^s*3zu_FN0L%1jcTp6l22Thl~XbwQbl#m0_EshimTyOYJwpf%KTM1 zC2-Da&Xe##!XW3mJm{Q@gx6Ec=!Bm3R8E>l#=hILH)|iqlt0OwbIS>K2{6@k;HJfXSO`R_TJWDkDp69XUNUwtx+40 zJ*Ceb*!)V)xg~7!S0dZvvemg8!hVlP1A958Y%P`txcIP2?Wl%##x331^P14lZV z^|pDm2Pk88z{AJLtU~C%^yblABa^F^`L)T27kgzUq76BI_28CJXj@|8<|=>bwSCE; z*^;@_`iU1WI2)J)!})Z35SqdWA&>_0LP=sc8aq zmbT5|KI**VF-4!^O&nT0+n7vEXkGZi;uC+gm)t5bd!jzN|1CjQW7$>|&&`iIRuCJZCk6n$0V-PHvklot~M=l6@PO z>=&rag3+YQS(NAP=QLQ)AX2{Z0r*0Td≥LP)&0WqH)KCB&GxIBJhyl>?QhooU5t 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