diff --git a/.gitignore b/.gitignore index 0103d507..9b4f2613 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ node_modules/ **/*/target **/*/tests/fixtures/* !**/*/tests/fixtures/*.so +# Exception to the exception: escrow native's fixture .so is OUR program's +# build output (cargo build-sbf --sbf-out-dir=tests/fixtures regenerates it), +# not a third-party dump, so it stays untracked. +finance/escrow/native/tests/fixtures/escrow_native_program.so **/*.rs.bk **/*/test-ledger **/*/yarn.lock diff --git a/.reference/ANCHOR-1.0-MIGRATION.md b/.reference/ANCHOR-1.0-MIGRATION.md index 2db51869..27a86e3e 100644 --- a/.reference/ANCHOR-1.0-MIGRATION.md +++ b/.reference/ANCHOR-1.0-MIGRATION.md @@ -5,8 +5,8 @@ ### Cargo.toml - Change `anchor-lang = "0.32.1"` → `anchor-lang = "1.0.0"` - Change `anchor-lang = { version = "0.32.1", ... }` → `anchor-lang = { version = "1.0.0", ... }` -- Same for `anchor-spl` if present — change to `1.0.0` -- Add comment: `# Anchor 1.0.0 — pin to RC until stable release` +- Same for `anchor-spl` if present - change to `1.0.0` +- Add comment: `# Anchor 1.0.0 - pin to RC until stable release` - **REMOVE `interface-instructions` feature** if present (removed in Anchor 1.0). This affects transfer-hook projects. - Keep all other features as-is (`idl-build`, `init-if-needed`, `cpi`, etc.) @@ -37,5 +37,5 @@ ### interface-instructions removal (transfer-hook projects) For projects that had `features = ["interface-instructions"]`: - Remove that feature from Cargo.toml -- The `#[interface]` attribute is removed — check if the program source uses it +- The `#[interface]` attribute is removed - check if the program source uses it - If it does, this needs manual intervention to refactor diff --git a/CHANGELOG.md b/CHANGELOG.md index 136c0305..7a97d4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [2026-04-08] — Quicknode fork modernization (Mike MacCana) +## [2026-04-08] - Quicknode fork modernization (Mike MacCana) Mike MacCana led the Quicknode fork of the [Solana Foundation program examples](https://github.com/solana-developers/program-examples) from late 2025. The first commits on this repository lineage are dated **8 April 2026**; the summary below covers that work through the initial merge. @@ -12,13 +12,13 @@ Mike MacCana led the Quicknode fork of the [Solana Foundation program examples]( **Toolchain and frameworks.** The tree had accumulated examples from several years of Solana development (including Anchor releases going back to the ~0.26 era in 2022 and many intermediate versions). The fork brought the Anchor examples up to **Anchor 1.0.0** stable (from 1.0.0-rc.5), refreshed Agave/Solana CLI pins, standardized on **pnpm**, and added parallel implementations in **[Quasar](https://quasar-lang.com/docs)**, **Pinocchio**, **Native Rust**, and **ASM** where applicable. Token-2022 examples were renamed to **`token-extensions`**. -**Testing.** Replaced the old pattern of local validators, Bankrun, and scattered TypeScript `anchor test` flows with **LiteSVM in-process tests** for most Anchor programs — matching current Anchor defaults (`cargo test` wired through `Anchor.toml` / `pnpm test`). Fixed broken or flaky tests across Native, Pinocchio, and Anchor; added missing harnesses (e.g. block-list Pinocchio). CI was reworked for a repo this size: path filtering, caching, matrix sharding, and reliable detection of framework roots. +**Testing.** Replaced the old pattern of local validators, Bankrun, and scattered TypeScript `anchor test` flows with **LiteSVM in-process tests** for most Anchor programs - matching current Anchor defaults (`cargo test` wired through `Anchor.toml` / `pnpm test`). Fixed broken or flaky tests across Native, Pinocchio, and Anchor; added missing harnesses (e.g. block-list Pinocchio). CI was reworked for a repo this size: path filtering, caching, matrix sharding, and reliable detection of framework roots. **Programs and layout.** Broke large monolithic `lib.rs` files into **instruction handler modules**; adopted **`InitSpace`** and explicit PDA bumps instead of magic account sizes; corrected several logic bugs (escrow, token swap invariant, counter authority checks, compression Bubblegum program id, and more). Expanded finance and token-extension coverage; reorganized transfer-hook examples (including block-list under Pinocchio). **Documentation.** Rewrote the root README (framework badges, clearer example blurbs, ASM links), ran a style and **truth audit** on READMEs, and linked canonical [Solana terminology](https://solana.com/docs/references/terminology) on first mention. Added this changelog, `CONTRIBUTING.md` (aligned with LiteSVM testing), README templates, per-example Anchor and Quasar READMEs, fixed Husky for GUI git clients, removed unused maintainer scripts (`sync-package-json`, `cicd.sh`, local-validator helpers for the allow/block-list UI), dropped the orphan `tokens/spl-token-minter/` tree, and removed legacy root `package.json` dependencies (web3.js, Bankrun, chai). -**Removed / deferred.** Dropped duplicate or WIP trees (duplicate block-list Pinocchio copy, Quasar metadata example blocked on `sol_realloc`, root `yarn.lock`). Some examples remain excluded from CI via `.ghaignore` until they build cleanly again (compression, escrow, pyth, and others — see that file for the live list). +**Removed / deferred.** Dropped duplicate or WIP trees (duplicate block-list Pinocchio copy, Quasar metadata example blocked on `sol_realloc`, root `yarn.lock`). Some examples remain excluded from CI via `.ghaignore` until they build cleanly again (compression, escrow, pyth, and others - see that file for the live list). ## Before June 2026 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c905be16..eb6f7fe8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,28 +20,24 @@ Thank you for considering a contribution to this repository. We welcome new exam ## Testing -This repo uses an in-process test runtime — no local validator boot, no `solana-test-validator`, no `anchor test --validator legacy`. +This repo uses an in-process test runtime - no local validator boot, no `solana-test-validator`, no `anchor test --validator legacy`. -For Anchor and Quasar examples, tests are written in TypeScript and run with `node:test` via `tsx`: - -```bash -npx tsx --test --test-reporter=spec tests/*.ts -``` - -The conventional `Anchor.toml` `[scripts]` entry is: +**Anchor examples** are tested in Rust with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm). Tests live in `programs//tests/`, load the compiled program with `include_bytes!("../../../target/deploy/.so")`, and run with `cargo test` (build the `.so` first with `cargo build-sbf` or `anchor build`). The conventional `Anchor.toml` `[scripts]` entry is: ```toml [scripts] -test = "npx create-codama-clients; npx tsx --test --test-reporter=spec tests/*.ts" +test = "cargo test" ``` -The TypeScript tests use: +Optional helpers come from the [`solana-kite`](https://crates.io/crates/solana-kite) crate (wallet creation, token mint helpers, `send_transaction_from_instructions`). + +**Quasar examples** are tested in Rust with QuasarSVM. Run `quasar build` (which also generates the Rust client crate under `target/client/rust/` that the tests import), then `quasar test` or `cargo test`. + +**Native and Pinocchio examples** use `litesvm` directly from Rust, except for a few that keep TypeScript tests (`tsx --test` with [`solana-kite`](https://solanakite.org) and [`@solana/kit`](https://solanakit.com)) where the example is specifically about client-side tooling. -- [`solana-kite`](https://solanakite.org) for the connection, wallet creation, token mint helpers, PDA derivation, and `sendTransactionFromInstructions`. -- [`@solana/kit`](https://solanakit.com) for the core types (`KeyPairSigner`, `Address`, `lamports`). -- A [Codama](https://github.com/codama-idl/codama)-generated client (via `npx create-codama-clients`) for invoking the program instructions. Do **not** use `anchor.workspace` or `program.methods.X().rpc()`. +Do not write TypeScript tests for Anchor or Quasar programs, and do not use `anchor.workspace` or `program.methods.X().rpc()`. -Native and Pinocchio examples may use `litesvm` directly from Rust where appropriate. +Tests must exercise the program for real: initialize accounts, send transactions through the program's instruction handlers, and assert resulting state and balances. Placeholder tests (`assert!(true)`, build-only checks) don't count. ## Style @@ -54,7 +50,7 @@ Other conventions: - Use full words rather than abbreviations (`transaction`, not `tx` or `txn`; `account`, not `acc`). - Prefer `async`/`await` over `.then()`/`.catch()`. - Use `Array` rather than `T[]` in TypeScript. -- Avoid magic numbers — name or explain them. +- Avoid magic numbers - name or explain them. - Write "onchain" / "offchain" as single words (no hyphen). ## Excluding an example from CI diff --git a/Cargo.lock b/Cargo.lock index 09654b5e..2a68a992 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,15 +1156,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "counter-mpl-stack" -version = "0.1.0" -dependencies = [ - "borsh 1.6.1", - "shank", - "solana-program 4.0.0", -] - [[package]] name = "counter-solana-native" version = "0.1.0" @@ -3172,52 +3163,6 @@ dependencies = [ "keccak", ] -[[package]] -name = "shank" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1dc1d3af4ba5f02190110598b2abac0d13ce9dc58408aba4549e1c0f91a24c" -dependencies = [ - "shank_macro", -] - -[[package]] -name = "shank_macro" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63dbf105335507ad339dccacf3b1ea20e4c0b70d992b4de7cc11d5c0b91b0747" -dependencies = [ - "proc-macro2", - "quote", - "shank_macro_impl", - "shank_render", - "syn 1.0.109", -] - -[[package]] -name = "shank_macro_impl" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346563412da6d1a53bc53c81f9d8b102f177952b95fd8de00e5d2203a4685635" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "serde", - "syn 1.0.109", -] - -[[package]] -name = "shank_render" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8358067ec1787814d2577e76d9ddcc980559ad821e6bd04584f4847f4d1d955c" -dependencies = [ - "proc-macro2", - "quote", - "shank_macro_impl", -] - [[package]] name = "shlex" version = "1.3.0" diff --git a/README.md b/README.md index f930adb6..67305c41 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Each example is available in one or more of the following frameworks: -- [⚓ Anchor](https://www.anchor-lang.com/) — the most popular framework for Solana development. Build with `anchor build`, test with `pnpm test` as defined in `Anchor.toml`. -- [💫 Quasar](https://quasar-lang.com/docs) — a newer, more performant framework with Anchor-compatible ergonomics. Run `pnpm test` to execute tests. -- [🤥 Pinocchio](https://github.com/anza-xyz/pinocchio) — a zero-copy, zero-allocation library for Solana programs. Run `pnpm test` to execute tests. -- [🦀 Native Rust](https://docs.anza.xyz/) — vanilla Rust using Solana's native crates. Run `pnpm test` to execute tests. -- [🧬 ASM](https://github.com/blueshift-gg/sbpf) — hand-written sBPF assembly built with the `sbpf` toolchain. Run `pnpm build-and-test` to build and test. +- [⚓ Anchor](https://www.anchor-lang.com/) - the most popular framework for Solana development. Build with `anchor build`, test with `pnpm test` as defined in `Anchor.toml`. +- [💫 Quasar](https://quasar-lang.com/docs) - a newer, more performant framework with Anchor-compatible ergonomics. Run `pnpm test` to execute tests. +- [🤥 Pinocchio](https://github.com/anza-xyz/pinocchio) - a zero-copy, zero-allocation library for Solana programs. Run `pnpm test` to execute tests. +- [🦀 Native Rust](https://docs.anza.xyz/) - vanilla Rust using Solana's native crates. Run `pnpm test` to execute tests. +- [🧬 ASM](https://github.com/blueshift-gg/sbpf) - hand-written sBPF assembly built with the `sbpf` toolchain. Run `pnpm build-and-test` to build and test. > [!NOTE] > You don't need to write your own program for basic tasks like creating [accounts](https://solana.com/docs/terminology#account), transferring SOL, or minting tokens. These are handled by existing programs like the System Program and Token Program. @@ -19,7 +19,7 @@ Each example is available in one or more of the following frameworks: ### Escrow -**Start here — the best first finance program to learn on Solana.** A neutral account that holds funds until both sides deliver, like a real-estate escrow or a lawyer's trust account. The maker deposits token A and names how much token B they want; when a taker supplies token B, the program swaps both in a single all-or-nothing transaction. This swap is the core idea behind every onchain exchange. +**Start here - the best first finance program to learn on Solana.** A neutral account that holds funds until both sides deliver, like a real-estate escrow or a lawyer's trust account. The maker deposits token A and names how much token B they want; when a taker supplies token B, the program swaps both in a single all-or-nothing transaction. This swap is the core idea behind every onchain exchange. [⚓ Anchor](./finance/escrow/anchor) [💫 Quasar](./finance/escrow/quasar) [🦀 Native](./finance/escrow/native) @@ -49,7 +49,7 @@ A managed investment fund onchain, like an ETF or mutual fund. Investors deposit ### Betting Market -Parimutuel (pooled) prediction market — an admin opens an event with multiple outcomes, bettors stake tokens on an outcome, and at settlement the losing pool (minus a protocol fee) is split among winners in proportion to their stake. +Parimutuel (pooled) prediction market - an admin opens an event with multiple outcomes, bettors stake tokens on an outcome, and at settlement the losing pool (minus a protocol fee) is split among winners in proportion to their stake. [⚓ Anchor](./tokens/betting-market/anchor) @@ -70,7 +70,7 @@ Store and retrieve data using Solana accounts. ### Counter -Use a [PDA](https://solana.com/docs/terminology#program-derived-address-pda) to store global state — a counter that increments when called. +Use a [PDA](https://solana.com/docs/terminology#program-derived-address-pda) to store global state - a counter that increments when called. [⚓ Anchor](./basics/counter/anchor) [💫 Quasar](./basics/counter/quasar) [🤥 Pinocchio](./basics/counter/pinocchio) [🦀 Native](./basics/counter/native) @@ -100,7 +100,7 @@ Create new accounts on the blockchain. ### Cross-Program Invocation -Call one program from another — the hand program invokes the lever program to toggle a switch. +Call one program from another - the hand program invokes the lever program to toggle a switch. [⚓ Anchor](./basics/cross-program-invocation/anchor) [💫 Quasar](./basics/cross-program-invocation/quasar) [🦀 Native](./basics/cross-program-invocation/native) @@ -148,7 +148,7 @@ Send SOL between two accounts. ### Pyth Price Feeds -An **oracle** brings real-world market prices — a dollar, a stock, a token — [onchain](https://solana.com/docs/terminology#onchain), like a Bloomberg terminal feeding live quotes. [Pyth](https://pyth.network/) publishes low-latency prices from institutional sources, each in its own price feed account. This example reads a feed and logs its price, confidence interval, and exponent — the building block an AMM, lending market, or vault uses to value assets. +An **oracle** brings real-world market prices - a dollar, a stock, a token - [onchain](https://solana.com/docs/terminology#onchain), like a Bloomberg terminal feeding live quotes. [Pyth](https://pyth.network/) publishes low-latency prices from institutional sources, each in its own price feed account. This example reads a feed and logs its price, confidence interval, and exponent - the building block an AMM, lending market, or vault uses to value assets. [⚓ Anchor](./basics/pyth/anchor) [💫 Quasar](./basics/pyth/quasar) @@ -282,43 +282,43 @@ Create tokens with a built-in transfer fee. [⚓ Anchor](./tokens/token-extensions/transfer-fee/anchor) [💫 Quasar](./tokens/token-extensions/transfer-fee/quasar) [🦀 Native](./tokens/token-extensions/transfer-fee/native) -### Transfer Hook — Hello World +### Transfer Hook - Hello World A minimal transfer hook that executes custom logic on every token transfer. [⚓ Anchor](./tokens/token-extensions/transfer-hook/hello-world/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/hello-world/quasar) -### Transfer Hook — Counter +### Transfer Hook - Counter Count how many times tokens have been transferred. [⚓ Anchor](./tokens/token-extensions/transfer-hook/counter/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/counter/quasar) -### Transfer Hook — Account Data as Seed +### Transfer Hook - Account Data as Seed Use token account owner data as seeds to derive extra accounts in a transfer hook. [⚓ Anchor](./tokens/token-extensions/transfer-hook/account-data-as-seed/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/account-data-as-seed/quasar) -### Transfer Hook — Allow/Block List +### Transfer Hook - Allow/Block List Restrict or allow token transfers using an onchain list managed by a list authority. [⚓ Anchor](./tokens/token-extensions/transfer-hook/allow-block-list-token/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/allow-block-list-token/quasar) -### Transfer Hook — Transfer Cost +### Transfer Hook - Transfer Cost Charge an additional fee on every token transfer. [⚓ Anchor](./tokens/token-extensions/transfer-hook/transfer-cost/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/transfer-cost/quasar) -### Transfer Hook — Transfer Switch +### Transfer Hook - Transfer Switch Enable or disable token transfers with an onchain switch. [⚓ Anchor](./tokens/token-extensions/transfer-hook/transfer-switch/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/transfer-switch/quasar) -### Transfer Hook — Whitelist +### Transfer Hook - Whitelist Restrict transfers so only whitelisted accounts can receive tokens. @@ -344,6 +344,14 @@ Work with Metaplex compressed NFTs. [⚓ Anchor](./compression/cutils/anchor) [💫 Quasar](./compression/cutils/quasar) +## Tools + +### Shank and Codama + +Generate an IDL from a native Rust program with [Shank](https://github.com/metaplex-foundation/shank), then generate a TypeScript client from that IDL with [Codama](https://github.com/codama-idl/codama). + +[🦀 Native](./tools/shank-and-codama/native) + --- **PRs welcome!** Follow the [contributing guidelines](./CONTRIBUTING.md) and see [CHANGELOG.md](./CHANGELOG.md) for release history. diff --git a/basics/account-data/anchor/Anchor.toml b/basics/account-data/anchor/Anchor.toml index d31e9d34..08545285 100644 --- a/basics/account-data/anchor/Anchor.toml +++ b/basics/account-data/anchor/Anchor.toml @@ -8,12 +8,9 @@ skip-lint = false [programs.localnet] account_data_anchor_program = "GpVcgWdgVErgLqsn8VYUch6EqDerMgNqoLSmGyKrd6MR" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "cargo test" -litesvm-test = "pnpm ts-mocha -p ./tsconfig.json -t 1000000 tests/litesvm.test.ts" #For litesvm test +test = "cargo test" diff --git a/basics/account-data/anchor/programs/anchor-program-example/src/instructions/create.rs b/basics/account-data/anchor/programs/anchor-program-example/src/instructions/create.rs index 727a70f5..535efc85 100644 --- a/basics/account-data/anchor/programs/anchor-program-example/src/instructions/create.rs +++ b/basics/account-data/anchor/programs/anchor-program-example/src/instructions/create.rs @@ -2,7 +2,7 @@ use crate::state::AddressInfo; use anchor_lang::prelude::*; #[derive(Accounts)] -pub struct CreateAddressInfo<'info> { +pub struct CreateAddressInfoAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -16,7 +16,7 @@ pub struct CreateAddressInfo<'info> { } pub fn handle_create_address_info( - context: Context, + context: Context, name: String, house_number: u8, street: String, diff --git a/basics/account-data/anchor/programs/anchor-program-example/src/lib.rs b/basics/account-data/anchor/programs/anchor-program-example/src/lib.rs index d0e056d8..f5fd99da 100644 --- a/basics/account-data/anchor/programs/anchor-program-example/src/lib.rs +++ b/basics/account-data/anchor/programs/anchor-program-example/src/lib.rs @@ -11,7 +11,7 @@ pub mod account_data_anchor_program { use super::*; pub fn create_address_info( - context: Context, + context: Context, name: String, house_number: u8, street: String, diff --git a/basics/account-data/anchor/programs/anchor-program-example/tests/test_account_data.rs b/basics/account-data/anchor/programs/anchor-program-example/tests/test_account_data.rs index df3bac0a..567a816a 100644 --- a/basics/account-data/anchor/programs/anchor-program-example/tests/test_account_data.rs +++ b/basics/account-data/anchor/programs/anchor-program-example/tests/test_account_data.rs @@ -39,7 +39,7 @@ fn test_create_address_info() { city: "Solana Beach".to_string(), } .data(), - account_data_anchor_program::accounts::CreateAddressInfo { + account_data_anchor_program::accounts::CreateAddressInfoAccountConstraints { payer: payer.pubkey(), address_info: address_info_keypair.pubkey(), system_program: system_program::id(), diff --git a/basics/account-data/quasar/Cargo.toml b/basics/account-data/quasar/Cargo.toml index 35e46ce2..a11557aa 100644 --- a/basics/account-data/quasar/Cargo.toml +++ b/basics/account-data/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-account-data" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/account-data/quasar/src/instructions/create.rs b/basics/account-data/quasar/src/instructions/create.rs index efd4fe73..423caa4f 100644 --- a/basics/account-data/quasar/src/instructions/create.rs +++ b/basics/account-data/quasar/src/instructions/create.rs @@ -5,7 +5,7 @@ use { /// Accounts for creating a new address info account. #[derive(Accounts)] -pub struct CreateAddressInfo { +pub struct CreateAddressInfoAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut, init, payer = payer, address = AddressInfo::seeds(payer.address()))] @@ -15,7 +15,7 @@ pub struct CreateAddressInfo { #[inline(always)] pub fn handle_create_address_info( - accounts: &mut CreateAddressInfo, + accounts: &mut CreateAddressInfoAccountConstraints, name: &str, house_number: u8, street: &str, diff --git a/basics/account-data/quasar/src/lib.rs b/basics/account-data/quasar/src/lib.rs index cc6bb40c..5063a4bd 100644 --- a/basics/account-data/quasar/src/lib.rs +++ b/basics/account-data/quasar/src/lib.rs @@ -24,7 +24,7 @@ mod quasar_account_data { /// pass them directly (not by reference) to the handler. #[instruction(discriminator = 0)] pub fn create_address_info( - ctx: Ctx, + ctx: Ctx, house_number: u8, name: String<50>, street: String<50>, diff --git a/basics/checking-accounts/anchor/Anchor.toml b/basics/checking-accounts/anchor/Anchor.toml index b5dbaa10..ab5a598d 100644 --- a/basics/checking-accounts/anchor/Anchor.toml +++ b/basics/checking-accounts/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] checking_account_program = "ECWPhR3rJbaPfyNFgphnjxSEexbTArc7vxD8fnW6tgKw" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/checking-accounts/anchor/programs/anchor-program-example/src/lib.rs b/basics/checking-accounts/anchor/programs/anchor-program-example/src/lib.rs index 7781ebdf..5cd90a85 100644 --- a/basics/checking-accounts/anchor/programs/anchor-program-example/src/lib.rs +++ b/basics/checking-accounts/anchor/programs/anchor-program-example/src/lib.rs @@ -6,7 +6,7 @@ declare_id!("ECWPhR3rJbaPfyNFgphnjxSEexbTArc7vxD8fnW6tgKw"); pub mod checking_account_program { use super::*; - pub fn check_accounts(_context: Context) -> Result<()> { + pub fn check_accounts(_context: Context) -> Result<()> { Ok(()) } } @@ -14,7 +14,7 @@ pub mod checking_account_program { // Account validation in Anchor is done using the types and constraints specified in the #[derive(Accounts)] structs // This is a simple example and does not include all possible constraints and types #[derive(Accounts)] -pub struct CheckingAccounts<'info> { +pub struct CheckingAccountsAccountConstraints<'info> { payer: Signer<'info>, // checks account is signer /// CHECK: No checks performed, example of an unchecked account diff --git a/basics/checking-accounts/anchor/programs/anchor-program-example/tests/test_checking_accounts.rs b/basics/checking-accounts/anchor/programs/anchor-program-example/tests/test_checking_accounts.rs index 33de2b6b..15cb4485 100644 --- a/basics/checking-accounts/anchor/programs/anchor-program-example/tests/test_checking_accounts.rs +++ b/basics/checking-accounts/anchor/programs/anchor-program-example/tests/test_checking_accounts.rs @@ -43,7 +43,7 @@ fn test_check_accounts() { let check_accounts_ix = Instruction::new_with_bytes( program_id, &checking_account_program::instruction::CheckAccounts {}.data(), - checking_account_program::accounts::CheckingAccounts { + checking_account_program::accounts::CheckingAccountsAccountConstraints { payer: payer.pubkey(), account_to_create: account_to_create.pubkey(), account_to_change: account_to_change.pubkey(), diff --git a/basics/checking-accounts/quasar/Cargo.toml b/basics/checking-accounts/quasar/Cargo.toml index f791fb67..069c83bf 100644 --- a/basics/checking-accounts/quasar/Cargo.toml +++ b/basics/checking-accounts/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-checking-accounts" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/checking-accounts/quasar/src/instructions/check_accounts.rs b/basics/checking-accounts/quasar/src/instructions/check_accounts.rs index aa1efda9..8de395b3 100644 --- a/basics/checking-accounts/quasar/src/instructions/check_accounts.rs +++ b/basics/checking-accounts/quasar/src/instructions/check_accounts.rs @@ -8,10 +8,10 @@ use quasar_lang::prelude::*; /// Note: Anchor's `#[account(owner = id())]` owner constraint is not directly available /// in Quasar. Owner checks can be done manually in the instruction body if needed. #[derive(Accounts)] -pub struct CheckAccounts { +pub struct CheckAccountsAccountConstraints { /// Checks that this account signed the transaction. pub payer: Signer, - /// No checks performed — the caller is responsible for validation. + /// No checks performed - the caller is responsible for validation. #[account(mut)] pub account_to_create: UncheckedAccount, /// No automatic owner check in Quasar; see note above. @@ -22,7 +22,7 @@ pub struct CheckAccounts { } #[inline(always)] -pub fn handle_check_accounts(_accounts: &mut CheckAccounts) -> Result<(), ProgramError> { +pub fn handle_check_accounts(_accounts: &mut CheckAccountsAccountConstraints) -> Result<(), ProgramError> { // All validation happens declaratively via the account types above. // If any check fails, the runtime rejects the transaction before this runs. Ok(()) diff --git a/basics/checking-accounts/quasar/src/lib.rs b/basics/checking-accounts/quasar/src/lib.rs index 43d52319..289910bb 100644 --- a/basics/checking-accounts/quasar/src/lib.rs +++ b/basics/checking-accounts/quasar/src/lib.rs @@ -18,7 +18,7 @@ mod quasar_checking_accounts { /// - UncheckedAccount: no validation (opt-in to unchecked access) /// - Program: checks account is executable and is the system program #[instruction(discriminator = 0)] - pub fn check_accounts(ctx: Ctx) -> Result<(), ProgramError> { + pub fn check_accounts(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_check_accounts(&mut ctx.accounts) } } diff --git a/basics/close-account/anchor/Anchor.toml b/basics/close-account/anchor/Anchor.toml index a61bb8ae..3c9714ad 100644 --- a/basics/close-account/anchor/Anchor.toml +++ b/basics/close-account/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] close_account_program = "99TQtoDdQ5NS2v5Ppha93aqEmv3vV9VZVfHTP5rGST3c" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/close-account/anchor/migrations/deploy.ts b/basics/close-account/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/basics/close-account/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/basics/close-account/anchor/programs/close-account/src/instructions/close_user.rs b/basics/close-account/anchor/programs/close-account/src/instructions/close_user.rs index 978cfe24..6606fe22 100644 --- a/basics/close-account/anchor/programs/close-account/src/instructions/close_user.rs +++ b/basics/close-account/anchor/programs/close-account/src/instructions/close_user.rs @@ -2,7 +2,7 @@ use crate::state::*; use anchor_lang::prelude::*; #[derive(Accounts)] -pub struct CloseUserContext<'info> { +pub struct CloseUserAccountConstraints<'info> { #[account(mut)] pub user: Signer<'info>, @@ -18,6 +18,6 @@ pub struct CloseUserContext<'info> { pub user_account: Account<'info, User>, } -pub fn handle_close_user(_context: Context) -> Result<()> { +pub fn handle_close_user(_context: Context) -> Result<()> { Ok(()) } diff --git a/basics/close-account/anchor/programs/close-account/src/instructions/create_user.rs b/basics/close-account/anchor/programs/close-account/src/instructions/create_user.rs index 6e54fb96..a64ebd79 100644 --- a/basics/close-account/anchor/programs/close-account/src/instructions/create_user.rs +++ b/basics/close-account/anchor/programs/close-account/src/instructions/create_user.rs @@ -2,7 +2,7 @@ use crate::state::*; use anchor_lang::prelude::*; #[derive(Accounts)] -pub struct CreateUserContext<'info> { +pub struct CreateUserAccountConstraints<'info> { #[account(mut)] pub user: Signer<'info>, @@ -20,7 +20,10 @@ pub struct CreateUserContext<'info> { pub system_program: Program<'info, System>, } -pub fn handle_create_user(context: Context, name: String) -> Result<()> { +pub fn handle_create_user( + context: Context, + name: String, +) -> Result<()> { *context.accounts.user_account = User { bump: context.bumps.user_account, user: context.accounts.user.key(), diff --git a/basics/close-account/anchor/programs/close-account/src/lib.rs b/basics/close-account/anchor/programs/close-account/src/lib.rs index 4e18972d..0819ae85 100644 --- a/basics/close-account/anchor/programs/close-account/src/lib.rs +++ b/basics/close-account/anchor/programs/close-account/src/lib.rs @@ -9,11 +9,11 @@ declare_id!("99TQtoDdQ5NS2v5Ppha93aqEmv3vV9VZVfHTP5rGST3c"); pub mod close_account_program { use super::*; - pub fn create_user(context: Context, name: String) -> Result<()> { + pub fn create_user(context: Context, name: String) -> Result<()> { create_user::handle_create_user(context, name) } - pub fn close_user(context: Context) -> Result<()> { + pub fn close_user(context: Context) -> Result<()> { close_user::handle_close_user(context) } } diff --git a/basics/close-account/anchor/programs/close-account/tests/test_close_account.rs b/basics/close-account/anchor/programs/close-account/tests/test_close_account.rs index cbc2952d..f7189df6 100644 --- a/basics/close-account/anchor/programs/close-account/tests/test_close_account.rs +++ b/basics/close-account/anchor/programs/close-account/tests/test_close_account.rs @@ -34,7 +34,7 @@ fn test_create_and_close_user() { name: "John Doe".to_string(), } .data(), - close_account_program::accounts::CreateUserContext { + close_account_program::accounts::CreateUserAccountConstraints { user: payer.pubkey(), user_account: user_account_pda, system_program: system_program::id(), @@ -56,7 +56,7 @@ fn test_create_and_close_user() { let close_ix = Instruction::new_with_bytes( program_id, &close_account_program::instruction::CloseUser {}.data(), - close_account_program::accounts::CloseUserContext { + close_account_program::accounts::CloseUserAccountConstraints { user: payer.pubkey(), user_account: user_account_pda, } diff --git a/basics/close-account/native/package.json b/basics/close-account/native/package.json index f464f367..740e87a4 100644 --- a/basics/close-account/native/package.json +++ b/basics/close-account/native/package.json @@ -1,22 +1,20 @@ { "type": "module", "scripts": { - "test": "pnpm ts-mocha -p ./tests/tsconfig.test.json -t 1000000 ./tests/close-account.test.ts", + "test": "node --import tsx --test ./tests/close-account.test.ts", "build-and-test": "cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./tests/fixtures && pnpm test", "build": "cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./program/target/so", "deploy": "solana program deploy ./program/target/so/program.so" }, "dependencies": { - "@solana/web3.js": "^1.98.4" + "@solana/kit": "^6.9.0", + "@solana/web3.js": "^1.98.4", + "borsh": "^0.7.0", + "litesvm": "^1.1.0" }, "devDependencies": { - "@types/bn.js": "^5.1.0", - "@types/chai": "^4.3.1", - "@types/mocha": "^9.1.1", - "chai": "^4.3.4", - "mocha": "^9.0.3", - "solana-bankrun": "^0.3.0", - "ts-mocha": "^10.0.0", - "typescript": "^4.3.5" + "@types/node": "^25.9.1", + "tsx": "^4.22.4", + "typescript": "^5.9.0" } } diff --git a/basics/close-account/native/pnpm-lock.yaml b/basics/close-account/native/pnpm-lock.yaml index 6f8339c0..67600698 100644 --- a/basics/close-account/native/pnpm-lock.yaml +++ b/basics/close-account/native/pnpm-lock.yaml @@ -8,34 +8,28 @@ importers: .: dependencies: + '@solana/kit': + specifier: ^6.9.0 + version: 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) '@solana/web3.js': specifier: ^1.98.4 - version: 1.98.4(bufferutil@4.0.9)(typescript@4.9.5)(utf-8-validate@5.0.10) + version: 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + borsh: + specifier: ^0.7.0 + version: 0.7.0 + litesvm: + specifier: ^1.1.0 + version: 1.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) devDependencies: - '@types/bn.js': - specifier: ^5.1.0 - version: 5.1.6 - '@types/chai': - specifier: ^4.3.1 - version: 4.3.20 - '@types/mocha': - specifier: ^9.1.1 - version: 9.1.1 - chai: - specifier: ^4.3.4 - version: 4.5.0 - mocha: - specifier: ^9.0.3 - version: 9.2.2 - solana-bankrun: - specifier: ^0.3.0 - version: 0.3.1(bufferutil@4.0.9)(typescript@4.9.5)(utf-8-validate@5.0.10) - ts-mocha: - specifier: ^10.0.0 - version: 10.1.0(mocha@9.2.2) + '@types/node': + specifier: ^25.9.1 + version: 25.9.2 + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: - specifier: ^4.3.5 - version: 4.9.5 + specifier: ^5.9.0 + version: 5.9.3 packages: @@ -43,6 +37,162 @@ packages: resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@noble/curves@1.9.1': resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} @@ -51,6 +201,43 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@solana-program/system@0.12.2': + resolution: {integrity: sha512-MaBeOxlvTruQhA7UYkOb3hVTEHPPagOtd+PvTm6a8rGgvEAP0kD4BbC37NceOaR4ABNqdaCmD5OMVRKgrE6KAg==} + peerDependencies: + '@solana/kit': ^6.4.0 + + '@solana-program/token@0.13.0': + resolution: {integrity: sha512-/Apjrd5lwOJGrPB0J5Rv7EBeclvyEBQPAGA85Scm7wBH+GpkbdLDM9uK3TNg8jjFKyWQYai/JtPHbrx7VgFLSg==} + peerDependencies: + '@solana/kit': ^6.5.0 + + '@solana/accounts@6.9.0': + resolution: {integrity: sha512-g36AJreJrgf9AAjOfbdFHEFUTymBgzbWHoEDElZ+fDKvqBINDiUVKzDApwc7C7kGPMFqQBaoEHnQRxf2IqfKZQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/addresses@6.9.0': + resolution: {integrity: sha512-tWnG2L6lo/ZhcMT019F3myDsH87MM8EZbTO0cgwgvVPlEdIGblROFF3tGVrb7FVCOlbPI0ONCFyPbnrmR58LsA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/assertions@6.9.0': + resolution: {integrity: sha512-FjWWD6e0in+HFsHMvU2zKCbyPfKtDW6iGXZZ9+Qg1QUYpO1AEObsya3F7hb9RkZKUueK4WwWAQnIuvEUp3A1uA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + '@solana/buffer-layout@4.0.1': resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} engines: {node: '>=5.10'} @@ -61,12 +248,60 @@ packages: peerDependencies: typescript: '>=5.3.3' + '@solana/codecs-core@6.9.0': + resolution: {integrity: sha512-F2BmLecG/1nTtnjyD509NsEc254pxJKa2bpvotymv1lL1WfEn3zchcZ9SMIiLyL4G6J8b9F3OKIq2YSZho2AOQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-data-structures@6.9.0': + resolution: {integrity: sha512-f7GYtiHafvJDhqiwzUUSr/6AYSK4DCw6quPmA80NZGtkNiFa+g6LoJy2wbC0wp2dxvCwNpxf6x3ILCYRutAvvg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + '@solana/codecs-numbers@2.1.1': resolution: {integrity: sha512-m20IUPJhPUmPkHSlZ2iMAjJ7PaYUvlMtFhCQYzm9BEBSI6OCvXTG3GAPpAnSGRBfg5y+QNqqmKn4QHU3B6zzCQ==} engines: {node: '>=20.18.0'} peerDependencies: typescript: '>=5.3.3' + '@solana/codecs-numbers@6.9.0': + resolution: {integrity: sha512-XMI0FOHV2h7yPAllxWCX8z+J1msidNjXzN1mRjH5KR6C+vfzyKa2xWHve0bNSV/bjVAhqqhc7dQCpBKuF4+ScQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-strings@6.9.0': + resolution: {integrity: sha512-PTqYQxMsmdfEEq29bV1AnALD4FjFEsSxOj1fYNqooOSTEQEpUoYEQtsd55/kBsnIKltXbvYwXYXBusm19n1sQA==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5.4.0' + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: + optional: true + typescript: + optional: true + + '@solana/codecs@6.9.0': + resolution: {integrity: sha512-oWOybKa1PTGI1D/FyrvGKralADM1jmVZC2AtgEo+4JTKG0+i1p9ZbwNY2UcJqdYsDMDaGHAx0LMAid9LDCxXTQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + '@solana/errors@2.1.1': resolution: {integrity: sha512-sj6DaWNbSJFvLzT8UZoabMefQUfSW/8tXK7NTiagsDmh+Q87eyQDDC9L3z+mNmx9b6dEf6z660MOIplDD2nfEw==} engines: {node: '>=20.18.0'} @@ -74,32 +309,327 @@ packages: peerDependencies: typescript: '>=5.3.3' + '@solana/errors@6.9.0': + resolution: {integrity: sha512-7i+b07KMnkbHvFlz7uWade3jvyc22UmVm8o9taxPK8YV3JNM/NkS8oQFvMac2MIaLPAlEs7I8MHyVLUal1yY4g==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/fast-stable-stringify@6.9.0': + resolution: {integrity: sha512-l14zGVsURbT5Aox/kLFQywqV4VaE9/j3h2EvCu9oULVPMwzQB6yezJb1/KyiDwhm/RscooPd0gFQFIKEGQbayw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/fixed-points@6.9.0': + resolution: {integrity: sha512-0K7mbYC4jdAZFlXqXjpNanmEyZxk7K9NtXDLc1zuhGuxwH8J9guvohwdw2V7TQ9bfjCYsprY3Tp2kUVQpECGmA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/functional@6.9.0': + resolution: {integrity: sha512-sgNHOaIjETZZuziZdlwPsU5EjBVj5M0dUbwrSQTTNZe0SxX3pQ1QFVcs5KyvdS7AQcpBVdLjx4CfQjdKXk52GA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/instruction-plans@6.9.0': + resolution: {integrity: sha512-SxTSOetEKD+WPzvDuYRsP1+KkwUp8KqL1n7oFx9ThxjyfEY0ly0i9KdbvX5yYVDOA2TSwrltgdu14y/Pf6y3Cg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/instructions@6.9.0': + resolution: {integrity: sha512-LZfJx3bGdUSbGaswoOEPHygticqkCg3TusRczPJXyCmKhoQzPCcGQQ99qMzP7Wg8pEV5tWA5t7tycf8E237ydg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/keys@6.9.0': + resolution: {integrity: sha512-1g2QARiqSjNqT0EIqLDLQ5vRm7hCsbqgFwFAp5GsMV/8BTYT8s1Ct2wLHDZiJ4eAX6beTHVf8LbOBfVejtn3oQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/kit@6.9.0': + resolution: {integrity: sha512-k7BRz7Akfv8wiRtlCR/xUyDLfuMfYMelMR1+AC5KgwaRRJReDF0BucMLNN1In7WoI+KuWwr1OKv4na/oKpyeAQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/nominal-types@6.9.0': + resolution: {integrity: sha512-ouhrnY7a6nsLXRGcariwcmHDdXroCNqOuzwtdjKt2c8e8Drwao9yxPH2VoViNgpq8IGNJeQMEI1TVnoJZRn0gw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/offchain-messages@6.9.0': + resolution: {integrity: sha512-qK3tqRPb+E0kmTz5qFXZbEdF4pyzfOWRZjyVESHVGemDDeGzZ1SV3zAxcA6HBCnv4wCBnlyaDPw8t+5sryNMAw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/options@6.9.0': + resolution: {integrity: sha512-H5ZRWNzzLMwHU/fRU9aVx+3TaMN4gDNCUYxsZxq0h7mqiwxFy6mpy95xPsfdldthCHDYtYnUTxe2sBatGbNHig==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/plugin-core@6.9.0': + resolution: {integrity: sha512-KslLSnzY8zbGZibEBVMVUm2ZS8T2xf+cut7F65VjWPoWNAxU+p7933wsMz/az6CF7b65RI7iU3HhCr5/5QF50w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/plugin-interfaces@6.9.0': + resolution: {integrity: sha512-Qj4sk9thkM1UgnFXvWIoezd/CbqpX/2jigLBDsMB5Ed/gmFlkBSTL127LFDSY3OtzBpXl4hROs+Zqv+5xqtguA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/program-client-core@6.9.0': + resolution: {integrity: sha512-+iUnsddhs72QoBJoUO+/yHUXoBvYWa1sGCBRJk35zeg8j7ZXEwRkk6eX0VOrUPxhEpQbYJsIOCrIYApNIt8RFw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/programs@6.9.0': + resolution: {integrity: sha512-L9LAnQtfFFcCDLcbbnxhUtgAmu/kS4aRmrVncdnX5CFyQshlpo0/Qhrq3UA7vnhute4gjYV4pFT+64onH5qGEQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/promises@6.9.0': + resolution: {integrity: sha512-227PlXRi6KZX4ODYTkJitr9InSa79NTquI72slay4gzxO9VmMepgvYdMAX6kawdN5pt+VzaklKhNhWXk50Pi9g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-api@6.9.0': + resolution: {integrity: sha512-3KhXS6A1ie6GqTywW/KEMSXJ1VJEU66fxjhuiiqPILuJstP7kex3ycr3H6DirKydUsy6gaKaPN43rE+LfyS7OA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-parsed-types@6.9.0': + resolution: {integrity: sha512-6ThH8izY+DWDyrVOOlS40vTcFjwjCinjfqnId7zhRk8OxhkfHQ/iEj+OnGwD4Yhe8pGdVa7GNVYlrQgQgzQ3eQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-spec-types@6.9.0': + resolution: {integrity: sha512-A4fY1JRrcKqX3EfttO4Q8L97nGPqdjfekAV0eDyxN5nu9ngf5p7GKenkl7AYDoHLNr6ZX/C96cRADxXjsRJ0iA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-spec@6.9.0': + resolution: {integrity: sha512-3yHRoChc0IpsJbUq0/94l+ar3t9U3Ax58W0HON7eyYe7zFP10UAxpkHn7DPch9DeALyuGph8kVnvl+kXRgJlGg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-api@6.9.0': + resolution: {integrity: sha512-UA/rPQeNx6zQMUFcS8PPPuB4vzUOtSzIY/igMH0DRoP020NyES2GguIb7Zo7sqDNi4n0gkQRhoW4dPVotcNKdA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-channel-websocket@6.9.0': + resolution: {integrity: sha512-kT8Yne9HjJD2gooaOFNSyKrvaIfOy2GR0Ymv8OfecBCwFStdz+SPo5eYXq8ZWoZbr5E/MMpHgqsHBanqa2Ffyg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-spec@6.9.0': + resolution: {integrity: sha512-DbaG67s99vRZQxFMK80UQ7DEKkRJK6JEZeYg/U5UttD6n7ax/vct7qopxGnrt4RCkaaac2fU8Sr+fcnvWQweUg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions@6.9.0': + resolution: {integrity: sha512-IMctZQaMxzvRACQ6ooW98lP+7tVoUJnRgOZtkAdzgBizldQAYPIKd3MulP0jbQPCMfdPsa2Hs0NBcUwfgonq3w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-transformers@6.9.0': + resolution: {integrity: sha512-dg4LK2wEBpaY+KRk/SJIkYvrvjdsc1AwD4bkmGY4Fp7EwVlvwBQShAQn78Qi4IP0WQ/0n9ncFyUxgcB1Y01ZuQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-transport-http@6.9.0': + resolution: {integrity: sha512-4gy30fWJcS6jrcXCoP/optFpGJ/gD9xdkE8wDbe1Ys/Y+e4XjyBt45xtTnbdmMdukvdRX+oXS3zgUIYoagpNzQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-types@6.9.0': + resolution: {integrity: sha512-iFhPzZK3qiQ1lhfNTNBTI7BIs5PfWZSgRLD3enKm8ZAQggzvUklfO3KPh47jVsc/Jsr1UGPH8M3o3m17qjO1Cg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc@6.9.0': + resolution: {integrity: sha512-ny1Kt20+oq3xZErNA56+Magmb2JKYfQgHwZTsBmHKVl/9mBpv1y1+ygV+KNiiX/wWXWstLbdIo1jgPwZPbU2Vg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/signers@6.9.0': + resolution: {integrity: sha512-x7WyoRm9IORMqeSqNivZgyY+RERPkmqWxpINPD13kUH+oaZzonORIgxk2Lz+u5iPRXiJPkdRPrQ4FoFWv8i6kQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/subscribable@6.9.0': + resolution: {integrity: sha512-YV0/BrJNfepf10CTfLwD7kRY1kkELDHd+BbHJZhBeiuiXTY3xQTvvx1RFs3NtfFCcTHG25Uh8NpRacQJnxSSIQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/sysvars@6.9.0': + resolution: {integrity: sha512-e0e+QKr/th9t/O2N1oUoJmcodLghzAtWKUlGb1zyYub0/WJrPImnKqJqp/gDP4tK98mJxopPMcprCeHk4B+TQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transaction-confirmation@6.9.0': + resolution: {integrity: sha512-fzYCOih7hhtBzzNSkAnxMjeFeQ8U7e27k9i0RsgQc3/e3OCynF5HoIVNhhqZbwfIBKiaD4ginJR6slRnfqO32Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transaction-messages@6.9.0': + resolution: {integrity: sha512-OWpryt0w6SHlwHx12Vd1wvx2QwSGBXAIUEHTCtkctcM3AaZRy5cIl7CAq9iD5PgahUsaOyRLBV0zlCJcC2JrJA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transactions@6.9.0': + resolution: {integrity: sha512-uKPzLwHbjwChfVl82he17ntkh02PfgnMMhN7uOAC+VbkIt1O+EEw8sX87gi6kdG/EV+QBDQXm9PLAo5W0tYylw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} - '@types/bn.js@5.1.6': - resolution: {integrity: sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w==} - - '@types/chai@4.3.20': - resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} - '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - - '@types/mocha@9.1.1': - resolution: {integrity: sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@22.15.19': - resolution: {integrity: sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==} + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -110,74 +640,25 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@ungap/promise-all-settled@1.1.2': - resolution: {integrity: sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==} - agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} - ansi-colors@4.1.1: - resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} - engines: {node: '>=6'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - arrify@1.0.1: - resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} - engines: {node: '>=0.10.0'} - - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - base-x@3.0.11: resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - bn.js@5.2.2: resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browser-stdout@1.3.1: - resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - bs58@4.0.1: resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -185,94 +666,39 @@ packages: resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} engines: {node: '>=6.14.2'} - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chalk@5.4.1: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - - chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - debug@4.3.3: - resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decamelize@4.0.0: - resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} - engines: {node: '>=10'} - - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - delay@5.0.0: resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} - diff@3.5.0: - resolution: {integrity: sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==} - engines: {node: '>=0.3.1'} - - diff@5.0.0: - resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==} - engines: {node: '>=0.3.1'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} es6-promisify@5.0.0: resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -282,99 +708,19 @@ packages: engines: {node: '> 0.1.90'} fast-stable-stringify@1.0.0: - resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} - hasBin: true - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob@7.2.0: - resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Glob versions prior to v9 are no longer supported - - growl@1.10.5: - resolution: {integrity: sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==} - engines: {node: '>=4.x'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} - engines: {node: '>=8'} - - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-ws@4.0.1: resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} peerDependencies: @@ -385,61 +731,56 @@ packages: engines: {node: '>=8'} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + litesvm-darwin-arm64@1.1.0: + resolution: {integrity: sha512-SjcivEOOjBk65U6TgIeMJ7CCnHNKQXHx0qf6K6GIFZC1aHTg7ePrEi+WhAQD6VUBMdDHIMCVKC/uXnXPi6EKIw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + litesvm-darwin-x64@1.1.0: + resolution: {integrity: sha512-hTs+eZ9sHVZXhjggpnn/8A/E+Nt/E6Gf8E2ejdWWL9bBQKmq1Y0VcrDpORbIvqqRpTLHXqbxCuH1wQB2C8frJg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] - minimatch@4.2.1: - resolution: {integrity: sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==} - engines: {node: '>=10'} + litesvm-linux-arm64-gnu@1.1.0: + resolution: {integrity: sha512-6EjJ6+E+1SUXdJmCyeyhvlKhNncccqQNH241+P8d4E72rE3zuFxeCtLHhusCQk2p/Xau3dBI0qTLogZ1F1IGSA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + litesvm-linux-arm64-musl@1.1.0: + resolution: {integrity: sha512-mNuBOfX6GnDFT2i/kYPWud7eZGe57dDP0u4lwiSTQPRE0BxQbGZT2aEwX8LTwbonhbc6HSt50LamaZZzK4h4ig==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true + litesvm-linux-x64-gnu@1.1.0: + resolution: {integrity: sha512-Ot8RgUVlMKzKJi2nVDxaHVo0hjB5vtYTomYNIf26mIA32DOy0+dQfwOqUhynhvvSMxN3VFec3r/OtCnk6lRBrw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] - mocha@9.2.2: - resolution: {integrity: sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==} - engines: {node: '>= 12.0.0'} - hasBin: true + litesvm-linux-x64-musl@1.1.0: + resolution: {integrity: sha512-6kmneOIsTBSActELRTwxIYVJOVaLm3P6uwlmkqc9BUtDAQ7bRdRmwREWSbM8XxKBGw2LjiUfgRJ5WJGYo8fUFg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + litesvm@1.1.0: + resolution: {integrity: sha512-UOlMIEst50gSUyPnC2pGjGLygH8iC/GOqnNXQIHc8iGwD76m44ReeA/0h0vu/AIieZ2zG5/ERLxFV0kdNxkNsA==} + engines: {node: '>= 20'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.1: - resolution: {integrity: sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -453,171 +794,46 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - rpc-websockets@9.1.1: resolution: {integrity: sha512-1IXGM/TfPT6nfYMIXkJdzn+L4JEsmb0FL1O2OBjaH03V3yuUDdKFulGLMFG6ErV+8pZ5HVC0limve01RyO+saA==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - serialize-javascript@6.0.0: - resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} - - solana-bankrun-darwin-arm64@0.3.1: - resolution: {integrity: sha512-9LWtH/3/WR9fs8Ve/srdo41mpSqVHmRqDoo69Dv1Cupi+o1zMU6HiEPUHEvH2Tn/6TDbPEDf18MYNfReLUqE6A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - solana-bankrun-darwin-universal@0.3.1: - resolution: {integrity: sha512-muGHpVYWT7xCd8ZxEjs/bmsbMp8XBqroYGbE4lQPMDUuLvsJEIrjGqs3MbxEFr71sa58VpyvgywWd5ifI7sGIg==} - engines: {node: '>= 10'} - os: [darwin] - - solana-bankrun-darwin-x64@0.3.1: - resolution: {integrity: sha512-oCaxfHyt7RC3ZMldrh5AbKfy4EH3YRMl8h6fSlMZpxvjQx7nK7PxlRwMeflMnVdkKKp7U8WIDak1lilIPd3/lg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - solana-bankrun-linux-x64-gnu@0.3.1: - resolution: {integrity: sha512-PfRFhr7igGFNt2Ecfdzh3li9eFPB3Xhmk0Eib17EFIB62YgNUg3ItRnQQFaf0spazFjjJLnglY1TRKTuYlgSVA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - solana-bankrun-linux-x64-musl@0.3.1: - resolution: {integrity: sha512-6r8i0NuXg3CGURql8ISMIUqhE7Hx/O7MlIworK4oN08jYrP0CXdLeB/hywNn7Z8d1NXrox/NpYUgvRm2yIzAsQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - solana-bankrun@0.3.1: - resolution: {integrity: sha512-inRwON7fBU5lPC36HdEqPeDg15FXJYcf77+o0iz9amvkUMJepcwnRwEfTNyMVpVYdgjTOBW5vg+596/3fi1kGA==} - engines: {node: '>= 10'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - stream-chain@2.2.5: resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} stream-json@1.9.1: resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - superstruct@2.0.2: resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} engines: {node: '>=14.0.0'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - text-encoding-utf-8@1.0.2: resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-mocha@10.1.0: - resolution: {integrity: sha512-T0C0Xm3/WqCuF2tpa0GNGESTBoKZaiqdUP8guNv4ZY316AFXlyidnrzQ1LUrCT0Wb1i3J0zFTgOh/55Un44WdA==} - engines: {node: '>= 6.X.X'} - hasBin: true - peerDependencies: - mocha: ^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X || ^11.X.X - - ts-node@7.0.1: - resolution: {integrity: sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==} - engines: {node: '>=4.2.0'} - hasBin: true - - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true - typescript@4.9.5: - resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} - engines: {node: '>=4.2.0'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici-types@8.4.1: + resolution: {integrity: sha512-iIXDNrTeaM0lDZvNUY1Urfs9dVgOWdQCkv6VMiePh644EKce0qoz6FNxxg7/DS4CxbFI36Atlz0VgHKS2qL1Dw==} utf-8-validate@5.0.10: resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} @@ -633,21 +849,6 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - workerpool@6.2.0: - resolution: {integrity: sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.10: resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} engines: {node: '>=8.3.0'} @@ -672,68 +873,612 @@ packages: utf-8-validate: optional: true - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@babel/runtime@7.27.1': {} + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@solana-program/system@0.12.2(@solana/kit@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/kit': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + + '@solana-program/token@0.13.0(@solana/kit@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana-program/system': 0.12.2(@solana/kit@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana/kit': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + + '@solana/accounts@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/addresses@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/assertions': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/assertions@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 + + '@solana/codecs-core@2.1.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 2.1.1(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-core@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/codecs-data-structures@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/codecs-numbers@2.1.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.1.1(typescript@5.9.3) + '@solana/errors': 2.1.1(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-numbers@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/codecs-strings@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/codecs@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/fixed-points': 6.9.0(typescript@5.9.3) + '@solana/options': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@2.1.1(typescript@5.9.3)': + dependencies: + chalk: 5.4.1 + commander: 13.1.0 + typescript: 5.9.3 + + '@solana/errors@6.9.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + optionalDependencies: + typescript: 5.9.3 + + '@solana/fast-stable-stringify@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/fixed-points@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/functional@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/instruction-plans@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/instructions': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/promises': 6.9.0(typescript@5.9.3) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + '@solana/transactions': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/instructions@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/keys@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/assertions': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + '@solana/promises': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/kit@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/accounts': 6.9.0(typescript@5.9.3) + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/instruction-plans': 6.9.0(typescript@5.9.3) + '@solana/instructions': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/offchain-messages': 6.9.0(typescript@5.9.3) + '@solana/plugin-core': 6.9.0(typescript@5.9.3) + '@solana/plugin-interfaces': 6.9.0(typescript@5.9.3) + '@solana/program-client-core': 6.9.0(typescript@5.9.3) + '@solana/programs': 6.9.0(typescript@5.9.3) + '@solana/rpc': 6.9.0(typescript@5.9.3) + '@solana/rpc-api': 6.9.0(typescript@5.9.3) + '@solana/rpc-parsed-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/signers': 6.9.0(typescript@5.9.3) + '@solana/subscribable': 6.9.0(typescript@5.9.3) + '@solana/sysvars': 6.9.0(typescript@5.9.3) + '@solana/transaction-confirmation': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + '@solana/transactions': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/nominal-types@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/offchain-messages@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/options@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - yargs-parser@20.2.4: - resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} - engines: {node: '>=10'} + '@solana/plugin-core@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - yargs-unparser@2.0.0: - resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} - engines: {node: '>=10'} + '@solana/plugin-interfaces@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/instruction-plans': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/signers': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/program-client-core@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/accounts': 6.9.0(typescript@5.9.3) + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/instruction-plans': 6.9.0(typescript@5.9.3) + '@solana/instructions': 6.9.0(typescript@5.9.3) + '@solana/plugin-interfaces': 6.9.0(typescript@5.9.3) + '@solana/rpc-api': 6.9.0(typescript@5.9.3) + '@solana/signers': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} + '@solana/programs@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - yn@2.0.0: - resolution: {integrity: sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==} - engines: {node: '>=4'} + '@solana/promises@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-api@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/rpc-parsed-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + '@solana/transactions': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + '@solana/rpc-parsed-types@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 -snapshots: + '@solana/rpc-spec-types@6.9.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@babel/runtime@7.27.1': {} + '@solana/rpc-spec@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@noble/curves@1.9.1': + '@solana/rpc-subscriptions-api@6.9.0(typescript@5.9.3)': dependencies: - '@noble/hashes': 1.8.0 + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + '@solana/transactions': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@noble/hashes@1.8.0': {} + '@solana/rpc-subscriptions-channel-websocket@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 6.9.0(typescript@5.9.3) + '@solana/subscribable': 6.9.0(typescript@5.9.3) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - '@solana/buffer-layout@4.0.1': + '@solana/rpc-subscriptions-spec@6.9.0(typescript@5.9.3)': dependencies: - buffer: 6.0.3 + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/promises': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + '@solana/subscribable': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-subscriptions@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/fast-stable-stringify': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/promises': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions-api': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions-channel-websocket': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-subscriptions-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/subscribable': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/rpc-transformers@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc-transport-http@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + undici-types: 8.4.1 + optionalDependencies: + typescript: 5.9.3 - '@solana/codecs-core@2.1.1(typescript@4.9.5)': + '@solana/rpc-types@6.9.0(typescript@5.9.3)': dependencies: - '@solana/errors': 2.1.1(typescript@4.9.5) - typescript: 4.9.5 + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/fixed-points': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/fast-stable-stringify': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/rpc-api': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec': 6.9.0(typescript@5.9.3) + '@solana/rpc-spec-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-transformers': 6.9.0(typescript@5.9.3) + '@solana/rpc-transport-http': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/signers@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/instructions': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + '@solana/offchain-messages': 6.9.0(typescript@5.9.3) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + '@solana/transactions': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@solana/codecs-numbers@2.1.1(typescript@4.9.5)': + '@solana/subscribable@6.9.0(typescript@5.9.3)': dependencies: - '@solana/codecs-core': 2.1.1(typescript@4.9.5) - '@solana/errors': 2.1.1(typescript@4.9.5) - typescript: 4.9.5 + '@solana/errors': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@solana/errors@2.1.1(typescript@4.9.5)': + '@solana/sysvars@6.9.0(typescript@5.9.3)': dependencies: - chalk: 5.4.1 - commander: 13.1.0 - typescript: 4.9.5 + '@solana/accounts': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transaction-confirmation@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/promises': 6.9.0(typescript@5.9.3) + '@solana/rpc': 6.9.0(typescript@5.9.3) + '@solana/rpc-subscriptions': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + '@solana/transactions': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/transaction-messages@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/instructions': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transactions@6.9.0(typescript@5.9.3)': + dependencies: + '@solana/addresses': 6.9.0(typescript@5.9.3) + '@solana/codecs-core': 6.9.0(typescript@5.9.3) + '@solana/codecs-data-structures': 6.9.0(typescript@5.9.3) + '@solana/codecs-numbers': 6.9.0(typescript@5.9.3) + '@solana/codecs-strings': 6.9.0(typescript@5.9.3) + '@solana/errors': 6.9.0(typescript@5.9.3) + '@solana/functional': 6.9.0(typescript@5.9.3) + '@solana/instructions': 6.9.0(typescript@5.9.3) + '@solana/keys': 6.9.0(typescript@5.9.3) + '@solana/nominal-types': 6.9.0(typescript@5.9.3) + '@solana/rpc-types': 6.9.0(typescript@5.9.3) + '@solana/transaction-messages': 6.9.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@4.9.5)(utf-8-validate@5.0.10)': + '@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: '@babel/runtime': 7.27.1 '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 - '@solana/codecs-numbers': 2.1.1(typescript@4.9.5) + '@solana/codecs-numbers': 2.1.1(typescript@5.9.3) agentkeepalive: 4.6.0 bn.js: 5.2.2 borsh: 0.7.0 @@ -754,72 +1499,36 @@ snapshots: dependencies: tslib: 2.8.1 - '@types/bn.js@5.1.6': - dependencies: - '@types/node': 22.15.19 - - '@types/chai@4.3.20': {} - '@types/connect@3.4.38': dependencies: - '@types/node': 22.15.19 - - '@types/json5@0.0.29': - optional: true - - '@types/mocha@9.1.1': {} + '@types/node': 25.9.2 '@types/node@12.20.55': {} - '@types/node@22.15.19': + '@types/node@25.9.2': dependencies: - undici-types: 6.21.0 + undici-types: 7.24.6 '@types/uuid@8.3.4': {} '@types/ws@7.4.7': dependencies: - '@types/node': 22.15.19 + '@types/node': 25.9.2 '@types/ws@8.18.1': dependencies: - '@types/node': 22.15.19 - - '@ungap/promise-all-settled@1.1.2': {} + '@types/node': 25.9.2 agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 - ansi-colors@4.1.1: {} - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - argparse@2.0.1: {} - - arrify@1.0.1: {} - - assertion-error@1.1.0: {} - - balanced-match@1.0.2: {} - base-x@3.0.11: dependencies: safe-buffer: 5.2.1 base64-js@1.5.1: {} - binary-extensions@2.3.0: {} - bn.js@5.2.2: {} borsh@0.7.0: @@ -828,23 +1537,10 @@ snapshots: bs58: 4.0.1 text-encoding-utf-8: 1.0.2 - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browser-stdout@1.3.1: {} - bs58@4.0.1: dependencies: base-x: 3.0.11 - buffer-from@1.1.2: {} - buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -855,88 +1551,52 @@ snapshots: node-gyp-build: 4.8.4 optional: true - camelcase@6.3.0: {} - - chai@4.5.0: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chalk@5.4.1: {} - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - chokidar@3.5.3: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} + chalk@5.6.2: {} commander@13.1.0: {} - commander@2.20.3: {} - - concat-map@0.0.1: {} - - debug@4.3.3(supports-color@8.1.1): - dependencies: - ms: 2.1.2 - optionalDependencies: - supports-color: 8.1.1 - - decamelize@4.0.0: {} + commander@14.0.3: {} - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 + commander@2.20.3: {} delay@5.0.0: {} - diff@3.5.0: {} - - diff@5.0.0: {} - - emoji-regex@8.0.0: {} - es6-promise@4.2.8: {} es6-promisify@5.0.0: dependencies: es6-promise: 4.2.8 - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 eventemitter3@5.0.1: {} @@ -944,78 +1604,15 @@ snapshots: fast-stable-stringify@1.0.0: {} - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat@5.0.2: {} - - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true - get-caller-file@2.0.5: {} - - get-func-name@2.0.2: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob@7.2.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - growl@1.10.5: {} - - has-flag@4.0.0: {} - - he@1.2.0: {} - humanize-ms@1.2.1: dependencies: ms: 2.1.3 ieee754@1.2.1: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-plain-obj@2.1.0: {} - - is-unicode-supported@0.1.0: {} - - isexe@2.0.0: {} - isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)): dependencies: ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -1038,79 +1635,46 @@ snapshots: - bufferutil - utf-8-validate - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - json-stringify-safe@5.0.1: {} - json5@1.0.2: - dependencies: - minimist: 1.2.8 + litesvm-darwin-arm64@1.1.0: optional: true - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 - - make-error@1.3.6: {} + litesvm-darwin-x64@1.1.0: + optional: true - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.11 + litesvm-linux-arm64-gnu@1.1.0: + optional: true - minimatch@4.2.1: - dependencies: - brace-expansion: 1.1.11 + litesvm-linux-arm64-musl@1.1.0: + optional: true - minimist@1.2.8: {} + litesvm-linux-x64-gnu@1.1.0: + optional: true - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 + litesvm-linux-x64-musl@1.1.0: + optional: true - mocha@9.2.2: + litesvm@1.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10): dependencies: - '@ungap/promise-all-settled': 1.1.2 - ansi-colors: 4.1.1 - browser-stdout: 1.3.1 - chokidar: 3.5.3 - debug: 4.3.3(supports-color@8.1.1) - diff: 5.0.0 - escape-string-regexp: 4.0.0 - find-up: 5.0.0 - glob: 7.2.0 - growl: 1.10.5 - he: 1.2.0 - js-yaml: 4.1.0 - log-symbols: 4.1.0 - minimatch: 4.2.1 - ms: 2.1.3 - nanoid: 3.3.1 - serialize-javascript: 6.0.0 - strip-json-comments: 3.1.1 - supports-color: 8.1.1 - which: 2.0.2 - workerpool: 6.2.0 - yargs: 16.2.0 - yargs-parser: 20.2.4 - yargs-unparser: 2.0.0 - - ms@2.1.2: {} + '@solana-program/system': 0.12.2(@solana/kit@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/token': 0.13.0(@solana/kit@6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana/kit': 6.9.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + optionalDependencies: + litesvm-darwin-arm64: 1.1.0 + litesvm-darwin-x64: 1.1.0 + litesvm-linux-arm64-gnu: 1.1.0 + litesvm-linux-arm64-musl: 1.1.0 + litesvm-linux-x64-gnu: 1.1.0 + litesvm-linux-x64-musl: 1.1.0 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate ms@2.1.3: {} - nanoid@3.3.1: {} - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -1118,38 +1682,6 @@ snapshots: node-gyp-build@4.8.4: optional: true - normalize-path@3.0.0: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - pathval@1.1.1: {} - - picomatch@2.3.1: {} - - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - - require-directory@2.1.1: {} - rpc-websockets@9.1.1: dependencies: '@swc/helpers': 0.5.17 @@ -1165,120 +1697,31 @@ snapshots: safe-buffer@5.2.1: {} - serialize-javascript@6.0.0: - dependencies: - randombytes: 2.1.0 - - solana-bankrun-darwin-arm64@0.3.1: - optional: true - - solana-bankrun-darwin-universal@0.3.1: - optional: true - - solana-bankrun-darwin-x64@0.3.1: - optional: true - - solana-bankrun-linux-x64-gnu@0.3.1: - optional: true - - solana-bankrun-linux-x64-musl@0.3.1: - optional: true - - solana-bankrun@0.3.1(bufferutil@4.0.9)(typescript@4.9.5)(utf-8-validate@5.0.10): - dependencies: - '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@4.9.5)(utf-8-validate@5.0.10) - bs58: 4.0.1 - optionalDependencies: - solana-bankrun-darwin-arm64: 0.3.1 - solana-bankrun-darwin-universal: 0.3.1 - solana-bankrun-darwin-x64: 0.3.1 - solana-bankrun-linux-x64-gnu: 0.3.1 - solana-bankrun-linux-x64-musl: 0.3.1 - transitivePeerDependencies: - - bufferutil - - encoding - - typescript - - utf-8-validate - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - stream-chain@2.2.5: {} stream-json@1.9.1: dependencies: stream-chain: 2.2.5 - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: - optional: true - - strip-json-comments@3.1.1: {} - superstruct@2.0.2: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - text-encoding-utf-8@1.0.2: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - tr46@0.0.3: {} - ts-mocha@10.1.0(mocha@9.2.2): - dependencies: - mocha: 9.2.2 - ts-node: 7.0.1 - optionalDependencies: - tsconfig-paths: 3.15.0 - - ts-node@7.0.1: - dependencies: - arrify: 1.0.1 - buffer-from: 1.1.2 - diff: 3.5.0 - make-error: 1.3.6 - minimist: 1.2.8 - mkdirp: 0.5.6 - source-map-support: 0.5.21 - yn: 2.0.0 + tslib@2.8.1: {} - tsconfig-paths@3.15.0: + tsx@4.22.4: dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - optional: true - - tslib@2.8.1: {} + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 - type-detect@4.1.0: {} + typescript@5.9.3: {} - typescript@4.9.5: {} + undici-types@7.24.6: {} - undici-types@6.21.0: {} + undici-types@8.4.1: {} utf-8-validate@5.0.10: dependencies: @@ -1294,20 +1737,6 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 - which@2.0.2: - dependencies: - isexe: 2.0.0 - - workerpool@6.2.0: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10): optionalDependencies: bufferutil: 4.0.9 @@ -1318,27 +1747,7 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 - y18n@5.0.8: {} - - yargs-parser@20.2.4: {} - - yargs-unparser@2.0.0: - dependencies: - camelcase: 6.3.0 - decamelize: 4.0.0 - flat: 5.0.2 - is-plain-obj: 2.1.0 - - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.4 - - yn@2.0.0: {} - - yocto-queue@0.1.0: {} + ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.0.9 + utf-8-validate: 5.0.10 diff --git a/basics/close-account/native/program/src/instructions/close_user.rs b/basics/close-account/native/program/src/instructions/close_user.rs index c928008c..7a4664a1 100644 --- a/basics/close-account/native/program/src/instructions/close_user.rs +++ b/basics/close-account/native/program/src/instructions/close_user.rs @@ -1,29 +1,50 @@ use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, - rent::Rent, - sysvar::Sysvar, + program_error::ProgramError, + pubkey::Pubkey, }; -pub fn close_user(accounts: &[AccountInfo]) -> ProgramResult { +use crate::state::user::User; + +pub fn close_user(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let target_account = next_account_info(accounts_iter)?; let payer = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; - let account_span = 0usize; - let lamports_required = (Rent::get()?).minimum_balance(account_span); + // Only the user whose key derives the PDA may close it. + if !payer.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } - let diff = target_account.lamports() - lamports_required; + // The target must be this payer's own User PDA; otherwise anyone could + // close anyone else's account and pocket the rent. + let (user_pda, _) = Pubkey::find_program_address( + &[User::SEED_PREFIX.as_bytes(), payer.key.as_ref()], + program_id, + ); + if &user_pda != target_account.key { + return Err(ProgramError::InvalidSeeds); + } - // Send the rent back to the payer - **target_account.lamports.borrow_mut() -= diff; - **payer.lamports.borrow_mut() += diff; + // The account must belong to this program before we drain it. + if target_account.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } - // Realloc the account to zero - target_account.resize(account_span)?; + // Move ALL lamports back to the payer. Leaving any balance behind would + // strand it forever: nobody can sign for the PDA to recover it later. + let lamports_to_return = target_account.lamports(); + let new_payer_lamports = payer + .lamports() + .checked_add(lamports_to_return) + .ok_or(ProgramError::ArithmeticOverflow)?; + **payer.lamports.borrow_mut() = new_payer_lamports; + **target_account.lamports.borrow_mut() = 0; - // Assign the account to the System Program + // Wipe the data and hand the empty account back to the System Program. + target_account.resize(0)?; target_account.assign(system_program.key); Ok(()) diff --git a/basics/close-account/native/program/src/processor.rs b/basics/close-account/native/program/src/processor.rs index 9d9a9247..44c4254b 100644 --- a/basics/close-account/native/program/src/processor.rs +++ b/basics/close-account/native/program/src/processor.rs @@ -18,6 +18,6 @@ pub fn process_instruction( let instruction = MyInstruction::try_from_slice(input)?; match instruction { MyInstruction::CreateUser(data) => create_user(program_id, accounts, data), - MyInstruction::CloseUser => close_user(accounts), + MyInstruction::CloseUser => close_user(program_id, accounts), } } diff --git a/basics/close-account/native/program/tests/test.rs b/basics/close-account/native/program/tests/test.rs index 94549910..a9ab4ccb 100644 --- a/basics/close-account/native/program/tests/test.rs +++ b/basics/close-account/native/program/tests/test.rs @@ -8,65 +8,171 @@ use solana_transaction::Transaction; use close_account_native_program::processor::MyInstruction; -#[test] -fn test_close_account() { - let mut svm = LiteSVM::new(); +/// LiteSVM's default fee: 5000 lamports per signature, one signer per +/// transaction in these tests. +const TRANSACTION_FEE_LAMPORTS: u64 = 5000; +fn setup() -> (LiteSVM, Pubkey) { + let mut svm = LiteSVM::new(); let program_id = Pubkey::new_unique(); let program_bytes = include_bytes!("../../tests/fixtures/close_account_native_program.so"); - svm.add_program(program_id, program_bytes).unwrap(); + (svm, program_id) +} - let payer = Keypair::new(); - svm.airdrop(&payer.pubkey(), LAMPORTS_PER_SOL * 10).unwrap(); +fn funded_keypair(svm: &mut LiteSVM) -> Keypair { + let keypair = Keypair::new(); + svm.airdrop(&keypair.pubkey(), LAMPORTS_PER_SOL * 10) + .unwrap(); + keypair +} - let test_account_pubkey = - Pubkey::find_program_address(&[b"USER".as_ref(), &payer.pubkey().as_ref()], &program_id).0; +fn user_pda(program_id: &Pubkey, user: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[User::SEED_PREFIX.as_bytes(), user.as_ref()], program_id).0 +} - // create user ix +fn create_user_instruction(program_id: Pubkey, target: Pubkey, payer: Pubkey) -> Instruction { let data = borsh::to_vec(&MyInstruction::CreateUser(User { name: "Jacob".to_string(), })) .unwrap(); - - let ix = Instruction { + Instruction { program_id, accounts: vec![ - AccountMeta::new(test_account_pubkey, false), - AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(target, false), + AccountMeta::new(payer, true), AccountMeta::new(solana_system_interface::program::ID, false), ], data, - }; - - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&payer.pubkey()), - &[&payer], - svm.latest_blockhash(), - ); - - assert!(svm.send_transaction(tx).is_ok()); + } +} - // clsose user ix +fn close_user_instruction( + program_id: Pubkey, + target: Pubkey, + payer: Pubkey, + payer_is_signer: bool, +) -> Instruction { let data = borsh::to_vec(&MyInstruction::CloseUser).unwrap(); - - let ix = Instruction { + Instruction { program_id, accounts: vec![ - AccountMeta::new(test_account_pubkey, false), - AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(target, false), + AccountMeta::new(payer, payer_is_signer), AccountMeta::new(solana_system_interface::program::ID, false), ], data, - }; + } +} +fn send(svm: &mut LiteSVM, instruction: Instruction, payer: &Keypair) -> Result<(), String> { let tx = Transaction::new_signed_with_payer( - &[ix], + &[instruction], Some(&payer.pubkey()), - &[&payer], + &[payer], svm.latest_blockhash(), ); + svm.send_transaction(tx) + .map(|_| ()) + .map_err(|failed| format!("{:?}", failed.err)) +} + +#[test] +fn close_returns_all_lamports_to_owner() { + let (mut svm, program_id) = setup(); + let payer = funded_keypair(&mut svm); + let target = user_pda(&program_id, &payer.pubkey()); + + send( + &mut svm, + create_user_instruction(program_id, target, payer.pubkey()), + &payer, + ) + .unwrap(); + + let target_lamports = svm.get_account(&target).unwrap().lamports; + assert!(target_lamports > 0, "created PDA should hold rent lamports"); + let payer_balance_before_close = svm.get_balance(&payer.pubkey()).unwrap(); + + send( + &mut svm, + close_user_instruction(program_id, target, payer.pubkey(), true), + &payer, + ) + .unwrap(); + + // Every lamport in the PDA comes back to the payer; only the + // transaction fee is lost. + let payer_balance_after_close = svm.get_balance(&payer.pubkey()).unwrap(); + assert_eq!( + payer_balance_after_close, + payer_balance_before_close + target_lamports - TRANSACTION_FEE_LAMPORTS, + ); + + // The drained account no longer exists (0 lamports, no data). + let closed = svm.get_account(&target); + assert!( + closed.is_none() || closed.unwrap().lamports == 0, + "closed account should hold no lamports", + ); +} + +#[test] +fn close_rejects_non_owner() { + let (mut svm, program_id) = setup(); + let victim = funded_keypair(&mut svm); + let attacker = funded_keypair(&mut svm); + let victim_account = user_pda(&program_id, &victim.pubkey()); + + send( + &mut svm, + create_user_instruction(program_id, victim_account, victim.pubkey()), + &victim, + ) + .unwrap(); + + // The attacker signs, but the target is the victim's PDA, not the + // attacker's, so the seeds check fails. + let result = send( + &mut svm, + close_user_instruction(program_id, victim_account, attacker.pubkey(), true), + &attacker, + ); + assert!(result.is_err(), "non-owner close must fail"); + + // The victim's account is untouched. + let victim_account_after = svm.get_account(&victim_account).unwrap(); + assert_eq!(victim_account_after.owner, program_id); + assert!(victim_account_after.lamports > 0); +} + +#[test] +fn close_rejects_payer_that_did_not_sign() { + let (mut svm, program_id) = setup(); + let victim = funded_keypair(&mut svm); + let attacker = funded_keypair(&mut svm); + let victim_account = user_pda(&program_id, &victim.pubkey()); + + send( + &mut svm, + create_user_instruction(program_id, victim_account, victim.pubkey()), + &victim, + ) + .unwrap(); + + // The attacker names the victim as the payer without the victim's + // signature: rejected by the signer check. + let result = send( + &mut svm, + close_user_instruction(program_id, victim_account, victim.pubkey(), false), + &attacker, + ); + assert!( + result.is_err(), + "close without the owner's signature must fail" + ); - assert!(svm.send_transaction(tx).is_ok()); + let victim_account_after = svm.get_account(&victim_account).unwrap(); + assert_eq!(victim_account_after.owner, program_id); + assert!(victim_account_after.lamports > 0); } diff --git a/basics/close-account/native/tests/close-account.test.ts b/basics/close-account/native/tests/close-account.test.ts index df253f1f..d399d239 100644 --- a/basics/close-account/native/tests/close-account.test.ts +++ b/basics/close-account/native/tests/close-account.test.ts @@ -1,38 +1,153 @@ +// In-process integration test: the program `.so` is loaded into a LiteSVM +// instance (no validator) and driven through the web3.js instruction +// builders in ../ts. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { describe, test } from "node:test"; -import { PublicKey, Transaction } from "@solana/web3.js"; -import { start } from "solana-bankrun"; +import { fileURLToPath } from "node:url"; + +import { + AccountRole, + type Address, + appendTransactionMessageInstruction, + createTransactionMessage, + generateKeyPairSigner, + type Instruction, + lamports, + pipe, + setTransactionMessageFeePayerSigner, + signTransactionMessageWithSigners, +} from "@solana/kit"; +import { PublicKey, type TransactionInstruction } from "@solana/web3.js"; +import { FailedTransactionMetadata, LiteSVM } from "litesvm"; + import { createCloseUserInstruction, createCreateUserInstruction } from "../ts"; -describe("Close Account!", async () => { - const PROGRAM_ID = PublicKey.unique(); - const context = await start([{ name: "close_account_native_program", programId: PROGRAM_ID }], []); - const client = context.banksClient; - const payer = context.payer; +const here = dirname(fileURLToPath(import.meta.url)); +const programSoPath = join(here, "fixtures", "close_account_native_program.so"); - const testAccountPublicKey = PublicKey.findProgramAddressSync( - [Buffer.from("USER"), payer.publicKey.toBuffer()], - PROGRAM_ID, - )[0]; +// LiteSVM's default fee: 5000 lamports per signature, one signer per +// transaction in these tests. +const TRANSACTION_FEE_LAMPORTS = 5000n; - test("Create the account", async () => { - const blockhash = context.lastBlockhash; - const ix = createCreateUserInstruction(testAccountPublicKey, payer.publicKey, PROGRAM_ID, "Jacob"); +/** Convert a web3.js TransactionInstruction (from ../ts) into a kit Instruction. */ +function toKitInstruction(instruction: TransactionInstruction): Instruction { + return { + programAddress: instruction.programId.toBase58() as Address, + accounts: instruction.keys.map((meta) => ({ + address: meta.pubkey.toBase58() as Address, + role: meta.isSigner + ? meta.isWritable + ? AccountRole.WRITABLE_SIGNER + : AccountRole.READONLY_SIGNER + : meta.isWritable + ? AccountRole.WRITABLE + : AccountRole.READONLY, + })), + data: new Uint8Array(instruction.data), + }; +} - const tx = new Transaction(); - tx.recentBlockhash = blockhash; - tx.add(ix).sign(payer); +async function sendIx( + svm: LiteSVM, + feePayer: Awaited>, + instruction: Instruction, +) { + const tx = await pipe( + createTransactionMessage({ version: 0 }), + (m) => setTransactionMessageFeePayerSigner(feePayer, m), + (m) => svm.setTransactionMessageLifetimeUsingLatestBlockhash(m), + (m) => appendTransactionMessageInstruction(instruction, m), + (m) => signTransactionMessageWithSigners(m), + ); + const result = svm.sendTransaction(tx); + if (result instanceof FailedTransactionMetadata) { + throw new Error(`Transaction failed: ${result.err()}\n${result.meta().logs().join("\n")}`); + } + return result; +} - await client.processTransaction(tx); - }); +describe("Close Account!", () => { + test("create, reject a non-owner close, then close and recover every lamport", async () => { + const svm = new LiteSVM(); + const programId = (await generateKeyPairSigner()).address; + svm.addProgram(programId, readFileSync(programSoPath)); + + const payer = await generateKeyPairSigner(); + svm.airdrop(payer.address, lamports(10_000_000_000n)); + const payerPublicKey = new PublicKey(payer.address); + const programPublicKey = new PublicKey(programId); + + const userAccount = PublicKey.findProgramAddressSync( + [Buffer.from("USER"), payerPublicKey.toBuffer()], + programPublicKey, + )[0]; + const userAccountAddress = userAccount.toBase58() as Address; + + // 1. Create the user account. + await sendIx( + svm, + payer, + toKitInstruction(createCreateUserInstruction(userAccount, payerPublicKey, programPublicKey, "Jacob")), + ); + + const userAccountLamports = svm.getBalance(userAccountAddress); + assert.ok( + userAccountLamports !== null && userAccountLamports > 0n, + "user account should hold rent lamports after create", + ); + + // 2. A non-owner cannot close it: the attacker signs as payer, but the + // target is the victim's PDA, so the program's seeds check rejects it. + const attacker = await generateKeyPairSigner(); + svm.airdrop(attacker.address, lamports(1_000_000_000n)); + const attackerPublicKey = new PublicKey(attacker.address); + + await assert.rejects( + sendIx( + svm, + attacker, + toKitInstruction(createCloseUserInstruction(userAccount, attackerPublicKey, programPublicKey)), + ), + "closing someone else's account must fail", + ); + + // 3. Naming the victim as payer without their signature is rejected too. + const closeWithoutSignature = toKitInstruction( + createCloseUserInstruction(userAccount, payerPublicKey, programPublicKey), + ); + const demotedAccounts = closeWithoutSignature.accounts!.map((meta) => + meta.address === payer.address ? { address: meta.address, role: AccountRole.WRITABLE } : meta, + ); + await assert.rejects( + sendIx(svm, attacker, { ...closeWithoutSignature, accounts: demotedAccounts }), + "closing without the owner's signature must fail", + ); + assert.equal( + svm.getBalance(userAccountAddress), + userAccountLamports, + "victim account must survive the attacks untouched", + ); - test("Close the account", async () => { - const blockhash = context.lastBlockhash; + // 4. The owner closes it and recovers every lamport (minus the + // transaction fee). Nothing is stranded at the PDA. + const payerBalanceBefore = svm.getBalance(payer.address)!; + await sendIx( + svm, + payer, + toKitInstruction(createCloseUserInstruction(userAccount, payerPublicKey, programPublicKey)), + ); - const ix = createCloseUserInstruction(testAccountPublicKey, payer.publicKey, PROGRAM_ID); - const tx = new Transaction(); - tx.recentBlockhash = blockhash; - tx.add(ix).sign(payer); + const payerBalanceAfter = svm.getBalance(payer.address)!; + assert.equal( + payerBalanceAfter, + payerBalanceBefore + userAccountLamports - TRANSACTION_FEE_LAMPORTS, + "payer should recover every lamport the PDA held", + ); - await client.processTransaction(tx); + const closedBalance = svm.getBalance(userAccountAddress); + assert.ok(closedBalance === null || closedBalance === 0n, "closed account should hold no lamports"); }); }); diff --git a/basics/close-account/native/tests/fixtures/close_account_native_program.so b/basics/close-account/native/tests/fixtures/close_account_native_program.so new file mode 100755 index 00000000..bb1c5e0d Binary files /dev/null and b/basics/close-account/native/tests/fixtures/close_account_native_program.so differ diff --git a/basics/close-account/native/tests/tsconfig.test.json b/basics/close-account/native/tests/tsconfig.test.json deleted file mode 100644 index cd5d2e3d..00000000 --- a/basics/close-account/native/tests/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "types": ["mocha", "chai"], - "typeRoots": ["./node_modules/@types"], - "lib": ["es2015"], - "module": "commonjs", - "target": "es6", - "esModuleInterop": true - } -} diff --git a/basics/close-account/pinocchio/program/src/lib.rs b/basics/close-account/pinocchio/program/src/lib.rs index d63f67b0..d8a2873f 100644 --- a/basics/close-account/pinocchio/program/src/lib.rs +++ b/basics/close-account/pinocchio/program/src/lib.rs @@ -20,7 +20,7 @@ fn process_instruction( ) -> ProgramResult { match instruction_data.split_first() { Some((&CREATE_DISCRIMINATOR, data)) => process_user(program_id, accounts, data), - Some((&CLOSE_DISCRIMINATOR, _)) => process_close(accounts), + Some((&CLOSE_DISCRIMINATOR, _)) => process_close(program_id, accounts), _ => Err(ProgramError::InvalidInstructionData), } } @@ -46,12 +46,30 @@ fn process_user( return Err(ProgramError::NotEnoughAccountKeys); }; + // Expected layout: 1 bump byte followed by `User::LEN` name bytes. + // Bounds-check before slicing so malformed input returns a clean error + // instead of panicking. + if instruction_data.len() < 1 + User::LEN { + return Err(ProgramError::InvalidInstructionData); + } + let bump = instruction_data[0]; + + // The bump comes from the client, so verify it: it must be the canonical + // bump and the derived PDA must be the account we were asked to create. + let (user_pda, canonical_bump) = Address::find_program_address( + &[User::SEED_PREFIX.as_bytes(), payer.address().as_ref()], + program_id, + ); + if bump != canonical_bump || target_account.address() != &user_pda { + return Err(ProgramError::InvalidSeeds); + } + let rent = Rent::get()?; let account_span = User::LEN; let lamports_required = rent.try_minimum_balance(account_span)?; - let bump_bytes = instruction_data[0].to_le_bytes(); + let bump_bytes = [bump]; let seeds = [ Seed::from(User::SEED_PREFIX.as_bytes()), @@ -69,28 +87,49 @@ fn process_user( } .invoke_signed(&signers)?; - let mut address_info_data = target_account.try_borrow_mut()?; - address_info_data.copy_from_slice(&instruction_data[1..]); + let mut user_account_data = target_account.try_borrow_mut()?; + user_account_data.copy_from_slice(&instruction_data[1..1 + User::LEN]); Ok(()) } -fn process_close(accounts: &[AccountView]) -> ProgramResult { +fn process_close(program_id: &Address, accounts: &[AccountView]) -> ProgramResult { let [target_account, payer, system_program] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; - let rent = Rent::get()?; - - let account_span = 0usize; - let lamports_required = rent.try_minimum_balance(account_span)?; + // Only the user whose key derives the PDA may close it. + if !payer.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } - let diff = target_account.lamports() - lamports_required; + // The target must be this payer's own User PDA; otherwise anyone could + // close anyone else's account and pocket the rent. + let (user_pda, _) = Address::find_program_address( + &[User::SEED_PREFIX.as_bytes(), payer.address().as_ref()], + program_id, + ); + if target_account.address() != &user_pda { + return Err(ProgramError::InvalidSeeds); + } - target_account.set_lamports(target_account.lamports() - diff); - payer.set_lamports(payer.lamports() + diff); + // The account must belong to this program before we drain it. + if !target_account.owned_by(program_id) { + return Err(ProgramError::IncorrectProgramId); + } - target_account.resize(account_span)?; + // Move ALL lamports back to the payer. Leaving any balance behind would + // strand it forever: nobody can sign for the PDA to recover it later. + let lamports_to_return = target_account.lamports(); + let new_payer_lamports = payer + .lamports() + .checked_add(lamports_to_return) + .ok_or(ProgramError::ArithmeticOverflow)?; + payer.set_lamports(new_payer_lamports); + target_account.set_lamports(0); + + // Wipe the data and hand the empty account back to the System Program. + target_account.resize(0)?; unsafe { target_account.assign(system_program.address()); diff --git a/basics/close-account/pinocchio/program/tests/tests.rs b/basics/close-account/pinocchio/program/tests/tests.rs index 9814b4d4..d6b8d11f 100644 --- a/basics/close-account/pinocchio/program/tests/tests.rs +++ b/basics/close-account/pinocchio/program/tests/tests.rs @@ -7,28 +7,30 @@ use solana_transaction::Transaction; use close_account_pinocchio_program::{User, CLOSE_DISCRIMINATOR, CREATE_DISCRIMINATOR}; -#[test] -fn test_close_account() { - let mut svm = LiteSVM::new(); +/// LiteSVM's default fee: 5000 lamports per signature, one signer per +/// transaction in these tests. +const TRANSACTION_FEE_LAMPORTS: u64 = 5000; +fn setup() -> (LiteSVM, Pubkey) { + let mut svm = LiteSVM::new(); let program_id = Pubkey::new_unique(); let program_bytes = include_bytes!("../../tests/fixtures/close_account_pinocchio_program.so"); - svm.add_program(program_id, program_bytes).unwrap(); + (svm, program_id) +} - let payer = Keypair::new(); - svm.airdrop(&payer.pubkey(), LAMPORTS_PER_SOL * 10).unwrap(); - - let test_account_pubkey = - Pubkey::find_program_address(&[b"USER".as_ref(), &payer.pubkey().as_ref()], &program_id).0; +fn funded_keypair(svm: &mut LiteSVM) -> Keypair { + let keypair = Keypair::new(); + svm.airdrop(&keypair.pubkey(), LAMPORTS_PER_SOL * 10) + .unwrap(); + keypair +} - let bump = Pubkey::find_program_address( - &[User::SEED_PREFIX.as_bytes(), payer.pubkey().as_ref()], - &program_id, - ) - .1; +fn user_pda(program_id: &Pubkey, user: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[User::SEED_PREFIX.as_bytes(), user.as_ref()], program_id) +} - // process_user +fn create_user_data(bump: u8) -> Vec { let mut data = Vec::new(); data.push(CREATE_DISCRIMINATOR); data.push(bump); @@ -36,57 +38,191 @@ fn test_close_account() { let name_len = b"Jacob".len().min(User::LEN); name[..name_len].copy_from_slice(&b"Jacob"[..name_len]); data.extend_from_slice(&name); + data +} - let ix = Instruction { +fn user_instruction( + program_id: Pubkey, + target: Pubkey, + payer: Pubkey, + data: Vec, +) -> Instruction { + Instruction { program_id, accounts: vec![ - AccountMeta::new(test_account_pubkey, false), - AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(target, false), + AccountMeta::new(payer, true), AccountMeta::new(solana_system_interface::program::ID, false), ], data, - }; + } +} +fn send(svm: &mut LiteSVM, instruction: Instruction, payer: &Keypair) -> Result<(), String> { let tx = Transaction::new_signed_with_payer( - &[ix], + &[instruction], Some(&payer.pubkey()), - &[&payer], + &[payer], svm.latest_blockhash(), ); + svm.send_transaction(tx) + .map(|_| ()) + .map_err(|failed| format!("{:?}", failed.err)) +} - let res = svm.send_transaction(tx); - assert!(res.is_ok()); +fn create_user_account(svm: &mut LiteSVM, program_id: Pubkey, payer: &Keypair) -> Pubkey { + let (target, bump) = user_pda(&program_id, &payer.pubkey()); + send( + svm, + user_instruction(program_id, target, payer.pubkey(), create_user_data(bump)), + payer, + ) + .unwrap(); + target +} - let account = svm.get_account(&test_account_pubkey).unwrap(); - assert_eq!(account.data.len(), User::LEN); - assert_eq!(account.owner, program_id); - assert_eq!(&account.data[..5], b"Jacob"); +#[test] +fn create_then_close_returns_all_lamports() { + let (mut svm, program_id) = setup(); + let payer = funded_keypair(&mut svm); + + let target = create_user_account(&mut svm, program_id, &payer); + + let created = svm.get_account(&target).unwrap(); + assert_eq!(created.data.len(), User::LEN); + assert_eq!(created.owner, program_id); + assert_eq!(&created.data[..5], b"Jacob"); + let target_lamports = created.lamports; + assert!(target_lamports > 0, "created PDA should hold rent lamports"); + + let payer_balance_before_close = svm.get_balance(&payer.pubkey()).unwrap(); + + send( + &mut svm, + user_instruction( + program_id, + target, + payer.pubkey(), + vec![CLOSE_DISCRIMINATOR], + ), + &payer, + ) + .unwrap(); + + // Every lamport in the PDA comes back to the payer; only the + // transaction fee is lost. + let payer_balance_after_close = svm.get_balance(&payer.pubkey()).unwrap(); + assert_eq!( + payer_balance_after_close, + payer_balance_before_close + target_lamports - TRANSACTION_FEE_LAMPORTS, + ); - // process_close - let mut data = Vec::new(); - data.push(CLOSE_DISCRIMINATOR); + // The drained account no longer exists (0 lamports, no data). + let closed = svm.get_account(&target); + assert!( + closed.is_none() || closed.unwrap().lamports == 0, + "closed account should hold no lamports", + ); +} - let ix = Instruction { +#[test] +fn close_rejects_non_owner() { + let (mut svm, program_id) = setup(); + let victim = funded_keypair(&mut svm); + let attacker = funded_keypair(&mut svm); + + let victim_account = create_user_account(&mut svm, program_id, &victim); + + // The attacker signs, but the target is the victim's PDA, not the + // attacker's, so the seeds check fails. + let result = send( + &mut svm, + user_instruction( + program_id, + victim_account, + attacker.pubkey(), + vec![CLOSE_DISCRIMINATOR], + ), + &attacker, + ); + assert!(result.is_err(), "non-owner close must fail"); + + let victim_account_after = svm.get_account(&victim_account).unwrap(); + assert_eq!(victim_account_after.owner, program_id); + assert!(victim_account_after.lamports > 0); +} + +#[test] +fn close_rejects_payer_that_did_not_sign() { + let (mut svm, program_id) = setup(); + let victim = funded_keypair(&mut svm); + let attacker = funded_keypair(&mut svm); + + let victim_account = create_user_account(&mut svm, program_id, &victim); + + // The attacker names the victim as the payer without the victim's + // signature: rejected by the signer check. + let close_with_unsigned_payer = Instruction { program_id, accounts: vec![ - AccountMeta::new(test_account_pubkey, false), - AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(victim_account, false), + AccountMeta::new(victim.pubkey(), false), AccountMeta::new(solana_system_interface::program::ID, false), ], - data, + data: vec![CLOSE_DISCRIMINATOR], }; - - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&payer.pubkey()), - &[&payer], - svm.latest_blockhash(), + let result = send(&mut svm, close_with_unsigned_payer, &attacker); + assert!( + result.is_err(), + "close without the owner's signature must fail" ); - let res = svm.send_transaction(tx); - assert!(res.is_ok()); + let victim_account_after = svm.get_account(&victim_account).unwrap(); + assert_eq!(victim_account_after.owner, program_id); + assert!(victim_account_after.lamports > 0); +} + +#[test] +fn create_rejects_wrong_bump() { + let (mut svm, program_id) = setup(); + let payer = funded_keypair(&mut svm); + let (target, bump) = user_pda(&program_id, &payer.pubkey()); + + let wrong_bump = bump.wrapping_sub(1); + let result = send( + &mut svm, + user_instruction( + program_id, + target, + payer.pubkey(), + create_user_data(wrong_bump), + ), + &payer, + ); + assert!( + result.is_err(), + "create with a non-canonical bump must fail" + ); + assert!(svm.get_account(&target).is_none()); +} - let account = svm.get_account(&test_account_pubkey).unwrap(); - assert_eq!(account.data.len(), 0); - assert_eq!(account.owner, solana_system_interface::program::ID); +#[test] +fn create_rejects_short_instruction_data() { + let (mut svm, program_id) = setup(); + let payer = funded_keypair(&mut svm); + let (target, bump) = user_pda(&program_id, &payer.pubkey()); + + // Discriminator plus bump only: name bytes are missing entirely. + let result = send( + &mut svm, + user_instruction( + program_id, + target, + payer.pubkey(), + vec![CREATE_DISCRIMINATOR, bump], + ), + &payer, + ); + assert!(result.is_err(), "create with short data must fail cleanly"); + assert!(svm.get_account(&target).is_none()); } diff --git a/basics/close-account/pinocchio/tests/fixtures/close_account_pinocchio_program.so b/basics/close-account/pinocchio/tests/fixtures/close_account_pinocchio_program.so new file mode 100755 index 00000000..4fa09d9f Binary files /dev/null and b/basics/close-account/pinocchio/tests/fixtures/close_account_pinocchio_program.so differ diff --git a/basics/close-account/quasar/README.md b/basics/close-account/quasar/README.md index 6989dfeb..154f4789 100644 --- a/basics/close-account/quasar/README.md +++ b/basics/close-account/quasar/README.md @@ -8,6 +8,7 @@ See also: the [repository catalog](../../../README.md). - PDA init and close - Rent reclamation +- `close_user` binds the user account to the signer's own PDA, so only the account's owner can close it ## Setup diff --git a/basics/close-account/quasar/src/instructions/close_user.rs b/basics/close-account/quasar/src/instructions/close_user.rs index 6fdfe490..8f05bf41 100644 --- a/basics/close-account/quasar/src/instructions/close_user.rs +++ b/basics/close-account/quasar/src/instructions/close_user.rs @@ -1,18 +1,22 @@ use {crate::state::User, quasar_lang::prelude::*}; /// Accounts for closing a user account. -/// The `close(dest = user)` attribute mirrors Anchor's `close = user`: at the -/// derive epilogue Quasar zeroes the discriminator, drains lamports to the -/// destination, reassigns the owner to the system program, and resizes to 0. +/// The `address = ...` check binds `user_account` to the signer's own PDA: +/// without it, anyone could pass someone else's user account and pocket its +/// rent. The `close(dest = user)` attribute mirrors Anchor's `close = user`: +/// at the derive epilogue Quasar zeroes the discriminator, drains lamports to +/// the destination, reassigns the owner to the system program, and resizes +/// to 0. #[derive(Accounts)] -pub struct CloseUser { +pub struct CloseUserAccountConstraints { #[account(mut)] pub user: Signer, - #[account(mut, close(dest = user))] + + #[account(mut, close(dest = user), address = User::seeds(user.address()))] pub user_account: Account, } #[inline(always)] -pub fn handle_close_user(_accounts: &mut CloseUser) -> Result<(), ProgramError> { +pub fn handle_close_user(_accounts: &mut CloseUserAccountConstraints) -> Result<(), ProgramError> { Ok(()) } diff --git a/basics/close-account/quasar/src/instructions/create_user.rs b/basics/close-account/quasar/src/instructions/create_user.rs index 94c131c8..efa28322 100644 --- a/basics/close-account/quasar/src/instructions/create_user.rs +++ b/basics/close-account/quasar/src/instructions/create_user.rs @@ -5,7 +5,7 @@ use { /// Accounts for creating a new user. #[derive(Accounts)] -pub struct CreateUser { +pub struct CreateUserAccountConstraints { #[account(mut)] pub user: Signer, #[account(mut, init, payer = user, address = User::seeds(user.address()))] @@ -15,7 +15,7 @@ pub struct CreateUser { #[inline(always)] pub fn handle_create_user( - accounts: &mut CreateUser, + accounts: &mut CreateUserAccountConstraints, name: &str, bump: u8, ) -> Result<(), ProgramError> { diff --git a/basics/close-account/quasar/src/lib.rs b/basics/close-account/quasar/src/lib.rs index 64af1b7f..7abb5f8a 100644 --- a/basics/close-account/quasar/src/lib.rs +++ b/basics/close-account/quasar/src/lib.rs @@ -16,14 +16,14 @@ mod quasar_close_account { /// Create a user account with a name. #[instruction(discriminator = 0)] - pub fn create_user(ctx: Ctx, name: String<50>) -> Result<(), ProgramError> { + pub fn create_user(ctx: Ctx, name: String<50>) -> Result<(), ProgramError> { let bump = ctx.bumps.user_account; instructions::handle_create_user(&mut ctx.accounts, name, bump) } /// Close a user account and return lamports to the user. #[instruction(discriminator = 1)] - pub fn close_user(ctx: Ctx) -> Result<(), ProgramError> { + pub fn close_user(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_close_user(&mut ctx.accounts) } } diff --git a/basics/close-account/quasar/src/tests.rs b/basics/close-account/quasar/src/tests.rs index a1fcc3d5..3ef71dc0 100644 --- a/basics/close-account/quasar/src/tests.rs +++ b/basics/close-account/quasar/src/tests.rs @@ -127,8 +127,55 @@ fn test_close_user() { // owner, and resize data are applied to the BPF input buffer but aren't read back // by the TransactionContext in the test harness. // - // The close instruction executes successfully onchain — verified by: - // - The instruction succeeds (assert_success above) - // - Program log shows "close_user: executing close" when logging is enabled - // - CU consumption is consistent with close operations + // So the strongest assertion available here is that the instruction + // succeeds (assert_success above). The Anchor twin's LiteSVM suite + // verifies the post-close account state (lamports drained, data cleared). +} + +#[test] +fn test_close_user_rejects_non_owner() { + let mut svm = setup(); + + let victim = Pubkey::new_unique(); + let attacker = Pubkey::new_unique(); + let system_program = quasar_svm::system_program::ID; + let program_id = Pubkey::from(crate::ID); + + let (victim_account, _) = Pubkey::find_program_address(&[b"USER", victim.as_ref()], &program_id); + + // The victim creates their user account. + let create_ix = Instruction { + program_id, + accounts: vec![ + solana_instruction::AccountMeta::new(Address::from(victim.to_bytes()), true), + solana_instruction::AccountMeta::new(Address::from(victim_account.to_bytes()), false), + solana_instruction::AccountMeta::new_readonly( + Address::from(system_program.to_bytes()), + false, + ), + ], + data: build_create_instruction("Alice"), + }; + let result = svm.process_instruction(&create_ix, &[signer(victim), empty(victim_account)]); + result.assert_success(); + + let victim_account_after_create = result.account(&victim_account).unwrap().clone(); + + // The attacker signs as `user` but passes the victim's account: the + // PDA derivation check must reject it before any lamports move. + let close_ix = Instruction { + program_id, + accounts: vec![ + solana_instruction::AccountMeta::new(Address::from(attacker.to_bytes()), true), + solana_instruction::AccountMeta::new(Address::from(victim_account.to_bytes()), false), + ], + data: vec![1u8], // close_user discriminator + }; + let result = svm.process_instruction( + &close_ix, + &[signer(attacker), victim_account_after_create], + ); + result.assert_error(quasar_svm::ProgramError::Custom( + quasar_lang::prelude::QuasarError::InvalidPda as u32, + )); } diff --git a/basics/counter/anchor/Anchor.toml b/basics/counter/anchor/Anchor.toml index 93d108fd..4d5b359f 100644 --- a/basics/counter/anchor/Anchor.toml +++ b/basics/counter/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] counter_anchor = "BmDHboaj1kBUoinJKKSRqKfMeRKJqQqEbUj1VgzeQe4A" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/counter/anchor/migrations/deploy.ts b/basics/counter/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/basics/counter/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/basics/counter/anchor/programs/counter_anchor/src/instructions/increment.rs b/basics/counter/anchor/programs/counter_anchor/src/instructions/increment.rs index cbfbd41e..fa2a6594 100644 --- a/basics/counter/anchor/programs/counter_anchor/src/instructions/increment.rs +++ b/basics/counter/anchor/programs/counter_anchor/src/instructions/increment.rs @@ -1,14 +1,19 @@ use anchor_lang::prelude::*; -use crate::Counter; +use crate::{Counter, CounterError}; #[derive(Accounts)] -pub struct Increment<'info> { +pub struct IncrementAccountConstraints<'info> { #[account(mut)] pub counter: Account<'info, Counter>, } -pub fn handler(context: Context) -> Result<()> { - context.accounts.counter.count = context.accounts.counter.count.checked_add(1).unwrap(); +pub fn handler(context: Context) -> Result<()> { + context.accounts.counter.count = context + .accounts + .counter + .count + .checked_add(1) + .ok_or(CounterError::MathOverflow)?; Ok(()) } diff --git a/basics/counter/anchor/programs/counter_anchor/src/instructions/initialize_counter.rs b/basics/counter/anchor/programs/counter_anchor/src/instructions/initialize_counter.rs index 9624dd09..fa17cfd5 100644 --- a/basics/counter/anchor/programs/counter_anchor/src/instructions/initialize_counter.rs +++ b/basics/counter/anchor/programs/counter_anchor/src/instructions/initialize_counter.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; use crate::Counter; #[derive(Accounts)] -pub struct InitializeCounter<'info> { +pub struct InitializeCounterAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -16,6 +16,6 @@ pub struct InitializeCounter<'info> { pub system_program: Program<'info, System>, } -pub fn handler(_context: Context) -> Result<()> { +pub fn handler(_context: Context) -> Result<()> { Ok(()) } diff --git a/basics/counter/anchor/programs/counter_anchor/src/lib.rs b/basics/counter/anchor/programs/counter_anchor/src/lib.rs index f9396ced..d6a48dfc 100644 --- a/basics/counter/anchor/programs/counter_anchor/src/lib.rs +++ b/basics/counter/anchor/programs/counter_anchor/src/lib.rs @@ -9,11 +9,11 @@ declare_id!("BmDHboaj1kBUoinJKKSRqKfMeRKJqQqEbUj1VgzeQe4A"); pub mod counter_anchor { use super::*; - pub fn initialize_counter(context: Context) -> Result<()> { + pub fn initialize_counter(context: Context) -> Result<()> { instructions::initialize_counter::handler(context) } - pub fn increment(context: Context) -> Result<()> { + pub fn increment(context: Context) -> Result<()> { instructions::increment::handler(context) } } @@ -23,3 +23,9 @@ pub mod counter_anchor { pub struct Counter { count: u64, } + +#[error_code] +pub enum CounterError { + #[msg("Counter overflowed u64::MAX")] + MathOverflow, +} diff --git a/basics/counter/anchor/programs/counter_anchor/tests/test_counter.rs b/basics/counter/anchor/programs/counter_anchor/tests/test_counter.rs index 8a0648b3..97a3d3c0 100644 --- a/basics/counter/anchor/programs/counter_anchor/tests/test_counter.rs +++ b/basics/counter/anchor/programs/counter_anchor/tests/test_counter.rs @@ -40,7 +40,7 @@ fn test_initialize_counter() { let instruction = Instruction::new_with_bytes( counter_anchor::id(), &counter_anchor::instruction::InitializeCounter {}.data(), - counter_anchor::accounts::InitializeCounter { + counter_anchor::accounts::InitializeCounterAccountConstraints { payer: payer.pubkey(), counter: counter_keypair.pubkey(), system_program: system_program::id(), @@ -69,7 +69,7 @@ fn test_increment_counter() { let init_ix = Instruction::new_with_bytes( counter_anchor::id(), &counter_anchor::instruction::InitializeCounter {}.data(), - counter_anchor::accounts::InitializeCounter { + counter_anchor::accounts::InitializeCounterAccountConstraints { payer: payer.pubkey(), counter: counter_keypair.pubkey(), system_program: system_program::id(), @@ -88,7 +88,7 @@ fn test_increment_counter() { let inc_ix = Instruction::new_with_bytes( counter_anchor::id(), &counter_anchor::instruction::Increment {}.data(), - counter_anchor::accounts::Increment { + counter_anchor::accounts::IncrementAccountConstraints { counter: counter_keypair.pubkey(), } .to_account_metas(None), @@ -108,7 +108,7 @@ fn test_increment_counter_again() { let init_ix = Instruction::new_with_bytes( counter_anchor::id(), &counter_anchor::instruction::InitializeCounter {}.data(), - counter_anchor::accounts::InitializeCounter { + counter_anchor::accounts::InitializeCounterAccountConstraints { payer: payer.pubkey(), counter: counter_keypair.pubkey(), system_program: system_program::id(), @@ -128,7 +128,7 @@ fn test_increment_counter_again() { let inc_ix = Instruction::new_with_bytes( counter_anchor::id(), &counter_anchor::instruction::Increment {}.data(), - counter_anchor::accounts::Increment { + counter_anchor::accounts::IncrementAccountConstraints { counter: counter_keypair.pubkey(), } .to_account_metas(None), diff --git a/basics/counter/quasar/Cargo.toml b/basics/counter/quasar/Cargo.toml index d1e64051..b690d88f 100644 --- a/basics/counter/quasar/Cargo.toml +++ b/basics/counter/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-counter" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/counter/quasar/src/error.rs b/basics/counter/quasar/src/error.rs new file mode 100644 index 00000000..765b098b --- /dev/null +++ b/basics/counter/quasar/src/error.rs @@ -0,0 +1,10 @@ +use quasar_lang::prelude::*; + +#[error_code] +pub enum CounterError { + /// The counter is at u64::MAX and cannot be incremented further. + // 6000 is the conventional Anchor-compatible starting offset for + // program-specific error codes (Quasar's #[error_code] starts at 0 + // unless told otherwise; framework errors occupy 3000+). + MathOverflow = 6000, +} diff --git a/basics/counter/quasar/src/instructions/increment.rs b/basics/counter/quasar/src/instructions/increment.rs index 0a6fc9b9..a8c1a9d6 100644 --- a/basics/counter/quasar/src/instructions/increment.rs +++ b/basics/counter/quasar/src/instructions/increment.rs @@ -1,15 +1,21 @@ -use {crate::state::Counter, quasar_lang::prelude::*}; +use { + crate::{error::CounterError, state::Counter}, + quasar_lang::prelude::*, +}; /// Accounts for incrementing a counter. #[derive(Accounts)] -pub struct Increment { +pub struct IncrementAccountConstraints { #[account(mut)] pub counter: Account, } #[inline(always)] -pub fn handle_increment(accounts: &mut Increment) -> Result<(), ProgramError> { +pub fn handle_increment(accounts: &mut IncrementAccountConstraints) -> Result<(), ProgramError> { let current: u64 = accounts.counter.count.into(); - accounts.counter.count = PodU64::from(current.checked_add(1).unwrap()); + let next = current + .checked_add(1) + .ok_or(CounterError::MathOverflow)?; + accounts.counter.count = PodU64::from(next); Ok(()) } diff --git a/basics/counter/quasar/src/instructions/initialize_counter.rs b/basics/counter/quasar/src/instructions/initialize_counter.rs index d438f127..666951e8 100644 --- a/basics/counter/quasar/src/instructions/initialize_counter.rs +++ b/basics/counter/quasar/src/instructions/initialize_counter.rs @@ -4,7 +4,7 @@ use quasar_lang::prelude::*; /// Accounts for creating a new counter. /// The counter is derived as a PDA from ["counter", payer] seeds. #[derive(Accounts)] -pub struct InitializeCounter { +pub struct InitializeCounterAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut, init, payer = payer, address = Counter::seeds(payer.address()))] @@ -13,7 +13,7 @@ pub struct InitializeCounter { } #[inline(always)] -pub fn handle_initialize_counter(accounts: &mut InitializeCounter) -> Result<(), ProgramError> { +pub fn handle_initialize_counter(accounts: &mut InitializeCounterAccountConstraints) -> Result<(), ProgramError> { accounts.counter.set_inner(CounterInner { count: 0 }); Ok(()) } diff --git a/basics/counter/quasar/src/lib.rs b/basics/counter/quasar/src/lib.rs index b265456e..c1bf1ce2 100644 --- a/basics/counter/quasar/src/lib.rs +++ b/basics/counter/quasar/src/lib.rs @@ -2,25 +2,26 @@ use quasar_lang::prelude::*; +mod error; mod instructions; use instructions::*; mod state; #[cfg(test)] mod tests; -declare_id!("HYSDBQLVUSMRQKQZxfKJwDy5PPrZb7bvuBLaWfbcYhEP"); +declare_id!("BmDHboaj1kBUoinJKKSRqKfMeRKJqQqEbUj1VgzeQe4A"); #[program] mod quasar_counter { use super::*; #[instruction(discriminator = 0)] - pub fn initialize_counter(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize_counter(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_initialize_counter(&mut ctx.accounts) } #[instruction(discriminator = 1)] - pub fn increment(ctx: Ctx) -> Result<(), ProgramError> { + pub fn increment(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_increment(&mut ctx.accounts) } } diff --git a/basics/create-account/README.md b/basics/create-account/README.md index 5fff31e4..2867ab03 100644 --- a/basics/create-account/README.md +++ b/basics/create-account/README.md @@ -2,7 +2,7 @@ Create a Solana [account](https://solana.com/docs/terminology#account). -The account is a **system account** — owned by the System Program, which means only the System Program can modify its data. In this example, the account simply holds some SOL. +The account is a **system account** - owned by the System Program, which means only the System Program can modify its data. In this example, the account simply holds some SOL. The tests cover two ways to create the account: @@ -13,5 +13,5 @@ See [cross-program-invocation](../cross-program-invocation) for more CPI example ## Links -- [Solana Cookbook — How to Create a System Account](https://solana.com/developers/cookbook/accounts/create-account) -- [Rust Docs — `solana_system_interface::instruction::create_account`](https://docs.rs/solana-system-interface/latest/solana_system_interface/instruction/fn.create_account.html) +- [Solana Cookbook - How to Create a System Account](https://solana.com/developers/cookbook/accounts/create-account) +- [Rust Docs - `solana_system_interface::instruction::create_account`](https://docs.rs/solana-system-interface/latest/solana_system_interface/instruction/fn.create_account.html) diff --git a/basics/create-account/anchor/Anchor.toml b/basics/create-account/anchor/Anchor.toml index b5ac95c8..18431cd4 100644 --- a/basics/create-account/anchor/Anchor.toml +++ b/basics/create-account/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] create_system_account = "ARVNCsYKDQsCLHbwUTJLpFXVrJdjhWZStyzvxmKe2xHi" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/create-account/anchor/programs/create-system-account/src/lib.rs b/basics/create-account/anchor/programs/create-system-account/src/lib.rs index 921c357b..8898ba27 100644 --- a/basics/create-account/anchor/programs/create-system-account/src/lib.rs +++ b/basics/create-account/anchor/programs/create-system-account/src/lib.rs @@ -7,7 +7,9 @@ declare_id!("ARVNCsYKDQsCLHbwUTJLpFXVrJdjhWZStyzvxmKe2xHi"); pub mod create_system_account { use super::*; - pub fn create_system_account(context: Context) -> Result<()> { + pub fn create_system_account( + context: Context, + ) -> Result<()> { msg!("Program invoked. Creating a system account..."); msg!( " New public key will be: {}", @@ -30,13 +32,13 @@ pub mod create_system_account { &context.accounts.system_program.key(), // Owner Program )?; - msg!("Account created succesfully."); + msg!("Account created successfully."); Ok(()) } } #[derive(Accounts)] -pub struct CreateSystemAccount<'info> { +pub struct CreateSystemAccountAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut)] diff --git a/basics/create-account/anchor/programs/create-system-account/tests/test_create_account.rs b/basics/create-account/anchor/programs/create-system-account/tests/test_create_account.rs index 7c1bcd4f..70fe47ec 100644 --- a/basics/create-account/anchor/programs/create-system-account/tests/test_create_account.rs +++ b/basics/create-account/anchor/programs/create-system-account/tests/test_create_account.rs @@ -22,7 +22,7 @@ fn test_create_the_account() { let instruction = Instruction::new_with_bytes( program_id, &create_system_account::instruction::CreateSystemAccount {}.data(), - create_system_account::accounts::CreateSystemAccount { + create_system_account::accounts::CreateSystemAccountAccountConstraints { payer: payer.pubkey(), new_account: new_account.pubkey(), system_program: system_program::id(), diff --git a/basics/create-account/native/program/src/lib.rs b/basics/create-account/native/program/src/lib.rs index be1864b4..e26cda14 100644 --- a/basics/create-account/native/program/src/lib.rs +++ b/basics/create-account/native/program/src/lib.rs @@ -34,6 +34,6 @@ fn process_instruction( &[payer.clone(), new_account.clone(), system_program.clone()], )?; - msg!("Account created succesfully."); + msg!("Account created successfully."); Ok(()) } diff --git a/basics/create-account/pinocchio/program/src/lib.rs b/basics/create-account/pinocchio/program/src/lib.rs index ea66cfd7..2a97f466 100644 --- a/basics/create-account/pinocchio/program/src/lib.rs +++ b/basics/create-account/pinocchio/program/src/lib.rs @@ -34,6 +34,6 @@ fn process_instruction( } .invoke()?; - log!("Account created succesfully."); + log!("Account created successfully."); Ok(()) } diff --git a/basics/create-account/quasar/Cargo.toml b/basics/create-account/quasar/Cargo.toml index 3387bec7..0be3a1d3 100644 --- a/basics/create-account/quasar/Cargo.toml +++ b/basics/create-account/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-create-account" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/create-account/quasar/src/instructions/create_system_account.rs b/basics/create-account/quasar/src/instructions/create_system_account.rs index c28253a9..80e6d92d 100644 --- a/basics/create-account/quasar/src/instructions/create_system_account.rs +++ b/basics/create-account/quasar/src/instructions/create_system_account.rs @@ -3,7 +3,7 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; /// Accounts for creating a new system-owned account. /// Both payer and new_account must sign the transaction. #[derive(Accounts)] -pub struct CreateSystemAccount { +pub struct CreateSystemAccountAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -13,7 +13,7 @@ pub struct CreateSystemAccount { #[inline(always)] pub fn handle_create_system_account( - accounts: &mut CreateSystemAccount, + accounts: &mut CreateSystemAccountAccountConstraints, ) -> Result<(), ProgramError> { let system_program_address = Address::default(); let rent = Rent::get()?; diff --git a/basics/create-account/quasar/src/lib.rs b/basics/create-account/quasar/src/lib.rs index 8f71eeba..ae8ad033 100644 --- a/basics/create-account/quasar/src/lib.rs +++ b/basics/create-account/quasar/src/lib.rs @@ -15,7 +15,7 @@ mod quasar_create_account { /// Create a new system-owned account via CPI to the system program. #[instruction(discriminator = 0)] - pub fn create_system_account(ctx: Ctx) -> Result<(), ProgramError> { + pub fn create_system_account(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_create_system_account(&mut ctx.accounts) } } diff --git a/basics/cross-program-invocation/README.md b/basics/cross-program-invocation/README.md index 6e81fc62..d63c2864 100644 --- a/basics/cross-program-invocation/README.md +++ b/basics/cross-program-invocation/README.md @@ -11,7 +11,7 @@ Consider this sequence in a token [mint](https://solana.com/docs/terminology#tok 3. Create and initialize a user's [token account](https://solana.com/docs/terminology#token-account) for the mint. 4. Mint some tokens to the user's token account. -You cannot create a metadata account without first having the mint. Once you decide that steps 1 and 4 must be onchain, the only sensible option is to also do steps 2 and 3 onchain — you cannot pause a program mid-flight to let the client do work. +You cannot create a metadata account without first having the mint. Once you decide that steps 1 and 4 must be onchain, the only sensible option is to also do steps 2 and 3 onchain - you cannot pause a program mid-flight to let the client do work. ## Native setup notes diff --git a/basics/cross-program-invocation/anchor/Anchor.toml b/basics/cross-program-invocation/anchor/Anchor.toml index 2e116931..4d93696d 100644 --- a/basics/cross-program-invocation/anchor/Anchor.toml +++ b/basics/cross-program-invocation/anchor/Anchor.toml @@ -9,8 +9,6 @@ skip-lint = false hand = "Bi5N7SUQhpGknVcqPTzdFFVueQoxoUu8YTLz75J6fT8A" lever = "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/cross-program-invocation/anchor/migrations/deploy.ts b/basics/cross-program-invocation/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/basics/cross-program-invocation/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/basics/cross-program-invocation/anchor/programs/hand/src/lib.rs b/basics/cross-program-invocation/anchor/programs/hand/src/lib.rs index 591fc493..2efa0b06 100644 --- a/basics/cross-program-invocation/anchor/programs/hand/src/lib.rs +++ b/basics/cross-program-invocation/anchor/programs/hand/src/lib.rs @@ -13,7 +13,7 @@ use lever::program::Lever; pub mod hand { use super::*; - pub fn pull_lever(context: Context, name: String) -> Result<()> { + pub fn pull_lever(context: Context, name: String) -> Result<()> { let cpi_ctx = CpiContext::new( context.accounts.lever_program.key(), SwitchPower { @@ -26,7 +26,7 @@ pub mod hand { } #[derive(Accounts)] -pub struct PullLever<'info> { +pub struct PullLeverAccountConstraints<'info> { #[account(mut)] pub power: Account<'info, PowerStatus>, pub lever_program: Program<'info, Lever>, diff --git a/basics/cross-program-invocation/anchor/programs/hand/tests/test_hand.rs b/basics/cross-program-invocation/anchor/programs/hand/tests/test_hand.rs index 0bb83e1a..e9c7c64a 100644 --- a/basics/cross-program-invocation/anchor/programs/hand/tests/test_hand.rs +++ b/basics/cross-program-invocation/anchor/programs/hand/tests/test_hand.rs @@ -58,7 +58,7 @@ fn test_pull_lever_cpi() { env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/lever.so" )) - .expect("lever.so not found — run `anchor build` first"); + .expect("lever.so not found - run `anchor build` first"); svm.add_program(hand_program_id, hand_bytes).unwrap(); svm.add_program(lever_program_id, &lever_bytes).unwrap(); let payer = create_wallet(&mut svm, 10_000_000_000).unwrap(); @@ -90,7 +90,7 @@ fn test_pull_lever_cpi() { name: "Jacob".to_string(), } .data(), - hand::accounts::PullLever { + hand::accounts::PullLeverAccountConstraints { power: power_keypair.pubkey(), lever_program: lever_program_id, } @@ -113,7 +113,7 @@ fn test_pull_lever_cpi() { name: "sol-warrior".to_string(), } .data(), - hand::accounts::PullLever { + hand::accounts::PullLeverAccountConstraints { power: power_keypair.pubkey(), lever_program: lever_program_id, } diff --git a/basics/cross-program-invocation/anchor/programs/lever/src/instructions/initialize.rs b/basics/cross-program-invocation/anchor/programs/lever/src/instructions/initialize.rs index 90f72182..025fc5b7 100644 --- a/basics/cross-program-invocation/anchor/programs/lever/src/instructions/initialize.rs +++ b/basics/cross-program-invocation/anchor/programs/lever/src/instructions/initialize.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; use crate::PowerStatus; #[derive(Accounts)] -pub struct InitializeLever<'info> { +pub struct InitializeLeverAccountConstraints<'info> { #[account(init, payer = user, space = PowerStatus::DISCRIMINATOR.len() + PowerStatus::INIT_SPACE)] pub power: Account<'info, PowerStatus>, #[account(mut)] @@ -11,6 +11,6 @@ pub struct InitializeLever<'info> { pub system_program: Program<'info, System>, } -pub fn handler(_context: Context) -> Result<()> { +pub fn handler(_context: Context) -> Result<()> { Ok(()) } diff --git a/basics/cross-program-invocation/anchor/programs/lever/src/instructions/switch_power.rs b/basics/cross-program-invocation/anchor/programs/lever/src/instructions/switch_power.rs index 53ec109c..55674ab6 100644 --- a/basics/cross-program-invocation/anchor/programs/lever/src/instructions/switch_power.rs +++ b/basics/cross-program-invocation/anchor/programs/lever/src/instructions/switch_power.rs @@ -3,12 +3,12 @@ use anchor_lang::prelude::*; use crate::PowerStatus; #[derive(Accounts)] -pub struct SetPowerStatus<'info> { +pub struct SetPowerStatusAccountConstraints<'info> { #[account(mut)] pub power: Account<'info, PowerStatus>, } -pub fn handler(context: Context, name: String) -> Result<()> { +pub fn handler(context: Context, name: String) -> Result<()> { let power = &mut context.accounts.power; power.is_on = !power.is_on; diff --git a/basics/cross-program-invocation/anchor/programs/lever/src/lib.rs b/basics/cross-program-invocation/anchor/programs/lever/src/lib.rs index eac17651..fa90458d 100644 --- a/basics/cross-program-invocation/anchor/programs/lever/src/lib.rs +++ b/basics/cross-program-invocation/anchor/programs/lever/src/lib.rs @@ -9,11 +9,14 @@ declare_id!("E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); pub mod lever { use super::*; - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { instructions::initialize::handler(context) } - pub fn switch_power(context: Context, name: String) -> Result<()> { + pub fn switch_power( + context: Context, + name: String, + ) -> Result<()> { instructions::switch_power::handler(context, name) } } diff --git a/basics/cross-program-invocation/anchor/programs/lever/tests/test_lever.rs b/basics/cross-program-invocation/anchor/programs/lever/tests/test_lever.rs index 4cd3aec7..47d32a63 100644 --- a/basics/cross-program-invocation/anchor/programs/lever/tests/test_lever.rs +++ b/basics/cross-program-invocation/anchor/programs/lever/tests/test_lever.rs @@ -29,7 +29,7 @@ fn test_initialize_lever() { let instruction = Instruction::new_with_bytes( program_id, &lever::instruction::Initialize {}.data(), - lever::accounts::InitializeLever { + lever::accounts::InitializeLeverAccountConstraints { power: power_keypair.pubkey(), user: payer.pubkey(), system_program: system_program::id(), @@ -65,7 +65,7 @@ fn test_switch_power() { let init_ix = Instruction::new_with_bytes( program_id, &lever::instruction::Initialize {}.data(), - lever::accounts::InitializeLever { + lever::accounts::InitializeLeverAccountConstraints { power: power_keypair.pubkey(), user: payer.pubkey(), system_program: system_program::id(), @@ -87,7 +87,7 @@ fn test_switch_power() { name: "Alice".to_string(), } .data(), - lever::accounts::SetPowerStatus { + lever::accounts::SetPowerStatusAccountConstraints { power: power_keypair.pubkey(), } .to_account_metas(None), @@ -108,7 +108,7 @@ fn test_switch_power() { name: "Bob".to_string(), } .data(), - lever::accounts::SetPowerStatus { + lever::accounts::SetPowerStatusAccountConstraints { power: power_keypair.pubkey(), } .to_account_metas(None), diff --git a/basics/cross-program-invocation/quasar/README.md b/basics/cross-program-invocation/quasar/README.md index edf789ca..99797486 100644 --- a/basics/cross-program-invocation/quasar/README.md +++ b/basics/cross-program-invocation/quasar/README.md @@ -1,9 +1,9 @@ -# Cross-Program Invocation — Quasar +# Cross-Program Invocation - Quasar This example contains **two separate Quasar [programs](https://solana.com/docs/terminology#program)** that work together: -- **`lever/`** — A program with [onchain](https://solana.com/docs/terminology#onchain) `PowerStatus` state and a `switch_power` [instruction handler](https://solana.com/docs/terminology#instruction-handler) that toggles a boolean. -- **`hand/`** — A program that calls the lever program's `switch_power` via [CPI](https://solana.com/docs/terminology#cross-program-invocation-cpi). +- **`lever/`** - A program with [onchain](https://solana.com/docs/terminology#onchain) `PowerStatus` state and a `switch_power` [instruction handler](https://solana.com/docs/terminology#instruction-handler) that toggles a boolean. +- **`hand/`** - A program that calls the lever program's `switch_power` via [CPI](https://solana.com/docs/terminology#cross-program-invocation-cpi). ## Building diff --git a/basics/cross-program-invocation/quasar/hand/Cargo.toml b/basics/cross-program-invocation/quasar/hand/Cargo.toml index 95cb621b..ebbc9b0d 100644 --- a/basics/cross-program-invocation/quasar/hand/Cargo.toml +++ b/basics/cross-program-invocation/quasar/hand/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-hand" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. [workspace] [lints.rust.unexpected_cfgs] diff --git a/basics/cross-program-invocation/quasar/hand/src/instructions/pull_lever.rs b/basics/cross-program-invocation/quasar/hand/src/instructions/pull_lever.rs index 95f1d7eb..8e0cb25d 100644 --- a/basics/cross-program-invocation/quasar/hand/src/instructions/pull_lever.rs +++ b/basics/cross-program-invocation/quasar/hand/src/instructions/pull_lever.rs @@ -2,17 +2,17 @@ use quasar_lang::prelude::*; /// Accounts for the hand program's pull_lever instruction. /// The lever_program uses `Program` with a custom marker type -/// that implements `Id` — this lets Quasar verify the program address and +/// that implements `Id` - this lets Quasar verify the program address and /// the executable flag during account parsing. #[derive(Accounts)] -pub struct PullLever { +pub struct PullLeverAccountConstraints { #[account(mut)] pub power: UncheckedAccount, pub lever_program: Program, } #[inline(always)] -pub fn handle_pull_lever(accounts: &PullLever, name: &str) -> Result<(), ProgramError> { +pub fn handle_pull_lever(accounts: &PullLeverAccountConstraints, name: &str) -> Result<(), ProgramError> { log("Hand is pulling the lever!"); // Build the switch_power instruction data for the lever program. diff --git a/basics/cross-program-invocation/quasar/hand/src/lib.rs b/basics/cross-program-invocation/quasar/hand/src/lib.rs index e3e425d9..6fa7e61c 100644 --- a/basics/cross-program-invocation/quasar/hand/src/lib.rs +++ b/basics/cross-program-invocation/quasar/hand/src/lib.rs @@ -9,7 +9,7 @@ mod tests; declare_id!("Bi5N7SUQhpGknVcqPTzdFFVueQoxoUu8YTLz75J6fT8A"); -/// The lever program's ID — used to verify the correct program is passed. +/// The lever program's ID - used to verify the correct program is passed. pub const LEVER_PROGRAM_ID: Address = address!("E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); /// Marker type for the lever program, implementing `Id` so it can be used @@ -26,7 +26,7 @@ mod quasar_hand { /// Pull the lever by invoking the lever program's switch_power via CPI. #[instruction(discriminator = 0)] - pub fn pull_lever(ctx: Ctx, name: String<50>) -> Result<(), ProgramError> { + pub fn pull_lever(ctx: Ctx, name: String<50>) -> Result<(), ProgramError> { instructions::handle_pull_lever(&mut ctx.accounts, name) } } diff --git a/basics/cross-program-invocation/quasar/hand/src/tests.rs b/basics/cross-program-invocation/quasar/hand/src/tests.rs index 05ede556..753f0105 100644 --- a/basics/cross-program-invocation/quasar/hand/src/tests.rs +++ b/basics/cross-program-invocation/quasar/hand/src/tests.rs @@ -1,7 +1,7 @@ use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; use solana_address::Address; -/// Lever program's program ID — must match the lever's declare_id!(). +/// Lever program's program ID - must match the lever's declare_id!(). fn lever_program_id() -> Pubkey { Pubkey::from(crate::LEVER_PROGRAM_ID) } diff --git a/basics/cross-program-invocation/quasar/lever/Cargo.toml b/basics/cross-program-invocation/quasar/lever/Cargo.toml index 6b44f783..066cdcdb 100644 --- a/basics/cross-program-invocation/quasar/lever/Cargo.toml +++ b/basics/cross-program-invocation/quasar/lever/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-lever" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. [workspace] [lints.rust.unexpected_cfgs] diff --git a/basics/cross-program-invocation/quasar/lever/src/instructions/initialize.rs b/basics/cross-program-invocation/quasar/lever/src/instructions/initialize.rs index e820b851..a7c19715 100644 --- a/basics/cross-program-invocation/quasar/lever/src/instructions/initialize.rs +++ b/basics/cross-program-invocation/quasar/lever/src/instructions/initialize.rs @@ -5,7 +5,7 @@ use { /// Accounts for initialising the power status (PDA seeded by "power"). #[derive(Accounts)] -pub struct InitializeLever { +pub struct InitializeLeverAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut, init, payer = payer, address = PowerStatus::seeds())] @@ -14,7 +14,7 @@ pub struct InitializeLever { } #[inline(always)] -pub fn handle_initialize(accounts: &mut InitializeLever) -> Result<(), ProgramError> { +pub fn handle_initialize(accounts: &mut InitializeLeverAccountConstraints) -> Result<(), ProgramError> { // Power starts off (false). Counter-style fixed-size set_inner takes only the inner value. accounts.power.set_inner(PowerStatusInner { is_on: PodBool::from(false) }); Ok(()) diff --git a/basics/cross-program-invocation/quasar/lever/src/instructions/switch_power.rs b/basics/cross-program-invocation/quasar/lever/src/instructions/switch_power.rs index 52ac4401..a4cccf07 100644 --- a/basics/cross-program-invocation/quasar/lever/src/instructions/switch_power.rs +++ b/basics/cross-program-invocation/quasar/lever/src/instructions/switch_power.rs @@ -5,18 +5,18 @@ use { /// Accounts for toggling the power switch. #[derive(Accounts)] -pub struct SwitchPower { +pub struct SwitchPowerAccountConstraints { #[account(mut)] pub power: Account, } #[inline(always)] -pub fn handle_switch_power(accounts: &mut SwitchPower, name: &str) -> Result<(), ProgramError> { +pub fn handle_switch_power(accounts: &mut SwitchPowerAccountConstraints, name: &str) -> Result<(), ProgramError> { let current: bool = accounts.power.is_on.into(); let new_state = !current; accounts.power.is_on = PodBool::from(new_state); - // Quasar's log() takes &str — no format! in no_std. + // Quasar's log() takes &str - no format! in no_std. // Logging the name verifies the wire format end-to-end: a stale u32 // length prefix would surface here as a corrupted name (e.g. the // first three bytes parsed as zeros, leaving "\0\0\0Al" instead of diff --git a/basics/cross-program-invocation/quasar/lever/src/lib.rs b/basics/cross-program-invocation/quasar/lever/src/lib.rs index 1922feca..644cd868 100644 --- a/basics/cross-program-invocation/quasar/lever/src/lib.rs +++ b/basics/cross-program-invocation/quasar/lever/src/lib.rs @@ -16,13 +16,13 @@ mod quasar_lever { /// Initialize the power status account (off by default). #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_initialize(&mut ctx.accounts) } /// Toggle the power switch. Logs who is pulling the lever. #[instruction(discriminator = 1)] - pub fn switch_power(ctx: Ctx, name: String<50>) -> Result<(), ProgramError> { + pub fn switch_power(ctx: Ctx, name: String<50>) -> Result<(), ProgramError> { instructions::handle_switch_power(&mut ctx.accounts, name) } } diff --git a/basics/favorites/anchor/Anchor.toml b/basics/favorites/anchor/Anchor.toml index efc19fb4..d64e4f29 100644 --- a/basics/favorites/anchor/Anchor.toml +++ b/basics/favorites/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] favorites = "ww9C83noARSQVBnqmCUmaVdbJjmiwcV9j2LkXYMoUCV" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/favorites/anchor/migrations/deploy.ts b/basics/favorites/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/basics/favorites/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/basics/favorites/anchor/programs/favorites/src/lib.rs b/basics/favorites/anchor/programs/favorites/src/lib.rs index 198d484a..6f4fabc8 100644 --- a/basics/favorites/anchor/programs/favorites/src/lib.rs +++ b/basics/favorites/anchor/programs/favorites/src/lib.rs @@ -10,7 +10,7 @@ pub mod favorites { // Our instruction handler! It sets the user's favorite number and color pub fn set_favorites( - context: Context, + context: Context, number: u64, color: String, hobbies: Vec, @@ -51,7 +51,7 @@ pub struct Favorites { } // When people call the set_favorites instruction, they will need to provide the accounts that will be modifed. This keeps Solana fast! #[derive(Accounts)] -pub struct SetFavorites<'info> { +pub struct SetFavoritesAccountConstraints<'info> { #[account(mut)] pub user: Signer<'info>, diff --git a/basics/favorites/anchor/programs/favorites/tests/test_favorites.rs b/basics/favorites/anchor/programs/favorites/tests/test_favorites.rs index 8a9cf7ae..6f217c0d 100644 --- a/basics/favorites/anchor/programs/favorites/tests/test_favorites.rs +++ b/basics/favorites/anchor/programs/favorites/tests/test_favorites.rs @@ -83,7 +83,7 @@ fn test_set_favorites() { ], } .data(), - favorites::accounts::SetFavorites { + favorites::accounts::SetFavoritesAccountConstraints { user: payer.pubkey(), favorites: pda, system_program: system_program::id(), @@ -118,7 +118,7 @@ fn test_update_favorites() { ], } .data(), - favorites::accounts::SetFavorites { + favorites::accounts::SetFavoritesAccountConstraints { user: payer.pubkey(), favorites: pda, system_program: system_program::id(), @@ -143,7 +143,7 @@ fn test_update_favorites() { ], } .data(), - favorites::accounts::SetFavorites { + favorites::accounts::SetFavoritesAccountConstraints { user: payer.pubkey(), favorites: pda, system_program: system_program::id(), diff --git a/basics/favorites/quasar/src/instructions/set_favorites.rs b/basics/favorites/quasar/src/instructions/set_favorites.rs index ebb60bee..0abe21d8 100644 --- a/basics/favorites/quasar/src/instructions/set_favorites.rs +++ b/basics/favorites/quasar/src/instructions/set_favorites.rs @@ -6,7 +6,7 @@ use { /// Accounts for setting user favourites. Uses `init_if_needed` so the same /// instruction can create or update the favourites PDA. #[derive(Accounts)] -pub struct SetFavorites { +pub struct SetFavoritesAccountConstraints { #[account(mut)] pub user: Signer, #[account(mut, init(idempotent), payer = user, address = Favorites::seeds(user.address()))] @@ -15,7 +15,7 @@ pub struct SetFavorites { } #[inline(always)] -pub fn handle_set_favorites(accounts: &mut SetFavorites, number: u64, color: &str) -> Result<(), ProgramError> { +pub fn handle_set_favorites(accounts: &mut SetFavoritesAccountConstraints, number: u64, color: &str) -> Result<(), ProgramError> { let rent = Rent::get()?; accounts.favorites.set_inner( FavoritesInner { number, color }, diff --git a/basics/favorites/quasar/src/lib.rs b/basics/favorites/quasar/src/lib.rs index e716772f..d188a08f 100644 --- a/basics/favorites/quasar/src/lib.rs +++ b/basics/favorites/quasar/src/lib.rs @@ -20,7 +20,7 @@ mod quasar_favorites { /// support nested dynamic types. See state.rs for details. #[instruction(discriminator = 0)] pub fn set_favorites( - ctx: Ctx, + ctx: Ctx, number: u64, color: String<50>, ) -> Result<(), ProgramError> { diff --git a/basics/hello-solana/README.md b/basics/hello-solana/README.md index 4ba5ce52..a5704f96 100644 --- a/basics/hello-solana/README.md +++ b/basics/hello-solana/README.md @@ -1,6 +1,6 @@ # Hello Solana -Our first Solana [program](https://solana.com/docs/terminology#program) — a "hello, world" that logs a greeting. Along the way, a quick look at what's inside a Solana transaction. +Our first Solana [program](https://solana.com/docs/terminology#program) - a "hello, world" that logs a greeting. Along the way, a quick look at what's inside a Solana transaction. ## Transactions diff --git a/basics/hello-solana/anchor/Anchor.toml b/basics/hello-solana/anchor/Anchor.toml index eff8031e..fcd9e800 100644 --- a/basics/hello-solana/anchor/Anchor.toml +++ b/basics/hello-solana/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] hello_solana = "2phbC62wekpw95XuBk4i1KX4uA8zBUWmYbiTMhicSuBV" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/hello-solana/anchor/programs/hello-solana/src/lib.rs b/basics/hello-solana/anchor/programs/hello-solana/src/lib.rs index b0f36828..54fc73ec 100644 --- a/basics/hello-solana/anchor/programs/hello-solana/src/lib.rs +++ b/basics/hello-solana/anchor/programs/hello-solana/src/lib.rs @@ -6,7 +6,7 @@ declare_id!("2phbC62wekpw95XuBk4i1KX4uA8zBUWmYbiTMhicSuBV"); pub mod hello_solana { use super::*; - pub fn hello(_context: Context) -> Result<()> { + pub fn hello(_context: Context) -> Result<()> { msg!("Hello, Solana!"); msg!("Our program's Program ID: {}", &id()); @@ -16,4 +16,4 @@ pub mod hello_solana { } #[derive(Accounts)] -pub struct Hello {} +pub struct HelloAccountConstraints {} diff --git a/basics/hello-solana/anchor/programs/hello-solana/tests/test_hello.rs b/basics/hello-solana/anchor/programs/hello-solana/tests/test_hello.rs index 815d051a..095a59e9 100644 --- a/basics/hello-solana/anchor/programs/hello-solana/tests/test_hello.rs +++ b/basics/hello-solana/anchor/programs/hello-solana/tests/test_hello.rs @@ -16,7 +16,7 @@ fn test_say_hello() { let instruction = Instruction::new_with_bytes( program_id, &hello_solana::instruction::Hello {}.data(), - hello_solana::accounts::Hello {}.to_account_metas(None), + hello_solana::accounts::HelloAccountConstraints {}.to_account_metas(None), ); send_transaction_from_instructions(&mut svm, vec![instruction], &[&payer], &payer.pubkey()) diff --git a/basics/hello-solana/quasar/Cargo.toml b/basics/hello-solana/quasar/Cargo.toml index 6cbedbd6..c16ce2a5 100644 --- a/basics/hello-solana/quasar/Cargo.toml +++ b/basics/hello-solana/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-hello-solana" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/hello-solana/quasar/src/instructions/hello.rs b/basics/hello-solana/quasar/src/instructions/hello.rs index d36abb01..410c3b68 100644 --- a/basics/hello-solana/quasar/src/instructions/hello.rs +++ b/basics/hello-solana/quasar/src/instructions/hello.rs @@ -4,14 +4,14 @@ use quasar_lang::prelude::*; /// A payer (signer) is required to submit the transaction, but the program /// simply logs a greeting and the program ID. #[derive(Accounts)] -pub struct Hello { +pub struct HelloAccountConstraints { #[allow(dead_code)] pub payer: Signer, } #[inline(always)] -pub fn handle_hello(_accounts: &mut Hello) -> Result<(), ProgramError> { +pub fn handle_hello(_accounts: &mut HelloAccountConstraints) -> Result<(), ProgramError> { log("Hello, Solana!"); - log("Our program's Program ID: FLUH9c5oAfXb1eYbkZvdGK9r9SLQJBUi2DZQaBVj7Tzr"); + log("Our program's Program ID: 2phbC62wekpw95XuBk4i1KX4uA8zBUWmYbiTMhicSuBV"); Ok(()) } diff --git a/basics/hello-solana/quasar/src/lib.rs b/basics/hello-solana/quasar/src/lib.rs index 53fcf9ad..9a9fbb4e 100644 --- a/basics/hello-solana/quasar/src/lib.rs +++ b/basics/hello-solana/quasar/src/lib.rs @@ -7,14 +7,14 @@ use instructions::*; #[cfg(test)] mod tests; -declare_id!("FLUH9c5oAfXb1eYbkZvdGK9r9SLQJBUi2DZQaBVj7Tzr"); +declare_id!("2phbC62wekpw95XuBk4i1KX4uA8zBUWmYbiTMhicSuBV"); #[program] mod quasar_hello_solana { use super::*; #[instruction(discriminator = 0)] - pub fn hello(ctx: Ctx) -> Result<(), ProgramError> { + pub fn hello(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_hello(&mut ctx.accounts) } } diff --git a/basics/pda-rent-payer/anchor/Anchor.toml b/basics/pda-rent-payer/anchor/Anchor.toml index 633d86b5..b387fce5 100644 --- a/basics/pda-rent-payer/anchor/Anchor.toml +++ b/basics/pda-rent-payer/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] pda_rent_payer = "7Hm9nsYVuBZ9rf8z9AMUHreZRv8Q4vLhqwdVTCawRZtA" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/create_new_account.rs b/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/create_new_account.rs index 5069c0d2..80a2e84c 100644 --- a/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/create_new_account.rs +++ b/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/create_new_account.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_lang::system_program::{create_account, CreateAccount}; #[derive(Accounts)] -pub struct CreateNewAccount<'info> { +pub struct CreateNewAccountAccountConstraints<'info> { #[account(mut)] new_account: Signer<'info>, @@ -17,7 +17,9 @@ pub struct CreateNewAccount<'info> { system_program: Program<'info, System>, } -pub fn handle_create_new_account(context: Context) -> Result<()> { +pub fn handle_create_new_account( + context: Context, +) -> Result<()> { // PDA signer seeds let signer_seeds: &[&[&[u8]]] = &[&[b"rent_vault", &[context.bumps.rent_vault]]]; diff --git a/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/init_rent_vault.rs b/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/init_rent_vault.rs index 46fa1b4d..a15c49b4 100644 --- a/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/init_rent_vault.rs +++ b/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/instructions/init_rent_vault.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_lang::system_program::{transfer, Transfer}; #[derive(Accounts)] -pub struct InitRentVault<'info> { +pub struct InitRentVaultAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -19,7 +19,10 @@ pub struct InitRentVault<'info> { // When lamports are transferred to a new address (without and existing account), // An account owned by the system program is created by default -pub fn handle_init_rent_vault(context: Context, fund_lamports: u64) -> Result<()> { +pub fn handle_init_rent_vault( + context: Context, + fund_lamports: u64, +) -> Result<()> { transfer( CpiContext::new( context.accounts.system_program.key(), diff --git a/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/lib.rs b/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/lib.rs index c6759041..c3f3b686 100644 --- a/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/lib.rs +++ b/basics/pda-rent-payer/anchor/programs/anchor-program-example/src/lib.rs @@ -8,11 +8,14 @@ declare_id!("7Hm9nsYVuBZ9rf8z9AMUHreZRv8Q4vLhqwdVTCawRZtA"); pub mod pda_rent_payer { use super::*; - pub fn init_rent_vault(context: Context, fund_lamports: u64) -> Result<()> { + pub fn init_rent_vault( + context: Context, + fund_lamports: u64, + ) -> Result<()> { init_rent_vault::handle_init_rent_vault(context, fund_lamports) } - pub fn create_new_account(context: Context) -> Result<()> { + pub fn create_new_account(context: Context) -> Result<()> { create_new_account::handle_create_new_account(context) } } diff --git a/basics/pda-rent-payer/anchor/programs/anchor-program-example/tests/test_pda_rent_payer.rs b/basics/pda-rent-payer/anchor/programs/anchor-program-example/tests/test_pda_rent_payer.rs index 3bc8c1f5..650d7186 100644 --- a/basics/pda-rent-payer/anchor/programs/anchor-program-example/tests/test_pda_rent_payer.rs +++ b/basics/pda-rent-payer/anchor/programs/anchor-program-example/tests/test_pda_rent_payer.rs @@ -33,7 +33,7 @@ fn test_init_rent_vault() { fund_lamports: fund_amount, } .data(), - pda_rent_payer::accounts::InitRentVault { + pda_rent_payer::accounts::InitRentVaultAccountConstraints { payer: payer.pubkey(), rent_vault: rent_vault_pda, system_program: system_program::id(), @@ -68,7 +68,7 @@ fn test_create_new_account_from_rent_vault() { fund_lamports: fund_amount, } .data(), - pda_rent_payer::accounts::InitRentVault { + pda_rent_payer::accounts::InitRentVaultAccountConstraints { payer: payer.pubkey(), rent_vault: rent_vault_pda, system_program: system_program::id(), @@ -85,7 +85,7 @@ fn test_create_new_account_from_rent_vault() { let create_ix = Instruction::new_with_bytes( program_id, &pda_rent_payer::instruction::CreateNewAccount {}.data(), - pda_rent_payer::accounts::CreateNewAccount { + pda_rent_payer::accounts::CreateNewAccountAccountConstraints { new_account: new_account.pubkey(), rent_vault: rent_vault_pda, system_program: system_program::id(), diff --git a/basics/pda-rent-payer/quasar/Cargo.toml b/basics/pda-rent-payer/quasar/Cargo.toml index 9ed691b8..7248a277 100644 --- a/basics/pda-rent-payer/quasar/Cargo.toml +++ b/basics/pda-rent-payer/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-pda-rent-payer" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/pda-rent-payer/quasar/src/instructions/create_new_account.rs b/basics/pda-rent-payer/quasar/src/instructions/create_new_account.rs index da30f590..0da52274 100644 --- a/basics/pda-rent-payer/quasar/src/instructions/create_new_account.rs +++ b/basics/pda-rent-payer/quasar/src/instructions/create_new_account.rs @@ -6,7 +6,7 @@ use { /// Accounts for creating a new account funded by the rent vault PDA. /// The rent vault signs the create_account CPI via PDA seeds. #[derive(Accounts)] -pub struct CreateNewAccount { +pub struct CreateNewAccountAccountConstraints { #[account(mut)] pub new_account: Signer, #[account(mut, address = RentVault::seeds())] @@ -15,7 +15,7 @@ pub struct CreateNewAccount { } #[inline(always)] -pub fn handle_create_new_account(accounts: &mut CreateNewAccount, rent_vault_bump: u8) -> Result<(), ProgramError> { +pub fn handle_create_new_account(accounts: &mut CreateNewAccountAccountConstraints, rent_vault_bump: u8) -> Result<(), ProgramError> { // Build PDA signer seeds: ["rent_vault", bump]. let bump_bytes = [rent_vault_bump]; let seeds: &[Seed] = &[ diff --git a/basics/pda-rent-payer/quasar/src/instructions/init_rent_vault.rs b/basics/pda-rent-payer/quasar/src/instructions/init_rent_vault.rs index c186c845..c452f40a 100644 --- a/basics/pda-rent-payer/quasar/src/instructions/init_rent_vault.rs +++ b/basics/pda-rent-payer/quasar/src/instructions/init_rent_vault.rs @@ -1,8 +1,8 @@ use quasar_lang::prelude::*; -/// PDA seed marker for the rent-vault account. With the new derive grammar -/// (`address = `) we need a `Seeds` impl to validate the address; -/// `seeds = [b"rent_vault"]` is no longer accepted. +/// PDA seed marker for the rent-vault account. Quasar's derive grammar +/// (`address = `) needs a `Seeds` impl to validate the address; +/// inline `seeds = [b"rent_vault"]` is not accepted. #[derive(Seeds)] #[seeds(b"rent_vault")] pub struct RentVault; @@ -12,7 +12,7 @@ pub struct RentVault; /// When lamports are sent to a new address, the system program creates /// a system-owned account automatically. #[derive(Accounts)] -pub struct InitRentVault { +pub struct InitRentVaultAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut, address = RentVault::seeds())] @@ -21,7 +21,7 @@ pub struct InitRentVault { } #[inline(always)] -pub fn handle_init_rent_vault(accounts: &mut InitRentVault, fund_lamports: u64) -> Result<(), ProgramError> { +pub fn handle_init_rent_vault(accounts: &mut InitRentVaultAccountConstraints, fund_lamports: u64) -> Result<(), ProgramError> { accounts.system_program .transfer(&accounts.payer, &accounts.rent_vault, fund_lamports) .invoke() diff --git a/basics/pda-rent-payer/quasar/src/lib.rs b/basics/pda-rent-payer/quasar/src/lib.rs index 925379a5..4ad3b689 100644 --- a/basics/pda-rent-payer/quasar/src/lib.rs +++ b/basics/pda-rent-payer/quasar/src/lib.rs @@ -15,14 +15,14 @@ mod quasar_pda_rent_payer { /// Fund a PDA "rent vault" by transferring lamports from the payer. #[instruction(discriminator = 0)] - pub fn init_rent_vault(ctx: Ctx, fund_lamports: u64) -> Result<(), ProgramError> { + pub fn init_rent_vault(ctx: Ctx, fund_lamports: u64) -> Result<(), ProgramError> { instructions::handle_init_rent_vault(&mut ctx.accounts, fund_lamports) } /// Create a new account using the rent vault PDA as the funding source. /// The vault signs the CPI via PDA seeds. #[instruction(discriminator = 1)] - pub fn create_new_account(ctx: Ctx) -> Result<(), ProgramError> { + pub fn create_new_account(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_create_new_account(&mut ctx.accounts, ctx.bumps.rent_vault) } } diff --git a/basics/processing-instructions/README.md b/basics/processing-instructions/README.md index f8d31a61..68c2bad3 100644 --- a/basics/processing-instructions/README.md +++ b/basics/processing-instructions/README.md @@ -1,6 +1,6 @@ # Custom Instruction Data -Pass your own custom [instruction](https://solana.com/docs/terminology#instruction) data to a [program](https://solana.com/docs/terminology#program). The data must be serialized in a format the Solana runtime can read — typically via the `borsh` crate on both the client and program sides. +Pass your own custom [instruction](https://solana.com/docs/terminology#instruction) data to a [program](https://solana.com/docs/terminology#program). The data must be serialized in a format the Solana runtime can read - typically via the `borsh` crate on both the client and program sides. - **For `native`:** add `borsh` and `borsh-derive` to `Cargo.toml` so you can mark a struct as serializable. - **For [Anchor](https://solana.com/docs/terminology#anchor):** the framework handles serialization for you via the IDL. diff --git a/basics/processing-instructions/anchor/Anchor.toml b/basics/processing-instructions/anchor/Anchor.toml index d4125344..9e3e864b 100644 --- a/basics/processing-instructions/anchor/Anchor.toml +++ b/basics/processing-instructions/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] processing_instructions = "DgoL5J44aspizyUs9fcnpGEUJjWTLJRCfx8eYtUMYczf" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/processing-instructions/anchor/programs/processing-instructions/src/lib.rs b/basics/processing-instructions/anchor/programs/processing-instructions/src/lib.rs index 2083ad08..1cc3a11f 100644 --- a/basics/processing-instructions/anchor/programs/processing-instructions/src/lib.rs +++ b/basics/processing-instructions/anchor/programs/processing-instructions/src/lib.rs @@ -8,7 +8,11 @@ pub mod processing_instructions { // With Anchor, we just put instruction data in the function signature! // - pub fn go_to_park(_context: Context, name: String, height: u32) -> Result<()> { + pub fn go_to_park( + _context: Context, + name: String, + height: u32, + ) -> Result<()> { msg!("Welcome to the park, {}!", name); if height > 5 { msg!("You are tall enough to ride this ride. Congratulations."); @@ -21,4 +25,4 @@ pub mod processing_instructions { } #[derive(Accounts)] -pub struct Park {} +pub struct ParkAccountConstraints {} diff --git a/basics/processing-instructions/anchor/programs/processing-instructions/tests/test_processing_instructions.rs b/basics/processing-instructions/anchor/programs/processing-instructions/tests/test_processing_instructions.rs index 5e5d26b1..413688cc 100644 --- a/basics/processing-instructions/anchor/programs/processing-instructions/tests/test_processing_instructions.rs +++ b/basics/processing-instructions/anchor/programs/processing-instructions/tests/test_processing_instructions.rs @@ -27,7 +27,7 @@ fn test_go_to_park() { height: 3, } .data(), - processing_instructions::accounts::Park {}.to_account_metas(None), + processing_instructions::accounts::ParkAccountConstraints {}.to_account_metas(None), ); send_transaction_from_instructions(&mut svm, vec![ix_short], &[&payer], &payer.pubkey()) .unwrap(); @@ -42,7 +42,7 @@ fn test_go_to_park() { height: 10, } .data(), - processing_instructions::accounts::Park {}.to_account_metas(None), + processing_instructions::accounts::ParkAccountConstraints {}.to_account_metas(None), ); send_transaction_from_instructions(&mut svm, vec![ix_tall], &[&payer], &payer.pubkey()) .unwrap(); diff --git a/basics/processing-instructions/quasar/src/instructions/go_to_park.rs b/basics/processing-instructions/quasar/src/instructions/go_to_park.rs index ce71aeef..90260386 100644 --- a/basics/processing-instructions/quasar/src/instructions/go_to_park.rs +++ b/basics/processing-instructions/quasar/src/instructions/go_to_park.rs @@ -1,18 +1,18 @@ use quasar_lang::prelude::*; -/// Minimal accounts context — a signer is needed to submit the transaction. +/// Minimal accounts context - a signer is needed to submit the transaction. /// The instruction just processes instruction data (name + height). #[derive(Accounts)] -pub struct Park { +pub struct ParkAccountConstraints { #[allow(dead_code)] pub signer: Signer, } #[inline(always)] -pub fn handle_go_to_park(_accounts: &mut Park, _name: &str, height: u32) -> Result<(), ProgramError> { +pub fn handle_go_to_park(_accounts: &mut ParkAccountConstraints, _name: &str, height: u32) -> Result<(), ProgramError> { // Quasar's `log()` takes &str, no format! macro available in no_std. // We can't interpolate the name or height into the log message, so - // we use static messages — same logic as the Anchor version, just + // we use static messages - same logic as the Anchor version, just // without formatted output. log("Welcome to the park!"); if height > 5 { diff --git a/basics/processing-instructions/quasar/src/lib.rs b/basics/processing-instructions/quasar/src/lib.rs index 653b403d..10518496 100644 --- a/basics/processing-instructions/quasar/src/lib.rs +++ b/basics/processing-instructions/quasar/src/lib.rs @@ -17,7 +17,7 @@ mod quasar_processing_instructions { /// Quasar can parse String instruction args (u32-prefixed wire format) but /// can't interpolate them into log messages (no format! in no_std). #[instruction(discriminator = 0)] - pub fn go_to_park(ctx: Ctx, height: u32, name: String<50>) -> Result<(), ProgramError> { + pub fn go_to_park(ctx: Ctx, height: u32, name: String<50>) -> Result<(), ProgramError> { instructions::handle_go_to_park(&mut ctx.accounts, name, height) } } diff --git a/basics/program-derived-addresses/anchor/Anchor.toml b/basics/program-derived-addresses/anchor/Anchor.toml index d8ad92af..c8f1d4fa 100644 --- a/basics/program-derived-addresses/anchor/Anchor.toml +++ b/basics/program-derived-addresses/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] program_derived_addresses_program = "oCCQRZyAbVxujyd8m57MPmDzZDmy2FoKW4ULS7KofCE" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/create.rs b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/create.rs index a872c2fe..2278e1d1 100644 --- a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/create.rs +++ b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/create.rs @@ -2,13 +2,13 @@ use crate::state::PageVisits; use anchor_lang::prelude::*; #[derive(Accounts)] -pub struct CreatePageVisits<'info> { +pub struct CreatePageVisitsAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, #[account( init, - space = 8 + PageVisits::INIT_SPACE, + space = PageVisits::DISCRIMINATOR.len() + PageVisits::INIT_SPACE, payer = payer, seeds = [ PageVisits::SEED_PREFIX, @@ -20,7 +20,9 @@ pub struct CreatePageVisits<'info> { system_program: Program<'info, System>, } -pub fn handle_create_page_visits(context: Context) -> Result<()> { +pub fn handle_create_page_visits( + context: Context, +) -> Result<()> { *context.accounts.page_visits = PageVisits { page_visits: 0, bump: context.bumps.page_visits, diff --git a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/increment.rs b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/increment.rs index fe25bf02..206ef731 100644 --- a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/increment.rs +++ b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/instructions/increment.rs @@ -1,8 +1,8 @@ -use crate::state::PageVisits; +use crate::{state::PageVisits, PageVisitsError}; use anchor_lang::prelude::*; #[derive(Accounts)] -pub struct IncrementPageVisits<'info> { +pub struct IncrementPageVisitsAccountConstraints<'info> { user: SystemAccount<'info>, #[account( mut, @@ -15,8 +15,13 @@ pub struct IncrementPageVisits<'info> { page_visits: Account<'info, PageVisits>, } -pub fn handle_increment_page_visits(context: Context) -> Result<()> { +pub fn handle_increment_page_visits( + context: Context, +) -> Result<()> { let page_visits = &mut context.accounts.page_visits; - page_visits.increment(); + page_visits.page_visits = page_visits + .page_visits + .checked_add(1) + .ok_or(PageVisitsError::MathOverflow)?; Ok(()) } diff --git a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/lib.rs b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/lib.rs index 5a688c48..40a9f3bf 100644 --- a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/lib.rs +++ b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/lib.rs @@ -11,11 +11,19 @@ declare_id!("oCCQRZyAbVxujyd8m57MPmDzZDmy2FoKW4ULS7KofCE"); pub mod program_derived_addresses_program { use super::*; - pub fn create_page_visits(context: Context) -> Result<()> { + pub fn create_page_visits(context: Context) -> Result<()> { create::handle_create_page_visits(context) } - pub fn increment_page_visits(context: Context) -> Result<()> { + pub fn increment_page_visits( + context: Context, + ) -> Result<()> { increment::handle_increment_page_visits(context) } } + +#[error_code] +pub enum PageVisitsError { + #[msg("Page visit count overflowed u32::MAX")] + MathOverflow, +} diff --git a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/state/page_visits.rs b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/state/page_visits.rs index a2bfa65f..edb1fd71 100644 --- a/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/state/page_visits.rs +++ b/basics/program-derived-addresses/anchor/programs/anchor-program-example/src/state/page_visits.rs @@ -9,8 +9,4 @@ pub struct PageVisits { impl PageVisits { pub const SEED_PREFIX: &'static [u8; 11] = b"page_visits"; - - pub fn increment(&mut self) { - self.page_visits = self.page_visits.checked_add(1).unwrap(); - } } diff --git a/basics/program-derived-addresses/anchor/programs/anchor-program-example/tests/test_program_derived_addresses.rs b/basics/program-derived-addresses/anchor/programs/anchor-program-example/tests/test_program_derived_addresses.rs index 173a4296..1ef8645b 100644 --- a/basics/program-derived-addresses/anchor/programs/anchor-program-example/tests/test_program_derived_addresses.rs +++ b/basics/program-derived-addresses/anchor/programs/anchor-program-example/tests/test_program_derived_addresses.rs @@ -37,7 +37,7 @@ fn test_create_and_increment_page_visits() { let create_ix = Instruction::new_with_bytes( program_id, &program_derived_addresses_program::instruction::CreatePageVisits {}.data(), - program_derived_addresses_program::accounts::CreatePageVisits { + program_derived_addresses_program::accounts::CreatePageVisitsAccountConstraints { payer: payer.pubkey(), page_visits: page_visits_pda, system_program: system_program::id(), @@ -58,7 +58,7 @@ fn test_create_and_increment_page_visits() { let increment_ix = Instruction::new_with_bytes( program_id, &program_derived_addresses_program::instruction::IncrementPageVisits {}.data(), - program_derived_addresses_program::accounts::IncrementPageVisits { + program_derived_addresses_program::accounts::IncrementPageVisitsAccountConstraints { user: payer.pubkey(), page_visits: page_visits_pda, } @@ -81,7 +81,7 @@ fn test_create_and_increment_page_visits() { let increment_ix2 = Instruction::new_with_bytes( program_id, &program_derived_addresses_program::instruction::IncrementPageVisits {}.data(), - program_derived_addresses_program::accounts::IncrementPageVisits { + program_derived_addresses_program::accounts::IncrementPageVisitsAccountConstraints { user: payer.pubkey(), page_visits: page_visits_pda, } diff --git a/basics/program-derived-addresses/native/tests/test.ts b/basics/program-derived-addresses/native/tests/test.ts index 3065e8c5..c4c0d149 100644 --- a/basics/program-derived-addresses/native/tests/test.ts +++ b/basics/program-derived-addresses/native/tests/test.ts @@ -26,7 +26,7 @@ describe("PDAs", async () => { }, }; - // Empty struct — just needs to serialize to zero bytes + // Empty struct - just needs to serialize to zero bytes const IncrementPageVisitsSchema = { struct: {} }; function borshSerialize(schema: borsh.Schema, data: object): Buffer { diff --git a/basics/program-derived-addresses/quasar/Cargo.toml b/basics/program-derived-addresses/quasar/Cargo.toml index 35551fac..03d8001f 100644 --- a/basics/program-derived-addresses/quasar/Cargo.toml +++ b/basics/program-derived-addresses/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-program-derived-addresses" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/program-derived-addresses/quasar/src/error.rs b/basics/program-derived-addresses/quasar/src/error.rs new file mode 100644 index 00000000..feb9e0e1 --- /dev/null +++ b/basics/program-derived-addresses/quasar/src/error.rs @@ -0,0 +1,10 @@ +use quasar_lang::prelude::*; + +#[error_code] +pub enum PageVisitsError { + /// The visit count is at its maximum and cannot be incremented further. + // 6000 is the conventional Anchor-compatible starting offset for + // program-specific error codes (Quasar's #[error_code] starts at 0 + // unless told otherwise; framework errors occupy 3000+). + MathOverflow = 6000, +} diff --git a/basics/program-derived-addresses/quasar/src/instructions/create.rs b/basics/program-derived-addresses/quasar/src/instructions/create.rs index 3088b422..2df91ade 100644 --- a/basics/program-derived-addresses/quasar/src/instructions/create.rs +++ b/basics/program-derived-addresses/quasar/src/instructions/create.rs @@ -6,7 +6,7 @@ use { /// Accounts for creating a new page visits counter. /// The counter is derived as a PDA from ["page_visits", payer] seeds. #[derive(Accounts)] -pub struct CreatePageVisits { +pub struct CreatePageVisitsAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut, init, payer = payer, address = PageVisits::seeds(payer.address()))] @@ -15,7 +15,7 @@ pub struct CreatePageVisits { } #[inline(always)] -pub fn handle_create_page_visits(accounts: &mut CreatePageVisits) -> Result<(), ProgramError> { +pub fn handle_create_page_visits(accounts: &mut CreatePageVisitsAccountConstraints) -> Result<(), ProgramError> { accounts.page_visits.set_inner(PageVisitsInner { page_visits: 0 }); Ok(()) } diff --git a/basics/program-derived-addresses/quasar/src/instructions/increment.rs b/basics/program-derived-addresses/quasar/src/instructions/increment.rs index 04203838..f3aaa56a 100644 --- a/basics/program-derived-addresses/quasar/src/instructions/increment.rs +++ b/basics/program-derived-addresses/quasar/src/instructions/increment.rs @@ -1,20 +1,23 @@ use { - crate::state::PageVisits, + crate::{error::PageVisitsError, state::PageVisits}, quasar_lang::prelude::*, }; /// Accounts for incrementing page visits. /// The user account is needed to derive the PDA seeds for validation. #[derive(Accounts)] -pub struct IncrementPageVisits { +pub struct IncrementPageVisitsAccountConstraints { pub user: UncheckedAccount, #[account(mut)] pub page_visits: Account, } #[inline(always)] -pub fn handle_increment_page_visits(accounts: &mut IncrementPageVisits) -> Result<(), ProgramError> { +pub fn handle_increment_page_visits(accounts: &mut IncrementPageVisitsAccountConstraints) -> Result<(), ProgramError> { let current: u64 = accounts.page_visits.page_visits.into(); - accounts.page_visits.page_visits = PodU64::from(current.checked_add(1).unwrap()); + let next = current + .checked_add(1) + .ok_or(PageVisitsError::MathOverflow)?; + accounts.page_visits.page_visits = PodU64::from(next); Ok(()) } diff --git a/basics/program-derived-addresses/quasar/src/lib.rs b/basics/program-derived-addresses/quasar/src/lib.rs index 1f44e5fb..b729cbf0 100644 --- a/basics/program-derived-addresses/quasar/src/lib.rs +++ b/basics/program-derived-addresses/quasar/src/lib.rs @@ -2,6 +2,7 @@ use quasar_lang::prelude::*; +mod error; mod instructions; use instructions::*; mod state; @@ -16,13 +17,13 @@ mod quasar_program_derived_addresses { /// Create a PDA-based page visits counter for the payer. #[instruction(discriminator = 0)] - pub fn create_page_visits(ctx: Ctx) -> Result<(), ProgramError> { + pub fn create_page_visits(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_create_page_visits(&mut ctx.accounts) } /// Increment the page visits counter. #[instruction(discriminator = 1)] - pub fn increment_page_visits(ctx: Ctx) -> Result<(), ProgramError> { + pub fn increment_page_visits(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_increment_page_visits(&mut ctx.accounts) } } diff --git a/basics/pyth/README.md b/basics/pyth/README.md index 40817584..ac5599e4 100644 --- a/basics/pyth/README.md +++ b/basics/pyth/README.md @@ -2,7 +2,7 @@ [Pyth](https://pyth.network/) is an oracle that publishes low-latency market data from institutional sources [onchain](https://solana.com/docs/terminology#onchain). You can use it to read real-world asset prices from Solana [programs](https://solana.com/docs/terminology#program). -Each asset's price lives in its own Solana [account](https://solana.com/docs/terminology#account) — a **price feed**. +Each asset's price lives in its own Solana [account](https://solana.com/docs/terminology#account) - a **price feed**. For example, the SOL/USD price feed on mainnet lives at `H6ARHf6YXhGYeQfUzQNGk6rDNnLBQKrenN712K4AQJEG`. diff --git a/basics/pyth/anchor/Anchor.toml b/basics/pyth/anchor/Anchor.toml index d89a162d..93967357 100644 --- a/basics/pyth/anchor/Anchor.toml +++ b/basics/pyth/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] pythexample = "GUkjQmrLPFXXNK1bFLKt8XQi6g3TjxcHVspbjDoHvMG2" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/pyth/anchor/README.md b/basics/pyth/anchor/README.md index 0a3d38bd..f5cc8650 100644 --- a/basics/pyth/anchor/README.md +++ b/basics/pyth/anchor/README.md @@ -13,14 +13,16 @@ See also: [Pyth overview](../README.md) and the [repository catalog](../../../RE > error[E0277]: the trait bound `pythnet_sdk::messages::PriceFeedMessage: BorshSerialize` is not satisfied > ``` > -> No published `pyth-solana-receiver-sdk` targets `anchor-lang` 1.0 (which this repo standardizes on), and no `pythnet-sdk` release has migrated to borsh 1.x — so the dependency can't simply be upgraded. Tracked upstream at [pyth-network/pyth-crosschain#3756](https://github.com/pyth-network/pyth-crosschain/issues/3756). +> No published `pyth-solana-receiver-sdk` targets `anchor-lang` 1.0 (which this repo standardizes on), and no `pythnet-sdk` release has migrated to borsh 1.x - so the dependency can't simply be upgraded. Tracked upstream at [pyth-network/pyth-crosschain#3756](https://github.com/pyth-network/pyth-crosschain/issues/3756). > -> As a workaround, `programs/pythexample/src/lib.rs` mirrors the on-chain `PriceUpdateV2` layout locally (same fields, same 8-byte discriminator, owned by the Pyth Receiver program) so accounts written by Pyth deserialize unchanged. Replace the vendored type with the SDK import once an Anchor 1.0 / borsh 1.x compatible release ships. +> As a workaround, `programs/pythexample/src/lib.rs` mirrors the onchain `PriceUpdateV2` layout locally (same fields, same 8-byte discriminator, owned by the Pyth Receiver program) so accounts written by Pyth deserialize unchanged. Replace the vendored type with the SDK import once an Anchor 1.0 / borsh 1.x compatible release ships. ## Major concepts - Oracle price accounts - Consuming external onchain data in a program +- Oracle account validation: `Account` enforces that the price account is owned by the Pyth Receiver program (`rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ`) +- Price freshness: `read_price` rejects updates older than `MAXIMUM_PRICE_AGE_SECONDS` (compared against `publish_time`, a unix timestamp in seconds, mirroring the SDK's `get_price_no_older_than`) ## Setup diff --git a/basics/pyth/anchor/programs/pythexample/Cargo.toml b/basics/pyth/anchor/programs/pythexample/Cargo.toml index 2acfd580..ff98f3e7 100644 --- a/basics/pyth/anchor/programs/pythexample/Cargo.toml +++ b/basics/pyth/anchor/programs/pythexample/Cargo.toml @@ -24,6 +24,12 @@ custom-panic = [] anchor-lang = "1.0.0" [dev-dependencies] +# Self-dependency with no-entrypoint: host test builds otherwise export a +# #[no_mangle] `entrypoint` symbol from this crate AND from spl-token (via +# solana-kite), and the linker rejects the duplicate. The SBF build +# (cargo build-sbf) ignores dev-dependencies, so the deployed program keeps +# its entrypoint. +pythexample = { path = ".", features = ["no-entrypoint"] } litesvm = "0.11.0" solana-signer = "3.0.0" solana-keypair = "3.0.1" diff --git a/basics/pyth/anchor/programs/pythexample/src/lib.rs b/basics/pyth/anchor/programs/pythexample/src/lib.rs index 8416108c..013b6882 100644 --- a/basics/pyth/anchor/programs/pythexample/src/lib.rs +++ b/basics/pyth/anchor/programs/pythexample/src/lib.rs @@ -5,12 +5,38 @@ declare_id!("GUkjQmrLPFXXNK1bFLKt8XQi6g3TjxcHVspbjDoHvMG2"); /// The Pyth Receiver program that owns `PriceUpdateV2` accounts on devnet/mainnet. pub const PYTH_RECEIVER_PROGRAM_ID: Pubkey = pubkey!("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"); +/// Maximum allowed age of a price update before it is rejected as stale. +/// Pyth's `publish_time` is a unix timestamp in seconds, so the age check +/// uses unix time rather than slots: seconds are the only freshness signal +/// the price message carries (this mirrors the official +/// `pyth-solana-receiver-sdk`'s `get_price_no_older_than`). +pub const MAXIMUM_PRICE_AGE_SECONDS: i64 = 60; + +#[error_code] +pub enum PythExampleError { + #[msg("The price update is older than the maximum allowed age")] + PriceTooOld, + #[msg("Computing the price update's age overflowed an i64")] + MathOverflow, +} + #[program] pub mod anchor_test { use super::*; - pub fn read_price(context: Context) -> Result<()> { + pub fn read_price(context: Context) -> Result<()> { let price_update = &context.accounts.price_update; + + // Reject stale prices: a price that stopped updating is wrong. + let price_age_seconds = Clock::get()? + .unix_timestamp + .checked_sub(price_update.price_message.publish_time) + .ok_or(PythExampleError::MathOverflow)?; + require!( + price_age_seconds <= MAXIMUM_PRICE_AGE_SECONDS, + PythExampleError::PriceTooOld + ); + msg!("Price feed id: {:?}", price_update.price_message.feed_id); msg!("Price: {:?}", price_update.price_message.price); msg!("Confidence: {:?}", price_update.price_message.conf); @@ -24,7 +50,7 @@ pub mod anchor_test { } #[derive(Accounts)] -pub struct ReadPrice<'info> { +pub struct ReadPriceAccountConstraints<'info> { pub price_update: Account<'info, PriceUpdateV2>, } @@ -50,7 +76,7 @@ pub struct ReadPrice<'info> { // https://github.com/pyth-network/pyth-crosschain/issues/3756 // // The fields, order, and 8-byte -// discriminator below match the on-chain account exactly, and it is owned by +// discriminator below match the onchain account exactly, and it is owned by // the Pyth Receiver program (see the `Owner` impl), so accounts written by Pyth // deserialize unchanged. Replace this with the SDK type once an Anchor 1.0 / // borsh 1.x compatible `pyth-solana-receiver-sdk` release ships. diff --git a/basics/pyth/anchor/programs/pythexample/tests/test_pyth.rs b/basics/pyth/anchor/programs/pythexample/tests/test_pyth.rs index 04d545fb..5071866a 100644 --- a/basics/pyth/anchor/programs/pythexample/tests/test_pyth.rs +++ b/basics/pyth/anchor/programs/pythexample/tests/test_pyth.rs @@ -1,11 +1,18 @@ use { - anchor_lang::{solana_program::instruction::Instruction, InstructionData, ToAccountMetas}, + anchor_lang::{ + solana_program::{clock::Clock, instruction::Instruction}, + InstructionData, ToAccountMetas, + }, litesvm::LiteSVM, + pythexample::MAXIMUM_PRICE_AGE_SECONDS, solana_keypair::Keypair, solana_kite::{create_wallet, send_transaction_from_instructions}, solana_signer::Signer, }; +/// The `publish_time` baked into the mock price update below. +const MOCK_PUBLISH_TIME: i64 = 1_700_000_000; + /// Pyth Receiver program ID (rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ) fn pyth_receiver_program_id() -> anchor_lang::solana_program::pubkey::Pubkey { pythexample::PYTH_RECEIVER_PROGRAM_ID @@ -47,8 +54,7 @@ fn build_mock_price_update_account( data.extend_from_slice(&exponent.to_le_bytes()); // publish_time: i64 - let publish_time: i64 = 1_700_000_000; - data.extend_from_slice(&publish_time.to_le_bytes()); + data.extend_from_slice(&MOCK_PUBLISH_TIME.to_le_bytes()); // prev_publish_time: i64 let prev_publish_time: i64 = 1_699_999_999; @@ -69,20 +75,25 @@ fn build_mock_price_update_account( data } -#[test] -fn test_read_price() { +/// Set the test clock so the mock price update is `age_seconds` old. +fn set_clock_to_price_age(svm: &mut LiteSVM, age_seconds: i64) { + let mut clock: Clock = svm.get_sysvar(); + clock.unix_timestamp = MOCK_PUBLISH_TIME + age_seconds; + svm.set_sysvar(&clock); +} + +fn setup_with_price_account( + owner: anchor_lang::solana_program::pubkey::Pubkey, +) -> (LiteSVM, solana_keypair::Keypair, Keypair) { let program_id = pythexample::id(); let mut svm = LiteSVM::new(); let bytes = include_bytes!("../../../target/deploy/pythexample.so"); svm.add_program(program_id, bytes).unwrap(); let payer = create_wallet(&mut svm, 10_000_000_000).unwrap(); - // Create a mock PriceUpdateV2 account + // Create a mock PriceUpdateV2 account with the given owner. let price_update_key = Keypair::new(); let account_data = build_mock_price_update_account(&payer.pubkey()); - - // Set the account in LiteSVM with the Pyth Receiver program as owner - let pyth_receiver_id = pyth_receiver_program_id(); let rent = svm.minimum_balance_for_rent_exemption(account_data.len()); svm.set_account( @@ -90,23 +101,62 @@ fn test_read_price() { solana_account::Account { lamports: rent, data: account_data, - owner: pyth_receiver_id, + owner, executable: false, rent_epoch: 0, }, ) .unwrap(); - // Call read_price — program just reads the account and logs the price info + (svm, payer, price_update_key) +} + +fn read_price_instruction(price_update: anchor_lang::solana_program::pubkey::Pubkey) -> Instruction { let ix_data = pythexample::instruction::ReadPrice {}.data(); + let accounts = pythexample::accounts::ReadPriceAccountConstraints { price_update }.to_account_metas(None); + Instruction::new_with_bytes(pythexample::id(), &ix_data, accounts) +} - let accounts = pythexample::accounts::ReadPrice { - price_update: price_update_key.pubkey(), - } - .to_account_metas(None); +#[test] +fn test_read_price() { + let (mut svm, payer, price_update_key) = setup_with_price_account(pyth_receiver_program_id()); - let instruction = Instruction::new_with_bytes(program_id, &ix_data, accounts); + // A price exactly at the maximum allowed age is still accepted. + set_clock_to_price_age(&mut svm, MAXIMUM_PRICE_AGE_SECONDS); + let instruction = read_price_instruction(price_update_key.pubkey()); send_transaction_from_instructions(&mut svm, vec![instruction], &[&payer], &payer.pubkey()) .unwrap(); } + +#[test] +fn test_read_price_rejects_stale_price() { + let (mut svm, payer, price_update_key) = setup_with_price_account(pyth_receiver_program_id()); + + // One second past the maximum age: rejected as stale. + set_clock_to_price_age(&mut svm, MAXIMUM_PRICE_AGE_SECONDS + 1); + + let instruction = read_price_instruction(price_update_key.pubkey()); + let result = + send_transaction_from_instructions(&mut svm, vec![instruction], &[&payer], &payer.pubkey()); + assert!(result.is_err(), "a stale price update must be rejected"); +} + +#[test] +fn test_read_price_rejects_wrong_owner() { + // Plausible price data, but the account is owned by some random program + // instead of the Pyth Receiver: Anchor's Account owner + // check must reject it. + let fake_owner = Keypair::new().pubkey(); + let (mut svm, payer, price_update_key) = setup_with_price_account(fake_owner); + + set_clock_to_price_age(&mut svm, 0); + + let instruction = read_price_instruction(price_update_key.pubkey()); + let result = + send_transaction_from_instructions(&mut svm, vec![instruction], &[&payer], &payer.pubkey()); + assert!( + result.is_err(), + "a price account not owned by the Pyth Receiver must be rejected" + ); +} diff --git a/basics/pyth/quasar/Cargo.toml b/basics/pyth/quasar/Cargo.toml index ec4480da..1687eb6f 100644 --- a/basics/pyth/quasar/Cargo.toml +++ b/basics/pyth/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-pyth-example" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/pyth/quasar/README.md b/basics/pyth/quasar/README.md index 4b7fba4f..feaeb88e 100644 --- a/basics/pyth/quasar/README.md +++ b/basics/pyth/quasar/README.md @@ -8,6 +8,8 @@ See also: [Pyth overview](../README.md) and the [repository catalog](../../../RE - Oracle accounts - Price feed layout +- Oracle account validation: `read_price` only accepts accounts owned by the Pyth Receiver program (`rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ`) +- Price freshness: updates older than `MAXIMUM_PRICE_AGE_SECONDS` are rejected (compared against `publish_time`, a unix timestamp in seconds) ## Setup diff --git a/basics/pyth/quasar/src/instructions/read_price.rs b/basics/pyth/quasar/src/instructions/read_price.rs index 9c28d901..7da158b9 100644 --- a/basics/pyth/quasar/src/instructions/read_price.rs +++ b/basics/pyth/quasar/src/instructions/read_price.rs @@ -1,4 +1,29 @@ -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar}; + +/// The Pyth Receiver program that owns `PriceUpdateV2` accounts on +/// devnet/mainnet (same constant as the Anchor twin). +pub const PYTH_RECEIVER_PROGRAM_ID: Address = + address!("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"); + +/// Maximum allowed age of a price update before it is rejected as stale. +/// Pyth's `publish_time` is a unix timestamp in seconds, so the age check +/// uses unix time rather than slots: seconds are the only freshness signal +/// the price message carries (this mirrors the official +/// `pyth-solana-receiver-sdk`'s `get_price_no_older_than`). +pub const MAXIMUM_PRICE_AGE_SECONDS: i64 = 60; + +/// Errors for reading Pyth price updates. Codes start at 6000, the same +/// offset Anchor uses for custom errors. +#[error_code] +pub enum PythExampleError { + /// The price update account is not owned by the Pyth Receiver program, + /// so its bytes cannot be trusted as a `PriceUpdateV2`. + PriceUpdateNotOwnedByPythReceiver = 6000, + /// The price update is older than `MAXIMUM_PRICE_AGE_SECONDS`. + PriceTooOld, + /// Computing the price update's age overflowed an i64. + MathOverflow, +} /// Byte layout offsets for a Pyth PriceUpdateV2 account: /// [0..8] Anchor discriminator @@ -16,16 +41,22 @@ const PUBLISH_TIME_OFFSET: usize = 93; const MIN_DATA_LEN: usize = 101; /// Accounts for reading a Pyth PriceUpdateV2 account. -/// Uses `UncheckedAccount` because Quasar does not have a built-in Pyth account type; -/// the caller is responsible for passing a valid PriceUpdateV2 account. +/// Uses `UncheckedAccount` because Quasar does not have a built-in Pyth +/// account type; the `constraints(...)` check below enforces that the +/// account is owned by the Pyth Receiver program, so an attacker cannot +/// substitute an arbitrary account with plausible bytes. #[derive(Accounts)] -pub struct ReadPrice { +pub struct ReadPriceAccountConstraints { /// The Pyth PriceUpdateV2 price update account. + #[account( + constraints(price_update.to_account_view().owner() == &PYTH_RECEIVER_PROGRAM_ID) + @ PythExampleError::PriceUpdateNotOwnedByPythReceiver + )] pub price_update: UncheckedAccount, } #[inline(always)] -pub fn handle_read_price(accounts: &mut ReadPrice) -> Result<(), ProgramError> { +pub fn handle_read_price(accounts: &mut ReadPriceAccountConstraints) -> Result<(), ProgramError> { let view = accounts.price_update.to_account_view(); let data = unsafe { core::slice::from_raw_parts(view.data_ptr(), view.data_len()) }; @@ -48,12 +79,21 @@ pub fn handle_read_price(accounts: &mut ReadPrice) -> Result<(), ProgramError> { .try_into() .map_err(|_| ProgramError::InvalidAccountData)?, ); - let _publish_time = i64::from_le_bytes( + let publish_time = i64::from_le_bytes( data[PUBLISH_TIME_OFFSET..PUBLISH_TIME_OFFSET + 8] .try_into() .map_err(|_| ProgramError::InvalidAccountData)?, ); + // Reject stale prices: a price that stopped updating is wrong. + let now: i64 = Clock::get()?.unix_timestamp.into(); + let price_age_seconds = now + .checked_sub(publish_time) + .ok_or(PythExampleError::MathOverflow)?; + if price_age_seconds > MAXIMUM_PRICE_AGE_SECONDS { + return Err(PythExampleError::PriceTooOld.into()); + } + log("Pyth price feed data read successfully."); Ok(()) diff --git a/basics/pyth/quasar/src/lib.rs b/basics/pyth/quasar/src/lib.rs index 87c23f22..e52d3c74 100644 --- a/basics/pyth/quasar/src/lib.rs +++ b/basics/pyth/quasar/src/lib.rs @@ -15,7 +15,7 @@ mod quasar_pyth_example { /// Read and log Pyth price feed data from a PriceUpdateV2 account. #[instruction(discriminator = 0)] - pub fn read_price(ctx: Ctx) -> Result<(), ProgramError> { + pub fn read_price(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_read_price(&mut ctx.accounts) } } diff --git a/basics/pyth/quasar/src/tests.rs b/basics/pyth/quasar/src/tests.rs index 5655f33d..f71a2517 100644 --- a/basics/pyth/quasar/src/tests.rs +++ b/basics/pyth/quasar/src/tests.rs @@ -1,6 +1,13 @@ use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; use solana_address::Address; +use crate::instructions::{ + PythExampleError, MAXIMUM_PRICE_AGE_SECONDS, PYTH_RECEIVER_PROGRAM_ID, +}; + +/// The `publish_time` baked into the mock price update below. +const MOCK_PUBLISH_TIME: i64 = 1_700_000_000; + fn setup() -> QuasarSvm { let elf = include_bytes!("../target/deploy/quasar_pyth_example.so"); QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) @@ -16,8 +23,8 @@ fn setup() -> QuasarSvm { /// [73..81] price = 15_000_000_000 i64 LE (150.00 USD @ exponent -8) /// [81..89] conf = 100_000 u64 LE /// [89..93] exponent = -8 i32 LE -/// [93..101] publish_time = 1_700_000_000 i64 LE -/// [101..109] prev_publish_time = 1_699_999_999 i64 LE +/// [93..101] publish_time = MOCK_PUBLISH_TIME i64 LE +/// [101..109] prev_publish_time = MOCK_PUBLISH_TIME - 1 i64 LE /// [109..117] ema_price = 14_900_000_000 i64 LE /// [117..125] ema_conf = 120_000 u64 LE /// [125..133] posted_slot = 42 u64 LE @@ -27,45 +34,89 @@ fn build_mock_price_update_account() -> Vec { data.extend_from_slice(&discriminator); data.extend_from_slice(&[0u8; 32]); // write_authority - data.push(1u8); // verification_level: Full + data.push(1u8); // verification_level: Full data.extend_from_slice(&[0xEFu8; 32]); // feed_id data.extend_from_slice(&15_000_000_000i64.to_le_bytes()); // price - data.extend_from_slice(&100_000u64.to_le_bytes()); // conf - data.extend_from_slice(&(-8i32).to_le_bytes()); // exponent - data.extend_from_slice(&1_700_000_000i64.to_le_bytes()); // publish_time - data.extend_from_slice(&1_699_999_999i64.to_le_bytes()); // prev_publish_time + data.extend_from_slice(&100_000u64.to_le_bytes()); // conf + data.extend_from_slice(&(-8i32).to_le_bytes()); // exponent + data.extend_from_slice(&MOCK_PUBLISH_TIME.to_le_bytes()); // publish_time + data.extend_from_slice(&(MOCK_PUBLISH_TIME - 1).to_le_bytes()); // prev_publish_time data.extend_from_slice(&14_900_000_000i64.to_le_bytes()); // ema_price - data.extend_from_slice(&120_000u64.to_le_bytes()); // ema_conf - data.extend_from_slice(&42u64.to_le_bytes()); // posted_slot + data.extend_from_slice(&120_000u64.to_le_bytes()); // ema_conf + data.extend_from_slice(&42u64.to_le_bytes()); // posted_slot data } -#[test] -fn test_read_price() { - let mut svm = setup(); - - let price_update = Pubkey::new_unique(); - let account_data = build_mock_price_update_account(); - - let price_account = Account { - address: price_update, +fn price_update_account(address: Pubkey, owner: Pubkey) -> Account { + Account { + address, lamports: 1_000_000_000, - data: account_data, - owner: Pubkey::new_unique(), // UncheckedAccount — no owner validation + data: build_mock_price_update_account(), + owner, executable: false, - }; + } +} - // Instruction data: discriminator = 0, no args. - let instruction = Instruction { +fn read_price_instruction(price_update: Pubkey) -> Instruction { + Instruction { program_id: Pubkey::from(crate::ID), accounts: vec![solana_instruction::AccountMeta::new_readonly( Address::from(price_update.to_bytes()), false, )], - data: vec![0u8], - }; + data: vec![0u8], // read_price discriminator + } +} + +#[test] +fn test_read_price() { + let mut svm = setup(); + + // A price exactly at the maximum allowed age is still accepted. + svm.warp_to_timestamp(MOCK_PUBLISH_TIME + MAXIMUM_PRICE_AGE_SECONDS); + + let price_update = Pubkey::new_unique(); + let price_account = + price_update_account(price_update, Pubkey::from(PYTH_RECEIVER_PROGRAM_ID)); - let result = svm.process_instruction(&instruction, &[price_account]); + let result = + svm.process_instruction(&read_price_instruction(price_update), &[price_account]); result.assert_success(); } + +#[test] +fn test_read_price_rejects_stale_price() { + let mut svm = setup(); + + // One second past the maximum age: rejected as stale. + svm.warp_to_timestamp(MOCK_PUBLISH_TIME + MAXIMUM_PRICE_AGE_SECONDS + 1); + + let price_update = Pubkey::new_unique(); + let price_account = + price_update_account(price_update, Pubkey::from(PYTH_RECEIVER_PROGRAM_ID)); + + let result = + svm.process_instruction(&read_price_instruction(price_update), &[price_account]); + result.assert_error(quasar_svm::ProgramError::Custom( + PythExampleError::PriceTooOld as u32, + )); +} + +#[test] +fn test_read_price_rejects_wrong_owner() { + let mut svm = setup(); + + svm.warp_to_timestamp(MOCK_PUBLISH_TIME); + + // Plausible price bytes, but the account is owned by some random program + // instead of the Pyth Receiver: the owner constraint must reject it. + let price_update = Pubkey::new_unique(); + let price_account = price_update_account(price_update, Pubkey::new_unique()); + + let result = + svm.process_instruction(&read_price_instruction(price_update), &[price_account]); + result.assert_error(quasar_svm::ProgramError::Custom( + PythExampleError::PriceUpdateNotOwnedByPythReceiver as u32, + )); +} diff --git a/basics/realloc/README.md b/basics/realloc/README.md index 1f68ec97..419898b0 100644 --- a/basics/realloc/README.md +++ b/basics/realloc/README.md @@ -1,6 +1,6 @@ # Realloc -Resize a Solana [account](https://solana.com/docs/terminology#account) after it has been created — grow or shrink the data it can hold. +Resize a Solana [account](https://solana.com/docs/terminology#account) after it has been created - grow or shrink the data it can hold. ## A note on `realloc` vs `resize` diff --git a/basics/realloc/anchor/Anchor.toml b/basics/realloc/anchor/Anchor.toml index eabcf8d2..80cd0ed6 100644 --- a/basics/realloc/anchor/Anchor.toml +++ b/basics/realloc/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] anchor_realloc = "Fod47xKXjdHVQDzkFPBvfdWLm8gEAV4iMSXkfUzCHiSD" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/realloc/anchor/migrations/deploy.ts b/basics/realloc/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/basics/realloc/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/basics/realloc/anchor/programs/anchor-realloc/src/instructions/initialize.rs b/basics/realloc/anchor/programs/anchor-realloc/src/instructions/initialize.rs index 1fc5afb4..dc45b857 100644 --- a/basics/realloc/anchor/programs/anchor-realloc/src/instructions/initialize.rs +++ b/basics/realloc/anchor/programs/anchor-realloc/src/instructions/initialize.rs @@ -4,7 +4,7 @@ use crate::Message; #[derive(Accounts)] #[instruction(input: String)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -17,7 +17,7 @@ pub struct Initialize<'info> { pub system_program: Program<'info, System>, } -pub fn handler(context: Context, input: String) -> Result<()> { +pub fn handler(context: Context, input: String) -> Result<()> { context.accounts.message_account.message = input; Ok(()) } diff --git a/basics/realloc/anchor/programs/anchor-realloc/src/instructions/update.rs b/basics/realloc/anchor/programs/anchor-realloc/src/instructions/update.rs index f3ff4425..cd1032c5 100644 --- a/basics/realloc/anchor/programs/anchor-realloc/src/instructions/update.rs +++ b/basics/realloc/anchor/programs/anchor-realloc/src/instructions/update.rs @@ -4,7 +4,7 @@ use crate::Message; #[derive(Accounts)] #[instruction(input: String)] -pub struct Update<'info> { +pub struct UpdateAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -18,7 +18,7 @@ pub struct Update<'info> { pub system_program: Program<'info, System>, } -pub fn handler(context: Context, input: String) -> Result<()> { +pub fn handler(context: Context, input: String) -> Result<()> { context.accounts.message_account.message = input; Ok(()) } diff --git a/basics/realloc/anchor/programs/anchor-realloc/src/lib.rs b/basics/realloc/anchor/programs/anchor-realloc/src/lib.rs index 0d628b2e..f51e65d6 100644 --- a/basics/realloc/anchor/programs/anchor-realloc/src/lib.rs +++ b/basics/realloc/anchor/programs/anchor-realloc/src/lib.rs @@ -9,11 +9,11 @@ declare_id!("Fod47xKXjdHVQDzkFPBvfdWLm8gEAV4iMSXkfUzCHiSD"); pub mod anchor_realloc { use super::*; - pub fn initialize(context: Context, input: String) -> Result<()> { + pub fn initialize(context: Context, input: String) -> Result<()> { instructions::initialize::handler(context, input) } - pub fn update(context: Context, input: String) -> Result<()> { + pub fn update(context: Context, input: String) -> Result<()> { instructions::update::handler(context, input) } } diff --git a/basics/realloc/anchor/programs/anchor-realloc/tests/test_realloc.rs b/basics/realloc/anchor/programs/anchor-realloc/tests/test_realloc.rs index ffc045d2..fecf1c6c 100644 --- a/basics/realloc/anchor/programs/anchor-realloc/tests/test_realloc.rs +++ b/basics/realloc/anchor/programs/anchor-realloc/tests/test_realloc.rs @@ -38,7 +38,7 @@ fn test_initialize() { input: "hello".to_string(), } .data(), - anchor_realloc::accounts::Initialize { + anchor_realloc::accounts::InitializeAccountConstraints { payer: payer.pubkey(), message_account: message_keypair.pubkey(), system_program: system_program::id(), @@ -79,7 +79,7 @@ fn test_update_grows() { input: "hello".to_string(), } .data(), - anchor_realloc::accounts::Initialize { + anchor_realloc::accounts::InitializeAccountConstraints { payer: payer.pubkey(), message_account: message_keypair.pubkey(), system_program: system_program::id(), @@ -102,7 +102,7 @@ fn test_update_grows() { input: "hello world".to_string(), } .data(), - anchor_realloc::accounts::Update { + anchor_realloc::accounts::UpdateAccountConstraints { payer: payer.pubkey(), message_account: message_keypair.pubkey(), system_program: system_program::id(), @@ -136,7 +136,7 @@ fn test_update_shrinks() { input: "hello world".to_string(), } .data(), - anchor_realloc::accounts::Initialize { + anchor_realloc::accounts::InitializeAccountConstraints { payer: payer.pubkey(), message_account: message_keypair.pubkey(), system_program: system_program::id(), @@ -159,7 +159,7 @@ fn test_update_shrinks() { input: "hi".to_string(), } .data(), - anchor_realloc::accounts::Update { + anchor_realloc::accounts::UpdateAccountConstraints { payer: payer.pubkey(), message_account: message_keypair.pubkey(), system_program: system_program::id(), diff --git a/basics/realloc/quasar/src/instructions/initialize.rs b/basics/realloc/quasar/src/instructions/initialize.rs index 82409c47..2c1b52db 100644 --- a/basics/realloc/quasar/src/instructions/initialize.rs +++ b/basics/realloc/quasar/src/instructions/initialize.rs @@ -4,9 +4,9 @@ use { }; /// Accounts for initialising a new message account. -/// The message_account is a random keypair (not a PDA) — same as the Anchor version. +/// The message_account is a random keypair (not a PDA) - same as the Anchor version. #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut, init, payer = payer)] @@ -15,7 +15,7 @@ pub struct Initialize { } #[inline(always)] -pub fn handle_initialize(accounts: &mut Initialize, message: &str) -> Result<(), ProgramError> { +pub fn handle_initialize(accounts: &mut InitializeAccountConstraints, message: &str) -> Result<(), ProgramError> { let rent = Rent::get()?; accounts.message_account.set_inner( MessageAccountInner { message }, diff --git a/basics/realloc/quasar/src/instructions/update.rs b/basics/realloc/quasar/src/instructions/update.rs index 88d3bd75..04b7265b 100644 --- a/basics/realloc/quasar/src/instructions/update.rs +++ b/basics/realloc/quasar/src/instructions/update.rs @@ -7,7 +7,7 @@ use { /// Quasar's `set_inner` automatically handles realloc when the new message /// is longer than the current account data. No explicit realloc needed. #[derive(Accounts)] -pub struct Update { +pub struct UpdateAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -16,7 +16,7 @@ pub struct Update { } #[inline(always)] -pub fn handle_update(accounts: &mut Update, message: &str) -> Result<(), ProgramError> { +pub fn handle_update(accounts: &mut UpdateAccountConstraints, message: &str) -> Result<(), ProgramError> { let rent = Rent::get()?; accounts.message_account.set_inner( MessageAccountInner { message }, diff --git a/basics/realloc/quasar/src/lib.rs b/basics/realloc/quasar/src/lib.rs index 6b0a512b..ecd4062a 100644 --- a/basics/realloc/quasar/src/lib.rs +++ b/basics/realloc/quasar/src/lib.rs @@ -16,14 +16,14 @@ mod quasar_realloc { /// Create a message account with an initial message. #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx, message: String<1024>) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx, message: String<1024>) -> Result<(), ProgramError> { instructions::handle_initialize(&mut ctx.accounts, message) } /// Update the message, reallocating if the new message is longer. /// Quasar's `set_inner` handles realloc transparently. #[instruction(discriminator = 1)] - pub fn update(ctx: Ctx, message: String<1024>) -> Result<(), ProgramError> { + pub fn update(ctx: Ctx, message: String<1024>) -> Result<(), ProgramError> { instructions::handle_update(&mut ctx.accounts, message) } } diff --git a/basics/realloc/quasar/src/tests.rs b/basics/realloc/quasar/src/tests.rs index 25d35106..5b9f6350 100644 --- a/basics/realloc/quasar/src/tests.rs +++ b/basics/realloc/quasar/src/tests.rs @@ -111,7 +111,7 @@ fn test_update_longer_message() { let payer_after_init = result.account(&payer).unwrap().clone(); let msg_after_init = result.account(&message_account).unwrap().clone(); - // Update with longer message — triggers realloc + // Update with longer message - triggers realloc let update_ix = Instruction { program_id, accounts: vec![ diff --git a/basics/rent/README.md b/basics/rent/README.md index 92b3ec7e..579d6aa0 100644 --- a/basics/rent/README.md +++ b/basics/rent/README.md @@ -2,6 +2,6 @@ All storage on Solana costs **[rent](https://solana.com/docs/terminology#rent)**. -In practice, rent is a small amount and [accounts](https://solana.com/docs/terminology#account) that hold at least two years' worth of rent are **rent-exempt** — they pay nothing. If your account holds more [lamports](https://solana.com/docs/terminology#lamport) than the two-year cost, it isn't charged rent. +In practice, rent is a small amount and [accounts](https://solana.com/docs/terminology#account) that hold at least two years' worth of rent are **rent-exempt** - they pay nothing. If your account holds more [lamports](https://solana.com/docs/terminology#lamport) than the two-year cost, it isn't charged rent. Rent is calculated from the size of the data stored in the account. diff --git a/basics/rent/anchor/Anchor.toml b/basics/rent/anchor/Anchor.toml index 579d486d..b05f48f3 100644 --- a/basics/rent/anchor/Anchor.toml +++ b/basics/rent/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] rent_example = "ED6f4gweAE7hWPQPXMt4kWxzDJne8VQEm9zkb1tMpFNB" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/rent/anchor/programs/rent-example/src/lib.rs b/basics/rent/anchor/programs/rent-example/src/lib.rs index 44acf961..e2d8a840 100644 --- a/basics/rent/anchor/programs/rent-example/src/lib.rs +++ b/basics/rent/anchor/programs/rent-example/src/lib.rs @@ -8,7 +8,7 @@ pub mod rent_example { use super::*; pub fn create_system_account( - context: Context, + context: Context, address_data: AddressData, ) -> Result<()> { msg!("Program invoked. Creating a system account..."); @@ -39,13 +39,13 @@ pub mod rent_example { &context.accounts.system_program.key(), )?; - msg!("Account created succesfully."); + msg!("Account created successfully."); Ok(()) } } #[derive(Accounts)] -pub struct CreateSystemAccount<'info> { +pub struct CreateSystemAccountAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut)] diff --git a/basics/rent/anchor/programs/rent-example/tests/test_rent.rs b/basics/rent/anchor/programs/rent-example/tests/test_rent.rs index 447a1ad0..2451d0dc 100644 --- a/basics/rent/anchor/programs/rent-example/tests/test_rent.rs +++ b/basics/rent/anchor/programs/rent-example/tests/test_rent.rs @@ -48,7 +48,7 @@ fn test_create_system_account() { let instruction = Instruction::new_with_bytes( program_id, &ix_data, - rent_example::accounts::CreateSystemAccount { + rent_example::accounts::CreateSystemAccountAccountConstraints { payer: payer.pubkey(), new_account: new_account.pubkey(), system_program: system_program::id(), diff --git a/basics/rent/native/program/src/lib.rs b/basics/rent/native/program/src/lib.rs index 01c711ab..4161473f 100644 --- a/basics/rent/native/program/src/lib.rs +++ b/basics/rent/native/program/src/lib.rs @@ -43,6 +43,6 @@ fn process_instruction( &[payer.clone(), new_account.clone(), system_program.clone()], )?; - msg!("Account created succesfully."); + msg!("Account created successfully."); Ok(()) } diff --git a/basics/rent/pinocchio/program/src/lib.rs b/basics/rent/pinocchio/program/src/lib.rs index e57f6923..65fbb821 100644 --- a/basics/rent/pinocchio/program/src/lib.rs +++ b/basics/rent/pinocchio/program/src/lib.rs @@ -45,6 +45,6 @@ fn process_instruction( } .invoke()?; - log!("Account created succesfully."); + log!("Account created successfully."); Ok(()) } diff --git a/basics/rent/quasar/Cargo.toml b/basics/rent/quasar/Cargo.toml index 10fe8d76..a9e57e93 100644 --- a/basics/rent/quasar/Cargo.toml +++ b/basics/rent/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-rent" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/rent/quasar/src/instructions/create_system_account.rs b/basics/rent/quasar/src/instructions/create_system_account.rs index 0fbcfb6f..396c98be 100644 --- a/basics/rent/quasar/src/instructions/create_system_account.rs +++ b/basics/rent/quasar/src/instructions/create_system_account.rs @@ -2,7 +2,7 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; /// Accounts for creating a system account sized for address data. #[derive(Accounts)] -pub struct CreateSystemAccount { +pub struct CreateSystemAccountAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -11,7 +11,7 @@ pub struct CreateSystemAccount { } #[inline(always)] -pub fn handle_create_system_account(accounts: &mut CreateSystemAccount, name: &str, address: &str) -> Result<(), ProgramError> { +pub fn handle_create_system_account(accounts: &mut CreateSystemAccountAccountConstraints, name: &str, address: &str) -> Result<(), ProgramError> { // Calculate space needed for the serialised AddressData: // borsh-style: 4-byte length prefix + bytes for each String field. let space = 4 + name.len() + 4 + address.len(); diff --git a/basics/rent/quasar/src/lib.rs b/basics/rent/quasar/src/lib.rs index 931d0b02..5f17ba84 100644 --- a/basics/rent/quasar/src/lib.rs +++ b/basics/rent/quasar/src/lib.rs @@ -21,7 +21,7 @@ mod quasar_rent { /// (blueshift-gg/quasar#126). We pass the fields individually instead. #[instruction(discriminator = 0)] pub fn create_system_account( - ctx: Ctx, + ctx: Ctx, name: String<50>, address: String<50>, ) -> Result<(), ProgramError> { diff --git a/basics/repository-layout/README.md b/basics/repository-layout/README.md index 0a9d7475..c0eadd75 100644 --- a/basics/repository-layout/README.md +++ b/basics/repository-layout/README.md @@ -4,4 +4,4 @@ A typical layout for a Solana [program](https://solana.com/docs/terminology#prog > You can structure your `src` folder however you like, as long as it follows Cargo's conventions. This layout is shown so that the patterns in other programs are recognizable. -The `native` and `anchor` layouts are similar. The main difference is the `processor.rs` file in the `native` setup — one of the things [Anchor](https://solana.com/docs/terminology#anchor) abstracts away for you. +The `native` and `anchor` layouts are similar. The main difference is the `processor.rs` file in the `native` setup - one of the things [Anchor](https://solana.com/docs/terminology#anchor) abstracts away for you. diff --git a/basics/repository-layout/anchor/Anchor.toml b/basics/repository-layout/anchor/Anchor.toml index 1223ac40..2a54f9e3 100644 --- a/basics/repository-layout/anchor/Anchor.toml +++ b/basics/repository-layout/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] carnival = "8t94SEJh9jVjDwV7cbiuT6BvEsHo4YHP9x9a5rYH1NpP" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/repository-layout/anchor/programs/carnival/src/instructions/get_on_ride.rs b/basics/repository-layout/anchor/programs/carnival/src/instructions/get_on_ride.rs index 7e67d97e..0427f0c3 100644 --- a/basics/repository-layout/anchor/programs/carnival/src/instructions/get_on_ride.rs +++ b/basics/repository-layout/anchor/programs/carnival/src/instructions/get_on_ride.rs @@ -18,9 +18,8 @@ pub fn get_on_ride(ix: GetOnRideInstructionData) -> Result<()> { if ix.ride.eq(&ride.name) { msg!("You're about to ride the {}!", ride.name); - // Refuse service: failures used to log + return Ok(()), which made - // them indistinguishable from a successful ride for callers and - // tests. Return a real error instead. + // Refuse service with a real error so callers and tests can + // distinguish a refused ride from a successful one. if ix.rider_ticket_count < ride.tickets { msg!( " Sorry {}, you need {} tickets to ride the {}!", diff --git a/basics/repository-layout/anchor/programs/carnival/src/lib.rs b/basics/repository-layout/anchor/programs/carnival/src/lib.rs index 5048fed4..9d0bea35 100644 --- a/basics/repository-layout/anchor/programs/carnival/src/lib.rs +++ b/basics/repository-layout/anchor/programs/carnival/src/lib.rs @@ -15,7 +15,7 @@ pub mod carnival { use super::*; pub fn go_on_ride( - _context: Context, + _context: Context, name: String, height: u32, ticket_count: u32, @@ -30,7 +30,7 @@ pub mod carnival { } pub fn play_game( - _context: Context, + _context: Context, name: String, ticket_count: u32, game_name: String, @@ -43,7 +43,7 @@ pub mod carnival { } pub fn eat_food( - _context: Context, + _context: Context, name: String, ticket_count: u32, food_stand_name: String, @@ -57,7 +57,7 @@ pub mod carnival { } #[derive(Accounts)] -pub struct CarnivalContext<'info> { +pub struct CarnivalAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, } diff --git a/basics/repository-layout/anchor/programs/carnival/tests/test_carnival.rs b/basics/repository-layout/anchor/programs/carnival/tests/test_carnival.rs index 7ec028bc..c59114e2 100644 --- a/basics/repository-layout/anchor/programs/carnival/tests/test_carnival.rs +++ b/basics/repository-layout/anchor/programs/carnival/tests/test_carnival.rs @@ -21,7 +21,7 @@ fn go_on_ride_ix( ticket_count: u32, ride_name: &str, ) -> Instruction { - let accounts = carnival::accounts::CarnivalContext { + let accounts = carnival::accounts::CarnivalAccountConstraints { payer: payer.pubkey(), } .to_account_metas(None); @@ -44,7 +44,7 @@ fn play_game_ix( ticket_count: u32, game_name: &str, ) -> Instruction { - let accounts = carnival::accounts::CarnivalContext { + let accounts = carnival::accounts::CarnivalAccountConstraints { payer: payer.pubkey(), } .to_account_metas(None); @@ -66,7 +66,7 @@ fn eat_food_ix( ticket_count: u32, food_stand_name: &str, ) -> Instruction { - let accounts = carnival::accounts::CarnivalContext { + let accounts = carnival::accounts::CarnivalAccountConstraints { payer: payer.pubkey(), } .to_account_metas(None); diff --git a/basics/repository-layout/quasar/Cargo.toml b/basics/repository-layout/quasar/Cargo.toml index 7890f661..ec4cff1d 100644 --- a/basics/repository-layout/quasar/Cargo.toml +++ b/basics/repository-layout/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-carnival" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/repository-layout/quasar/src/instructions/carnival_context.rs b/basics/repository-layout/quasar/src/instructions/carnival_context.rs index 33960b49..c990a8c7 100644 --- a/basics/repository-layout/quasar/src/instructions/carnival_context.rs +++ b/basics/repository-layout/quasar/src/instructions/carnival_context.rs @@ -2,17 +2,17 @@ use quasar_lang::prelude::*; use super::{eat_food, get_on_ride, play_game}; -/// Minimal accounts context — a signer submits the transaction. +/// Minimal accounts context - a signer submits the transaction. /// The instructions just process instruction data (no onchain state). #[derive(Accounts)] -pub struct CarnivalContext { +pub struct CarnivalAccountConstraints { #[allow(dead_code)] pub payer: Signer, } #[inline(always)] pub fn handle_go_on_ride( - _accounts: &mut CarnivalContext, + _accounts: &mut CarnivalAccountConstraints, name: &str, height: u32, ticket_count: u32, @@ -23,7 +23,7 @@ pub fn handle_go_on_ride( #[inline(always)] pub fn handle_play_game( - _accounts: &mut CarnivalContext, + _accounts: &mut CarnivalAccountConstraints, name: &str, ticket_count: u32, game_name: &str, @@ -33,7 +33,7 @@ pub fn handle_play_game( #[inline(always)] pub fn handle_eat_food( - _accounts: &mut CarnivalContext, + _accounts: &mut CarnivalAccountConstraints, name: &str, ticket_count: u32, food_stand_name: &str, diff --git a/basics/repository-layout/quasar/src/instructions/get_on_ride.rs b/basics/repository-layout/quasar/src/instructions/get_on_ride.rs index 66b85c5e..a817e63c 100644 --- a/basics/repository-layout/quasar/src/instructions/get_on_ride.rs +++ b/basics/repository-layout/quasar/src/instructions/get_on_ride.rs @@ -3,7 +3,7 @@ use quasar_lang::prelude::*; use crate::state::ride; /// Validate rider requirements and log the result. -/// Quasar's `log()` takes &str — no format! in no_std — so we use static +/// Quasar's `log()` takes &str - no format! in no_std - so we use static /// messages matching the Anchor version's logic without string interpolation. pub fn get_on_ride( _name: &str, diff --git a/basics/repository-layout/quasar/src/lib.rs b/basics/repository-layout/quasar/src/lib.rs index a60775c3..65c5c108 100644 --- a/basics/repository-layout/quasar/src/lib.rs +++ b/basics/repository-layout/quasar/src/lib.rs @@ -17,7 +17,7 @@ mod quasar_carnival { /// Ride a carnival ride. Validates height and ticket requirements. #[instruction(discriminator = 0)] pub fn go_on_ride( - ctx: Ctx, + ctx: Ctx, height: u32, ticket_count: u32, name: String<50>, @@ -29,7 +29,7 @@ mod quasar_carnival { /// Play a carnival game. Validates ticket requirements. #[instruction(discriminator = 1)] pub fn play_game( - ctx: Ctx, + ctx: Ctx, ticket_count: u32, name: String<50>, game_name: String<50>, @@ -40,7 +40,7 @@ mod quasar_carnival { /// Eat at a carnival food stand. Validates ticket requirements. #[instruction(discriminator = 2)] pub fn eat_food( - ctx: Ctx, + ctx: Ctx, ticket_count: u32, name: String<50>, food_stand_name: String<50>, diff --git a/basics/transfer-sol/README.md b/basics/transfer-sol/README.md index 9378106e..1d9da7b0 100644 --- a/basics/transfer-sol/README.md +++ b/basics/transfer-sol/README.md @@ -2,4 +2,4 @@ A simple example of transferring SOL between two system [accounts](https://solana.com/docs/terminology#account). SOL can be transferred between many kinds of accounts, not just system accounts (accounts owned by the System Program). -The tests generate a fresh keypair for both the `native` and `anchor` versions. Transferring SOL to the new keypair's address initializes it as a default system account — hence the `/// CHECK` annotation above it in the [Anchor](https://solana.com/docs/terminology#anchor) example. +The tests generate a fresh keypair for both the `native` and `anchor` versions. Transferring SOL to the new keypair's address initializes it as a default system account - hence the `/// CHECK` annotation above it in the [Anchor](https://solana.com/docs/terminology#anchor) example. diff --git a/basics/transfer-sol/anchor/Anchor.toml b/basics/transfer-sol/anchor/Anchor.toml index 7a4567ac..ebd9018e 100644 --- a/basics/transfer-sol/anchor/Anchor.toml +++ b/basics/transfer-sol/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] transfer_sol = "4fQVnLWKKKYxtxgGn7Haw8v2g2Hzbu8K61JvWKvqAi7W" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_cpi.rs b/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_cpi.rs index e9059286..4aa9ed0a 100644 --- a/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_cpi.rs +++ b/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_cpi.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_lang::system_program; #[derive(Accounts)] -pub struct TransferSolWithCpi<'info> { +pub struct TransferSolWithCpiAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, #[account(mut)] @@ -10,7 +10,7 @@ pub struct TransferSolWithCpi<'info> { system_program: Program<'info, System>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { +pub fn handler(context: Context, amount: u64) -> Result<()> { system_program::transfer( CpiContext::new( context.accounts.system_program.key(), diff --git a/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_program.rs b/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_program.rs index 3ac2dc00..03d98b73 100644 --- a/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_program.rs +++ b/basics/transfer-sol/anchor/programs/transfer-sol/src/instructions/transfer_sol_with_program.rs @@ -1,20 +1,44 @@ use anchor_lang::prelude::*; +#[error_code] +pub enum TransferSolError { + #[msg("The payer does not hold enough lamports for this transfer")] + InsufficientFunds, + #[msg("Adding the amount to the recipient balance would overflow a u64")] + AmountOverflow, +} + #[derive(Accounts)] -pub struct TransferSolWithProgram<'info> { +pub struct TransferSolWithProgramAccountConstraints<'info> { /// CHECK: Use owner constraint to check account is owned by our program #[account( mut, owner = crate::ID // value of declare_id!() )] payer: UncheckedAccount<'info>, + #[account(mut)] recipient: SystemAccount<'info>, } // Directly modifying lamports is only possible if the program is the owner of the account -pub fn handler(context: Context, amount: u64) -> Result<()> { - **context.accounts.payer.try_borrow_mut_lamports()? -= amount; - **context.accounts.recipient.try_borrow_mut_lamports()? += amount; +pub fn handler( + context: Context, + amount: u64, +) -> Result<()> { + let payer = &context.accounts.payer; + let recipient = &context.accounts.recipient; + + let new_payer_lamports = payer + .lamports() + .checked_sub(amount) + .ok_or(TransferSolError::InsufficientFunds)?; + let new_recipient_lamports = recipient + .lamports() + .checked_add(amount) + .ok_or(TransferSolError::AmountOverflow)?; + + **payer.try_borrow_mut_lamports()? = new_payer_lamports; + **recipient.try_borrow_mut_lamports()? = new_recipient_lamports; Ok(()) } diff --git a/basics/transfer-sol/anchor/programs/transfer-sol/src/lib.rs b/basics/transfer-sol/anchor/programs/transfer-sol/src/lib.rs index 733f346f..49f40cb3 100644 --- a/basics/transfer-sol/anchor/programs/transfer-sol/src/lib.rs +++ b/basics/transfer-sol/anchor/programs/transfer-sol/src/lib.rs @@ -9,12 +9,15 @@ declare_id!("4fQVnLWKKKYxtxgGn7Haw8v2g2Hzbu8K61JvWKvqAi7W"); pub mod transfer_sol { use super::*; - pub fn transfer_sol_with_cpi(context: Context, amount: u64) -> Result<()> { + pub fn transfer_sol_with_cpi( + context: Context, + amount: u64, + ) -> Result<()> { instructions::transfer_sol_with_cpi::handler(context, amount) } pub fn transfer_sol_with_program( - context: Context, + context: Context, amount: u64, ) -> Result<()> { instructions::transfer_sol_with_program::handler(context, amount) diff --git a/basics/transfer-sol/anchor/programs/transfer-sol/tests/test_transfer_sol.rs b/basics/transfer-sol/anchor/programs/transfer-sol/tests/test_transfer_sol.rs index e9482936..3afb8661 100644 --- a/basics/transfer-sol/anchor/programs/transfer-sol/tests/test_transfer_sol.rs +++ b/basics/transfer-sol/anchor/programs/transfer-sol/tests/test_transfer_sol.rs @@ -27,7 +27,7 @@ fn test_transfer_sol_with_cpi() { amount: LAMPORTS_PER_SOL, } .data(), - transfer_sol::accounts::TransferSolWithCpi { + transfer_sol::accounts::TransferSolWithCpiAccountConstraints { payer: payer.pubkey(), recipient: recipient.pubkey(), system_program: system_program::id(), @@ -77,7 +77,7 @@ fn test_transfer_sol_with_program() { amount: LAMPORTS_PER_SOL, } .data(), - transfer_sol::accounts::TransferSolWithProgram { + transfer_sol::accounts::TransferSolWithProgramAccountConstraints { payer: payer_account.pubkey(), recipient: recipient.pubkey(), } @@ -90,3 +90,57 @@ fn test_transfer_sol_with_program() { let recipient_balance = svm.get_balance(&recipient.pubkey()).unwrap(); assert_eq!(recipient_balance, LAMPORTS_PER_SOL); } + +#[test] +fn test_transfer_sol_with_program_rejects_insufficient_funds() { + let program_id = transfer_sol::id(); + let mut svm = LiteSVM::new(); + let bytes = include_bytes!("../../../target/deploy/transfer_sol.so"); + svm.add_program(program_id, bytes).unwrap(); + let payer = create_wallet(&mut svm, 10 * LAMPORTS_PER_SOL).unwrap(); + + // Create an account owned by our program holding 1 SOL. + let program_owned_account = Keypair::new(); + let create_account_ix = anchor_lang::solana_program::system_instruction::create_account( + &payer.pubkey(), + &program_owned_account.pubkey(), + LAMPORTS_PER_SOL, + 0, + &program_id, + ); + send_transaction_from_instructions( + &mut svm, + vec![create_account_ix], + &[&payer, &program_owned_account], + &payer.pubkey(), + ) + .unwrap(); + + // Ask for more than the account holds: the checked subtraction must + // reject the transfer instead of wrapping. + svm.expire_blockhash(); + let recipient = Keypair::new(); + let instruction = Instruction::new_with_bytes( + program_id, + &transfer_sol::instruction::TransferSolWithProgram { + amount: 2 * LAMPORTS_PER_SOL, + } + .data(), + transfer_sol::accounts::TransferSolWithProgramAccountConstraints { + payer: program_owned_account.pubkey(), + recipient: recipient.pubkey(), + } + .to_account_metas(None), + ); + + let result = + send_transaction_from_instructions(&mut svm, vec![instruction], &[&payer], &payer.pubkey()); + assert!(result.is_err(), "overdrawing the payer must fail"); + + // Balances are untouched. + assert_eq!( + svm.get_balance(&program_owned_account.pubkey()).unwrap(), + LAMPORTS_PER_SOL + ); + assert_eq!(svm.get_balance(&recipient.pubkey()).unwrap_or(0), 0); +} diff --git a/basics/transfer-sol/native/program/src/instruction.rs b/basics/transfer-sol/native/program/src/instruction.rs index 70f2d515..cf6db650 100644 --- a/basics/transfer-sol/native/program/src/instruction.rs +++ b/basics/transfer-sol/native/program/src/instruction.rs @@ -2,6 +2,7 @@ use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, program::invoke, + program_error::ProgramError, pubkey::Pubkey, }; @@ -28,8 +29,19 @@ pub fn transfer_sol_with_program( let payer = next_account_info(accounts_iter)?; let recipient = next_account_info(accounts_iter)?; - **payer.try_borrow_mut_lamports()? -= amount; - **recipient.try_borrow_mut_lamports()? += amount; + // Checked math: reject an overdraw or a balance overflow instead of + // silently wrapping. + let new_payer_lamports = payer + .lamports() + .checked_sub(amount) + .ok_or(ProgramError::InsufficientFunds)?; + let new_recipient_lamports = recipient + .lamports() + .checked_add(amount) + .ok_or(ProgramError::ArithmeticOverflow)?; + + **payer.try_borrow_mut_lamports()? = new_payer_lamports; + **recipient.try_borrow_mut_lamports()? = new_recipient_lamports; Ok(()) } diff --git a/basics/transfer-sol/native/program/tests/test.rs b/basics/transfer-sol/native/program/tests/test.rs index 09e4abc1..1af39a39 100644 --- a/basics/transfer-sol/native/program/tests/test.rs +++ b/basics/transfer-sol/native/program/tests/test.rs @@ -91,3 +91,62 @@ fn test_transfer_sol() { assert!(svm.send_transaction(tx).is_ok()); } + +#[test] +fn test_program_transfer_rejects_insufficient_funds() { + let mut svm = LiteSVM::new(); + + let program_id = Pubkey::new_unique(); + let program_bytes = include_bytes!("../../tests/fixtures/transfer_sol_program.so"); + svm.add_program(program_id, program_bytes).unwrap(); + + let payer = Keypair::new(); + svm.airdrop(&payer.pubkey(), LAMPORTS_PER_SOL * 10).unwrap(); + + // A program-owned account holding 1 SOL. + let program_owned_account = Keypair::new(); + let recipient = Keypair::new(); + let create_ix = create_account( + &payer.pubkey(), + &program_owned_account.pubkey(), + LAMPORTS_PER_SOL, + 0, + &program_id, + ); + let tx = Transaction::new_signed_with_payer( + &[create_ix], + Some(&payer.pubkey()), + &[&payer, &program_owned_account], + svm.latest_blockhash(), + ); + assert!(svm.send_transaction(tx).is_ok()); + + // Ask for more than the account holds: the checked subtraction must + // reject the transfer instead of wrapping. + let data = borsh::to_vec(&TransferInstruction::ProgramTransfer(2 * LAMPORTS_PER_SOL)).unwrap(); + let ix = Instruction { + program_id, + accounts: vec![ + AccountMeta::new(program_owned_account.pubkey(), false), + AccountMeta::new(recipient.pubkey(), false), + ], + data, + }; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), + ); + assert!( + svm.send_transaction(tx).is_err(), + "overdrawing the payer must fail" + ); + + // Balances are untouched. + assert_eq!( + svm.get_balance(&program_owned_account.pubkey()).unwrap(), + LAMPORTS_PER_SOL + ); + assert_eq!(svm.get_balance(&recipient.pubkey()).unwrap_or(0), 0); +} diff --git a/basics/transfer-sol/native/tests/fixtures/transfer_sol_program.so b/basics/transfer-sol/native/tests/fixtures/transfer_sol_program.so new file mode 100755 index 00000000..6d26be31 Binary files /dev/null and b/basics/transfer-sol/native/tests/fixtures/transfer_sol_program.so differ diff --git a/basics/transfer-sol/quasar/Cargo.toml b/basics/transfer-sol/quasar/Cargo.toml index f7a42e5f..069c8b8d 100644 --- a/basics/transfer-sol/quasar/Cargo.toml +++ b/basics/transfer-sol/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-transfer-sol" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/basics/transfer-sol/quasar/README.md b/basics/transfer-sol/quasar/README.md index 26ff05fb..b285cc07 100644 --- a/basics/transfer-sol/quasar/README.md +++ b/basics/transfer-sol/quasar/README.md @@ -8,6 +8,7 @@ See also: [Transfer Sol overview](../README.md) and the [repository catalog](../ - System transfer CPI - Signer-funded lamports +- Direct lamport moves (`transfer_sol_with_program`) require the payer to be owned by this program, enforced by an account constraint, with checked balance math ## Setup diff --git a/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_cpi.rs b/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_cpi.rs index 26d7ea62..03aae247 100644 --- a/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_cpi.rs +++ b/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_cpi.rs @@ -2,7 +2,7 @@ use quasar_lang::prelude::*; /// Accounts for transferring SOL via system program CPI. #[derive(Accounts)] -pub struct TransferSolWithCpi { +pub struct TransferSolWithCpiAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -11,7 +11,7 @@ pub struct TransferSolWithCpi { } #[inline(always)] -pub fn handle_transfer_sol_with_cpi(accounts: &mut TransferSolWithCpi, amount: u64) -> Result<(), ProgramError> { +pub fn handle_transfer_sol_with_cpi(accounts: &mut TransferSolWithCpiAccountConstraints, amount: u64) -> Result<(), ProgramError> { accounts.system_program .transfer(&accounts.payer, &accounts.recipient, amount) .invoke() diff --git a/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_program.rs b/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_program.rs index 14dc016e..b5781384 100644 --- a/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_program.rs +++ b/basics/transfer-sol/quasar/src/instructions/transfer_sol_with_program.rs @@ -1,20 +1,52 @@ use quasar_lang::prelude::*; +/// Errors for direct lamport transfers. Codes start at 6000, the same +/// offset Anchor uses for custom errors. +#[error_code] +pub enum TransferSolError { + /// The runtime only lets a program debit lamports from accounts it + /// owns, so a payer owned by anyone else must be rejected up front. + PayerNotOwnedByProgram = 6000, + /// The payer does not hold `amount` lamports. + InsufficientFunds, + /// Adding `amount` to the recipient balance would overflow a u64. + AmountOverflow, +} + /// Accounts for transferring SOL by directly manipulating lamports. -/// The payer account must be owned by this program for direct lamport access. +/// The `constraints(...)` check enforces that the payer is owned by this +/// program, mirroring the Anchor twin's `owner = crate::ID` constraint. #[derive(Accounts)] -pub struct TransferSolWithProgram { - #[account(mut)] +pub struct TransferSolWithProgramAccountConstraints { + #[account( + mut, + constraints(payer.to_account_view().owner() == &crate::ID) + @ TransferSolError::PayerNotOwnedByProgram + )] pub payer: UncheckedAccount, + #[account(mut)] pub recipient: UncheckedAccount, } #[inline(always)] -pub fn handle_transfer_sol_with_program(accounts: &mut TransferSolWithProgram, amount: u64) -> Result<(), ProgramError> { +pub fn handle_transfer_sol_with_program( + accounts: &mut TransferSolWithProgramAccountConstraints, + amount: u64, +) -> Result<(), ProgramError> { let payer_view = accounts.payer.to_account_view(); let recipient_view = accounts.recipient.to_account_view(); - set_lamports(payer_view, payer_view.lamports() - amount); - set_lamports(recipient_view, recipient_view.lamports() + amount); + + let new_payer_lamports = payer_view + .lamports() + .checked_sub(amount) + .ok_or(TransferSolError::InsufficientFunds)?; + let new_recipient_lamports = recipient_view + .lamports() + .checked_add(amount) + .ok_or(TransferSolError::AmountOverflow)?; + + set_lamports(payer_view, new_payer_lamports); + set_lamports(recipient_view, new_recipient_lamports); Ok(()) } diff --git a/basics/transfer-sol/quasar/src/lib.rs b/basics/transfer-sol/quasar/src/lib.rs index 6942be4d..ca73e5ad 100644 --- a/basics/transfer-sol/quasar/src/lib.rs +++ b/basics/transfer-sol/quasar/src/lib.rs @@ -7,7 +7,7 @@ use instructions::*; #[cfg(test)] mod tests; -declare_id!("G4eCqMUNnR2q7Ej9Ep2rURUM4gXdZ7RswqU9QPjgSGrz"); +declare_id!("4fQVnLWKKKYxtxgGn7Haw8v2g2Hzbu8K61JvWKvqAi7W"); #[program] mod quasar_transfer_sol { @@ -16,7 +16,7 @@ mod quasar_transfer_sol { /// Transfer SOL from payer to recipient via system program CPI. #[instruction(discriminator = 0)] pub fn transfer_sol_with_cpi( - ctx: Ctx, + ctx: Ctx, amount: u64, ) -> Result<(), ProgramError> { instructions::handle_transfer_sol_with_cpi(&mut ctx.accounts, amount) @@ -26,7 +26,7 @@ mod quasar_transfer_sol { /// The payer account must be owned by this program. #[instruction(discriminator = 1)] pub fn transfer_sol_with_program( - ctx: Ctx, + ctx: Ctx, amount: u64, ) -> Result<(), ProgramError> { instructions::handle_transfer_sol_with_program(&mut ctx.accounts, amount) diff --git a/basics/transfer-sol/quasar/src/tests.rs b/basics/transfer-sol/quasar/src/tests.rs index 59f1486f..bdeb9c65 100644 --- a/basics/transfer-sol/quasar/src/tests.rs +++ b/basics/transfer-sol/quasar/src/tests.rs @@ -1,6 +1,7 @@ use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; use solana_address::Address; +use crate::instructions::TransferSolError; use quasar_transfer_sol_client::{ TransferSolWithCpiInstruction, TransferSolWithProgramInstruction, }; @@ -102,3 +103,76 @@ fn test_transfer_sol_with_program() { let recipient_after = result.account(&recipient).unwrap(); assert_eq!(recipient_after.lamports, 1_000_000_000 + amount); } + +#[test] +fn test_transfer_sol_with_program_rejects_foreign_owned_payer() { + let mut svm = setup(); + + let payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let amount = 500_000_000; // 0.5 SOL + + // The payer is owned by the system program, not this program, so the + // owner constraint must reject the transfer before any lamports move. + let payer_account = system_account(payer, 2_000_000_000); + let recipient_account = Account { + address: recipient, + lamports: 1_000_000_000, + data: vec![], + owner: Pubkey::from(crate::ID), + executable: false, + }; + + let instruction: Instruction = TransferSolWithProgramInstruction { + payer: Address::from(payer.to_bytes()), + recipient: Address::from(recipient.to_bytes()), + amount, + } + .into(); + + let result = svm.process_instruction(&instruction, &[payer_account, recipient_account]); + result.assert_error(quasar_svm::ProgramError::Custom( + TransferSolError::PayerNotOwnedByProgram as u32, + )); +} + +#[test] +fn test_transfer_sol_with_program_rejects_insufficient_funds() { + let mut svm = setup(); + + let payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let payer_lamports = 100_000_000; // 0.1 SOL + let amount = 500_000_000; // 0.5 SOL, more than the payer holds + + let payer_account = Account { + address: payer, + lamports: payer_lamports, + data: vec![], + owner: Pubkey::from(crate::ID), + executable: false, + }; + let recipient_account = Account { + address: recipient, + lamports: 1_000_000_000, + data: vec![], + owner: Pubkey::from(crate::ID), + executable: false, + }; + + let instruction: Instruction = TransferSolWithProgramInstruction { + payer: Address::from(payer.to_bytes()), + recipient: Address::from(recipient.to_bytes()), + amount, + } + .into(); + + let result = svm.process_instruction(&instruction, &[payer_account, recipient_account]); + result.assert_error(quasar_svm::ProgramError::Custom( + TransferSolError::InsufficientFunds as u32, + )); + + // No lamports moved. + let payer_after = result.account(&payer).unwrap(); + assert_eq!(payer_after.lamports, payer_lamports); +} diff --git a/compression/cnft-burn/anchor/Anchor.toml b/compression/cnft-burn/anchor/Anchor.toml index 94dbfdfb..a9b147ae 100644 --- a/compression/cnft-burn/anchor/Anchor.toml +++ b/compression/cnft-burn/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] cnft_burn = "C6qxH8n6mZxrrbtMtYWYSp8JR8vkQ55X1o4EBg7twnMv" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/compression/cnft-burn/anchor/README.md b/compression/cnft-burn/anchor/README.md index 715afc8d..d0fabdd4 100644 --- a/compression/cnft-burn/anchor/README.md +++ b/compression/cnft-burn/anchor/README.md @@ -4,14 +4,21 @@ An [Anchor](https://solana.com/docs/terminology#anchor) [program](https://solana ## Components -- `programs/cnft-burn/` — the Anchor program. -- `migrations/` — deployment script. +- `programs/cnft-burn/` - the Anchor program. +- `migrations/` - deployment script. -There is no `tests/` directory in this example today. The program is intended to be deployed and exercised against a real cluster. +## Testing + +A Rust [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) integration suite lives in `programs/cnft-burn/tests/`. It loads mainnet-dumped fixture binaries for Bubblegum, SPL Account Compression, and SPL Noop from `tests/fixtures/` (see the README there), so the CPIs run against the real programs in-process. + +```bash +cargo build-sbf +cargo test +``` ## Deployment -The program ID declared in [`programs/cnft-burn/src/lib.rs`](programs/cnft-burn/src/lib.rs) is `C6qxH8n6mZxrrbtMtYWYSp8JR8vkQ55X1o4EBg7twnMv`. Whether this address is currently deployed on any cluster is not tracked in this repo — verify with `solana program show ` against the cluster you care about. +The program ID declared in [`programs/cnft-burn/src/lib.rs`](programs/cnft-burn/src/lib.rs) is `C6qxH8n6mZxrrbtMtYWYSp8JR8vkQ55X1o4EBg7twnMv`. Whether this address is currently deployed on any cluster is not tracked in this repo - verify with `solana program show ` against the cluster you care about. To deploy your own copy, change the program ID in `lib.rs` and `Anchor.toml`, then run `anchor build && anchor deploy`. diff --git a/compression/cnft-burn/anchor/migrations/deploy.ts b/compression/cnft-burn/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/compression/cnft-burn/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/compression/cnft-burn/anchor/programs/cnft-burn/src/instructions/burn_cnft.rs b/compression/cnft-burn/anchor/programs/cnft-burn/src/instructions/burn_cnft.rs new file mode 100644 index 00000000..595a81da --- /dev/null +++ b/compression/cnft-burn/anchor/programs/cnft-burn/src/instructions/burn_cnft.rs @@ -0,0 +1,127 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{ + instruction::{AccountMeta, Instruction}, + program::invoke, +}; +use borsh::BorshSerialize; + +use crate::{MPL_BUBBLEGUM_ID, SPLCompression}; + +/// Burn instruction discriminator from mpl-bubblegum +const BURN_DISCRIMINATOR: [u8; 8] = [116, 110, 29, 56, 107, 219, 42, 93]; + +/// Instruction arguments for mpl-bubblegum Burn, serialized with borsh +#[derive(BorshSerialize)] +struct BurnArgs { + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, +} + +#[derive(Accounts)] +pub struct BurnCnftAccountConstraints<'info> { + #[account(mut)] + pub leaf_owner: Signer<'info>, + #[account(mut)] + #[account( + seeds = [merkle_tree.key().as_ref()], + bump, + seeds::program = bubblegum_program.key() + )] + /// CHECK: This account is modified in the downstream program + pub tree_authority: UncheckedAccount<'info>, + #[account(mut)] + /// CHECK: Written by the Bubblegum/Account Compression CPI (the burn + /// replaces the leaf and updates the tree root); validated downstream + /// by those programs. + pub merkle_tree: UncheckedAccount<'info>, + /// CHECK: This account is neither written to nor read from. + pub log_wrapper: UncheckedAccount<'info>, + pub compression_program: Program<'info, SPLCompression>, + // Pin the bubblegum program account to the known mpl-bubblegum id. Without + // this constraint the caller could pass any account and a malicious one + // could short-circuit the CPI in unexpected ways. + /// CHECK: address constrained to the mpl-bubblegum program id. + #[account(address = MPL_BUBBLEGUM_ID)] + pub bubblegum_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, +} + +pub fn handle_burn_cnft<'info>( + context: Context<'info, BurnCnftAccountConstraints<'info>>, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, +) -> Result<()> { + // Build instruction data: discriminator + borsh-serialized args + let args = BurnArgs { + root, + data_hash, + creator_hash, + nonce, + index, + }; + let mut data = BURN_DISCRIMINATOR.to_vec(); + args.serialize(&mut data)?; + + // Build account metas matching mpl-bubblegum Burn instruction layout + let mut accounts = Vec::with_capacity(7 + context.remaining_accounts.len()); + accounts.push(AccountMeta::new_readonly( + context.accounts.tree_authority.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.leaf_owner.key(), + true, + )); + // leaf_delegate = leaf_owner, not a signer in this call + accounts.push(AccountMeta::new_readonly( + context.accounts.leaf_owner.key(), + false, + )); + accounts.push(AccountMeta::new(context.accounts.merkle_tree.key(), false)); + accounts.push(AccountMeta::new_readonly( + context.accounts.log_wrapper.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.compression_program.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.system_program.key(), + false, + )); + // Append remaining accounts (proof nodes) + for acc in context.remaining_accounts.iter() { + accounts.push(AccountMeta::new_readonly(acc.key(), false)); + } + + let instruction = Instruction { + program_id: MPL_BUBBLEGUM_ID, + accounts, + data, + }; + + // Gather all account infos for the CPI + let mut account_infos = vec![ + context.accounts.bubblegum_program.to_account_info(), + context.accounts.tree_authority.to_account_info(), + context.accounts.leaf_owner.to_account_info(), + context.accounts.merkle_tree.to_account_info(), + context.accounts.log_wrapper.to_account_info(), + context.accounts.compression_program.to_account_info(), + context.accounts.system_program.to_account_info(), + ]; + for acc in context.remaining_accounts.iter() { + account_infos.push(acc.to_account_info()); + } + + invoke(&instruction, &account_infos)?; + + Ok(()) +} diff --git a/compression/cnft-burn/anchor/programs/cnft-burn/src/instructions/mod.rs b/compression/cnft-burn/anchor/programs/cnft-burn/src/instructions/mod.rs new file mode 100644 index 00000000..cd32a282 --- /dev/null +++ b/compression/cnft-burn/anchor/programs/cnft-burn/src/instructions/mod.rs @@ -0,0 +1,3 @@ +pub mod burn_cnft; + +pub use burn_cnft::*; diff --git a/compression/cnft-burn/anchor/programs/cnft-burn/src/lib.rs b/compression/cnft-burn/anchor/programs/cnft-burn/src/lib.rs index 94dc864b..7bd40193 100644 --- a/compression/cnft-burn/anchor/programs/cnft-burn/src/lib.rs +++ b/compression/cnft-burn/anchor/programs/cnft-burn/src/lib.rs @@ -4,38 +4,18 @@ #![allow(clippy::diverging_sub_expression)] use anchor_lang::prelude::*; -use anchor_lang::solana_program::{ - instruction::{AccountMeta, Instruction}, - program::invoke, -}; -use borsh::BorshSerialize; -declare_id!("C6qxH8n6mZxrrbtMtYWYSp8JR8vkQ55X1o4EBg7twnMv"); - -/// mpl-bubblegum program ID (BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY) -const MPL_BUBBLEGUM_ID: Pubkey = Pubkey::new_from_array([ - 0x98, 0x8b, 0x80, 0xeb, 0x79, 0x35, 0x28, 0x69, 0xb2, 0x24, 0x74, 0x5f, 0x59, 0xdd, 0xbf, 0x8a, - 0x26, 0x58, 0xca, 0x13, 0xdc, 0x68, 0x81, 0x21, 0x26, 0x35, 0x1c, 0xae, 0x07, 0xc1, 0xa5, 0xa5, -]); +pub mod instructions; +use instructions::*; -/// SPL Account Compression program ID (cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK) -const SPL_ACCOUNT_COMPRESSION_ID: Pubkey = Pubkey::new_from_array([ - 0x09, 0x2a, 0x13, 0xee, 0x95, 0xc4, 0x1c, 0xba, 0x08, 0xa6, 0x7f, 0x5a, 0xc6, 0x7e, 0x8d, 0xf7, - 0xe1, 0xda, 0x11, 0x62, 0x5e, 0x1d, 0x64, 0x13, 0x7f, 0x8f, 0x4f, 0x23, 0x83, 0x03, 0x7f, 0x14, -]); +declare_id!("C6qxH8n6mZxrrbtMtYWYSp8JR8vkQ55X1o4EBg7twnMv"); -/// Burn instruction discriminator from mpl-bubblegum -const BURN_DISCRIMINATOR: [u8; 8] = [116, 110, 29, 56, 107, 219, 42, 93]; +/// mpl-bubblegum program ID +pub const MPL_BUBBLEGUM_ID: Pubkey = pubkey!("BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY"); -/// Instruction arguments for mpl-bubblegum Burn, serialized with borsh -#[derive(BorshSerialize)] -struct BurnArgs { - root: [u8; 32], - data_hash: [u8; 32], - creator_hash: [u8; 32], - nonce: u64, - index: u32, -} +/// SPL Account Compression program ID +pub const SPL_ACCOUNT_COMPRESSION_ID: Pubkey = + pubkey!("cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK"); #[derive(Clone)] pub struct SPLCompression; @@ -51,106 +31,13 @@ pub mod cnft_burn { use super::*; pub fn burn_cnft<'info>( - context: Context<'info, BurnCnft<'info>>, + context: Context<'info, BurnCnftAccountConstraints<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { - // Build instruction data: discriminator + borsh-serialized args - let args = BurnArgs { - root, - data_hash, - creator_hash, - nonce, - index, - }; - let mut data = BURN_DISCRIMINATOR.to_vec(); - args.serialize(&mut data)?; - - // Build account metas matching mpl-bubblegum Burn instruction layout - let mut accounts = Vec::with_capacity(7 + context.remaining_accounts.len()); - accounts.push(AccountMeta::new_readonly( - context.accounts.tree_authority.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.leaf_owner.key(), - true, - )); - // leaf_delegate = leaf_owner, not a signer in this call - accounts.push(AccountMeta::new_readonly( - context.accounts.leaf_owner.key(), - false, - )); - accounts.push(AccountMeta::new(context.accounts.merkle_tree.key(), false)); - accounts.push(AccountMeta::new_readonly( - context.accounts.log_wrapper.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.compression_program.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.system_program.key(), - false, - )); - // Append remaining accounts (proof nodes) - for acc in context.remaining_accounts.iter() { - accounts.push(AccountMeta::new_readonly(acc.key(), false)); - } - - let instruction = Instruction { - program_id: MPL_BUBBLEGUM_ID, - accounts, - data, - }; - - // Gather all account infos for the CPI - let mut account_infos = vec![ - context.accounts.bubblegum_program.to_account_info(), - context.accounts.tree_authority.to_account_info(), - context.accounts.leaf_owner.to_account_info(), - context.accounts.merkle_tree.to_account_info(), - context.accounts.log_wrapper.to_account_info(), - context.accounts.compression_program.to_account_info(), - context.accounts.system_program.to_account_info(), - ]; - for acc in context.remaining_accounts.iter() { - account_infos.push(acc.to_account_info()); - } - - invoke(&instruction, &account_infos)?; - - Ok(()) + instructions::burn_cnft::handle_burn_cnft(context, root, data_hash, creator_hash, nonce, index) } } - -#[derive(Accounts)] -pub struct BurnCnft<'info> { - #[account(mut)] - pub leaf_owner: Signer<'info>, - #[account(mut)] - #[account( - seeds = [merkle_tree.key().as_ref()], - bump, - seeds::program = bubblegum_program.key() - )] - /// CHECK: This account is modified in the downstream program - pub tree_authority: UncheckedAccount<'info>, - #[account(mut)] - /// CHECK: This account is neither written to nor read from. - pub merkle_tree: UncheckedAccount<'info>, - /// CHECK: This account is neither written to nor read from. - pub log_wrapper: UncheckedAccount<'info>, - pub compression_program: Program<'info, SPLCompression>, - // Pin the bubblegum program account to the known mpl-bubblegum id. Without - // this constraint the caller could pass any account and a malicious one - // could short-circuit the CPI in unexpected ways. - /// CHECK: address constrained to the mpl-bubblegum program id. - #[account(address = MPL_BUBBLEGUM_ID)] - pub bubblegum_program: UncheckedAccount<'info>, - pub system_program: Program<'info, System>, -} diff --git a/compression/cnft-burn/anchor/programs/cnft-burn/tests/test_burn.rs b/compression/cnft-burn/anchor/programs/cnft-burn/tests/test_burn.rs index 21abbcbf..a2ece44d 100644 --- a/compression/cnft-burn/anchor/programs/cnft-burn/tests/test_burn.rs +++ b/compression/cnft-burn/anchor/programs/cnft-burn/tests/test_burn.rs @@ -8,7 +8,7 @@ //! 3. Mint a single cNFT to `leaf_owner` via `mint_v1`. //! 4. Recompute `data_hash` / `creator_hash` exactly as Bubblegum does. //! 5. Build the Merkle proof for leaf 0 (all empty-node siblings) and read -//! the current root from the on-chain tree account. +//! the current root from the onchain tree account. //! 6. Call our program's `burn_cnft`, signed by `leaf_owner`, and assert the //! transaction succeeds and a second burn fails (leaf already zeroed). @@ -75,8 +75,8 @@ struct MetadataArgs { is_mutable: bool, edition_nonce: Option, token_standard: Option, // TokenStandard enum, encoded by variant index - collection: Option, // None — Collection, kept absent - uses: Option, // None — Uses, kept absent + collection: Option, // None - Collection, kept absent + uses: Option, // None - Uses, kept absent token_program_version: TokenProgramVersion, creators: Vec, } @@ -125,7 +125,7 @@ fn burn_cnft_disc() -> [u8; 8] { out } -// Minimal SHA-256 (FIPS 180-4) — only used to derive the Anchor discriminator. +// Minimal SHA-256 (FIPS 180-4) - only used to derive the Anchor discriminator. fn sha256(input: &[u8]) -> [u8; 32] { const K: [u32; 64] = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, @@ -392,7 +392,7 @@ fn test_burn_cnft() { // Proof for leaf index 0 in an otherwise-empty tree: empty-node siblings. let proof = [empty_node(0), empty_node(1), empty_node(2)]; - // Read the current root from the on-chain tree account. + // Read the current root from the onchain tree account. let tree_data = svm.get_account(&merkle_tree.pubkey()).unwrap().data; let root = read_current_root(&tree_data); diff --git a/compression/cnft-burn/anchor/tests/fixtures/README.md b/compression/cnft-burn/anchor/tests/fixtures/README.md index 6e1ab7d3..a10fd95b 100644 --- a/compression/cnft-burn/anchor/tests/fixtures/README.md +++ b/compression/cnft-burn/anchor/tests/fixtures/README.md @@ -1,9 +1,9 @@ -# Test fixtures — mainnet program binaries +# Test fixtures - mainnet program binaries -These `.so` files are the compiled on-chain programs the cNFT-burn test CPIs +These `.so` files are the compiled onchain programs the cNFT-burn test CPIs into, dumped from Solana **mainnet-beta** so [LiteSVM](https://github.com/LiteSVM/litesvm) -can load them locally (LiteSVM only bundles System/Token/Token-2022/ATA). They -are the real programs — not modified — so accounts they create/verify behave +can load them locally (LiteSVM only bundles System/Token/Token Extensions/ATA). They +are the real programs - not modified - so accounts they create/verify behave exactly as on mainnet. | File | Program | Program ID | Source | Dumped (UTC) | Slot | diff --git a/compression/cnft-burn/quasar/Cargo.toml b/compression/cnft-burn/quasar/Cargo.toml index 497e22f2..b4d5fb98 100644 --- a/compression/cnft-burn/quasar/Cargo.toml +++ b/compression/cnft-burn/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-cnft-burn" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] @@ -23,7 +23,7 @@ debug = [] [dependencies] quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } -# Direct dependency for invoke_with_bounds — needed for raw CPI with variable +# Direct dependency for invoke_with_bounds - needed for raw CPI with variable # proof accounts. quasar-lang re-exports types but not the invoke functions. solana-instruction-view = { version = "2", features = ["cpi"] } solana-instruction = { version = "3.2.0" } diff --git a/compression/cnft-burn/quasar/README.md b/compression/cnft-burn/quasar/README.md index 97bd2e3a..edcc0506 100644 --- a/compression/cnft-burn/quasar/README.md +++ b/compression/cnft-burn/quasar/README.md @@ -21,13 +21,9 @@ Prerequisites: [Quasar](https://quasar-lang.com/docs) CLI and [Agave](https://do ## Testing -In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): +This variant has no automated test suite yet: the instruction handlers CPI into external programs (Bubblegum, SPL Account Compression) and a QuasarSVM harness that loads those fixture binaries has not been written. `quasar build` verifies the program and CPI construction compile. -```bash -cargo test -``` - -Tests invoke instruction handlers and assert onchain state. No local validator. +The Anchor twin at `../anchor/` has a full LiteSVM integration suite that exercises the same flows against mainnet-dumped fixture programs; use it as the behavioural reference. ## Usage diff --git a/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs b/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs index 43c878d1..ae23e28f 100644 --- a/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs +++ b/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs @@ -10,7 +10,7 @@ const MAX_CPI_ACCOUNTS: usize = 7 + MAX_PROOF_NODES; /// Accounts for burning a compressed NFT via mpl-bubblegum CPI. #[derive(Accounts)] -pub struct BurnCnft { +pub struct BurnCnftAccountConstraints { #[account(mut)] pub leaf_owner: Signer, /// Tree authority PDA (seeds checked by Bubblegum). @@ -30,7 +30,7 @@ pub struct BurnCnft { pub system_program: Program, } -pub fn handle_burn_cnft(accounts: &mut BurnCnft, data: &[u8], remaining: RemainingAccounts<'_>) -> Result<(), ProgramError> { +pub fn handle_burn_cnft(accounts: &mut BurnCnftAccountConstraints, data: &[u8], remaining: RemainingAccounts<'_>) -> Result<(), ProgramError> { // Parse instruction args from raw data: // root(32) + data_hash(32) + creator_hash(32) + nonce(8) + index(4) = 108 bytes if data.len() < 108 { @@ -47,7 +47,7 @@ pub fn handle_burn_cnft(accounts: &mut BurnCnft, data: &[u8], remaining: Remaini // // `remaining.iter()` yields `Result` in newer // quasar-lang. Reach the inner `AccountView` via the unchecked accessor - // — this CPI only reads proof addresses and views, never touching the + // - this CPI only reads proof addresses and views, never touching the // accounts' data, so the aliasing/borrow invariants are upheld. let placeholder = accounts.system_program.to_account_view().clone(); let mut proof_views: [AccountView; MAX_PROOF_NODES] = diff --git a/compression/cnft-burn/quasar/src/lib.rs b/compression/cnft-burn/quasar/src/lib.rs index f2427eef..980d7095 100644 --- a/compression/cnft-burn/quasar/src/lib.rs +++ b/compression/cnft-burn/quasar/src/lib.rs @@ -31,7 +31,7 @@ mod quasar_cnft_burn { use super::*; #[instruction(discriminator = 0)] - pub fn burn_cnft(ctx: CtxWithRemaining) -> Result<(), ProgramError> { + pub fn burn_cnft(ctx: CtxWithRemaining) -> Result<(), ProgramError> { let data = ctx.data; let remaining = ctx.remaining_accounts(); instructions::handle_burn_cnft(&mut ctx.accounts, data, remaining) diff --git a/compression/cnft-burn/quasar/src/tests.rs b/compression/cnft-burn/quasar/src/tests.rs index 83435d50..0f15d10c 100644 --- a/compression/cnft-burn/quasar/src/tests.rs +++ b/compression/cnft-burn/quasar/src/tests.rs @@ -1,3 +1,5 @@ -// Compressed NFT operations require external programs (Bubblegum, SPL Account -// Compression) that are not available in the quasar-svm test harness. The build -// itself verifies the CPI instruction construction compiles correctly. +// No tests yet: the instruction handlers CPI into external programs +// (Bubblegum, SPL Account Compression) and a QuasarSVM harness that loads +// those fixture binaries has not been written. The Anchor twin's LiteSVM +// suite covers the same flows. TODO: port that suite to QuasarSVM using +// the fixture .so files under ../anchor/tests/fixtures/. diff --git a/compression/cnft-vault/anchor/Anchor.toml b/compression/cnft-vault/anchor/Anchor.toml index bfb853e3..13d4b4e8 100644 --- a/compression/cnft-vault/anchor/Anchor.toml +++ b/compression/cnft-vault/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] cnft_vault = "Fd4iwpPWaCU8BNwGQGtvvrcvG4Tfizq3RgLm8YLBJX6D" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/compression/cnft-vault/anchor/README.md b/compression/cnft-vault/anchor/README.md index 687cb0a3..0bf60022 100644 --- a/compression/cnft-vault/anchor/README.md +++ b/compression/cnft-vault/anchor/README.md @@ -2,30 +2,42 @@ Example code for working with Metaplex compressed NFTs (cNFTs) inside Solana [Anchor](https://solana.com/docs/terminology#anchor) [programs](https://solana.com/docs/terminology#program). -The program keeps a PDA-owned vault. You send cNFTs to the vault, then withdraw them via the program's [instruction handlers](https://solana.com/docs/terminology#instruction-handler). +The program keeps a PDA-owned vault. You send cNFTs to the vault, then the vault authority withdraws them via the program's [instruction handlers](https://solana.com/docs/terminology#instruction-handler). -Two handlers: +## Authority model -- A simple transfer that withdraws one cNFT. -- A withdraw that handles two cNFTs in a single transaction. +Deposits are plain Bubblegum transfers to the **vault PDA** (seeds `["cNFT-vault"]`); no program instruction runs on deposit. Because of that, withdraw authorization is per-vault, not per-deposit: `initialize_vault` creates the vault PDA as a `Vault` state account and stores the signer as its **authority**. Both withdraw handlers require that stored authority as a `Signer` (`has_one = authority`) and reject any other signer with `VaultError::InvalidWithdrawAuthority` before the Bubblegum CPI runs. The same PDA doubles as the Bubblegum leaf owner and signs the transfer CPIs via `invoke_signed`. + +Three handlers: + +- `initialize_vault` - creates the vault PDA and stores the withdraw authority. +- `withdraw_cnft` - withdraws one cNFT to a recipient chosen by the authority. +- `withdraw_two_cnfts` - withdraws two cNFTs (possibly from different trees) in a single transaction. The client passes `proof_1_length` and `proof_2_length` to split the proof accounts between the two Bubblegum transfers; the handler rejects lengths that do not add up to the supplied proof accounts with `VaultError::ProofLengthMismatch`. Use this as a reference for working with cNFTs in your own programs. ## Components -- `programs/cnft-vault/` — the Anchor program. +- `programs/cnft-vault/` - the Anchor program. + +## Testing + +A Rust [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) integration suite lives in `programs/cnft-vault/tests/`. It loads mainnet-dumped fixture binaries for Bubblegum, SPL Account Compression, and SPL Noop from `tests/fixtures/` (see the README there), so the CPIs run against the real programs in-process. The suite covers authority withdraws (single and two-cNFT), rejection of non-authority signers, stale-root replays, and out-of-range proof lengths. -There is no `tests/` directory in this example today. The program is intended to be deployed and exercised against a real cluster. +```bash +cargo build-sbf +cargo test +``` ## Deployment -The program ID declared in [`programs/cnft-vault/src/lib.rs`](programs/cnft-vault/src/lib.rs) is `Fd4iwpPWaCU8BNwGQGtvvrcvG4Tfizq3RgLm8YLBJX6D`. Whether this address is currently deployed on any cluster is not tracked in this repo — verify with `solana program show ` against the cluster you care about. +The program ID declared in [`programs/cnft-vault/src/lib.rs`](programs/cnft-vault/src/lib.rs) is `Fd4iwpPWaCU8BNwGQGtvvrcvG4Tfizq3RgLm8YLBJX6D`. Whether this address is currently deployed on any cluster is not tracked in this repo - verify with `solana program show ` against the cluster you care about. To deploy your own copy, change the program ID in `lib.rs` and `Anchor.toml`, then run `anchor build && anchor deploy`. ## Limitations -This is a reference implementation. There's no authorization on withdraws — anyone can withdraw any cNFT in the vault. It's not optimized for compute either. Treat it as a proof of concept. +This is a reference implementation and is not optimized for compute. The vault is global to the program deployment: there is one vault PDA with one authority, so anyone who deposits a cNFT is entrusting it to that authority. ## Further resources diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/error.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/error.rs new file mode 100644 index 00000000..a349bac1 --- /dev/null +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/error.rs @@ -0,0 +1,9 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum VaultError { + #[msg("Only the vault authority may withdraw cNFTs from the vault")] + InvalidWithdrawAuthority, + #[msg("proof_1_length + proof_2_length must equal the number of proof accounts supplied")] + ProofLengthMismatch, +} diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/initialize_vault.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/initialize_vault.rs new file mode 100644 index 00000000..58cf3389 --- /dev/null +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/initialize_vault.rs @@ -0,0 +1,27 @@ +use anchor_lang::prelude::*; + +use crate::state::{Vault, VAULT_SEED}; + +#[derive(Accounts)] +pub struct InitializeVaultAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + init, + payer = authority, + space = Vault::DISCRIMINATOR.len() + Vault::INIT_SPACE, + seeds = [VAULT_SEED], + bump, + )] + pub vault: Account<'info, Vault>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(context: Context) -> Result<()> { + let vault = &mut context.accounts.vault; + vault.authority = context.accounts.authority.key(); + vault.bump = context.bumps.vault; + Ok(()) +} diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/mod.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/mod.rs index 895527e3..435b40c7 100644 --- a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/mod.rs +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/mod.rs @@ -1,5 +1,7 @@ +pub mod initialize_vault; pub mod withdraw_cnft; pub mod withdraw_two_cnfts; +pub use initialize_vault::*; pub use withdraw_cnft::*; pub use withdraw_two_cnfts::*; diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_cnft.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_cnft.rs index d85dbe1e..79dfaed9 100644 --- a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_cnft.rs +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_cnft.rs @@ -1,10 +1,24 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::{instruction::AccountMeta, program::invoke_signed}; +use crate::error::VaultError; +use crate::state::{Vault, VAULT_SEED}; use crate::{build_transfer_instruction, SPLCompression, TransferArgs, MPL_BUBBLEGUM_ID}; #[derive(Accounts)] -pub struct Withdraw<'info> { +pub struct WithdrawCnftAccountConstraints<'info> { + /// The stored vault authority. Only this signer may withdraw. + pub authority: Signer<'info>, + + // The vault PDA owns the cNFTs (as Bubblegum leaf owner) and signs the + // transfer CPI via invoke_signed. + #[account( + seeds = [VAULT_SEED], + bump = vault.bump, + has_one = authority @ VaultError::InvalidWithdrawAuthority, + )] + pub vault: Account<'info, Vault>, + #[account(mut)] #[account( seeds = [merkle_tree.key().as_ref()], @@ -13,30 +27,30 @@ pub struct Withdraw<'info> { )] /// CHECK: This account is modified in the downstream program pub tree_authority: UncheckedAccount<'info>, - #[account( - seeds = [b"cNFT-vault"], - bump, - )] - /// CHECK: This account doesnt even exist (it is just the pda to sign) - pub leaf_owner: UncheckedAccount<'info>, + /// CHECK: This account is neither written to nor read from. pub new_leaf_owner: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account is modified in the downstream program pub merkle_tree: UncheckedAccount<'info>, + /// CHECK: This account is neither written to nor read from. pub log_wrapper: UncheckedAccount<'info>, + pub compression_program: Program<'info, SPLCompression>, + // Pin the bubblegum program account to the known mpl-bubblegum id. Without // this constraint the caller could pass any account to the CPI. /// CHECK: address constrained to the mpl-bubblegum program id. #[account(address = MPL_BUBBLEGUM_ID)] pub bubblegum_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, } pub fn handler<'info>( - context: Context<'info, Withdraw<'info>>, + context: Context<'info, WithdrawCnftAccountConstraints<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], @@ -57,8 +71,8 @@ pub fn handler<'info>( let instruction = build_transfer_instruction( context.accounts.tree_authority.key(), - context.accounts.leaf_owner.key(), - context.accounts.leaf_owner.key(), + context.accounts.vault.key(), + context.accounts.vault.key(), context.accounts.new_leaf_owner.key(), context.accounts.merkle_tree.key(), context.accounts.log_wrapper.key(), @@ -78,7 +92,7 @@ pub fn handler<'info>( let mut account_infos = vec![ context.accounts.bubblegum_program.to_account_info(), context.accounts.tree_authority.to_account_info(), - context.accounts.leaf_owner.to_account_info(), + context.accounts.vault.to_account_info(), context.accounts.new_leaf_owner.to_account_info(), context.accounts.merkle_tree.to_account_info(), context.accounts.log_wrapper.to_account_info(), @@ -92,7 +106,7 @@ pub fn handler<'info>( invoke_signed( &instruction, &account_infos, - &[&[b"cNFT-vault", &[context.bumps.leaf_owner]]], + &[&[VAULT_SEED, &[context.accounts.vault.bump]]], )?; Ok(()) diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_two_cnfts.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_two_cnfts.rs index 0ad80351..fadae6e0 100644 --- a/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_two_cnfts.rs +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/instructions/withdraw_two_cnfts.rs @@ -1,10 +1,24 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::{instruction::AccountMeta, program::invoke_signed}; +use crate::error::VaultError; +use crate::state::{Vault, VAULT_SEED}; use crate::{build_transfer_instruction, SPLCompression, TransferArgs, MPL_BUBBLEGUM_ID}; #[derive(Accounts)] -pub struct WithdrawTwo<'info> { +pub struct WithdrawTwoCnftsAccountConstraints<'info> { + /// The stored vault authority. Only this signer may withdraw. + pub authority: Signer<'info>, + + // The vault PDA owns the cNFTs (as Bubblegum leaf owner) and signs both + // transfer CPIs via invoke_signed. + #[account( + seeds = [VAULT_SEED], + bump = vault.bump, + has_one = authority @ VaultError::InvalidWithdrawAuthority, + )] + pub vault: Account<'info, Vault>, + #[account(mut)] #[account( seeds = [merkle_tree1.key().as_ref()], @@ -13,14 +27,10 @@ pub struct WithdrawTwo<'info> { )] /// CHECK: This account is modified in the downstream program pub tree_authority1: UncheckedAccount<'info>, - #[account( - seeds = [b"cNFT-vault"], - bump, - )] - /// CHECK: This account doesnt even exist (it is just the pda to sign) - pub leaf_owner: UncheckedAccount<'info>, + /// CHECK: This account is neither written to nor read from. pub new_leaf_owner1: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account is modified in the downstream program pub merkle_tree1: UncheckedAccount<'info>, @@ -33,26 +43,31 @@ pub struct WithdrawTwo<'info> { )] /// CHECK: This account is modified in the downstream program pub tree_authority2: UncheckedAccount<'info>, + /// CHECK: This account is neither written to nor read from. pub new_leaf_owner2: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account is modified in the downstream program pub merkle_tree2: UncheckedAccount<'info>, /// CHECK: This account is neither written to nor read from. pub log_wrapper: UncheckedAccount<'info>, + pub compression_program: Program<'info, SPLCompression>, + // Pin the bubblegum program account to the known mpl-bubblegum id. Without // this constraint the caller could pass any account to the two CPI calls. /// CHECK: address constrained to the mpl-bubblegum program id. #[account(address = MPL_BUBBLEGUM_ID)] pub bubblegum_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, } #[allow(clippy::too_many_arguments)] pub fn handler<'info>( - context: Context<'info, WithdrawTwo<'info>>, + context: Context<'info, WithdrawTwoCnftsAccountConstraints<'info>>, root1: [u8; 32], data_hash1: [u8; 32], creator_hash1: [u8; 32], @@ -64,7 +79,7 @@ pub fn handler<'info>( creator_hash2: [u8; 32], nonce2: u64, index2: u32, - _proof_2_length: u8, + proof_2_length: u8, ) -> Result<()> { let merkle_tree1 = context.accounts.merkle_tree1.key(); let merkle_tree2 = context.accounts.merkle_tree2.key(); @@ -74,11 +89,22 @@ pub fn handler<'info>( merkle_tree2 ); - let signer_seeds: &[&[u8]] = &[b"cNFT-vault", &[context.bumps.leaf_owner]]; + // The proof lengths are client-supplied: bounds-check them against the + // accounts actually provided before slicing, so adversarial input gets a + // clean named error instead of a panic. + let proof_1_length = proof_1_length as usize; + let proof_2_length = proof_2_length as usize; + require!( + proof_1_length + .checked_add(proof_2_length) + .is_some_and(|total| total == context.remaining_accounts.len()), + VaultError::ProofLengthMismatch + ); + + let signer_seeds: &[&[u8]] = &[VAULT_SEED, &[context.accounts.vault.bump]]; // Split remaining accounts into proof1 and proof2 - let (proof1_accounts, proof2_accounts) = - context.remaining_accounts.split_at(proof_1_length as usize); + let (proof1_accounts, proof2_accounts) = context.remaining_accounts.split_at(proof_1_length); let proof1_metas: Vec = proof1_accounts .iter() @@ -94,8 +120,8 @@ pub fn handler<'info>( msg!("withdrawing cNFT#1"); let instruction1 = build_transfer_instruction( context.accounts.tree_authority1.key(), - context.accounts.leaf_owner.key(), - context.accounts.leaf_owner.key(), + context.accounts.vault.key(), + context.accounts.vault.key(), context.accounts.new_leaf_owner1.key(), context.accounts.merkle_tree1.key(), context.accounts.log_wrapper.key(), @@ -114,7 +140,7 @@ pub fn handler<'info>( let mut account_infos1 = vec![ context.accounts.bubblegum_program.to_account_info(), context.accounts.tree_authority1.to_account_info(), - context.accounts.leaf_owner.to_account_info(), + context.accounts.vault.to_account_info(), context.accounts.new_leaf_owner1.to_account_info(), context.accounts.merkle_tree1.to_account_info(), context.accounts.log_wrapper.to_account_info(), @@ -131,8 +157,8 @@ pub fn handler<'info>( msg!("withdrawing cNFT#2"); let instruction2 = build_transfer_instruction( context.accounts.tree_authority2.key(), - context.accounts.leaf_owner.key(), - context.accounts.leaf_owner.key(), + context.accounts.vault.key(), + context.accounts.vault.key(), context.accounts.new_leaf_owner2.key(), context.accounts.merkle_tree2.key(), context.accounts.log_wrapper.key(), @@ -151,7 +177,7 @@ pub fn handler<'info>( let mut account_infos2 = vec![ context.accounts.bubblegum_program.to_account_info(), context.accounts.tree_authority2.to_account_info(), - context.accounts.leaf_owner.to_account_info(), + context.accounts.vault.to_account_info(), context.accounts.new_leaf_owner2.to_account_info(), context.accounts.merkle_tree2.to_account_info(), context.accounts.log_wrapper.to_account_info(), diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/lib.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/lib.rs index 5d6a3dbe..f86065d0 100644 --- a/compression/cnft-vault/anchor/programs/cnft-vault/src/lib.rs +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/lib.rs @@ -4,7 +4,9 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; use borsh::BorshSerialize; +pub mod error; mod instructions; +pub mod state; use instructions::*; declare_id!("Fd4iwpPWaCU8BNwGQGtvvrcvG4Tfizq3RgLm8YLBJX6D"); @@ -85,8 +87,12 @@ pub fn build_transfer_instruction( pub mod cnft_vault { use super::*; + pub fn initialize_vault(context: Context) -> Result<()> { + instructions::initialize_vault::handler(context) + } + pub fn withdraw_cnft<'info>( - context: Context<'info, Withdraw<'info>>, + context: Context<'info, WithdrawCnftAccountConstraints<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], @@ -98,7 +104,7 @@ pub mod cnft_vault { #[allow(clippy::too_many_arguments)] pub fn withdraw_two_cnfts<'info>( - context: Context<'info, WithdrawTwo<'info>>, + context: Context<'info, WithdrawTwoCnftsAccountConstraints<'info>>, root1: [u8; 32], data_hash1: [u8; 32], creator_hash1: [u8; 32], diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/state/mod.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/state/mod.rs new file mode 100644 index 00000000..2d068824 --- /dev/null +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/state/mod.rs @@ -0,0 +1,3 @@ +pub mod vault; + +pub use vault::*; diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/src/state/vault.rs b/compression/cnft-vault/anchor/programs/cnft-vault/src/state/vault.rs new file mode 100644 index 00000000..5ce304df --- /dev/null +++ b/compression/cnft-vault/anchor/programs/cnft-vault/src/state/vault.rs @@ -0,0 +1,14 @@ +use anchor_lang::prelude::*; + +/// Seed prefix for the vault PDA. The same PDA stores the withdraw authority +/// and acts as the cNFT leaf owner that signs Bubblegum transfers. +pub const VAULT_SEED: &[u8] = b"cNFT-vault"; + +#[derive(InitSpace)] +#[account] +pub struct Vault { + /// The only signer allowed to withdraw cNFTs from the vault. + pub authority: Pubkey, + + pub bump: u8, +} diff --git a/compression/cnft-vault/anchor/programs/cnft-vault/tests/test_vault.rs b/compression/cnft-vault/anchor/programs/cnft-vault/tests/test_vault.rs index a089e891..9e0df7d9 100644 --- a/compression/cnft-vault/anchor/programs/cnft-vault/tests/test_vault.rs +++ b/compression/cnft-vault/anchor/programs/cnft-vault/tests/test_vault.rs @@ -1,22 +1,33 @@ -//! LiteSVM integration test for the cnft-vault Anchor program. +//! LiteSVM integration tests for the cnft-vault Anchor program. //! -//! Full flow exercised: +//! Shared flow exercised by the tests: //! 1. Load the cnft-vault program plus the three mainnet fixtures //! (mpl-bubblegum, spl-account-compression, spl-noop) into LiteSVM. -//! 2. Allocate + initialize a Bubblegum Merkle tree (max_depth=3, +//! 2. Initialize the vault PDA via `initialize_vault`, storing the +//! withdraw authority. +//! 3. Allocate + initialize a Bubblegum Merkle tree (max_depth=3, //! max_buffer_size=8, canopy=0) via `create_tree_config`. -//! 3. Mint a single cNFT whose leaf_owner is the vault PDA (so the vault +//! 4. Mint a single cNFT whose leaf_owner is the vault PDA (so the vault //! holds it) via `mint_v1`. -//! 4. Recompute `data_hash` / `creator_hash` exactly as Bubblegum does. -//! 5. Build the Merkle proof for leaf 0 (all empty-node siblings) and read -//! the current root from the on-chain tree account. -//! 6. Call our program's `withdraw_cnft`, which CPIs Bubblegum `Transfer` -//! signed by the vault PDA (`invoke_signed`), to move the cNFT to a -//! recipient. Assert the transaction succeeds and that a second withdraw -//! with the now-stale root fails (the leaf moved, so the root changed). +//! 5. Recompute `data_hash` / `creator_hash` exactly as Bubblegum does. +//! 6. Build the Merkle proof for leaf 0 (all empty-node siblings) and read +//! the current root from the onchain tree account. +//! 7. Call the program's withdraw handlers, which CPI Bubblegum `Transfer` +//! signed by the vault PDA (`invoke_signed`), to move the cNFT(s) to a +//! recipient. +//! +//! Coverage: +//! - withdraw by the stored authority succeeds (single and two-cNFT) +//! - withdraw by a non-authority signer fails with +//! `VaultError::InvalidWithdrawAuthority` +//! - replaying a withdraw with the now-stale root fails +//! - a two-cNFT withdraw whose proof lengths do not match the supplied +//! proof accounts fails with `VaultError::ProofLengthMismatch` instead +//! of panicking inside `split_at` use { borsh::BorshSerialize, + cnft_vault::error::VaultError, litesvm::LiteSVM, solana_instruction::{account_meta::AccountMeta, Instruction}, solana_keccak_hasher::hashv, @@ -73,8 +84,8 @@ struct MetadataArgs { is_mutable: bool, edition_nonce: Option, token_standard: Option, // TokenStandard enum, encoded by variant index - collection: Option, // None — Collection, kept absent - uses: Option, // None — Uses, kept absent + collection: Option, // None - Collection, kept absent + uses: Option, // None - Uses, kept absent token_program_version: TokenProgramVersion, creators: Vec, } @@ -112,18 +123,19 @@ fn empty_node(level: u32) -> [u8; 32] { hashv(&[&lower, &lower]).to_bytes() } -// ---- Anchor discriminator for withdraw_cnft -------------------------------- +// ---- Anchor instruction discriminators -------------------------------------- -fn withdraw_cnft_disc() -> [u8; 8] { - // sha256("global:withdraw_cnft")[..8]. Implemented inline to avoid pulling - // a crypto crate that conflicts with the program's solana version. - let digest = sha256(b"global:withdraw_cnft"); +// sha256("global:")[..8]. Implemented inline to avoid pulling +// a crypto crate that conflicts with the program's solana version. +fn anchor_discriminator(handler_name: &str) -> [u8; 8] { + let preimage = format!("global:{handler_name}"); + let digest = sha256(preimage.as_bytes()); let mut out = [0u8; 8]; out.copy_from_slice(&digest[..8]); out } -// Minimal SHA-256 (FIPS 180-4) — only used to derive the Anchor discriminator. +// Minimal SHA-256 (FIPS 180-4) - only used to derive Anchor discriminators. fn sha256(input: &[u8]) -> [u8; 32] { const K: [u32; 64] = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, @@ -235,7 +247,7 @@ fn read_current_root(data: &[u8]) -> [u8; 32] { root } -// ---- Helpers --------------------------------------------------------------- +// ---- Transaction helpers ---------------------------------------------------- fn send( svm: &mut LiteSVM, @@ -250,8 +262,42 @@ fn send( svm.send_transaction(tx).map(|_| ()).map_err(Box::new) } -#[test] -fn test_withdraw_cnft() { +/// Assert a failed transaction carries the given program error. +fn assert_custom_error( + result: Result<(), Box>, + expected: VaultError, +) { + let failed = result.expect_err("transaction should fail"); + let expected_code = u32::from(expected); + let error_text = format!("{:?}", failed.err); + assert!( + error_text.contains(&format!("Custom({expected_code})")), + "expected Custom({expected_code}), got: {error_text}" + ); +} + +// ---- Fixture setup ---------------------------------------------------------- + +/// One Bubblegum tree holding a single cNFT owned by the vault PDA, plus +/// everything needed to withdraw it (root, hashes, proof). +struct TreeWithVaultCnft { + merkle_tree: Pubkey, + tree_config: Pubkey, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + proof: [[u8; 32]; MAX_DEPTH as usize], +} + +struct VaultTestContext { + svm: LiteSVM, + payer: Keypair, + /// The keypair stored as the vault's withdraw authority. + authority: Keypair, + vault_pda: Pubkey, +} + +fn setup_vault() -> VaultTestContext { let mut svm = LiteSVM::new(); // Load the cnft-vault program and the three mainnet fixtures. @@ -276,21 +322,58 @@ fn test_withdraw_cnft() { ) .unwrap(); - // Fund payer. let payer = Keypair::new(); svm.airdrop(&payer.pubkey(), 100 * solana_native_token::LAMPORTS_PER_SOL) .unwrap(); - // The vault PDA that owns the cNFT and signs the transfer CPI. - // seeds = [b"cNFT-vault"] under the cnft-vault program. + let authority = Keypair::new(); + svm.airdrop( + &authority.pubkey(), + 10 * solana_native_token::LAMPORTS_PER_SOL, + ) + .unwrap(); + + // The vault PDA that stores the authority, owns the cNFTs (as Bubblegum + // leaf owner) and signs the transfer CPI. let (vault_pda, _vault_bump) = Pubkey::find_program_address(&[b"cNFT-vault"], &CNFT_VAULT_ID); - // The recipient of the withdraw. - let recipient = Keypair::new(); + // initialize_vault: store `authority` on the vault PDA. + let initialize_ix = Instruction { + program_id: CNFT_VAULT_ID, + accounts: vec![ + AccountMeta::new(authority.pubkey(), true), + AccountMeta::new(vault_pda, false), + AccountMeta::new_readonly(SYSTEM_ID, false), + ], + data: anchor_discriminator("initialize_vault").to_vec(), + }; + let mut svm_context = VaultTestContext { + svm, + payer, + authority, + vault_pda, + }; + let authority_keypair = svm_context.authority.insecure_clone(); + send( + &mut svm_context.svm, + vec![initialize_ix], + &authority_keypair, + &[&authority_keypair], + ) + .expect("initialize_vault should succeed"); + + svm_context +} + +/// Create a Bubblegum tree and mint one cNFT into the vault PDA. +fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultCnft { + let payer = context.payer.insecure_clone(); // Create the Merkle tree account, owned by the compression program. let merkle_tree = Keypair::new(); - let rent = svm.minimum_balance_for_rent_exemption(TREE_ACCOUNT_SIZE); + let rent = context + .svm + .minimum_balance_for_rent_exemption(TREE_ACCOUNT_SIZE); let create_acc = Instruction { program_id: SYSTEM_ID, accounts: vec![ @@ -334,7 +417,7 @@ fn test_withdraw_cnft() { }; send( - &mut svm, + &mut context.svm, vec![create_acc, create_tree_ix], &payer, &[&payer, &merkle_tree], @@ -363,13 +446,13 @@ fn test_withdraw_cnft() { creators: vec![creator.clone()], }; - // mint_v1 — leaf_owner and leaf_delegate are the vault PDA. + // mint_v1 - leaf_owner and leaf_delegate are the vault PDA. let mint_ix = Instruction { program_id: BUBBLEGUM_ID, accounts: vec![ AccountMeta::new(tree_config, false), - AccountMeta::new_readonly(vault_pda, false), - AccountMeta::new_readonly(vault_pda, false), // leaf_delegate + AccountMeta::new_readonly(context.vault_pda, false), + AccountMeta::new_readonly(context.vault_pda, false), // leaf_delegate AccountMeta::new(merkle_tree.pubkey(), false), AccountMeta::new_readonly(payer.pubkey(), true), AccountMeta::new_readonly(payer.pubkey(), true), // tree_creator_or_delegate @@ -383,7 +466,7 @@ fn test_withdraw_cnft() { d }, }; - send(&mut svm, vec![mint_ix], &payer, &[&payer]).expect("mint_v1 should succeed"); + send(&mut context.svm, vec![mint_ix], &payer, &[&payer]).expect("mint_v1 should succeed"); // Recompute data_hash and creator_hash exactly as Bubblegum does. let data_hash = hash_metadata(&metadata); @@ -392,62 +475,294 @@ fn test_withdraw_cnft() { // Proof for leaf index 0 in an otherwise-empty tree: empty-node siblings. let proof = [empty_node(0), empty_node(1), empty_node(2)]; - // Read the current root from the on-chain tree account. - let tree_data = svm.get_account(&merkle_tree.pubkey()).unwrap().data; + // Read the current root from the onchain tree account. + let tree_data = context + .svm + .get_account(&merkle_tree.pubkey()) + .unwrap() + .data; let root = read_current_root(&tree_data); - // Build withdraw_cnft via our program. Accounts per Withdraw struct: - // tree_authority (mut), leaf_owner (vault PDA), new_leaf_owner (recipient), - // merkle_tree (mut), log_wrapper, compression_program, bubblegum_program, - // system_program, then proof nodes as remaining accounts. - let mut withdraw_accounts = vec![ - AccountMeta::new(tree_config, false), - AccountMeta::new_readonly(vault_pda, false), - AccountMeta::new_readonly(recipient.pubkey(), false), - AccountMeta::new(merkle_tree.pubkey(), false), + TreeWithVaultCnft { + merkle_tree: merkle_tree.pubkey(), + tree_config, + root, + data_hash, + creator_hash, + proof, + } +} + +// ---- Instruction builders for the program under test ------------------------ + +/// Build withdraw_cnft. Accounts per WithdrawCnftAccountConstraints: +/// authority (signer), vault, tree_authority (mut), new_leaf_owner, +/// merkle_tree (mut), log_wrapper, compression_program, bubblegum_program, +/// system_program, then proof nodes as remaining accounts. +fn build_withdraw_cnft_instruction( + context: &VaultTestContext, + signer: Pubkey, + tree: &TreeWithVaultCnft, + recipient: Pubkey, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new_readonly(signer, true), + AccountMeta::new_readonly(context.vault_pda, false), + AccountMeta::new(tree.tree_config, false), + AccountMeta::new_readonly(recipient, false), + AccountMeta::new(tree.merkle_tree, false), AccountMeta::new_readonly(NOOP_ID, false), AccountMeta::new_readonly(COMPRESSION_ID, false), AccountMeta::new_readonly(BUBBLEGUM_ID, false), AccountMeta::new_readonly(SYSTEM_ID, false), ]; - for node in proof.iter() { - withdraw_accounts.push(AccountMeta::new_readonly( + for node in tree.proof.iter() { + accounts.push(AccountMeta::new_readonly( Pubkey::new_from_array(*node), false, )); } - let withdraw_data = { - let mut d = withdraw_cnft_disc().to_vec(); - d.extend_from_slice(&root); - d.extend_from_slice(&data_hash); - d.extend_from_slice(&creator_hash); + let data = { + let mut d = anchor_discriminator("withdraw_cnft").to_vec(); + d.extend_from_slice(&tree.root); + d.extend_from_slice(&tree.data_hash); + d.extend_from_slice(&tree.creator_hash); d.extend_from_slice(&0u64.to_le_bytes()); // nonce d.extend_from_slice(&0u32.to_le_bytes()); // index d }; - let withdraw_ix = Instruction { + Instruction { program_id: CNFT_VAULT_ID, - accounts: withdraw_accounts.clone(), - data: withdraw_data.clone(), + accounts, + data, + } +} + +/// Build withdraw_two_cnfts. Accounts per WithdrawTwoCnftsAccountConstraints: +/// authority (signer), vault, tree_authority1 (mut), new_leaf_owner1, +/// merkle_tree1 (mut), tree_authority2 (mut), new_leaf_owner2, +/// merkle_tree2 (mut), log_wrapper, compression_program, bubblegum_program, +/// system_program, then proof1 ++ proof2 as remaining accounts. +fn build_withdraw_two_cnfts_instruction( + context: &VaultTestContext, + signer: Pubkey, + tree1: &TreeWithVaultCnft, + tree2: &TreeWithVaultCnft, + recipient: Pubkey, + proof_1_length: u8, + proof_2_length: u8, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new_readonly(signer, true), + AccountMeta::new_readonly(context.vault_pda, false), + AccountMeta::new(tree1.tree_config, false), + AccountMeta::new_readonly(recipient, false), + AccountMeta::new(tree1.merkle_tree, false), + AccountMeta::new(tree2.tree_config, false), + AccountMeta::new_readonly(recipient, false), + AccountMeta::new(tree2.merkle_tree, false), + AccountMeta::new_readonly(NOOP_ID, false), + AccountMeta::new_readonly(COMPRESSION_ID, false), + AccountMeta::new_readonly(BUBBLEGUM_ID, false), + AccountMeta::new_readonly(SYSTEM_ID, false), + ]; + for node in tree1.proof.iter().chain(tree2.proof.iter()) { + accounts.push(AccountMeta::new_readonly( + Pubkey::new_from_array(*node), + false, + )); + } + + let data = { + let mut d = anchor_discriminator("withdraw_two_cnfts").to_vec(); + d.extend_from_slice(&tree1.root); + d.extend_from_slice(&tree1.data_hash); + d.extend_from_slice(&tree1.creator_hash); + d.extend_from_slice(&0u64.to_le_bytes()); // nonce1 + d.extend_from_slice(&0u32.to_le_bytes()); // index1 + d.push(proof_1_length); + d.extend_from_slice(&tree2.root); + d.extend_from_slice(&tree2.data_hash); + d.extend_from_slice(&tree2.creator_hash); + d.extend_from_slice(&0u64.to_le_bytes()); // nonce2 + d.extend_from_slice(&0u32.to_le_bytes()); // index2 + d.push(proof_2_length); + d }; - // Withdraw is signed by the payer (the vault PDA signs via invoke_signed - // inside the program, not as a transaction signer). - send(&mut svm, vec![withdraw_ix], &payer, &[&payer]).expect("withdraw_cnft should succeed"); + Instruction { + program_id: CNFT_VAULT_ID, + accounts, + data, + } +} + +// ---- Tests ------------------------------------------------------------------ + +#[test] +fn test_withdraw_cnft_by_authority() { + let mut context = setup_vault(); + let tree = create_tree_with_vault_cnft(&mut context); + let recipient = Keypair::new(); + let authority = context.authority.insecure_clone(); + + let withdraw_ix = build_withdraw_cnft_instruction( + &context, + authority.pubkey(), + &tree, + recipient.pubkey(), + ); + + // The stored authority signs, so the withdraw succeeds (the vault PDA + // signs the Bubblegum CPI via invoke_signed inside the program). + send( + &mut context.svm, + vec![withdraw_ix.clone()], + &authority, + &[&authority], + ) + .expect("withdraw_cnft signed by the vault authority should succeed"); // After transfer, leaf 0's owner changed (vault -> recipient), so the root // moved. A second withdraw replaying the same (root, hashes) must fail: the // cached root is stale and the leaf no longer hashes to it for the vault. - let withdraw_ix2 = Instruction { - program_id: CNFT_VAULT_ID, - accounts: withdraw_accounts, - data: withdraw_data, - }; - let second = send(&mut svm, vec![withdraw_ix2], &payer, &[&payer]); + let second = send( + &mut context.svm, + vec![withdraw_ix], + &authority, + &[&authority], + ); assert!( second.is_err(), "second withdraw must fail: leaf already transferred out of the vault" ); } + +#[test] +fn test_withdraw_cnft_rejected_for_non_authority() { + let mut context = setup_vault(); + let tree = create_tree_with_vault_cnft(&mut context); + let recipient = Keypair::new(); + + // An attacker funds and signs their own withdraw attempt; the vault's + // stored authority did not sign. + let attacker = Keypair::new(); + context + .svm + .airdrop( + &attacker.pubkey(), + 10 * solana_native_token::LAMPORTS_PER_SOL, + ) + .unwrap(); + + let withdraw_ix = + build_withdraw_cnft_instruction(&context, attacker.pubkey(), &tree, recipient.pubkey()); + + let result = send(&mut context.svm, vec![withdraw_ix], &attacker, &[&attacker]); + assert_custom_error(result, VaultError::InvalidWithdrawAuthority); +} + +#[test] +fn test_withdraw_two_cnfts_by_authority() { + let mut context = setup_vault(); + let tree1 = create_tree_with_vault_cnft(&mut context); + let tree2 = create_tree_with_vault_cnft(&mut context); + let recipient = Keypair::new(); + let authority = context.authority.insecure_clone(); + + let withdraw_ix = build_withdraw_two_cnfts_instruction( + &context, + authority.pubkey(), + &tree1, + &tree2, + recipient.pubkey(), + MAX_DEPTH as u8, + MAX_DEPTH as u8, + ); + + send( + &mut context.svm, + vec![withdraw_ix], + &authority, + &[&authority], + ) + .expect("withdraw_two_cnfts signed by the vault authority should succeed"); + + // Both trees' roots moved, so both cNFTs left the vault: replaying the + // single-tree withdraw against either tree with the cached roots fails. + let replay1 = build_withdraw_cnft_instruction( + &context, + authority.pubkey(), + &tree1, + recipient.pubkey(), + ); + let replay = send(&mut context.svm, vec![replay1], &authority, &[&authority]); + assert!( + replay.is_err(), + "cNFT#1 already left the vault, replay must fail" + ); +} + +#[test] +fn test_withdraw_two_cnfts_rejects_out_of_range_proof_length() { + let mut context = setup_vault(); + let tree1 = create_tree_with_vault_cnft(&mut context); + let tree2 = create_tree_with_vault_cnft(&mut context); + let recipient = Keypair::new(); + let authority = context.authority.insecure_clone(); + + // Claim one more proof node for tree1 than the instruction supplies in + // total: the bounds check must return ProofLengthMismatch instead of + // letting split_at(proof_1_length) panic and abort the program. + let supplied_proof_nodes = 2 * MAX_DEPTH as u8; + let out_of_range_proof_1_length = supplied_proof_nodes + 1; + + let withdraw_ix = build_withdraw_two_cnfts_instruction( + &context, + authority.pubkey(), + &tree1, + &tree2, + recipient.pubkey(), + out_of_range_proof_1_length, + 0, + ); + + let result = send( + &mut context.svm, + vec![withdraw_ix], + &authority, + &[&authority], + ); + assert_custom_error(result, VaultError::ProofLengthMismatch); +} + +#[test] +fn test_withdraw_two_cnfts_rejects_inconsistent_proof_lengths() { + let mut context = setup_vault(); + let tree1 = create_tree_with_vault_cnft(&mut context); + let tree2 = create_tree_with_vault_cnft(&mut context); + let recipient = Keypair::new(); + let authority = context.authority.insecure_clone(); + + // proof_1_length is in range but the two lengths do not add up to the + // supplied proof accounts, so the split would misattribute proof nodes. + let withdraw_ix = build_withdraw_two_cnfts_instruction( + &context, + authority.pubkey(), + &tree1, + &tree2, + recipient.pubkey(), + MAX_DEPTH as u8 - 1, + MAX_DEPTH as u8, + ); + + let result = send( + &mut context.svm, + vec![withdraw_ix], + &authority, + &[&authority], + ); + assert_custom_error(result, VaultError::ProofLengthMismatch); +} diff --git a/compression/cnft-vault/anchor/tests/fixtures/README.md b/compression/cnft-vault/anchor/tests/fixtures/README.md index 6e1ab7d3..2af152e3 100644 --- a/compression/cnft-vault/anchor/tests/fixtures/README.md +++ b/compression/cnft-vault/anchor/tests/fixtures/README.md @@ -1,9 +1,9 @@ -# Test fixtures — mainnet program binaries +# Test fixtures - mainnet program binaries -These `.so` files are the compiled on-chain programs the cNFT-burn test CPIs +These `.so` files are the compiled onchain programs the cNFT-vault test CPIs into, dumped from Solana **mainnet-beta** so [LiteSVM](https://github.com/LiteSVM/litesvm) -can load them locally (LiteSVM only bundles System/Token/Token-2022/ATA). They -are the real programs — not modified — so accounts they create/verify behave +can load them locally (LiteSVM only bundles System/Token/Token Extensions/ATA). They +are the real programs - not modified - so accounts they create/verify behave exactly as on mainnet. | File | Program | Program ID | Source | Dumped (UTC) | Slot | diff --git a/compression/cnft-vault/quasar/Cargo.toml b/compression/cnft-vault/quasar/Cargo.toml index bd5cb17a..db8b0e30 100644 --- a/compression/cnft-vault/quasar/Cargo.toml +++ b/compression/cnft-vault/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-cnft-vault" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] @@ -23,7 +23,7 @@ debug = [] [dependencies] quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } -# Direct dependency for invoke_signed_with_bounds — needed for raw CPI with +# Direct dependency for invoke_signed_with_bounds - needed for raw CPI with # variable proof accounts. quasar-lang re-exports types but not the invoke fns. solana-instruction-view = { version = "2", features = ["cpi"] } solana-instruction = { version = "3.2.0" } @@ -31,3 +31,10 @@ solana-instruction = { version = "3.2.0" } [dev-dependencies] quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } solana-address = { version = "2.2.0", features = ["decode"] } +# Generated by `quasar build` (see [clients] in Quasar.toml); gives tests +# typed *Instruction builders instead of hand-built account metas. +quasar-cnft-vault-client = { path = "target/client/rust/quasar-cnft-vault-client" } +# Tests mirror Bubblegum's borsh metadata layout and keccak leaf hashing to +# recompute data_hash / creator_hash, same as the Anchor twin's suite. +borsh = { version = "1", features = ["derive"] } +solana-keccak-hasher = "3" diff --git a/compression/cnft-vault/quasar/README.md b/compression/cnft-vault/quasar/README.md index 2d3fdd4d..fdf2ba57 100644 --- a/compression/cnft-vault/quasar/README.md +++ b/compression/cnft-vault/quasar/README.md @@ -1,13 +1,20 @@ # cNFT Vault (Quasar) -Deposit and withdraw compressed NFTs from a PDA vault. +Hold compressed NFTs in a PDA vault and let the stored vault authority withdraw them. See also: the [repository catalog](../../../README.md). -## Major concepts +## Authority model -- cNFT transfers -- PDA vault +Deposits are plain Bubblegum transfers to the **vault PDA** (seeds `["cNFT-vault"]`); no program instruction runs on deposit. Because of that, withdraw authorization is per-vault, not per-deposit: `initialize_vault` creates the vault PDA as a `Vault` state account and stores the signer as its **authority**. Both withdraw handlers require that stored authority as a `Signer` (`has_one(authority)`) and reject any other signer with `VaultError::InvalidWithdrawAuthority` before the Bubblegum CPI runs. The same PDA doubles as the Bubblegum leaf owner and signs the transfer CPIs via `invoke_signed`. The seeds, state layout, and error codes match the [Anchor](../anchor/) twin. + +Three handlers: + +- `initialize_vault` - creates the vault PDA and stores the withdraw authority. +- `withdraw_cnft` - withdraws one cNFT to a recipient chosen by the authority. +- `withdraw_two_cnfts` - withdraws two cNFTs (possibly from different trees) in a single transaction. The client passes `proof_1_length` and `proof_2_length` to split the proof accounts between the two Bubblegum transfers; the handler rejects lengths that do not add up to the supplied proof accounts with `VaultError::ProofLengthMismatch`. + +The vault is global to the program deployment: there is one vault PDA with one authority, so anyone who deposits a cNFT is entrusting it to that authority. ## Setup @@ -21,14 +28,13 @@ Prerequisites: [Quasar](https://quasar-lang.com/docs) CLI and [Agave](https://do ## Testing -In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): +A QuasarSVM integration suite lives in `src/tests.rs`. It loads the same mainnet-dumped fixture binaries as the Anchor twin (Bubblegum, SPL Account Compression, SPL Noop, from `../anchor/tests/fixtures/`), creates a Bubblegum tree, mints cNFTs to the vault PDA, and exercises the withdraw handlers end to end. The suite covers authority withdraws (single and two-cNFT), rejection of non-authority signers, stale-root replays, and out-of-range proof lengths. ```bash -cargo test +quasar build +quasar test ``` -Tests invoke instruction handlers and assert onchain state. No local validator. - ## Usage -Read `src/` and `Quasar.toml`. Compare with the [Anchor](../anchor/) variant in the same example where present. +Read `src/` and `Quasar.toml`. Compare with the [Anchor](../anchor/) variant in the same example. diff --git a/compression/cnft-vault/quasar/src/error.rs b/compression/cnft-vault/quasar/src/error.rs new file mode 100644 index 00000000..19588717 --- /dev/null +++ b/compression/cnft-vault/quasar/src/error.rs @@ -0,0 +1,14 @@ +use quasar_lang::prelude::*; + +#[error_code] +pub enum VaultError { + /// Only the vault authority may withdraw cNFTs from the vault. + // 6000 is the conventional Anchor-compatible starting offset for + // program-specific error codes (Quasar's #[error_code] starts at 0 + // unless told otherwise; framework errors occupy 3000+). Matches the + // Anchor twin's codes. + InvalidWithdrawAuthority = 6000, + /// proof_1_length + proof_2_length must equal the number of proof + /// accounts supplied. + ProofLengthMismatch, +} diff --git a/compression/cnft-vault/quasar/src/instructions/initialize_vault.rs b/compression/cnft-vault/quasar/src/instructions/initialize_vault.rs new file mode 100644 index 00000000..6d834876 --- /dev/null +++ b/compression/cnft-vault/quasar/src/instructions/initialize_vault.rs @@ -0,0 +1,24 @@ +use crate::state::{Vault, VaultInner}; +use quasar_lang::prelude::*; + +#[derive(Accounts)] +pub struct InitializeVaultAccountConstraints { + #[account(mut)] + pub authority: Signer, + + #[account(mut, init, payer = authority, address = Vault::seeds())] + pub vault: Account, + + pub system_program: Program, +} + +pub fn handle_initialize_vault( + accounts: &mut InitializeVaultAccountConstraints, + bump: u8, +) -> Result<(), ProgramError> { + accounts.vault.set_inner(VaultInner { + authority: *accounts.authority.address(), + bump, + }); + Ok(()) +} diff --git a/compression/cnft-vault/quasar/src/instructions/mod.rs b/compression/cnft-vault/quasar/src/instructions/mod.rs index a2a5deb9..bc84daaf 100644 --- a/compression/cnft-vault/quasar/src/instructions/mod.rs +++ b/compression/cnft-vault/quasar/src/instructions/mod.rs @@ -1,3 +1,6 @@ +pub mod initialize_vault; +pub use initialize_vault::*; + pub mod withdraw; pub use withdraw::*; diff --git a/compression/cnft-vault/quasar/src/instructions/withdraw.rs b/compression/cnft-vault/quasar/src/instructions/withdraw.rs index 82d61bd7..2ff92a4c 100644 --- a/compression/cnft-vault/quasar/src/instructions/withdraw.rs +++ b/compression/cnft-vault/quasar/src/instructions/withdraw.rs @@ -1,5 +1,10 @@ +use crate::error::VaultError; +use crate::state::Vault; use crate::*; -use quasar_lang::{cpi::{InstructionAccount, InstructionView, Seed, Signer}, remaining::RemainingAccounts}; +use quasar_lang::{ + cpi::{InstructionAccount, InstructionView, Seed, Signer as CpiSigner}, + remaining::RemainingAccounts, +}; /// Maximum proof nodes for the merkle tree. const MAX_PROOF_NODES: usize = 24; @@ -12,13 +17,21 @@ const TRANSFER_ARGS_LEN: usize = 108; /// Accounts for withdrawing a single compressed NFT from the vault. #[derive(Accounts)] -pub struct Withdraw { +pub struct WithdrawCnftAccountConstraints { + /// The stored vault authority. Only this signer may withdraw. + pub authority: Signer, + + /// Vault PDA that owns the cNFT (as Bubblegum leaf owner) and signs the + /// transfer via invoke_signed. + #[account( + address = Vault::seeds(), + has_one(authority) @ VaultError::InvalidWithdrawAuthority, + )] + pub vault: Account, + /// Tree authority PDA (seeds checked by Bubblegum). #[account(mut)] pub tree_authority: UncheckedAccount, - /// Vault PDA that owns the cNFT — signs the transfer via invoke_signed. - #[account(address = crate::VaultPda::seeds())] - pub leaf_owner: UncheckedAccount, /// New owner to receive the cNFT. pub new_leaf_owner: UncheckedAccount, /// Merkle tree account. @@ -43,7 +56,12 @@ fn build_transfer_data(args: &[u8]) -> [u8; 8 + TRANSFER_ARGS_LEN] { ix_data } -pub fn handle_withdraw_cnft(accounts: &mut Withdraw, data: &[u8], remaining: RemainingAccounts<'_>, leaf_owner_bump: u8) -> Result<(), ProgramError> { +pub fn handle_withdraw_cnft( + accounts: &mut WithdrawCnftAccountConstraints, + data: &[u8], + remaining: RemainingAccounts<'_>, + vault_bump: u8, +) -> Result<(), ProgramError> { if data.len() < TRANSFER_ARGS_LEN { return Err(ProgramError::InvalidInstructionData); } @@ -54,7 +72,7 @@ pub fn handle_withdraw_cnft(accounts: &mut Withdraw, data: &[u8], remaining: Rem // // `remaining.iter()` yields `Result` in newer // quasar-lang. Reach the inner `AccountView` via the unchecked accessor - // — we only read addresses/views to forward to the bubblegum CPI as + // - we only read addresses/views to forward to the bubblegum CPI as // proof nodes; no aliased data access. let placeholder = accounts.system_program.to_account_view().clone(); let mut proof_views: [AccountView; MAX_PROOF_NODES] = @@ -80,9 +98,9 @@ pub fn handle_withdraw_cnft(accounts: &mut Withdraw, data: &[u8], remaining: Rem core::array::from_fn(|_| InstructionAccount::readonly(sys_addr)); ix_accounts[0] = InstructionAccount::readonly(accounts.tree_authority.address()); - ix_accounts[1] = InstructionAccount::readonly_signer(accounts.leaf_owner.address()); - // leaf_delegate = leaf_owner, not an additional signer - ix_accounts[2] = InstructionAccount::readonly(accounts.leaf_owner.address()); + ix_accounts[1] = InstructionAccount::readonly_signer(accounts.vault.address()); + // leaf_delegate = leaf_owner (the vault), not an additional signer + ix_accounts[2] = InstructionAccount::readonly(accounts.vault.address()); ix_accounts[3] = InstructionAccount::readonly(accounts.new_leaf_owner.address()); ix_accounts[4] = InstructionAccount::writable(accounts.merkle_tree.address()); ix_accounts[5] = InstructionAccount::readonly(accounts.log_wrapper.address()); @@ -95,12 +113,11 @@ pub fn handle_withdraw_cnft(accounts: &mut Withdraw, data: &[u8], remaining: Rem // Build account views let sys_view = accounts.system_program.to_account_view().clone(); - let mut views: [AccountView; MAX_CPI_ACCOUNTS] = - core::array::from_fn(|_| sys_view.clone()); + let mut views: [AccountView; MAX_CPI_ACCOUNTS] = core::array::from_fn(|_| sys_view.clone()); views[0] = accounts.tree_authority.to_account_view().clone(); - views[1] = accounts.leaf_owner.to_account_view().clone(); - views[2] = accounts.leaf_owner.to_account_view().clone(); + views[1] = accounts.vault.to_account_view().clone(); + views[2] = accounts.vault.to_account_view().clone(); views[3] = accounts.new_leaf_owner.to_account_view().clone(); views[4] = accounts.merkle_tree.to_account_view().clone(); views[5] = accounts.log_wrapper.to_account_view().clone(); @@ -118,12 +135,12 @@ pub fn handle_withdraw_cnft(accounts: &mut Withdraw, data: &[u8], remaining: Rem }; // PDA signer seeds: ["cNFT-vault", bump] - let bump_bytes = [leaf_owner_bump]; + let bump_bytes = [vault_bump]; let seeds: [Seed; 2] = [ Seed::from(b"cNFT-vault" as &[u8]), Seed::from(&bump_bytes as &[u8]), ]; - let signer = Signer::from(&seeds as &[Seed]); + let signer = CpiSigner::from(&seeds as &[Seed]); solana_instruction_view::cpi::invoke_signed_with_bounds::( &instruction, diff --git a/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs b/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs index 9d4be645..ea9a3f85 100644 --- a/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs +++ b/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs @@ -1,5 +1,10 @@ +use crate::error::VaultError; +use crate::state::Vault; use crate::*; -use quasar_lang::{cpi::{InstructionAccount, InstructionView, Seed, Signer}, remaining::RemainingAccounts}; +use quasar_lang::{ + cpi::{InstructionAccount, InstructionView, Seed, Signer as CpiSigner}, + remaining::RemainingAccounts, +}; /// Maximum proof nodes per tree. const MAX_PROOF_NODES: usize = 24; @@ -10,28 +15,44 @@ const MAX_CPI_ACCOUNTS: usize = 8 + MAX_PROOF_NODES; /// Transfer args byte length: root(32) + data_hash(32) + creator_hash(32) + nonce(8) + index(4). const TRANSFER_ARGS_LEN: usize = 108; +/// Instruction data length: +/// args1(108) + proof_1_length(1) + args2(108) + proof_2_length(1). +const WITHDRAW_TWO_DATA_LEN: usize = TRANSFER_ARGS_LEN * 2 + 2; + /// Accounts for withdrawing two compressed NFTs from the vault in one transaction. /// Each cNFT can be from a different merkle tree. #[derive(Accounts)] -pub struct WithdrawTwo { +pub struct WithdrawTwoCnftsAccountConstraints { + /// The stored vault authority. Only this signer may withdraw. + pub authority: Signer, + + /// Vault PDA that owns the cNFTs (as Bubblegum leaf owner) and signs + /// both transfers via invoke_signed. + #[account( + address = Vault::seeds(), + has_one(authority) @ VaultError::InvalidWithdrawAuthority, + )] + pub vault: Account, + /// Tree authority PDA for tree 1. #[account(mut)] pub tree_authority1: UncheckedAccount, - /// Vault PDA that owns the cNFTs — signs both transfers. - #[account(address = crate::VaultPda::seeds())] - pub leaf_owner: UncheckedAccount, /// Recipient for cNFT 1. pub new_leaf_owner1: UncheckedAccount, /// Merkle tree for cNFT 1. #[account(mut)] pub merkle_tree1: UncheckedAccount, + // The second tree's accounts and recipient are marked `dup` because they + // may legitimately repeat first-position accounts: both cNFTs can live in + // the same tree and both can go to the same recipient. /// Tree authority PDA for tree 2. - #[account(mut)] + #[account(mut, dup)] pub tree_authority2: UncheckedAccount, /// Recipient for cNFT 2. + #[account(dup)] pub new_leaf_owner2: UncheckedAccount, /// Merkle tree for cNFT 2. - #[account(mut)] + #[account(mut, dup)] pub merkle_tree2: UncheckedAccount, /// SPL Noop log wrapper. pub log_wrapper: UncheckedAccount, @@ -45,31 +66,34 @@ pub struct WithdrawTwo { } #[allow(clippy::too_many_lines)] -pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remaining: RemainingAccounts<'_>, leaf_owner_bump: u8) -> Result<(), ProgramError> { - // Parse instruction args: - // args1(108) + proof_1_length(1) + args2(108) + _proof_2_length(1) = 218 bytes - if data.len() < 218 { +pub fn handle_withdraw_two_cnfts( + accounts: &mut WithdrawTwoCnftsAccountConstraints, + data: &[u8], + remaining: RemainingAccounts<'_>, + vault_bump: u8, +) -> Result<(), ProgramError> { + if data.len() < WITHDRAW_TWO_DATA_LEN { return Err(ProgramError::InvalidInstructionData); } let args1 = &data[0..TRANSFER_ARGS_LEN]; let proof_1_length = data[TRANSFER_ARGS_LEN] as usize; let args2 = &data[TRANSFER_ARGS_LEN + 1..TRANSFER_ARGS_LEN * 2 + 1]; - // _proof_2_length at data[217] — not needed, remaining after proof1 is proof2 + let proof_2_length = data[TRANSFER_ARGS_LEN * 2 + 1] as usize; // PDA signer seeds - let bump_bytes = [leaf_owner_bump]; + let bump_bytes = [vault_bump]; let seeds: [Seed; 2] = [ Seed::from(b"cNFT-vault" as &[u8]), Seed::from(&bump_bytes as &[u8]), ]; - let signer = Signer::from(&seeds as &[Seed]); + let signer = CpiSigner::from(&seeds as &[Seed]); // Collect all remaining accounts (proof1 ++ proof2). // // `remaining.iter()` yields `Result` in newer // quasar-lang. Reach the inner `AccountView` via the unchecked accessor - // — we only read addresses/views to forward to the bubblegum CPIs as + // - we only read addresses/views to forward to the bubblegum CPIs as // proof nodes; no aliased data access. let placeholder = accounts.system_program.to_account_view().clone(); let mut all_proofs: [AccountView; MAX_PROOF_NODES * 2] = @@ -85,9 +109,18 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain total_proofs += 1; } - // Split into proof1 and proof2 - let proof1_count = proof_1_length.min(total_proofs); - let proof2_count = total_proofs.saturating_sub(proof1_count); + // The proof lengths are client-supplied: bounds-check them against the + // accounts actually provided before splitting, so adversarial input gets + // a clean named error instead of misattributed proof nodes. + require!( + proof_1_length + .checked_add(proof_2_length) + .is_some_and(|total| total == total_proofs), + VaultError::ProofLengthMismatch + ); + + let proof1_count = proof_1_length; + let proof2_count = proof_2_length; // --- Withdraw cNFT #1 --- log("withdrawing cNFT#1"); @@ -102,8 +135,8 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain core::array::from_fn(|_| InstructionAccount::readonly(sys_addr)); ix_accounts[0] = InstructionAccount::readonly(accounts.tree_authority1.address()); - ix_accounts[1] = InstructionAccount::readonly_signer(accounts.leaf_owner.address()); - ix_accounts[2] = InstructionAccount::readonly(accounts.leaf_owner.address()); + ix_accounts[1] = InstructionAccount::readonly_signer(accounts.vault.address()); + ix_accounts[2] = InstructionAccount::readonly(accounts.vault.address()); ix_accounts[3] = InstructionAccount::readonly(accounts.new_leaf_owner1.address()); ix_accounts[4] = InstructionAccount::writable(accounts.merkle_tree1.address()); ix_accounts[5] = InstructionAccount::readonly(accounts.log_wrapper.address()); @@ -115,12 +148,11 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain } let sys_view = accounts.system_program.to_account_view().clone(); - let mut views: [AccountView; MAX_CPI_ACCOUNTS] = - core::array::from_fn(|_| sys_view.clone()); + let mut views: [AccountView; MAX_CPI_ACCOUNTS] = core::array::from_fn(|_| sys_view.clone()); views[0] = accounts.tree_authority1.to_account_view().clone(); - views[1] = accounts.leaf_owner.to_account_view().clone(); - views[2] = accounts.leaf_owner.to_account_view().clone(); + views[1] = accounts.vault.to_account_view().clone(); + views[2] = accounts.vault.to_account_view().clone(); views[3] = accounts.new_leaf_owner1.to_account_view().clone(); views[4] = accounts.merkle_tree1.to_account_view().clone(); views[5] = accounts.log_wrapper.to_account_view().clone(); @@ -137,10 +169,11 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain accounts: &ix_accounts[..total_accounts], }; - solana_instruction_view::cpi::invoke_signed_with_bounds::< - MAX_CPI_ACCOUNTS, - AccountView, - >(&instruction, &views[..total_accounts], &[signer.clone()])?; + solana_instruction_view::cpi::invoke_signed_with_bounds::( + &instruction, + &views[..total_accounts], + &[signer.clone()], + )?; } // --- Withdraw cNFT #2 --- @@ -156,8 +189,8 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain core::array::from_fn(|_| InstructionAccount::readonly(sys_addr)); ix_accounts[0] = InstructionAccount::readonly(accounts.tree_authority2.address()); - ix_accounts[1] = InstructionAccount::readonly_signer(accounts.leaf_owner.address()); - ix_accounts[2] = InstructionAccount::readonly(accounts.leaf_owner.address()); + ix_accounts[1] = InstructionAccount::readonly_signer(accounts.vault.address()); + ix_accounts[2] = InstructionAccount::readonly(accounts.vault.address()); ix_accounts[3] = InstructionAccount::readonly(accounts.new_leaf_owner2.address()); ix_accounts[4] = InstructionAccount::writable(accounts.merkle_tree2.address()); ix_accounts[5] = InstructionAccount::readonly(accounts.log_wrapper.address()); @@ -171,12 +204,11 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain } let sys_view = accounts.system_program.to_account_view().clone(); - let mut views: [AccountView; MAX_CPI_ACCOUNTS] = - core::array::from_fn(|_| sys_view.clone()); + let mut views: [AccountView; MAX_CPI_ACCOUNTS] = core::array::from_fn(|_| sys_view.clone()); views[0] = accounts.tree_authority2.to_account_view().clone(); - views[1] = accounts.leaf_owner.to_account_view().clone(); - views[2] = accounts.leaf_owner.to_account_view().clone(); + views[1] = accounts.vault.to_account_view().clone(); + views[2] = accounts.vault.to_account_view().clone(); views[3] = accounts.new_leaf_owner2.to_account_view().clone(); views[4] = accounts.merkle_tree2.to_account_view().clone(); views[5] = accounts.log_wrapper.to_account_view().clone(); @@ -193,10 +225,11 @@ pub fn handle_withdraw_two_cnfts(accounts: &mut WithdrawTwo, data: &[u8], remain accounts: &ix_accounts[..total_accounts], }; - solana_instruction_view::cpi::invoke_signed_with_bounds::< - MAX_CPI_ACCOUNTS, - AccountView, - >(&instruction, &views[..total_accounts], &[signer])?; + solana_instruction_view::cpi::invoke_signed_with_bounds::( + &instruction, + &views[..total_accounts], + &[signer], + )?; } log("successfully sent cNFTs"); diff --git a/compression/cnft-vault/quasar/src/lib.rs b/compression/cnft-vault/quasar/src/lib.rs index 5bc921ff..f676485f 100644 --- a/compression/cnft-vault/quasar/src/lib.rs +++ b/compression/cnft-vault/quasar/src/lib.rs @@ -2,7 +2,9 @@ use quasar_lang::prelude::*; +pub mod error; mod instructions; +pub mod state; use instructions::*; #[cfg(test)] mod tests; @@ -26,32 +28,41 @@ const SPL_ACCOUNT_COMPRESSION_ID: Address = Address::new_from_array([ declare_id!("Fd4iwpPWaCU8BNwGQGtvvrcvG4Tfizq3RgLm8YLBJX6D"); -/// Marker carrying the seeds for the vault PDA. Used by the new -/// `address = VaultPda::seeds()` derive form (post-PR-#195) since -/// inline `seeds = [...]` is no longer accepted. -#[derive(Seeds)] -#[seeds(b"cNFT-vault")] -pub struct VaultPda; - #[program] mod quasar_cnft_vault { use super::*; - /// Withdraw a single compressed NFT from the vault PDA. + /// Withdraw a single compressed NFT from the vault PDA. Only the + /// authority stored by initialize_vault may sign this. #[instruction(discriminator = 0)] - pub fn withdraw_cnft(ctx: CtxWithRemaining) -> Result<(), ProgramError> { + pub fn withdraw_cnft( + ctx: CtxWithRemaining, + ) -> Result<(), ProgramError> { let data = ctx.data; let remaining = ctx.remaining_accounts(); - let leaf_owner_bump = ctx.bumps.leaf_owner; - instructions::handle_withdraw_cnft(&mut ctx.accounts, data, remaining, leaf_owner_bump) + let vault_bump = ctx.bumps.vault; + instructions::handle_withdraw_cnft(&mut ctx.accounts, data, remaining, vault_bump) } - /// Withdraw two compressed NFTs from the vault PDA in a single transaction. + /// Withdraw two compressed NFTs from the vault PDA in a single + /// transaction. Only the authority stored by initialize_vault may sign + /// this. #[instruction(discriminator = 1)] - pub fn withdraw_two_cnfts(ctx: CtxWithRemaining) -> Result<(), ProgramError> { + pub fn withdraw_two_cnfts( + ctx: CtxWithRemaining, + ) -> Result<(), ProgramError> { let data = ctx.data; let remaining = ctx.remaining_accounts(); - let leaf_owner_bump = ctx.bumps.leaf_owner; - instructions::handle_withdraw_two_cnfts(&mut ctx.accounts, data, remaining, leaf_owner_bump) + let vault_bump = ctx.bumps.vault; + instructions::handle_withdraw_two_cnfts(&mut ctx.accounts, data, remaining, vault_bump) + } + + /// Create the vault PDA and store the signer as its withdraw authority. + #[instruction(discriminator = 2)] + pub fn initialize_vault( + ctx: Ctx, + ) -> Result<(), ProgramError> { + let vault_bump = ctx.bumps.vault; + instructions::handle_initialize_vault(&mut ctx.accounts, vault_bump) } } diff --git a/compression/cnft-vault/quasar/src/state.rs b/compression/cnft-vault/quasar/src/state.rs new file mode 100644 index 00000000..86ad6c50 --- /dev/null +++ b/compression/cnft-vault/quasar/src/state.rs @@ -0,0 +1,13 @@ +use quasar_lang::prelude::*; + +/// Vault PDA state. The same PDA stores the withdraw authority, owns the +/// cNFTs (as Bubblegum leaf owner), and signs transfer CPIs via +/// invoke_signed. +#[account(discriminator = 1, set_inner)] +#[seeds(b"cNFT-vault")] +pub struct Vault { + /// The only signer allowed to withdraw cNFTs from the vault. + pub authority: Address, + + pub bump: u8, +} diff --git a/compression/cnft-vault/quasar/src/tests.rs b/compression/cnft-vault/quasar/src/tests.rs index 83435d50..c65c6a05 100644 --- a/compression/cnft-vault/quasar/src/tests.rs +++ b/compression/cnft-vault/quasar/src/tests.rs @@ -1,3 +1,617 @@ -// Compressed NFT operations require external programs (Bubblegum, SPL Account -// Compression) that are not available in the quasar-svm test harness. The build -// itself verifies the CPI instruction construction compiles correctly. +//! QuasarSVM integration tests for the cnft-vault Quasar program. +//! +//! Ported from the Anchor twin's LiteSVM suite. The SVM loads the program +//! plus the three mainnet fixtures (mpl-bubblegum, spl-account-compression, +//! spl-noop) from `../anchor/tests/fixtures/`, then: +//! 1. Initializes the vault PDA via `initialize_vault`, storing the +//! withdraw authority. +//! 2. Creates a Bubblegum Merkle tree (max_depth=3, max_buffer_size=8, +//! canopy=0) via `create_tree_config`. The pre-allocated tree account is +//! passed in as a compression-program-owned account, standing in for the +//! system `create_account` step. +//! 3. Mints a cNFT whose leaf owner is the vault PDA via `mint_v1`. +//! 4. Recomputes `data_hash` / `creator_hash` exactly as Bubblegum does and +//! builds the proof for leaf 0 (all empty-node siblings). +//! 5. Calls the program's withdraw handlers, which CPI Bubblegum `Transfer` +//! signed by the vault PDA. +//! +//! Coverage: +//! - withdraw by the stored authority succeeds (single and two-cNFT) +//! - withdraw by a non-authority signer fails with +//! `VaultError::InvalidWithdrawAuthority` +//! - replaying a withdraw with the now-stale root fails +//! - a two-cNFT withdraw whose proof lengths do not match the supplied +//! proof accounts fails with `VaultError::ProofLengthMismatch` + +extern crate std; +use { + borsh::BorshSerialize, + quasar_cnft_vault_client::{ + InitializeVaultInstruction, QuasarCnftVaultError, WithdrawCnftInstruction, + WithdrawTwoCnftsInstruction, + }, + quasar_svm::{Account, Instruction, ProgramError, Pubkey, QuasarSvm}, + solana_instruction::AccountMeta, + solana_keccak_hasher::hashv, + std::{string::ToString, vec, vec::Vec}, +}; + +// ---- Program IDs ---------------------------------------------------------- + +const BUBBLEGUM_ID: Pubkey = Pubkey::from_str_const("BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY"); +const COMPRESSION_ID: Pubkey = + Pubkey::from_str_const("cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK"); +const NOOP_ID: Pubkey = Pubkey::from_str_const("noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"); + +// ---- Bubblegum instruction discriminators --------------------------------- + +const CREATE_TREE_CONFIG_DISC: [u8; 8] = [165, 83, 136, 142, 89, 202, 47, 220]; +const MINT_V1_DISC: [u8; 8] = [145, 98, 192, 118, 184, 147, 118, 104]; + +// ---- Tree parameters ------------------------------------------------------ + +const MAX_DEPTH: u32 = 3; +const MAX_BUFFER_SIZE: u32 = 8; + +/// Lamports for funded signers and prefabricated accounts; comfortably above +/// rent exemption for every account size used here. +const FUNDING_LAMPORTS: u64 = 1_000_000_000; + +// ---- MetadataArgs (mirrors mpl_bubblegum::types::MetadataArgs borsh layout) ---- + +#[derive(BorshSerialize, Clone)] +struct Creator { + address: [u8; 32], + verified: bool, + share: u8, +} + +#[derive(BorshSerialize, Clone)] +enum TokenProgramVersion { + #[allow(dead_code)] + Original, + #[allow(dead_code)] + Token2022, +} + +#[derive(BorshSerialize, Clone)] +struct MetadataArgs { + name: std::string::String, + symbol: std::string::String, + uri: std::string::String, + seller_fee_basis_points: u16, + primary_sale_happened: bool, + is_mutable: bool, + edition_nonce: Option, + token_standard: Option, // TokenStandard enum, encoded by variant index + collection: Option, // None - Collection, kept absent + uses: Option, // None - Uses, kept absent + token_program_version: TokenProgramVersion, + creators: Vec, +} + +// ---- Hashing, exactly as the Bubblegum program does ------------------------ + +fn hash_metadata(metadata: &MetadataArgs) -> [u8; 32] { + let serialized = borsh::to_vec(metadata).unwrap(); + let inner = hashv(&[serialized.as_slice()]).to_bytes(); + hashv(&[&inner, &metadata.seller_fee_basis_points.to_le_bytes()]).to_bytes() +} + +fn hash_creators(creators: &[Creator]) -> [u8; 32] { + let creator_data: Vec> = creators + .iter() + .map(|c| [c.address.as_ref(), &[c.verified as u8], &[c.share]].concat()) + .collect(); + hashv( + creator_data + .iter() + .map(|c| c.as_slice()) + .collect::>() + .as_slice(), + ) + .to_bytes() +} + +// ---- SPL account-compression empty-node helper ----------------------------- + +fn empty_node(level: u32) -> [u8; 32] { + if level == 0 { + return [0u8; 32]; + } + let lower = empty_node(level - 1); + hashv(&[&lower, &lower]).to_bytes() +} + +// ---- ConcurrentMerkleTree<3,8> account layout ------------------------------ +// +// account_data = header (56 bytes) || zero-copy ConcurrentMerkleTree (1248) || canopy (0) +// +// Header (ConcurrentMerkleTreeHeader): account_type(1) + header-enum-discriminant(1) +// + V1{ max_buffer_size(4), max_depth(4), authority(32), creation_slot(8), +// is_batch_initialized(1), _padding[5] } = 56 bytes total. +// +// ConcurrentMerkleTree<3,8> (#[repr(C)]): +// sequence_number u64 (off 0) +// active_index u64 (off 8) +// buffer_size u64 (off 16) +// change_logs [ChangeLog<3>; 8] (off 24), stride = 136 +// ChangeLog<3> = root[32] + path[3*32] + index u32 + _padding u32 = 136 +// rightmost_proof Path<3> +// +// Current root = change_logs[active_index].root. + +const HEADER_SIZE: usize = 56; +const CMT_SIZE: usize = { + let changelog = 32 + 3 * 32 + 4 + 4; // 136 + let path = 3 * 32 + 32 + 4 + 4; // 136 + 8 + 8 + 8 + changelog * 8 + path +}; +const TREE_ACCOUNT_SIZE: usize = HEADER_SIZE + CMT_SIZE; + +fn read_current_root(data: &[u8]) -> [u8; 32] { + let tree = &data[HEADER_SIZE..]; + let active_index = u64::from_le_bytes(tree[8..16].try_into().unwrap()) as usize; + let changelog_stride = 136; + let root_off = 24 + active_index * changelog_stride; + let mut root = [0u8; 32]; + root.copy_from_slice(&tree[root_off..root_off + 32]); + root +} + +// ---- Account helpers -------------------------------------------------------- + +fn signer(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, FUNDING_LAMPORTS) +} + +fn empty(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: quasar_svm::system_program::ID, + executable: false, + } +} + +fn vault_error(error: QuasarCnftVaultError) -> ProgramError { + ProgramError::Custom(error as u32) +} + +// ---- Fixture setup ---------------------------------------------------------- + +/// One Bubblegum tree holding a single cNFT owned by the vault PDA, plus +/// everything needed to withdraw it (root, hashes, proof). +struct TreeWithVaultCnft { + merkle_tree: Pubkey, + tree_config: Pubkey, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + proof: [[u8; 32]; MAX_DEPTH as usize], +} + +struct VaultTestContext { + svm: QuasarSvm, + payer: Pubkey, + /// The address stored as the vault's withdraw authority. + authority: Pubkey, + vault_pda: Pubkey, +} + +fn setup_vault() -> VaultTestContext { + // The fixture binaries are shared with the Anchor twin's LiteSVM suite. + let program_elf = std::fs::read("target/deploy/quasar_cnft_vault.so").unwrap(); + let bubblegum_elf = std::fs::read("../anchor/tests/fixtures/mpl_bubblegum.so").unwrap(); + let compression_elf = + std::fs::read("../anchor/tests/fixtures/spl_account_compression.so").unwrap(); + let noop_elf = std::fs::read("../anchor/tests/fixtures/spl_noop.so").unwrap(); + + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &program_elf) + .with_program(&BUBBLEGUM_ID, &bubblegum_elf) + .with_program(&COMPRESSION_ID, &compression_elf) + .with_program(&NOOP_ID, &noop_elf); + + let payer = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let (vault_pda, _) = Pubkey::find_program_address(&[b"cNFT-vault"], &crate::ID); + + // initialize_vault: store `authority` on the vault PDA. + let instruction: Instruction = InitializeVaultInstruction { + authority, + vault: vault_pda, + system_program: quasar_svm::system_program::ID, + } + .into(); + let result = svm.process_instruction(&instruction, &[signer(authority), empty(vault_pda)]); + result.assert_success(); + + VaultTestContext { + svm, + payer, + authority, + vault_pda, + } +} + +/// Create a Bubblegum tree and mint one cNFT into the vault PDA. +fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultCnft { + let payer = context.payer; + let merkle_tree = Pubkey::new_unique(); + + // The allocated-but-uninitialized tree account the system program would + // have created in the `create_account` step (a foreign-program account, + // so prefabricating it is fine). + let tree_account = Account { + address: merkle_tree, + lamports: FUNDING_LAMPORTS, + data: vec![0; TREE_ACCOUNT_SIZE], + owner: COMPRESSION_ID, + executable: false, + }; + + // tree_authority (a.k.a tree_config) PDA = [merkle_tree] under bubblegum. + let (tree_config, _) = Pubkey::find_program_address(&[merkle_tree.as_ref()], &BUBBLEGUM_ID); + + // create_tree_config(max_depth, max_buffer_size, public=None) + let create_tree_instruction = Instruction { + program_id: BUBBLEGUM_ID, + accounts: vec![ + AccountMeta::new(tree_config, false), + AccountMeta::new(merkle_tree, false), + AccountMeta::new(payer, true), + AccountMeta::new_readonly(payer, true), // tree_creator + AccountMeta::new_readonly(NOOP_ID, false), + AccountMeta::new_readonly(COMPRESSION_ID, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + ], + data: { + let mut d = CREATE_TREE_CONFIG_DISC.to_vec(); + d.extend_from_slice(&MAX_DEPTH.to_le_bytes()); + d.extend_from_slice(&MAX_BUFFER_SIZE.to_le_bytes()); + d.push(0); // Option::None + d + }, + }; + context + .svm + .process_instruction( + &create_tree_instruction, + &[signer(payer), empty(tree_config), tree_account], + ) + .assert_success(); + + // Build the MetadataArgs for the single cNFT we mint. The leaf owner / + // delegate are the vault PDA, so the vault holds the cNFT. + let creator = Creator { + address: payer.to_bytes(), + verified: false, + share: 100, + }; + let metadata = MetadataArgs { + name: "Vault cNFT".to_string(), + symbol: "VCNFT".to_string(), + uri: "https://example.com/nft.json".to_string(), + seller_fee_basis_points: 500, + primary_sale_happened: false, + is_mutable: true, + edition_nonce: None, + token_standard: Some(0), // TokenStandard::NonFungible + collection: None, + uses: None, + token_program_version: TokenProgramVersion::Original, + creators: vec![creator], + }; + + // mint_v1 - leaf_owner and leaf_delegate are the vault PDA. + let mint_instruction = Instruction { + program_id: BUBBLEGUM_ID, + accounts: vec![ + AccountMeta::new(tree_config, false), + AccountMeta::new_readonly(context.vault_pda, false), + AccountMeta::new_readonly(context.vault_pda, false), // leaf_delegate + AccountMeta::new(merkle_tree, false), + AccountMeta::new_readonly(payer, true), + AccountMeta::new_readonly(payer, true), // tree_creator_or_delegate + AccountMeta::new_readonly(NOOP_ID, false), + AccountMeta::new_readonly(COMPRESSION_ID, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + ], + data: { + let mut d = MINT_V1_DISC.to_vec(); + d.extend_from_slice(&borsh::to_vec(&metadata).unwrap()); + d + }, + }; + context + .svm + .process_instruction(&mint_instruction, &[]) + .assert_success(); + + // Recompute data_hash and creator_hash exactly as Bubblegum does. + let data_hash = hash_metadata(&metadata); + let creator_hash = hash_creators(&metadata.creators); + + // Proof for leaf index 0 in an otherwise-empty tree: empty-node siblings. + let proof = [empty_node(0), empty_node(1), empty_node(2)]; + + // Read the current root from the onchain tree account. + let tree_data = context.svm.get_account(&merkle_tree).unwrap().data; + let root = read_current_root(&tree_data); + + TreeWithVaultCnft { + merkle_tree, + tree_config, + root, + data_hash, + creator_hash, + proof, + } +} + +// ---- Instruction builders for the program under test ------------------------ + +/// Bubblegum Transfer args: root(32) + data_hash(32) + creator_hash(32) + +/// nonce(8) + index(4). Leaf 0 in a fresh tree has nonce 0 and index 0. +fn transfer_args(tree: &TreeWithVaultCnft) -> Vec { + let mut args = Vec::new(); + args.extend_from_slice(&tree.root); + args.extend_from_slice(&tree.data_hash); + args.extend_from_slice(&tree.creator_hash); + args.extend_from_slice(&0u64.to_le_bytes()); // nonce + args.extend_from_slice(&0u32.to_le_bytes()); // index + args +} + +fn proof_metas(nodes: &[[u8; 32]]) -> Vec { + nodes + .iter() + .map(|node| AccountMeta::new_readonly(Pubkey::new_from_array(*node), false)) + .collect() +} + +fn proof_accounts(nodes: &[[u8; 32]]) -> Vec { + nodes + .iter() + .map(|node| Pubkey::new_from_array(*node)) + // empty_node(0) is all zeros, which is the system program's address. + // That account is already in the SVM's database; passing an empty + // account for it would overwrite the loaded program. + .filter(|address| *address != quasar_svm::system_program::ID) + .map(empty) + .collect() +} + +fn build_withdraw_cnft_instruction( + context: &VaultTestContext, + signer: Pubkey, + tree: &TreeWithVaultCnft, + recipient: Pubkey, +) -> Instruction { + let mut instruction: Instruction = WithdrawCnftInstruction { + authority: signer, + vault: context.vault_pda, + tree_authority: tree.tree_config, + new_leaf_owner: recipient, + merkle_tree: tree.merkle_tree, + log_wrapper: NOOP_ID, + compression_program: COMPRESSION_ID, + bubblegum_program: BUBBLEGUM_ID, + system_program: quasar_svm::system_program::ID, + remaining_accounts: proof_metas(&tree.proof), + } + .into(); + // The generated builder carries only the discriminator byte; the handler + // reads the raw Transfer args from the rest of the instruction data. + instruction.data.extend_from_slice(&transfer_args(tree)); + instruction +} + +#[allow(clippy::too_many_arguments)] +fn build_withdraw_two_cnfts_instruction( + context: &VaultTestContext, + signer: Pubkey, + tree1: &TreeWithVaultCnft, + tree2: &TreeWithVaultCnft, + recipient: Pubkey, + proof_1_length: u8, + proof_2_length: u8, +) -> Instruction { + let mut remaining_accounts = proof_metas(&tree1.proof); + remaining_accounts.extend(proof_metas(&tree2.proof)); + let mut instruction: Instruction = WithdrawTwoCnftsInstruction { + authority: signer, + vault: context.vault_pda, + tree_authority1: tree1.tree_config, + new_leaf_owner1: recipient, + merkle_tree1: tree1.merkle_tree, + tree_authority2: tree2.tree_config, + new_leaf_owner2: recipient, + merkle_tree2: tree2.merkle_tree, + log_wrapper: NOOP_ID, + compression_program: COMPRESSION_ID, + bubblegum_program: BUBBLEGUM_ID, + system_program: quasar_svm::system_program::ID, + remaining_accounts, + } + .into(); + // args1(108) + proof_1_length(1) + args2(108) + proof_2_length(1) + instruction.data.extend_from_slice(&transfer_args(tree1)); + instruction.data.push(proof_1_length); + instruction.data.extend_from_slice(&transfer_args(tree2)); + instruction.data.push(proof_2_length); + instruction +} + +/// Accounts a withdraw brings to process_instruction: the recipient and the +/// proof-node addresses (everything else is already in the SVM's database). +fn withdraw_extra_accounts(recipient: Pubkey, trees: &[&TreeWithVaultCnft]) -> Vec { + let mut accounts = vec![empty(recipient)]; + for tree in trees { + accounts.extend(proof_accounts(&tree.proof)); + } + accounts +} + +// ---- Tests ------------------------------------------------------------------ + +#[test] +fn test_initialize_vault_stores_authority() { + let context = setup_vault(); + + // Vault zero-copy layout: [disc:1][authority:32][bump:1] + let vault_account = context.svm.get_account(&context.vault_pda).unwrap(); + assert_eq!(vault_account.data[0], 1, "Vault discriminator"); + assert_eq!(&vault_account.data[1..33], context.authority.as_ref()); + let (_, expected_bump) = Pubkey::find_program_address(&[b"cNFT-vault"], &crate::ID); + assert_eq!(vault_account.data[33], expected_bump); +} + +#[test] +fn test_withdraw_cnft_by_authority() { + let mut context = setup_vault(); + let tree = create_tree_with_vault_cnft(&mut context); + let recipient = Pubkey::new_unique(); + + let instruction = + build_withdraw_cnft_instruction(&context, context.authority, &tree, recipient); + + // The stored authority signs, so the withdraw succeeds (the vault PDA + // signs the Bubblegum CPI via invoke_signed inside the program). + let result = context + .svm + .process_instruction(&instruction, &withdraw_extra_accounts(recipient, &[&tree])); + assert!( + result.is_ok(), + "withdraw_cnft signed by the vault authority should succeed: {:?}\nlogs: {:#?}", + result.raw_result, + result.logs + ); + + // After transfer, leaf 0's owner changed (vault -> recipient), so the root + // moved. A second withdraw replaying the same (root, hashes) must fail: the + // cached root is stale and the leaf no longer hashes to it for the vault. + let replay = context + .svm + .process_instruction(&instruction, &withdraw_extra_accounts(recipient, &[&tree])); + assert!( + !replay.is_ok(), + "second withdraw must fail: leaf already transferred out of the vault" + ); +} + +#[test] +fn test_withdraw_cnft_rejected_for_non_authority() { + let mut context = setup_vault(); + let tree = create_tree_with_vault_cnft(&mut context); + let recipient = Pubkey::new_unique(); + + // An attacker signs their own withdraw attempt; the vault's stored + // authority did not sign. + let attacker = Pubkey::new_unique(); + let instruction = build_withdraw_cnft_instruction(&context, attacker, &tree, recipient); + + let mut accounts = withdraw_extra_accounts(recipient, &[&tree]); + accounts.push(signer(attacker)); + let result = context.svm.process_instruction(&instruction, &accounts); + result.assert_error(vault_error(QuasarCnftVaultError::InvalidWithdrawAuthority)); +} + +#[test] +fn test_withdraw_two_cnfts_by_authority() { + let mut context = setup_vault(); + let tree1 = create_tree_with_vault_cnft(&mut context); + let tree2 = create_tree_with_vault_cnft(&mut context); + let recipient = Pubkey::new_unique(); + + let instruction = build_withdraw_two_cnfts_instruction( + &context, + context.authority, + &tree1, + &tree2, + recipient, + MAX_DEPTH as u8, + MAX_DEPTH as u8, + ); + + let result = context.svm.process_instruction( + &instruction, + &withdraw_extra_accounts(recipient, &[&tree1, &tree2]), + ); + assert!( + result.is_ok(), + "withdraw_two_cnfts signed by the vault authority should succeed: {:?}", + result.raw_result + ); + + // Both trees' roots moved, so both cNFTs left the vault: replaying the + // single-tree withdraw against tree1 with the cached root fails. + let replay_instruction = + build_withdraw_cnft_instruction(&context, context.authority, &tree1, recipient); + let replay = context.svm.process_instruction( + &replay_instruction, + &withdraw_extra_accounts(recipient, &[&tree1]), + ); + assert!( + !replay.is_ok(), + "cNFT#1 already left the vault, replay must fail" + ); +} + +#[test] +fn test_withdraw_two_cnfts_rejects_out_of_range_proof_length() { + let mut context = setup_vault(); + let tree1 = create_tree_with_vault_cnft(&mut context); + let tree2 = create_tree_with_vault_cnft(&mut context); + let recipient = Pubkey::new_unique(); + + // Claim one more proof node for tree1 than the instruction supplies in + // total: the bounds check must return ProofLengthMismatch instead of + // splitting past the end of the supplied proof accounts. + let supplied_proof_nodes = 2 * MAX_DEPTH as u8; + let out_of_range_proof_1_length = supplied_proof_nodes + 1; + + let instruction = build_withdraw_two_cnfts_instruction( + &context, + context.authority, + &tree1, + &tree2, + recipient, + out_of_range_proof_1_length, + 0, + ); + + let result = context.svm.process_instruction( + &instruction, + &withdraw_extra_accounts(recipient, &[&tree1, &tree2]), + ); + result.assert_error(vault_error(QuasarCnftVaultError::ProofLengthMismatch)); +} + +#[test] +fn test_withdraw_two_cnfts_rejects_inconsistent_proof_lengths() { + let mut context = setup_vault(); + let tree1 = create_tree_with_vault_cnft(&mut context); + let tree2 = create_tree_with_vault_cnft(&mut context); + let recipient = Pubkey::new_unique(); + + // proof_1_length is in range but the two lengths do not add up to the + // supplied proof accounts, so the split would misattribute proof nodes. + let instruction = build_withdraw_two_cnfts_instruction( + &context, + context.authority, + &tree1, + &tree2, + recipient, + MAX_DEPTH as u8 - 1, + MAX_DEPTH as u8, + ); + + let result = context.svm.process_instruction( + &instruction, + &withdraw_extra_accounts(recipient, &[&tree1, &tree2]), + ); + result.assert_error(vault_error(QuasarCnftVaultError::ProofLengthMismatch)); +} diff --git a/compression/cutils/anchor/Anchor.toml b/compression/cutils/anchor/Anchor.toml index 0d2e1b66..9bcb1fd2 100644 --- a/compression/cutils/anchor/Anchor.toml +++ b/compression/cutils/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] cutils = "BuFyrgRYzg2nPhqYrxZ7d9uYUs4VXtxH71U8EcoAfTQZ" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/compression/cutils/anchor/README.md b/compression/cutils/anchor/README.md index 7fe7e594..edd5ae69 100644 --- a/compression/cutils/anchor/README.md +++ b/compression/cutils/anchor/README.md @@ -4,20 +4,27 @@ Example code for working with Metaplex compressed NFTs (cNFTs) inside Solana [An This program shows how to add custom logic around the Bubblegum [mint](https://solana.com/docs/terminology#token-mint) via [CPI](https://solana.com/docs/terminology#cross-program-invocation-cpi). Two handlers: -1. `mint` — mints a cNFT to your collection by CPI'ing Bubblegum. You can also initialize your own program-specific [PDA](https://solana.com/docs/terminology#program-derived-address-pda) in this handler. -2. `verify` — verifies that the owner of a given cNFT actually invoked the [instruction](https://solana.com/docs/terminology#instruction). Useful as a building block for permissioned cNFT-gated logic. +1. `mint` - mints a cNFT to your collection by CPI'ing Bubblegum. You can also initialize your own program-specific [PDA](https://solana.com/docs/terminology#program-derived-address-pda) in this handler. +2. `verify` - verifies that the owner of a given cNFT actually invoked the [instruction](https://solana.com/docs/terminology#instruction). Useful as a building block for permissioned cNFT-gated logic. Use this as a reference for working with cNFTs in your own programs. ## Components -- `programs/cutils/` — the Anchor program. The setup uses a `validate`/`actuate` pattern via Anchor's `access_control` macro; this pairs well with the cNFT verification logic. +- `programs/cutils/` - the Anchor program. Instruction handlers live in `src/instructions/` (`handle_mint`, `handle_verify`). -There is no `tests/` directory in this example today. The program is intended to be deployed and exercised against a real cluster. +## Testing + +A Rust [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) integration suite lives in `programs/cutils/tests/`. It loads mainnet-dumped fixture binaries for Bubblegum, SPL Account Compression, and SPL Noop from `tests/fixtures/` (see the README there), so the CPIs run against the real programs in-process. + +```bash +cargo build-sbf +cargo test +``` ## Deployment -The program ID declared in [`programs/cutils/src/lib.rs`](programs/cutils/src/lib.rs) is `BuFyrgRYzg2nPhqYrxZ7d9uYUs4VXtxH71U8EcoAfTQZ`. Whether this address is currently deployed on any cluster is not tracked in this repo — verify with `solana program show ` against the cluster you care about. +The program ID declared in [`programs/cutils/src/lib.rs`](programs/cutils/src/lib.rs) is `BuFyrgRYzg2nPhqYrxZ7d9uYUs4VXtxH71U8EcoAfTQZ`. Whether this address is currently deployed on any cluster is not tracked in this repo - verify with `solana program show ` against the cluster you care about. To deploy your own copy, change the program ID in `lib.rs` and `Anchor.toml`, then run `anchor build && anchor deploy`. @@ -29,4 +36,3 @@ Reference implementation only. - [@nickfrosty](https://twitter.com/nickfrosty) for the sample code and [live demo](https://youtu.be/LxhTxS9DexU). - [@HeyAndyS](https://twitter.com/HeyAndyS) for the groundwork in `cnft-vault`. -- Switchboard VRF-flip (since archived) for inspiring the validate/actuate setup. diff --git a/compression/cutils/anchor/programs/cutils/Cargo.toml b/compression/cutils/anchor/programs/cutils/Cargo.toml index 8ded92cb..7f5c38be 100644 --- a/compression/cutils/anchor/programs/cutils/Cargo.toml +++ b/compression/cutils/anchor/programs/cutils/Cargo.toml @@ -26,7 +26,6 @@ anchor-lang = "1.0.0" # using raw invoke() with hardcoded program IDs and discriminators. Bubblegum types # (MetadataArgs, LeafSchema, etc.) are re-implemented in bubblegum_types.rs. borsh = "1" -sha2 = "0.10" sha3 = "0.10" [lints.rust] diff --git a/compression/cutils/anchor/programs/cutils/src/instructions/mint.rs b/compression/cutils/anchor/programs/cutils/src/instructions/mint.rs index 338539bc..795bb9ca 100644 --- a/compression/cutils/anchor/programs/cutils/src/instructions/mint.rs +++ b/compression/cutils/anchor/programs/cutils/src/instructions/mint.rs @@ -11,7 +11,7 @@ use borsh::BorshSerialize; #[derive(Accounts)] #[instruction(params: MintParams)] -pub struct Mint<'info> { +pub struct MintAccountConstraints<'info> { pub payer: Signer<'info>, #[account( @@ -30,7 +30,8 @@ pub struct Mint<'info> { pub leaf_delegate: UncheckedAccount<'info>, #[account(mut)] - /// CHECK: unsafe + /// CHECK: Written by the Bubblegum/Account Compression CPI (the mint + /// appends a leaf and updates the tree root); validated downstream. pub merkle_tree: UncheckedAccount<'info>, pub tree_delegate: Signer<'info>, @@ -70,140 +71,137 @@ pub struct MintParams { uri: String, } -impl Mint<'_> { - pub fn validate(&self, _context: &Context, _params: &MintParams) -> Result<()> { - Ok(()) - } - - // `with_capacity` + push is intentional here: it documents the exact 16-account - // MintToCollectionV1 layout in CPI order, so allow clippy's vec_init_then_push. - #[allow(clippy::vec_init_then_push)] - pub fn actuate<'info>(context: Context<'info, Mint<'info>>, params: MintParams) -> Result<()> { - // Build MintToCollectionV1 instruction data - let args = MintToCollectionV1InstructionArgs { - metadata: MetadataArgs { - name: "BURGER".to_string(), - symbol: "BURG".to_string(), - uri: params.uri, - creators: vec![Creator { - address: context.accounts.collection_authority.key(), - verified: false, - share: 100, - }], - seller_fee_basis_points: 0, - primary_sale_happened: false, - is_mutable: false, - edition_nonce: Some(0), - uses: None, - collection: Some(Collection { - verified: false, - key: context.accounts.collection_mint.key(), - }), - token_program_version: TokenProgramVersion::Original, - token_standard: Some(TokenStandard::NonFungible), - }, - }; - - let mut data = MINT_TO_COLLECTION_V1_DISCRIMINATOR.to_vec(); - args.serialize(&mut data)?; - - // Build account metas matching MintToCollectionV1 instruction layout - let mut accounts = Vec::with_capacity(16); - accounts.push(AccountMeta::new( - context.accounts.tree_authority.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.leaf_owner.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.leaf_delegate.key(), - false, - )); - accounts.push(AccountMeta::new(context.accounts.merkle_tree.key(), false)); - accounts.push(AccountMeta::new_readonly( - context.accounts.payer.key(), - true, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.tree_delegate.key(), - true, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.collection_authority.key(), - true, - )); - // collection_authority_record_pda — pass as-is - accounts.push(AccountMeta::new_readonly( - context.accounts.collection_authority_record_pda.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.collection_mint.key(), - false, - )); - accounts.push(AccountMeta::new( - context.accounts.collection_metadata.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.edition_account.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.bubblegum_signer.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.log_wrapper.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.compression_program.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.token_metadata_program.key(), - false, - )); - accounts.push(AccountMeta::new_readonly( - context.accounts.system_program.key(), - false, - )); - - let instruction = Instruction { - program_id: MPL_BUBBLEGUM_ID, - accounts, - data, - }; - - // Gather all account infos for the CPI - let account_infos = vec![ - context.accounts.bubblegum_program.to_account_info(), - context.accounts.tree_authority.to_account_info(), - context.accounts.leaf_owner.to_account_info(), - context.accounts.leaf_delegate.to_account_info(), - context.accounts.merkle_tree.to_account_info(), - context.accounts.payer.to_account_info(), - context.accounts.tree_delegate.to_account_info(), - context.accounts.collection_authority.to_account_info(), - context - .accounts - .collection_authority_record_pda - .to_account_info(), - context.accounts.collection_mint.to_account_info(), - context.accounts.collection_metadata.to_account_info(), - context.accounts.edition_account.to_account_info(), - context.accounts.bubblegum_signer.to_account_info(), - context.accounts.log_wrapper.to_account_info(), - context.accounts.compression_program.to_account_info(), - context.accounts.token_metadata_program.to_account_info(), - context.accounts.system_program.to_account_info(), - ]; - - invoke(&instruction, &account_infos)?; - - Ok(()) - } +// `with_capacity` + push is intentional here: it documents the exact 16-account +// MintToCollectionV1 layout in CPI order, so allow clippy's vec_init_then_push. +#[allow(clippy::vec_init_then_push)] +pub fn handle_mint<'info>( + context: Context<'info, MintAccountConstraints<'info>>, + params: MintParams, +) -> Result<()> { + // Build MintToCollectionV1 instruction data + let args = MintToCollectionV1InstructionArgs { + metadata: MetadataArgs { + name: "BURGER".to_string(), + symbol: "BURG".to_string(), + uri: params.uri, + creators: vec![Creator { + address: context.accounts.collection_authority.key(), + verified: false, + share: 100, + }], + seller_fee_basis_points: 0, + primary_sale_happened: false, + is_mutable: false, + edition_nonce: Some(0), + uses: None, + collection: Some(Collection { + verified: false, + key: context.accounts.collection_mint.key(), + }), + token_program_version: TokenProgramVersion::Original, + token_standard: Some(TokenStandard::NonFungible), + }, + }; + + let mut data = MINT_TO_COLLECTION_V1_DISCRIMINATOR.to_vec(); + args.serialize(&mut data)?; + + // Build account metas matching MintToCollectionV1 instruction layout + let mut accounts = Vec::with_capacity(16); + accounts.push(AccountMeta::new( + context.accounts.tree_authority.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.leaf_owner.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.leaf_delegate.key(), + false, + )); + accounts.push(AccountMeta::new(context.accounts.merkle_tree.key(), false)); + accounts.push(AccountMeta::new_readonly( + context.accounts.payer.key(), + true, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.tree_delegate.key(), + true, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.collection_authority.key(), + true, + )); + // collection_authority_record_pda - pass as-is + accounts.push(AccountMeta::new_readonly( + context.accounts.collection_authority_record_pda.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.collection_mint.key(), + false, + )); + accounts.push(AccountMeta::new( + context.accounts.collection_metadata.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.edition_account.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.bubblegum_signer.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.log_wrapper.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.compression_program.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.token_metadata_program.key(), + false, + )); + accounts.push(AccountMeta::new_readonly( + context.accounts.system_program.key(), + false, + )); + + let instruction = Instruction { + program_id: MPL_BUBBLEGUM_ID, + accounts, + data, + }; + + // Gather all account infos for the CPI + let account_infos = vec![ + context.accounts.bubblegum_program.to_account_info(), + context.accounts.tree_authority.to_account_info(), + context.accounts.leaf_owner.to_account_info(), + context.accounts.leaf_delegate.to_account_info(), + context.accounts.merkle_tree.to_account_info(), + context.accounts.payer.to_account_info(), + context.accounts.tree_delegate.to_account_info(), + context.accounts.collection_authority.to_account_info(), + context + .accounts + .collection_authority_record_pda + .to_account_info(), + context.accounts.collection_mint.to_account_info(), + context.accounts.collection_metadata.to_account_info(), + context.accounts.edition_account.to_account_info(), + context.accounts.bubblegum_signer.to_account_info(), + context.accounts.log_wrapper.to_account_info(), + context.accounts.compression_program.to_account_info(), + context.accounts.token_metadata_program.to_account_info(), + context.accounts.system_program.to_account_info(), + ]; + + invoke(&instruction, &account_infos)?; + + Ok(()) } diff --git a/compression/cutils/anchor/programs/cutils/src/instructions/verify.rs b/compression/cutils/anchor/programs/cutils/src/instructions/verify.rs index bb5cda15..60d72b51 100644 --- a/compression/cutils/anchor/programs/cutils/src/instructions/verify.rs +++ b/compression/cutils/anchor/programs/cutils/src/instructions/verify.rs @@ -4,13 +4,14 @@ use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; #[derive(Accounts)] #[instruction(params: VerifyParams)] -pub struct Verify<'info> { +pub struct VerifyAccountConstraints<'info> { pub leaf_owner: Signer<'info>, /// CHECK: This account is neither written to nor read from. pub leaf_delegate: UncheckedAccount<'info>, - /// CHECK: unsafe + /// CHECK: Read by the SPL Account Compression verify_leaf CPI, which + /// validates the proof against this tree's stored root. pub merkle_tree: UncheckedAccount<'info>, pub compression_program: Program<'info, SPLCompression>, @@ -25,62 +26,54 @@ pub struct VerifyParams { index: u32, } -impl Verify<'_> { - pub fn validate(&self, _context: &Context, _params: &VerifyParams) -> Result<()> { - Ok(()) - } - - pub fn actuate<'info>( - context: Context<'info, Verify<'info>>, - params: &VerifyParams, - ) -> Result<()> { - let asset_id = get_asset_id(&context.accounts.merkle_tree.key(), params.nonce); - let leaf_hash = leaf_schema_v1_hash( - &asset_id, - &context.accounts.leaf_owner.key(), - &context.accounts.leaf_delegate.key(), - params.nonce, - ¶ms.data_hash, - ¶ms.creator_hash, - ); +/// spl-account-compression `verify_leaf` instruction discriminator: +/// sha256("global:verify_leaf")[..8]. Precomputed because hashing a constant +/// at runtime burns compute for no benefit. +const VERIFY_LEAF_DISCRIMINATOR: [u8; 8] = [124, 220, 22, 223, 104, 10, 250, 224]; - // Build verify_leaf instruction manually because spl-account-compression 1.0.0 - // depends on solana-program 2.x which is incompatible with Anchor 1.0's solana 3.x - // types. Once a compatible version is available, replace this with the CPI wrapper. - use sha2::{Digest, Sha256}; +pub fn handle_verify<'info>( + context: Context<'info, VerifyAccountConstraints<'info>>, + params: &VerifyParams, +) -> Result<()> { + let asset_id = get_asset_id(&context.accounts.merkle_tree.key(), params.nonce); + let leaf_hash = leaf_schema_v1_hash( + &asset_id, + &context.accounts.leaf_owner.key(), + &context.accounts.leaf_delegate.key(), + params.nonce, + ¶ms.data_hash, + ¶ms.creator_hash, + ); - let mut accounts = vec![AccountMeta::new_readonly( - context.accounts.merkle_tree.key(), - false, - )]; - for acc in context.remaining_accounts.iter() { - accounts.push(AccountMeta::new_readonly(acc.key(), false)); - } + // Build verify_leaf instruction manually because spl-account-compression 1.0.0 + // depends on solana-program 2.x which is incompatible with Anchor 1.0's solana 3.x + // types. Once a compatible version is available, replace this with the CPI wrapper. + let mut accounts = vec![AccountMeta::new_readonly( + context.accounts.merkle_tree.key(), + false, + )]; + for acc in context.remaining_accounts.iter() { + accounts.push(AccountMeta::new_readonly(acc.key(), false)); + } - // Compute the spl-account-compression verify_leaf discriminator: - // sha256("global:verify_leaf")[..8] - let discriminator: [u8; 8] = Sha256::digest(b"global:verify_leaf")[..8] - .try_into() - .unwrap(); - let mut data = discriminator.to_vec(); - data.extend_from_slice(¶ms.root); - data.extend_from_slice(&leaf_hash); - data.extend_from_slice(¶ms.index.to_le_bytes()); + let mut data = VERIFY_LEAF_DISCRIMINATOR.to_vec(); + data.extend_from_slice(¶ms.root); + data.extend_from_slice(&leaf_hash); + data.extend_from_slice(¶ms.index.to_le_bytes()); - let mut account_infos = vec![context.accounts.merkle_tree.to_account_info()]; - for acc in context.remaining_accounts.iter() { - account_infos.push(acc.to_account_info()); - } + let mut account_infos = vec![context.accounts.merkle_tree.to_account_info()]; + for acc in context.remaining_accounts.iter() { + account_infos.push(acc.to_account_info()); + } - anchor_lang::solana_program::program::invoke( - &Instruction { - program_id: context.accounts.compression_program.key(), - accounts, - data, - }, - &account_infos, - )?; + anchor_lang::solana_program::program::invoke( + &Instruction { + program_id: context.accounts.compression_program.key(), + accounts, + data, + }, + &account_infos, + )?; - Ok(()) - } + Ok(()) } diff --git a/compression/cutils/anchor/programs/cutils/src/lib.rs b/compression/cutils/anchor/programs/cutils/src/lib.rs index e39a7602..dde598ed 100644 --- a/compression/cutils/anchor/programs/cutils/src/lib.rs +++ b/compression/cutils/anchor/programs/cutils/src/lib.rs @@ -7,9 +7,6 @@ pub use instructions::*; pub mod bubblegum_types; -pub mod state; -pub use state::*; - use anchor_lang::prelude::*; /// SPL Account Compression program ID (cmtDvXumGCrqC1Age74AVPhSRVXJMd8PJS91L8KbNCK) @@ -39,16 +36,17 @@ declare_id!("BuFyrgRYzg2nPhqYrxZ7d9uYUs4VXtxH71U8EcoAfTQZ"); pub mod cutils { use super::*; - #[access_control(context.accounts.validate(&context, ¶ms))] - pub fn mint<'info>(context: Context<'info, Mint<'info>>, params: MintParams) -> Result<()> { - Mint::actuate(context, params) + pub fn mint<'info>( + context: Context<'info, MintAccountConstraints<'info>>, + params: MintParams, + ) -> Result<()> { + instructions::mint::handle_mint(context, params) } - #[access_control(context.accounts.validate(&context, ¶ms))] pub fn verify<'info>( - context: Context<'info, Verify<'info>>, + context: Context<'info, VerifyAccountConstraints<'info>>, params: VerifyParams, ) -> Result<()> { - Verify::actuate(context, ¶ms) + instructions::verify::handle_verify(context, ¶ms) } } diff --git a/compression/cutils/anchor/programs/cutils/src/state/data.rs b/compression/cutils/anchor/programs/cutils/src/state/data.rs deleted file mode 100644 index a4d9ef5b..00000000 --- a/compression/cutils/anchor/programs/cutils/src/state/data.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::*; - -pub const SEED_DATA: &[u8] = b"DATA"; - -#[account] -#[derive(Default, Debug, InitSpace)] -pub struct Data { - /// The bump, used for PDA validation. - pub bump: u8, - pub tree: Pubkey, - pub tree_nonce: u64, -} - -impl Data { - pub fn new(bump: u8, tree: Pubkey, tree_nonce: u64) -> Self { - Self { - bump, - tree, - tree_nonce, - } - } -} diff --git a/compression/cutils/anchor/programs/cutils/src/state/mod.rs b/compression/cutils/anchor/programs/cutils/src/state/mod.rs deleted file mode 100644 index 8800e0f3..00000000 --- a/compression/cutils/anchor/programs/cutils/src/state/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod data; -pub use data::*; diff --git a/compression/cutils/anchor/programs/cutils/tests/test_cutils.rs b/compression/cutils/anchor/programs/cutils/tests/test_cutils.rs index e7a23055..36be2b73 100644 --- a/compression/cutils/anchor/programs/cutils/tests/test_cutils.rs +++ b/compression/cutils/anchor/programs/cutils/tests/test_cutils.rs @@ -1,9 +1,9 @@ //! LiteSVM integration test for the `cutils` Anchor program. //! //! The cutils program exposes two instructions: -//! * `mint` — CPIs Bubblegum `MintToCollectionV1` to mint a cNFT into a +//! * `mint` - CPIs Bubblegum `MintToCollectionV1` to mint a cNFT into a //! (Token-Metadata) verified collection and a Bubblegum tree. -//! * `verify` — recomputes the V1 leaf hash and CPIs SPL account-compression +//! * `verify` - recomputes the V1 leaf hash and CPIs SPL account-compression //! `verify_leaf` to prove the leaf is present in the tree. //! //! Full flow exercised here: @@ -21,7 +21,7 @@ //! them for the minted leaf (note: after MintToCollectionV1 the collection //! is stored *verified*, so the data_hash reflects `verified = true`). //! 6. Build the Merkle proof for leaf 0 (all empty-node siblings), read the -//! live root from the on-chain tree account, and call cutils `verify`, +//! live root from the onchain tree account, and call cutils `verify`, //! asserting success. A second `verify` with a tampered data_hash must //! fail. @@ -98,7 +98,7 @@ struct MetadataArgs { edition_nonce: Option, token_standard: Option, // TokenStandard, variant index (NonFungible = 0) collection: Option, - uses: Option, // None — Uses, kept absent + uses: Option, // None - Uses, kept absent token_program_version: TokenProgramVersion, creators: Vec, } @@ -169,7 +169,7 @@ fn anchor_disc(name: &str) -> [u8; 8] { out } -// Minimal SHA-256 (FIPS 180-4) — used only to derive Anchor discriminators, +// Minimal SHA-256 (FIPS 180-4) - used only to derive Anchor discriminators, // avoiding a crypto crate that conflicts with the program's solana version. fn sha256(input: &[u8]) -> [u8; 32] { const K: [u32; 64] = [ @@ -646,12 +646,12 @@ fn test_cutils_mint_and_verify() { // Proof for leaf index 0 in an otherwise-empty tree: empty-node siblings. let proof = [empty_node(0), empty_node(1), empty_node(2)]; - // Read the live root from the on-chain tree account. + // Read the live root from the onchain tree account. let tree_data = svm.get_account(&merkle_tree.pubkey()).unwrap().data; let root = read_current_root(&tree_data); // Sanity: the leaf we computed must equal what the program will recompute, - // and the proof must rebuild the on-chain root. + // and the proof must rebuild the onchain root. let asset_id = get_asset_id(&merkle_tree.pubkey(), 0); let leaf = leaf_schema_v1_hash( &asset_id, @@ -673,7 +673,7 @@ fn test_cutils_mint_and_verify() { } assert_eq!( node, root, - "locally recomputed root must match the on-chain tree root" + "locally recomputed root must match the onchain tree root" ); // ---- Call cutils `verify` ---------------------------------------------- diff --git a/compression/cutils/anchor/tests/fixtures/README.md b/compression/cutils/anchor/tests/fixtures/README.md index e516b6a9..0c41f712 100644 --- a/compression/cutils/anchor/tests/fixtures/README.md +++ b/compression/cutils/anchor/tests/fixtures/README.md @@ -1,9 +1,9 @@ -# Test fixtures — mainnet program binaries +# Test fixtures - mainnet program binaries -These `.so` files are the compiled on-chain programs the cutils test CPIs +These `.so` files are the compiled onchain programs the cutils test CPIs into, dumped from Solana **mainnet-beta** so [LiteSVM](https://github.com/LiteSVM/litesvm) -can load them locally (LiteSVM only bundles System/Token/Token-2022/ATA). They -are the real programs — not modified — so accounts they create/verify behave +can load them locally (LiteSVM only bundles System/Token/Token Extensions/ATA). They +are the real programs - not modified - so accounts they create/verify behave exactly as on mainnet. `mpl_token_metadata.so` is required because the cutils `mint` instruction CPIs diff --git a/compression/cutils/quasar/Cargo.toml b/compression/cutils/quasar/Cargo.toml index 16379e00..94384e5a 100644 --- a/compression/cutils/quasar/Cargo.toml +++ b/compression/cutils/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-cutils" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] @@ -23,7 +23,7 @@ debug = [] [dependencies] quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } -# Direct dependency for invoke_with_bounds — raw CPI with variable proof accounts. +# Direct dependency for invoke_with_bounds - raw CPI with variable proof accounts. solana-instruction-view = { version = "2", features = ["cpi"] } solana-instruction = { version = "3.2.0" } diff --git a/compression/cutils/quasar/README.md b/compression/cutils/quasar/README.md index 9c30e4c5..51ac12c5 100644 --- a/compression/cutils/quasar/README.md +++ b/compression/cutils/quasar/README.md @@ -21,13 +21,9 @@ Prerequisites: [Quasar](https://quasar-lang.com/docs) CLI and [Agave](https://do ## Testing -In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): +This variant has no automated test suite yet: the instruction handlers CPI into external programs (Bubblegum, SPL Account Compression) and a QuasarSVM harness that loads those fixture binaries has not been written. `quasar build` verifies the program and CPI construction compile. -```bash -cargo test -``` - -Tests invoke instruction handlers and assert onchain state. No local validator. +The Anchor twin at `../anchor/` has a full LiteSVM integration suite that exercises the same flows against mainnet-dumped fixture programs; use it as the behavioural reference. ## Usage diff --git a/compression/cutils/quasar/src/instructions/mint.rs b/compression/cutils/quasar/src/instructions/mint.rs index 1864e346..3f9aff15 100644 --- a/compression/cutils/quasar/src/instructions/mint.rs +++ b/compression/cutils/quasar/src/instructions/mint.rs @@ -13,7 +13,7 @@ const MAX_IX_DATA: usize = 400; /// Accounts for minting a compressed NFT to a collection. #[derive(Accounts)] -pub struct Mint { +pub struct MintAccountConstraints { pub payer: Signer, /// Tree authority PDA (seeds checked by Bubblegum). #[account(mut)] @@ -53,7 +53,7 @@ pub struct Mint { pub system_program: Program, } -pub fn handle_mint(accounts: &mut Mint, data: &[u8]) -> Result<(), ProgramError> { +pub fn handle_mint(accounts: &mut MintAccountConstraints, data: &[u8]) -> Result<(), ProgramError> { // Parse URI from instruction data: u32 length prefix + utf8 bytes (borsh String) if data.len() < 4 { return Err(ProgramError::InvalidInstructionData); diff --git a/compression/cutils/quasar/src/instructions/verify.rs b/compression/cutils/quasar/src/instructions/verify.rs index 2034d4ec..a970ad72 100644 --- a/compression/cutils/quasar/src/instructions/verify.rs +++ b/compression/cutils/quasar/src/instructions/verify.rs @@ -13,7 +13,7 @@ const VERIFY_LEAF_DISCRIMINATOR: [u8; 8] = [0x7c, 0xdc, 0x16, 0xdf, 0x68, 0x0a, /// Accounts for verifying a compressed NFT leaf in the merkle tree. #[derive(Accounts)] -pub struct Verify { +pub struct VerifyAccountConstraints { pub leaf_owner: Signer, /// Leaf delegate. pub leaf_delegate: UncheckedAccount, @@ -24,7 +24,7 @@ pub struct Verify { pub compression_program: UncheckedAccount, } -pub fn handle_verify(accounts: &mut Verify, data: &[u8], remaining: RemainingAccounts<'_>) -> Result<(), ProgramError> { +pub fn handle_verify(accounts: &mut VerifyAccountConstraints, data: &[u8], remaining: RemainingAccounts<'_>) -> Result<(), ProgramError> { // Parse verify params from instruction data: // root(32) + data_hash(32) + creator_hash(32) + nonce(8) + index(4) = 108 bytes if data.len() < 108 { @@ -59,7 +59,7 @@ pub fn handle_verify(accounts: &mut Verify, data: &[u8], remaining: RemainingAcc // // `remaining.iter()` yields `Result` in newer // quasar-lang. Reach the inner `AccountView` via the unchecked accessor - // — we only read addresses/views to forward to the compression CPI as + // - we only read addresses/views to forward to the compression CPI as // proof nodes; no aliased data access. let placeholder = accounts.compression_program.to_account_view().clone(); let mut proof_views: [AccountView; MAX_PROOF_NODES] = diff --git a/compression/cutils/quasar/src/lib.rs b/compression/cutils/quasar/src/lib.rs index e9b674c6..d06eba8c 100644 --- a/compression/cutils/quasar/src/lib.rs +++ b/compression/cutils/quasar/src/lib.rs @@ -4,7 +4,6 @@ use quasar_lang::prelude::*; mod bubblegum_types; mod instructions; -mod state; use instructions::*; #[cfg(test)] mod tests; @@ -31,14 +30,14 @@ mod quasar_cutils { /// Mint a compressed NFT to a collection via MintToCollectionV1. #[instruction(discriminator = 0)] - pub fn mint(ctx: Ctx) -> Result<(), ProgramError> { + pub fn mint(ctx: Ctx) -> Result<(), ProgramError> { let data = ctx.data; instructions::handle_mint(&mut ctx.accounts, data) } /// Verify a compressed NFT leaf exists in the merkle tree. #[instruction(discriminator = 1)] - pub fn verify(ctx: CtxWithRemaining) -> Result<(), ProgramError> { + pub fn verify(ctx: CtxWithRemaining) -> Result<(), ProgramError> { let data = ctx.data; let remaining = ctx.remaining_accounts(); instructions::handle_verify(&mut ctx.accounts, data, remaining) diff --git a/compression/cutils/quasar/src/state.rs b/compression/cutils/quasar/src/state.rs deleted file mode 100644 index aa57bee2..00000000 --- a/compression/cutils/quasar/src/state.rs +++ /dev/null @@ -1,17 +0,0 @@ -use quasar_lang::prelude::*; - -/// Seed for the data account PDA. -pub const SEED_DATA: &[u8] = b"DATA"; - -/// Tracks the merkle tree and its nonce for minting. -#[account(discriminator = 1)] -pub struct Data { - /// PDA bump seed. - pub bump: u8, - /// Padding for alignment. - pub _padding: [u8; 7], - /// The merkle tree address. - pub tree: Address, - /// Current nonce in the tree. - pub tree_nonce: u64, -} diff --git a/compression/cutils/quasar/src/tests.rs b/compression/cutils/quasar/src/tests.rs index 83435d50..0f15d10c 100644 --- a/compression/cutils/quasar/src/tests.rs +++ b/compression/cutils/quasar/src/tests.rs @@ -1,3 +1,5 @@ -// Compressed NFT operations require external programs (Bubblegum, SPL Account -// Compression) that are not available in the quasar-svm test harness. The build -// itself verifies the CPI instruction construction compiles correctly. +// No tests yet: the instruction handlers CPI into external programs +// (Bubblegum, SPL Account Compression) and a QuasarSVM harness that loads +// those fixture binaries has not been written. The Anchor twin's LiteSVM +// suite covers the same flows. TODO: port that suite to QuasarSVM using +// the fixture .so files under ../anchor/tests/fixtures/. diff --git a/finance/escrow/anchor/.mocharc.json b/finance/escrow/anchor/.mocharc.json deleted file mode 100644 index 13a83f47..00000000 --- a/finance/escrow/anchor/.mocharc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extension": ["ts"], - "spec": "tests/**/*.ts", - "require": "ts-node/register", - "node-option": ["experimental-specifier-resolution=node", "loader=ts-node/esm"] -} diff --git a/finance/escrow/anchor/Anchor.toml b/finance/escrow/anchor/Anchor.toml index 9901f5b9..865f3795 100644 --- a/finance/escrow/anchor/Anchor.toml +++ b/finance/escrow/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] escrow = "qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/finance/escrow/anchor/README.md b/finance/escrow/anchor/README.md index ef5ba5ef..04eca86f 100644 --- a/finance/escrow/anchor/README.md +++ b/finance/escrow/anchor/README.md @@ -1,43 +1,46 @@ # Anchor Escrow -## Introduction +This Solana [program](https://solana.com/docs/terminology#program) is an **escrow** - it lets a **maker** swap a specific amount of one token for a desired amount of another token with a **taker**, atomically and without either party having to trust the other. -This Solana [program](https://solana.com/docs/terminology#program) is an **escrow** — it lets a user swap a specific amount of one token for a desired amount of another token. +For example: Alice offers 10 USDC and wants 100 WIF in return. The program holds Alice's USDC in a vault until someone delivers the WIF, then releases both sides in a single transaction. Neither party can take the other's tokens and run, and there is no spread or middleman fee on the swap. -For example: Alice offers 10 USDC and wants 100 WIF in return. +See also the [native](../native/) and [Quasar](../quasar/) variants of the same program. -Without an escrow, users would have to swap tokens manually and trust each other. The escrow program acts as a trusted third party that only releases tokens to both sides when the swap can complete atomically. Neither party can take the other's tokens and run. +## Accounts and PDAs -Alice and Bob transact directly with each other through the program, so there's no spread or middleman fee taken on the swap. +- **Offer**: a [PDA](https://solana.com/docs/terminology#program-derived-address-pda) with seeds `["offer", maker, id]` storing the offer `id`, the `maker`, the two mints (`token_mint_a` is what the maker offers, `token_mint_b` is what the maker wants), the `token_b_wanted_amount`, and the PDA `bump`. The `id` lets one maker keep multiple offers open at once. +- **Vault**: the offer PDA's associated token account for token A. It holds the maker's offered tokens while the offer is open; only the offer PDA can sign transfers out of it. -## Usage +The maker pays the rent for the offer account and the vault, and every path that closes them (`take_offer`, `cancel_offer`) refunds that rent to the maker. -Run the tests with `pnpm test` (as configured in `Anchor.toml`). +## Lifecycle + +A maker opens an offer with `make_offer`, passing the `id`, `token_a_offered_amount`, and `token_b_wanted_amount`. The maker signs and pays all rent. The handler creates the offer PDA and the vault, creates the maker's token-B associated token account if needed (paid by the maker, so the eventual taker never funds a maker-owned account), moves the offered token A into the vault with `transfer_checked`, and records the offer state. + +A taker settles the offer with `take_offer`. The taker signs. Anchor's constraints bind every account to the stored offer state (`has_one` on the maker and both mints, associated-token constraints on the vault and all token accounts, and the PDA seeds on the offer itself). The handler sends the wanted token B from the taker to the maker, releases the vault's token A to the taker signed by the offer PDA, and closes both the vault and the offer account back to the maker, who paid their rent. The taker's own token-A account is created on the fly if needed, paid by the taker. + +A maker abandons an offer with `cancel_offer`. Only the maker can call it; without it, an unwanted offer would lock the maker's tokens in the vault forever. The handler returns the vault's token A to the maker and closes the vault and offer accounts, refunding both rents to the maker. + +## Setup + +Prerequisites: Rust, the [Agave](https://docs.anza.xyz/) toolchain, and the Anchor CLI. Build the program with: + +```bash +anchor build +``` + +(or `cargo build-sbf` from `programs/escrow/`). The tests load the resulting `target/deploy/escrow.so`. + +## Testing + +The tests are Rust integration tests running against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) (with [solana-kite](https://crates.io/crates/solana-kite) helpers). After building, run: + +```bash +cargo test +``` + +(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). ## Credit -Based on [Dean Little's Anchor Escrow](https://github.com/deanmlittle/anchor-escrow-2024), with a few changes to make it easier to discuss in class. - -### Changes from the original - -One challenge when teaching is avoiding ambiguity — names have to be clear and not confused with anything else. - -- Several custom handler functions were replaced by helpers from `@solana-developers/helpers` to reduce file size. -- Shared token-transfer logic now lives in `instructions/shared.rs`. -- The upstream project uses a custom file layout. This version uses the 'multiple files' [Anchor](https://solana.com/docs/terminology#anchor) layout. -- Contexts are separate data structures from the functions that use them. There's no need for OO-style `impl` patterns here — no mutable state is stored in the context, and the methods don't mutate it. -- The name 'deposit' was overloaded. `deposit` is both a verb and a noun, which made the code hard to read: - - deposit #1 → `token_a_offered_amount` - - deposit #2 (in `make()`) → `send_offered_tokens_to_vault` - - deposit #3 (in `take()`) → `send_wanted_tokens_to_maker` -- `seed` was renamed to `id`, because it conflicted with the `seeds` used for [PDA](https://solana.com/docs/terminology#program-derived-address-pda) derivation. -- `Escrow` was used for both the program name and the [account](https://solana.com/docs/terminology#account) that records an offer. People kept confusing the offer account with the vault. - - `Escrow` (the program) → still `Escrow`. - - `Escrow` (the offer) → `Offer`. -- `receive` was renamed to `token_b_wanted_amount`, since `receive` is a verb and not a good name for an integer. -- `mint_a` → `token_mint_a` (what the maker offered and what the taker wants). -- `mint_b` → `token_mint_b` (what the maker wants and what the taker must offer). -- `makerAtaA` → `makerTokenAccountA` -- `makerAtaB` → `makerTokenAccountB` -- `takerAtaA` → `takerTokenAccountA` -- `takerAtaB` → `takerTokenAccountB` +Based on [Dean Little's Anchor Escrow](https://github.com/deanmlittle/anchor-escrow-2024), restructured for teaching. diff --git a/finance/escrow/anchor/migrations/deploy.ts b/finance/escrow/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/finance/escrow/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/finance/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs b/finance/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs index 92472a37..c2cd0fb9 100644 --- a/finance/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs +++ b/finance/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs @@ -14,7 +14,7 @@ use super::{close_token_account, transfer_tokens}; // account's rent unclaimed). The maker signs, the vault tokens flow back to // the maker, and both the vault and the offer accounts are closed. #[derive(Accounts)] -pub struct CancelOffer<'info> { +pub struct CancelOfferAccountConstraints<'info> { #[account(mut)] pub maker: Signer<'info>, @@ -51,7 +51,7 @@ pub struct CancelOffer<'info> { pub system_program: Program<'info, System>, } -pub fn handle_cancel_offer(context: Context) -> Result<()> { +pub fn handle_cancel_offer(context: Context) -> Result<()> { let maker_key = context.accounts.maker.key(); let id_bytes = context.accounts.offer.id.to_le_bytes(); let bump = [context.accounts.offer.bump]; diff --git a/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs b/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs index a40ac32f..cee61433 100644 --- a/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs +++ b/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs @@ -12,7 +12,7 @@ use super::transfer_tokens; // See https://www.anchor-lang.com/docs/references/account-constraints#instruction-attribute #[derive(Accounts)] #[instruction(id: u64)] -pub struct MakeOffer<'info> { +pub struct MakeOfferAccountConstraints<'info> { #[account(mut)] pub maker: Signer<'info>, @@ -30,10 +30,9 @@ pub struct MakeOffer<'info> { )] pub maker_token_account_a: InterfaceAccount<'info, TokenAccount>, - // The maker's token-B ATA used to be init_if_needed on the taker side, which - // meant the taker paid the maker's rent. Initialize it here (paid by the - // maker) so the rent burden lives with the party who chose to open the - // offer. + // The maker's token-B ATA is initialized here, paid by the maker, so the + // rent burden lives with the party who chose to open the offer (take_offer + // requires this account to already exist). #[account( init_if_needed, payer = maker, @@ -68,7 +67,7 @@ pub struct MakeOffer<'info> { // Move the tokens from the maker's ATA to the vault pub fn handle_send_offered_tokens_to_vault( - context: &Context, + context: &Context, token_a_offered_amount: u64, ) -> Result<()> { transfer_tokens( @@ -83,7 +82,11 @@ pub fn handle_send_offered_tokens_to_vault( } // Save the details of the offer to the offer account -pub fn handle_save_offer(context: Context, id: u64, token_b_wanted_amount: u64) -> Result<()> { +pub fn handle_save_offer( + context: Context, + id: u64, + token_b_wanted_amount: u64, +) -> Result<()> { context.accounts.offer.set_inner(Offer { id, maker: context.accounts.maker.key(), diff --git a/finance/escrow/anchor/programs/escrow/src/instructions/take_offer.rs b/finance/escrow/anchor/programs/escrow/src/instructions/take_offer.rs index e3813b65..a8edc1a1 100644 --- a/finance/escrow/anchor/programs/escrow/src/instructions/take_offer.rs +++ b/finance/escrow/anchor/programs/escrow/src/instructions/take_offer.rs @@ -10,7 +10,7 @@ use crate::Offer; use super::{close_token_account, transfer_tokens}; #[derive(Accounts)] -pub struct TakeOffer<'info> { +pub struct TakeOfferAccountConstraints<'info> { #[account(mut)] pub taker: Signer<'info>, @@ -38,9 +38,7 @@ pub struct TakeOffer<'info> { )] pub taker_token_account_b: Box>, - // The maker's token-B ATA is initialized in make_offer (paid by the maker), - // so the taker no longer pays its rent. Treat it as a plain existing account - // here. + // The maker's token-B ATA is initialized in make_offer, paid by the maker. #[account( mut, associated_token::mint = token_mint_b, @@ -73,7 +71,9 @@ pub struct TakeOffer<'info> { pub system_program: Program<'info, System>, } -pub fn handle_send_wanted_tokens_to_maker(context: &Context) -> Result<()> { +pub fn handle_send_wanted_tokens_to_maker( + context: &Context, +) -> Result<()> { transfer_tokens( &context.accounts.taker_token_account_b, &context.accounts.maker_token_account_b, @@ -85,7 +85,7 @@ pub fn handle_send_wanted_tokens_to_maker(context: &Context) -> Resul ) } -pub fn handle_withdraw_and_close_vault(context: Context) -> Result<()> { +pub fn handle_withdraw_and_close_vault(context: Context) -> Result<()> { let maker_key = context.accounts.maker.key(); let id_bytes = context.accounts.offer.id.to_le_bytes(); let bump = [context.accounts.offer.bump]; @@ -101,9 +101,11 @@ pub fn handle_withdraw_and_close_vault(context: Context) -> Result<() Some(offer_seeds), )?; + // The maker paid the vault's rent in make_offer, so the vault closes back + // to the maker (the offer account does the same via `close = maker`). close_token_account( &context.accounts.vault, - &context.accounts.taker.to_account_info(), + &context.accounts.maker.to_account_info(), &context.accounts.offer.to_account_info(), &context.accounts.token_program, Some(offer_seeds), diff --git a/finance/escrow/anchor/programs/escrow/src/lib.rs b/finance/escrow/anchor/programs/escrow/src/lib.rs index 1635afea..fd8307da 100644 --- a/finance/escrow/anchor/programs/escrow/src/lib.rs +++ b/finance/escrow/anchor/programs/escrow/src/lib.rs @@ -14,7 +14,7 @@ pub mod escrow { use super::*; pub fn make_offer( - context: Context, + context: Context, id: u64, token_a_offered_amount: u64, token_b_wanted_amount: u64, @@ -23,7 +23,7 @@ pub mod escrow { instructions::make_offer::handle_save_offer(context, id, token_b_wanted_amount) } - pub fn take_offer(context: Context) -> Result<()> { + pub fn take_offer(context: Context) -> Result<()> { instructions::take_offer::handle_send_wanted_tokens_to_maker(&context)?; instructions::take_offer::handle_withdraw_and_close_vault(context) } @@ -32,7 +32,7 @@ pub mod escrow { // to the maker, and both the vault and offer accounts are closed (rent // refunded to the maker). Without this, abandoned offers would lock funds // forever. - pub fn cancel_offer(context: Context) -> Result<()> { + pub fn cancel_offer(context: Context) -> Result<()> { instructions::cancel_offer::handle_cancel_offer(context) } } diff --git a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs index 05f83ea7..db586e80 100644 --- a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs @@ -24,6 +24,10 @@ fn ata_program_id() -> Pubkey { .unwrap() } +fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { + svm.get_account(address).map(|a| a.lamports).unwrap_or(0) +} + fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { let (ata, _bump) = Pubkey::find_program_address( &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], @@ -130,7 +134,7 @@ fn test_make_offer() { token_b_wanted_amount, } .data(), - escrow::accounts::MakeOffer { + escrow::accounts::MakeOfferAccountConstraints { maker: es.alice.pubkey(), token_mint_a: es.mint_a, token_mint_b: es.mint_b, @@ -188,6 +192,13 @@ fn test_take_offer() { let vault = derive_ata(&offer_pda, &es.mint_a); + // Alice pays the offer + vault rent in make_offer and must recover it all + // when the offer is taken. (Alice's token-B ATA already exists, and the + // payer covers transaction fees, so her lamports should round-trip + // exactly.) + let alice_lamports_before_make = lamports(&es.svm, &es.alice.pubkey()); + let bob_lamports_before_take = lamports(&es.svm, &es.bob.pubkey()); + // Step 1: Alice makes the offer let make_offer_ix = Instruction::new_with_bytes( es.program_id, @@ -197,7 +208,7 @@ fn test_take_offer() { token_b_wanted_amount, } .data(), - escrow::accounts::MakeOffer { + escrow::accounts::MakeOfferAccountConstraints { maker: es.alice.pubkey(), token_mint_a: es.mint_a, token_mint_b: es.mint_b, @@ -230,7 +241,7 @@ fn test_take_offer() { let take_offer_ix = Instruction::new_with_bytes( es.program_id, &escrow::instruction::TakeOffer {}.data(), - escrow::accounts::TakeOffer { + escrow::accounts::TakeOfferAccountConstraints { taker: es.bob.pubkey(), maker: es.alice.pubkey(), token_mint_a: es.mint_a, @@ -278,6 +289,20 @@ fn test_take_offer() { es.svm.get_account(&offer_pda).is_none(), "Offer should be closed after take_offer" ); + + // Rent destinations: Alice (the maker) recovers the offer + vault rent in + // full. Bob (the taker) only paid the rent of his own new token-A ATA. + assert_eq!( + lamports(&es.svm, &es.alice.pubkey()), + alice_lamports_before_make, + "maker must recover the offer and vault rent after take_offer" + ); + let bob_ata_a_rent = lamports(&es.svm, &es.bob_ata_a); + assert_eq!( + lamports(&es.svm, &es.bob.pubkey()), + bob_lamports_before_take - bob_ata_a_rent, + "taker must only pay the rent of their own token-A ATA" + ); } #[test] @@ -298,8 +323,9 @@ fn test_cancel_offer() { ); let vault = derive_ata(&offer_pda, &es.mint_a); - // Snapshot Alice's token-A balance before the offer. + // Snapshot Alice's token-A balance and lamports before the offer. let alice_a_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); + let alice_lamports_before_make = lamports(&es.svm, &es.alice.pubkey()); // Alice makes the offer. let make_offer_ix = Instruction::new_with_bytes( @@ -310,7 +336,7 @@ fn test_cancel_offer() { token_b_wanted_amount, } .data(), - escrow::accounts::MakeOffer { + escrow::accounts::MakeOfferAccountConstraints { maker: es.alice.pubkey(), token_mint_a: es.mint_a, token_mint_b: es.mint_b, @@ -341,7 +367,7 @@ fn test_cancel_offer() { let cancel_offer_ix = Instruction::new_with_bytes( es.program_id, &escrow::instruction::CancelOffer {}.data(), - escrow::accounts::CancelOffer { + escrow::accounts::CancelOfferAccountConstraints { maker: es.alice.pubkey(), token_mint_a: es.mint_a, maker_token_account_a: es.alice_ata_a, @@ -374,6 +400,13 @@ fn test_cancel_offer() { // Alice should have her token-A back to its pre-make balance. let alice_a_after = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); assert_eq!(alice_a_after, alice_a_before); + + // Rent destination: Alice recovers the offer + vault rent in full. + assert_eq!( + lamports(&es.svm, &es.alice.pubkey()), + alice_lamports_before_make, + "maker must recover the offer and vault rent after cancel_offer" + ); } #[test] @@ -403,7 +436,7 @@ fn test_cancel_offer_rejects_non_maker() { token_b_wanted_amount, } .data(), - escrow::accounts::MakeOffer { + escrow::accounts::MakeOfferAccountConstraints { maker: es.alice.pubkey(), token_mint_a: es.mint_a, token_mint_b: es.mint_b, @@ -433,7 +466,7 @@ fn test_cancel_offer_rejects_non_maker() { let cancel_offer_ix = Instruction::new_with_bytes( es.program_id, &escrow::instruction::CancelOffer {}.data(), - escrow::accounts::CancelOffer { + escrow::accounts::CancelOfferAccountConstraints { maker: es.bob.pubkey(), token_mint_a: es.mint_a, maker_token_account_a: bob_ata_a, diff --git a/finance/escrow/anchor/register.js b/finance/escrow/anchor/register.js deleted file mode 100644 index b9c8afd2..00000000 --- a/finance/escrow/anchor/register.js +++ /dev/null @@ -1,4 +0,0 @@ -import { register } from "node:module"; -import { pathToFileURL } from "node:url"; - -register("ts-node/esm", pathToFileURL("./")); diff --git a/finance/escrow/native/README.md b/finance/escrow/native/README.md new file mode 100644 index 00000000..43990f6d --- /dev/null +++ b/finance/escrow/native/README.md @@ -0,0 +1,46 @@ +# Escrow (Native) + +This Solana program is an **escrow** written directly against `solana-program`, with no framework. It lets a **maker** swap a specific amount of one token for a desired amount of another token with a **taker**, atomically and without either party having to trust the other. + +For example: Alice offers 10 USDC and wants 100 WIF in return. The program holds Alice's USDC in a vault until someone delivers the WIF, then releases both sides in a single transaction. + +See also the [Anchor](../anchor/) and [Quasar](../quasar/) variants of the same program. + +## Accounts and PDAs + +- **Offer**: a PDA with seeds `["offer", maker, id]` storing the offer's `id`, the `maker`, the two mint addresses (`token_mint_a` is what the maker offers, `token_mint_b` is what the maker wants), the `token_b_wanted_amount`, and the PDA `bump`. The `id` lets one maker keep multiple offers open at once. +- **Vault**: the offer PDA's associated token account for mint A. It holds the maker's offered tokens while the offer is open. Only the offer PDA can sign transfers out of it. + +The maker pays the rent for the offer account and the vault (and for their own mint B token account if it does not exist yet). Every path that closes those accounts refunds that rent to the maker. + +## Lifecycle + +A maker opens an offer with the `MakeOffer` instruction, passing the offer `id`, the amount of token A offered, and the amount of token B wanted. The maker signs and pays all rent. The handler derives and creates the offer PDA, creates the vault, creates the maker's mint B token account if needed (so the eventual taker never pays rent for a maker-owned account), and moves the offered token A into the vault with `transfer_checked`. It then verifies the vault holds exactly the offered amount before writing the offer state. + +A taker settles the offer with the `TakeOffer` instruction. The taker signs. The handler validates every passed account against the stored offer state (maker, both mints, the vault address, and the offer PDA itself), requires the maker's mint B token account to already exist, and lazily creates the taker's mint A token account (rent paid by the taker, since it is the taker's own account). It then transfers the wanted token B from the taker to the maker, releases the vault's token A to the taker signed by the offer PDA, and verifies conservation with checked arithmetic: the taker gained exactly the vault balance and the maker gained exactly the wanted amount. Finally it closes the vault and the offer account, refunding both rents to the maker. + +A maker abandons an offer with the `CancelOffer` instruction. Only the maker can call it; without it, an unwanted offer would lock the maker's tokens in the vault forever. The handler validates the accounts against the offer state, returns the vault's token A to the maker's token account, verifies the maker received exactly the vault balance, and closes the vault and offer accounts back to the maker. + +Errors are reported through the named `EscrowError` enum in `program/src/error.rs` (key mismatches, missing maker token account, conservation violations, arithmetic overflow). + +## Setup + +Prerequisites: the [Agave](https://docs.anza.xyz/) toolchain (`cargo build-sbf`) and Rust. + +Build the program into the test fixtures directory: + +```bash +cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./tests/fixtures +``` + +(`npm run build-and-test` in `package.json` runs the same command.) + +## Testing + +The tests run against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm), loading the `.so` built above. After building, run: + +```bash +cargo test --manifest-path=./program/Cargo.toml +``` + +The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). diff --git a/finance/escrow/native/program/src/error.rs b/finance/escrow/native/program/src/error.rs index 7ad3f4d0..d7b66590 100644 --- a/finance/escrow/native/program/src/error.rs +++ b/finance/escrow/native/program/src/error.rs @@ -8,6 +8,21 @@ pub enum EscrowError { #[error("Token account provided does not match expected")] TokenAccountMismatch, + + #[error("Maker account provided does not match the offer's maker")] + MakerMismatch, + + #[error("Token mint provided does not match the offer's mint")] + MintMismatch, + + #[error("Maker's token B account must exist before the offer can be taken")] + MakerTokenAccountBNotInitialized, + + #[error("Token balances after transfer do not balance against the amounts moved")] + TokenConservationViolation, + + #[error("Arithmetic overflow")] + ArithmeticOverflow, } impl From for ProgramError { diff --git a/finance/escrow/native/program/src/instructions/cancel_offer.rs b/finance/escrow/native/program/src/instructions/cancel_offer.rs new file mode 100644 index 00000000..d3a77f5f --- /dev/null +++ b/finance/escrow/native/program/src/instructions/cancel_offer.rs @@ -0,0 +1,133 @@ +use { + crate::{error::*, state::*, utils::*}, + borsh::{BorshDeserialize, BorshSerialize}, + solana_program::{ + account_info::AccountInfo, + entrypoint::ProgramResult, + program::invoke_signed, + program_error::ProgramError, + program_pack::Pack, + pubkey::Pubkey, + }, + spl_token_interface::{ + instruction as token_instruction, + state::{Account as TokenAccount, Mint}, + }, +}; + +// Cancel an outstanding offer. Without this handler, an abandoned offer would +// keep the maker's mint A tokens locked in the vault forever (and the offer +// account's rent unclaimed). Only the maker can cancel: the vault tokens flow +// back to the maker's token A account, and the vault and offer accounts are +// closed with their rent refunded to the maker. +#[derive(BorshDeserialize, BorshSerialize, Debug)] +pub struct CancelOffer {} + +impl CancelOffer { + pub fn process(program_id: &Pubkey, accounts: &[AccountInfo<'_>]) -> ProgramResult { + let [ + offer_info, + token_mint_a, + maker_token_account_a, + vault, + maker, + token_program, + system_program + ] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // Only the maker may cancel their offer. + if !maker.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + let offer = Offer::try_from_slice(&offer_info.data.borrow()[..])?; + + // Validate the passed accounts against the stored offer state. + if &offer.maker != maker.key { + return Err(EscrowError::MakerMismatch.into()); + } + if &offer.token_mint_a != token_mint_a.key { + return Err(EscrowError::MintMismatch.into()); + } + + // Validate the offer account with its signer seeds. + let offer_signer_seeds = &[ + Offer::SEED_PREFIX, + maker.key.as_ref(), + &offer.id.to_le_bytes(), + &[offer.bump], + ]; + + let offer_key = Pubkey::create_program_address(offer_signer_seeds, program_id)?; + + if *offer_info.key != offer_key { + return Err(EscrowError::OfferKeyMismatch.into()); + }; + + // The receiving account is the maker's own token A account, and the + // vault is the offer PDA's associated token account for mint A. + assert_is_associated_token_account(maker_token_account_a.key, maker.key, token_mint_a.key)?; + assert_is_associated_token_account(vault.key, offer_info.key, token_mint_a.key)?; + + let vault_amount_a = TokenAccount::unpack(&vault.data.borrow())?.amount; + let maker_amount_a_before_transfer = + TokenAccount::unpack(&maker_token_account_a.data.borrow())?.amount; + + // `transfer` is deprecated in favour of `transfer_checked`, which also + // verifies the mint and its decimals. Read the decimals from the mint + // account the caller passed in. + let mint_a_decimals = Mint::unpack(&token_mint_a.data.borrow())?.decimals; + + // The vault returns its mint A tokens to the maker, signed by the + // offer PDA. + invoke_signed( + &token_instruction::transfer_checked( + token_program.key, + vault.key, + token_mint_a.key, + maker_token_account_a.key, + offer_info.key, + &[offer_info.key], + vault_amount_a, + mint_a_decimals, + )?, + &[ + token_mint_a.clone(), + vault.clone(), + maker_token_account_a.clone(), + offer_info.clone(), + token_program.clone(), + ], + &[offer_signer_seeds], + )?; + + // Conservation check: the maker got back exactly what the vault held. + let maker_amount_a = TokenAccount::unpack(&maker_token_account_a.data.borrow())?.amount; + let expected_maker_amount_a = maker_amount_a_before_transfer + .checked_add(vault_amount_a) + .ok_or(EscrowError::ArithmeticOverflow)?; + if maker_amount_a != expected_maker_amount_a { + return Err(EscrowError::TokenConservationViolation.into()); + } + + // Close the vault and the offer account. The maker paid the rent for + // both in make_offer, so both refunds go to the maker. + invoke_signed( + &token_instruction::close_account( + token_program.key, + vault.key, + maker.key, + offer_info.key, + &[], + )?, + &[vault.clone(), maker.clone(), offer_info.clone()], + &[offer_signer_seeds], + )?; + + close_offer_account(offer_info, maker, system_program)?; + + Ok(()) + } +} diff --git a/finance/escrow/native/program/src/instructions/make_offer.rs b/finance/escrow/native/program/src/instructions/make_offer.rs index 0b502059..173e35c0 100644 --- a/finance/escrow/native/program/src/instructions/make_offer.rs +++ b/finance/escrow/native/program/src/instructions/make_offer.rs @@ -32,25 +32,25 @@ impl MakeOffer { accounts: &[AccountInfo<'_>], args: MakeOffer, ) -> ProgramResult { - // accounts in order. - // let [ - offer_info, // offer account info - token_mint_a, // token_mint a - token_mint_b, // token mint b - maker_token_account_a, // maker token account a - vault, // vault - maker, // maker - payer, // payer - token_program, // token program - associated_token_program, // associated token program - system_program// system program + offer_info, + token_mint_a, + token_mint_b, + maker_token_account_a, + maker_token_account_b, + vault, + maker, + token_program, + associated_token_program, + system_program ] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; - // ensure the maker signs the instruction - // + // The maker signs and pays the rent for every account created here + // (the offer account, the vault, and the maker's token B account). + // take_offer and cancel_offer later close those accounts back to the + // maker, so the rent always returns to the party who paid it. if !maker.is_signer { return Err(ProgramError::MissingRequiredSignature); } @@ -63,16 +63,18 @@ impl MakeOffer { let (offer_key, bump) = Pubkey::find_program_address(offer_seeds, program_id); - // make sure the offer key is the same - // if *offer_info.key != offer_key { return Err(EscrowError::OfferKeyMismatch.into()); }; - // check vault is owned by the offer account - // + // The vault is the offer PDA's associated token account for mint A. assert_is_associated_token_account(vault.key, offer_info.key, token_mint_a.key)?; + // The maker's token B account receives tokens when the offer is taken. + // Create it now (paid by the maker) so take_offer never has to create + // an account whose rent would fall on the taker. + assert_is_associated_token_account(maker_token_account_b.key, maker.key, token_mint_b.key)?; + let offer = Offer { bump, maker: *maker.key, @@ -85,17 +87,16 @@ impl MakeOffer { let size = borsh::to_vec::(&offer)?.len(); let lamports_required = (Rent::get()?).minimum_balance(size); - // create account - // + // Create the offer account, rent paid by the maker. invoke_signed( &system_instruction::create_account( - payer.key, + maker.key, offer_info.key, lamports_required, size as u64, program_id, ), - &[payer.clone(), offer_info.clone(), system_program.clone()], + &[maker.clone(), offer_info.clone(), system_program.clone()], &[&[ Offer::SEED_PREFIX, maker.key.as_ref(), @@ -104,11 +105,10 @@ impl MakeOffer { ]], )?; - // create the vault token account - // + // Create the vault token account, rent paid by the maker. invoke( &associated_token_account_instruction::create_associated_token_account( - payer.key, + maker.key, offer_info.key, token_mint_a.key, token_program.key, @@ -117,14 +117,36 @@ impl MakeOffer { token_mint_a.clone(), vault.clone(), offer_info.clone(), - payer.clone(), + maker.clone(), system_program.clone(), token_program.clone(), associated_token_program.clone(), ], )?; - // transfer Mint A tokens to vault + // Create the maker's token B account if it does not exist yet, rent + // paid by the maker. + if maker_token_account_b.lamports() == 0 { + invoke( + &associated_token_account_instruction::create_associated_token_account( + maker.key, + maker.key, + token_mint_b.key, + token_program.key, + ), + &[ + token_mint_b.clone(), + maker_token_account_b.clone(), + maker.clone(), + maker.clone(), + system_program.clone(), + token_program.clone(), + associated_token_program.clone(), + ], + )?; + } + + // Move the offered mint A tokens into the vault. // // `transfer` is deprecated in favour of `transfer_checked`, which also // verifies the mint and its decimals. Read the decimals from the mint @@ -150,14 +172,13 @@ impl MakeOffer { ], )?; + // Conservation check: the vault must now hold exactly the offered + // amount. let vault_token_amount = TokenAccount::unpack(&vault.data.borrow())?.amount; + if vault_token_amount != args.token_a_offered_amount { + return Err(EscrowError::TokenConservationViolation.into()); + } - solana_program::msg!("Amount in vault: {}", vault_token_amount); - - assert_eq!(vault_token_amount, args.token_a_offered_amount); - - // write data into offer account - // offer.serialize(&mut *offer_info.data.borrow_mut())?; Ok(()) diff --git a/finance/escrow/native/program/src/instructions/mod.rs b/finance/escrow/native/program/src/instructions/mod.rs index bac9a654..2567ff99 100644 --- a/finance/escrow/native/program/src/instructions/mod.rs +++ b/finance/escrow/native/program/src/instructions/mod.rs @@ -3,3 +3,6 @@ pub use make_offer::*; pub mod take_offer; pub use take_offer::*; + +pub mod cancel_offer; +pub use cancel_offer::*; diff --git a/finance/escrow/native/program/src/instructions/take_offer.rs b/finance/escrow/native/program/src/instructions/take_offer.rs index 0fd4f643..c73bc7e0 100644 --- a/finance/escrow/native/program/src/instructions/take_offer.rs +++ b/finance/escrow/native/program/src/instructions/take_offer.rs @@ -21,43 +21,42 @@ pub struct TakeOffer {} impl TakeOffer { pub fn process(program_id: &Pubkey, accounts: &[AccountInfo<'_>]) -> ProgramResult { - // accounts in order - // let [ - offer_info, // offer account info - token_mint_a, // token mint A - token_mint_b, // token mint b - maker_token_account_b, // maker token a account - taker_token_account_a, // mkaer token b account - taker_token_account_b, // taker token a account - vault, // vault - maker, // maker - taker, // taker - payer, // payer - token_program, // token program - associated_token_program, // associated token program - system_program// system program + offer_info, + token_mint_a, + token_mint_b, + maker_token_account_b, + taker_token_account_a, + taker_token_account_b, + vault, + maker, + taker, + token_program, + associated_token_program, + system_program ] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; - // ensure the taker signs the instruction - // + // The taker signs the instruction. if !taker.is_signer { return Err(ProgramError::MissingRequiredSignature); } - // get the offer data - // let offer = Offer::try_from_slice(&offer_info.data.borrow()[..])?; - // validate the offer - // - assert_eq!(&offer.maker, maker.key); - assert_eq!(&offer.token_mint_a, token_mint_a.key); - assert_eq!(&offer.token_mint_b, token_mint_b.key); + // Validate the passed accounts against the stored offer state. + if &offer.maker != maker.key { + return Err(EscrowError::MakerMismatch.into()); + } + if &offer.token_mint_a != token_mint_a.key { + return Err(EscrowError::MintMismatch.into()); + } + if &offer.token_mint_b != token_mint_b.key { + return Err(EscrowError::MintMismatch.into()); + } - // validate the offer accout with signer seeds + // Validate the offer account with its signer seeds. let offer_signer_seeds = &[ Offer::SEED_PREFIX, maker.key.as_ref(), @@ -67,24 +66,22 @@ impl TakeOffer { let offer_key = Pubkey::create_program_address(offer_signer_seeds, program_id)?; - // make sure the offer key is the same - // if *offer_info.key != offer_key { return Err(EscrowError::OfferKeyMismatch.into()); }; - // validate receiving addresses - // + // Validate receiving addresses, including the vault (the offer PDA's + // associated token account for mint A). assert_is_associated_token_account(maker_token_account_b.key, maker.key, token_mint_b.key)?; assert_is_associated_token_account(taker_token_account_a.key, taker.key, token_mint_a.key)?; + assert_is_associated_token_account(vault.key, offer_info.key, token_mint_a.key)?; - // create taker token A account if needed, before receiveing tokens - // + // Create the taker's token A account if needed. The taker pays this + // rent: it is the taker's own account. if taker_token_account_a.lamports() == 0 { - // create the vault token account invoke( &associated_token_account_instruction::create_associated_token_account( - payer.key, + taker.key, taker.key, token_mint_a.key, token_program.key, @@ -93,7 +90,7 @@ impl TakeOffer { token_mint_a.clone(), taker_token_account_a.clone(), taker.clone(), - payer.clone(), + taker.clone(), system_program.clone(), token_program.clone(), associated_token_program.clone(), @@ -101,31 +98,13 @@ impl TakeOffer { )?; } - // create maker token B account if needed, before receiveing tokens - // + // The maker's token B account was created in make_offer (rent paid by + // the maker). Require it to exist rather than creating it here, which + // would make the taker pay rent for the maker's account. if maker_token_account_b.lamports() == 0 { - // create the vault token account - invoke( - &associated_token_account_instruction::create_associated_token_account( - payer.key, - maker.key, - token_mint_b.key, - token_program.key, - ), - &[ - token_mint_b.clone(), - maker_token_account_b.clone(), - maker.clone(), - payer.clone(), - system_program.clone(), - token_program.clone(), - associated_token_program.clone(), - ], - )?; + return Err(EscrowError::MakerTokenAccountBNotInitialized.into()); } - // read token accounts - // let vault_amount_a = TokenAccount::unpack(&vault.data.borrow())?.amount; let taker_amount_a_before_transfer = TokenAccount::unpack(&taker_token_account_a.data.borrow())?.amount; @@ -150,8 +129,7 @@ impl TakeOffer { let mint_a_decimals = Mint::unpack(&token_mint_a.data.borrow())?.decimals; let mint_b_decimals = Mint::unpack(&token_mint_b.data.borrow())?.decimals; - // taker transfers mint B tokens to the maker - // + // The taker transfers mint B tokens to the maker. invoke( &token_instruction::transfer_checked( token_program.key, @@ -172,8 +150,8 @@ impl TakeOffer { ], )?; - // transfer from vault to taker - // + // The vault releases its mint A tokens to the taker, signed by the + // offer PDA. invoke_signed( &token_instruction::transfer_checked( token_program.key, @@ -196,17 +174,24 @@ impl TakeOffer { &[offer_signer_seeds], )?; + // Conservation check: the taker gained exactly the vault's mint A + // balance and the maker gained exactly the wanted mint B amount. let taker_amount_a = TokenAccount::unpack(&taker_token_account_a.data.borrow())?.amount; let maker_amount_b = TokenAccount::unpack(&maker_token_account_b.data.borrow())?.amount; - assert_eq!( - taker_amount_a, - taker_amount_a_before_transfer + vault_amount_a - ); - assert_eq!( - maker_amount_b, - taker_amount_a_before_transfer + offer.token_b_wanted_amount - ); + let expected_taker_amount_a = taker_amount_a_before_transfer + .checked_add(vault_amount_a) + .ok_or(EscrowError::ArithmeticOverflow)?; + let expected_maker_amount_b = maker_amount_b_before_transfer + .checked_add(offer.token_b_wanted_amount) + .ok_or(EscrowError::ArithmeticOverflow)?; + + if taker_amount_a != expected_taker_amount_a { + return Err(EscrowError::TokenConservationViolation.into()); + } + if maker_amount_b != expected_maker_amount_b { + return Err(EscrowError::TokenConservationViolation.into()); + } let taker_amount_b = TokenAccount::unpack(&taker_token_account_b.data.borrow())?.amount; let vault_amount_a = TokenAccount::unpack(&vault.data.borrow())?.amount; @@ -216,33 +201,21 @@ impl TakeOffer { solana_program::msg!("Maker B Balance After Transfer: {}", maker_amount_b); solana_program::msg!("Taker B Balance After Transfer: {}", taker_amount_b); - // close the vault account - // + // Close the vault and the offer account. The maker paid the rent for + // both in make_offer, so both refunds go to the maker. invoke_signed( &token_instruction::close_account( token_program.key, vault.key, - taker.key, + maker.key, offer_info.key, &[], )?, - &[vault.clone(), taker.clone(), offer_info.clone()], + &[vault.clone(), maker.clone(), offer_info.clone()], &[offer_signer_seeds], )?; - // Send the rent back to the payer - // - let lamports = offer_info.lamports(); - **offer_info.lamports.borrow_mut() -= lamports; - **payer.lamports.borrow_mut() += lamports; - - // Resize the account to zero - // - offer_info.resize(0)?; - - // Assign the account to the System Program - // - offer_info.assign(system_program.key); + close_offer_account(offer_info, maker, system_program)?; Ok(()) } diff --git a/finance/escrow/native/program/src/lib.rs b/finance/escrow/native/program/src/lib.rs index d6a5249e..ecfa1e64 100644 --- a/finance/escrow/native/program/src/lib.rs +++ b/finance/escrow/native/program/src/lib.rs @@ -23,6 +23,7 @@ fn process_instruction( match instruction { EscrowInstruction::MakeOffer(data) => MakeOffer::process(program_id, accounts, data), EscrowInstruction::TakeOffer => TakeOffer::process(program_id, accounts), + EscrowInstruction::CancelOffer => CancelOffer::process(program_id, accounts), } } @@ -30,4 +31,5 @@ fn process_instruction( enum EscrowInstruction { MakeOffer(MakeOffer), TakeOffer, + CancelOffer, } diff --git a/finance/escrow/native/program/src/utils.rs b/finance/escrow/native/program/src/utils.rs index 509e3c97..84c9dc67 100644 --- a/finance/escrow/native/program/src/utils.rs +++ b/finance/escrow/native/program/src/utils.rs @@ -1,5 +1,5 @@ use crate::error::EscrowError; -use solana_program::{program_error::ProgramError, pubkey::Pubkey}; +use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; pub fn assert_is_associated_token_account( token_address: &Pubkey, @@ -15,3 +15,25 @@ pub fn assert_is_associated_token_account( Ok(()) } + +// Close a program-owned account: move all of its lamports to `destination` +// (the party who paid its rent), wipe its data, and hand it back to the +// System Program. +pub fn close_offer_account<'info>( + offer_info: &AccountInfo<'info>, + destination: &AccountInfo<'info>, + system_program: &AccountInfo<'info>, +) -> Result<(), ProgramError> { + let offer_lamports = offer_info.lamports(); + let destination_lamports = destination.lamports(); + + **offer_info.lamports.borrow_mut() = 0; + **destination.lamports.borrow_mut() = destination_lamports + .checked_add(offer_lamports) + .ok_or(EscrowError::ArithmeticOverflow)?; + + offer_info.resize(0)?; + offer_info.assign(system_program.key); + + Ok(()) +} diff --git a/finance/escrow/native/program/tests/test.rs b/finance/escrow/native/program/tests/test.rs index 379727d5..bbdf29cf 100644 --- a/finance/escrow/native/program/tests/test.rs +++ b/finance/escrow/native/program/tests/test.rs @@ -19,6 +19,7 @@ use { // borsh-encoded `EscrowInstruction` discriminants (see program/src/lib.rs). const MAKE_OFFER: u8 = 0; const TAKE_OFFER: u8 = 1; +const CANCEL_OFFER: u8 = 2; const DECIMALS: u8 = 6; const MINTED_AMOUNT: u64 = 100 * 1_000_000; // 100 tokens at 6 decimals @@ -29,6 +30,17 @@ const OFFER_ID: u64 = 0; /// Sign with `payer` (fee payer) plus any extra signers and send the tx, /// asserting success. fn send(svm: &mut LiteSVM, payer: &Keypair, ixs: &[Instruction], extra_signers: &[&Keypair]) { + try_send(svm, payer, ixs, extra_signers).unwrap(); +} + +/// Sign with `payer` (fee payer) plus any extra signers and send the tx, +/// returning the result. +fn try_send( + svm: &mut LiteSVM, + payer: &Keypair, + ixs: &[Instruction], + extra_signers: &[&Keypair], +) -> Result<(), Box> { let mut signers: Vec<&Keypair> = vec![payer]; signers.extend_from_slice(extra_signers); let tx = Transaction::new_signed_with_payer( @@ -37,7 +49,7 @@ fn send(svm: &mut LiteSVM, payer: &Keypair, ixs: &[Instruction], extra_signers: &signers, svm.latest_blockhash(), ); - svm.send_transaction(tx).unwrap(); + svm.send_transaction(tx).map(|_| ()).map_err(Box::new) } /// Create `mint`, an ATA for `holder`, and mint `MINTED_AMOUNT` into it. The @@ -83,8 +95,27 @@ fn token_amount(svm: &LiteSVM, address: &Pubkey) -> u64 { TokenAccount::unpack(&account.data).unwrap().amount } -#[test] -fn test_escrow_make_and_take() { +fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { + svm.get_account(address).map(|a| a.lamports).unwrap_or(0) +} + +struct EscrowSetup { + svm: LiteSVM, + program_id: Pubkey, + payer: Keypair, + maker: Keypair, + taker: Keypair, + mint_a: Keypair, + mint_b: Keypair, + offer: Pubkey, + vault: Pubkey, + maker_account_a: Pubkey, + maker_account_b: Pubkey, + taker_account_a: Pubkey, + taker_account_b: Pubkey, +} + +fn setup() -> EscrowSetup { let mut svm = LiteSVM::new(); let program_id = Pubkey::new_unique(); let program_bytes = include_bytes!("../../tests/fixtures/escrow_native_program.so"); @@ -105,10 +136,6 @@ fn test_escrow_make_and_take() { mint_tokens(&mut svm, &payer, &mint_a, &maker.pubkey()); mint_tokens(&mut svm, &payer, &mint_b, &taker.pubkey()); - let token_program = spl_token_interface::id(); - let ata_program = spl_associated_token_account_interface::program::id(); - let system_program = solana_system_interface::program::ID; - let (offer, _bump) = Pubkey::find_program_address( &[b"offer", maker.pubkey().as_ref(), &OFFER_ID.to_le_bytes()], &program_id, @@ -119,60 +146,205 @@ fn test_escrow_make_and_take() { let taker_account_a = get_associated_token_address(&taker.pubkey(), &mint_a.pubkey()); let taker_account_b = get_associated_token_address(&taker.pubkey(), &mint_b.pubkey()); - // ---- Make Offer ---- + EscrowSetup { + svm, + program_id, + payer, + maker, + taker, + mint_a, + mint_b, + offer, + vault, + maker_account_a, + maker_account_b, + taker_account_a, + taker_account_b, + } +} + +fn make_offer_instruction(es: &EscrowSetup) -> Instruction { let mut make_data = vec![MAKE_OFFER]; make_data.extend_from_slice(&OFFER_ID.to_le_bytes()); make_data.extend_from_slice(&AMOUNT_A.to_le_bytes()); make_data.extend_from_slice(&AMOUNT_B.to_le_bytes()); - let make_ix = Instruction { - program_id, + Instruction { + program_id: es.program_id, accounts: vec![ - AccountMeta::new(offer, false), - AccountMeta::new_readonly(mint_a.pubkey(), false), - AccountMeta::new_readonly(mint_b.pubkey(), false), - AccountMeta::new(maker_account_a, false), - AccountMeta::new(vault, false), - AccountMeta::new(maker.pubkey(), true), - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new_readonly(token_program, false), - AccountMeta::new_readonly(ata_program, false), - AccountMeta::new_readonly(system_program, false), + AccountMeta::new(es.offer, false), + AccountMeta::new_readonly(es.mint_a.pubkey(), false), + AccountMeta::new_readonly(es.mint_b.pubkey(), false), + AccountMeta::new(es.maker_account_a, false), + AccountMeta::new(es.maker_account_b, false), + AccountMeta::new(es.vault, false), + AccountMeta::new(es.maker.pubkey(), true), + AccountMeta::new_readonly(spl_token_interface::id(), false), + AccountMeta::new_readonly(spl_associated_token_account_interface::program::id(), false), + AccountMeta::new_readonly(solana_system_interface::program::ID, false), ], data: make_data, - }; - send(&mut svm, &payer, &[make_ix], &[&maker]); - - // Vault should hold the offered Mint A amount. - assert_eq!(token_amount(&svm, &vault), AMOUNT_A); + } +} - // ---- Take Offer ---- - let take_ix = Instruction { - program_id, +fn take_offer_instruction(es: &EscrowSetup) -> Instruction { + Instruction { + program_id: es.program_id, accounts: vec![ - AccountMeta::new(offer, false), - AccountMeta::new_readonly(mint_a.pubkey(), false), - AccountMeta::new_readonly(mint_b.pubkey(), false), - AccountMeta::new(maker_account_b, false), - AccountMeta::new(taker_account_a, false), - AccountMeta::new(taker_account_b, false), - AccountMeta::new(vault, false), - AccountMeta::new_readonly(maker.pubkey(), false), - AccountMeta::new(taker.pubkey(), true), - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new_readonly(token_program, false), - AccountMeta::new_readonly(ata_program, false), - AccountMeta::new_readonly(system_program, false), + AccountMeta::new(es.offer, false), + AccountMeta::new_readonly(es.mint_a.pubkey(), false), + AccountMeta::new_readonly(es.mint_b.pubkey(), false), + AccountMeta::new(es.maker_account_b, false), + AccountMeta::new(es.taker_account_a, false), + AccountMeta::new(es.taker_account_b, false), + AccountMeta::new(es.vault, false), + AccountMeta::new(es.maker.pubkey(), false), + AccountMeta::new(es.taker.pubkey(), true), + AccountMeta::new_readonly(spl_token_interface::id(), false), + AccountMeta::new_readonly(spl_associated_token_account_interface::program::id(), false), + AccountMeta::new_readonly(solana_system_interface::program::ID, false), ], data: vec![TAKE_OFFER], - }; - send(&mut svm, &payer, &[take_ix], &[&taker]); + } +} + +fn cancel_offer_instruction(es: &EscrowSetup, canceller: &Pubkey) -> Instruction { + Instruction { + program_id: es.program_id, + accounts: vec![ + AccountMeta::new(es.offer, false), + AccountMeta::new_readonly(es.mint_a.pubkey(), false), + AccountMeta::new(es.maker_account_a, false), + AccountMeta::new(es.vault, false), + AccountMeta::new(*canceller, true), + AccountMeta::new_readonly(spl_token_interface::id(), false), + AccountMeta::new_readonly(solana_system_interface::program::ID, false), + ], + data: vec![CANCEL_OFFER], + } +} + +#[test] +fn test_escrow_make_and_take() { + let mut es = setup(); + + // Pre-create the maker's Mint B ATA (paid by the global payer) so the + // maker's lamports can be compared exactly across make + take. + let create_maker_ata_b = create_associated_token_account( + &es.payer.pubkey(), + &es.maker.pubkey(), + &es.mint_b.pubkey(), + &spl_token_interface::id(), + ); + let payer = es.payer.insecure_clone(); + send(&mut es.svm, &payer, &[create_maker_ata_b], &[]); - // Offer + vault should be closed (zero-lamport accounts are purged). - assert!(svm.get_account(&offer).map(|a| a.lamports).unwrap_or(0) == 0); - assert!(svm.get_account(&vault).map(|a| a.lamports).unwrap_or(0) == 0); + let maker_lamports_before_make = lamports(&es.svm, &es.maker.pubkey()); + let taker_lamports_before_take = lamports(&es.svm, &es.taker.pubkey()); + + // ---- Make Offer ---- + let make_ix = make_offer_instruction(&es); + let maker = es.maker.insecure_clone(); + send(&mut es.svm, &payer, &[make_ix], &[&maker]); + + // Vault holds the offered Mint A amount, and the maker paid the rent for + // the offer account and the vault. + assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); + let offer_rent = lamports(&es.svm, &es.offer); + let vault_rent = lamports(&es.svm, &es.vault); + assert!(offer_rent > 0 && vault_rent > 0); + assert_eq!( + lamports(&es.svm, &es.maker.pubkey()), + maker_lamports_before_make - offer_rent - vault_rent + ); + + // ---- Take Offer ---- + let take_ix = take_offer_instruction(&es); + let taker = es.taker.insecure_clone(); + send(&mut es.svm, &payer, &[take_ix], &[&taker]); + + // Offer + vault are closed (zero-lamport accounts are purged). + assert_eq!(lamports(&es.svm, &es.offer), 0); + assert_eq!(lamports(&es.svm, &es.vault), 0); // Taker received Mint A; maker received Mint B. - assert_eq!(token_amount(&svm, &taker_account_a), AMOUNT_A); - assert_eq!(token_amount(&svm, &maker_account_b), AMOUNT_B); + assert_eq!(token_amount(&es.svm, &es.taker_account_a), AMOUNT_A); + assert_eq!(token_amount(&es.svm, &es.maker_account_b), AMOUNT_B); + + // Rent destinations: the maker's lamports fully recover (the offer and + // vault rent both come back to the maker). The taker only paid the rent + // for their own new Mint A ATA. + assert_eq!( + lamports(&es.svm, &es.maker.pubkey()), + maker_lamports_before_make + ); + let taker_ata_a_rent = lamports(&es.svm, &es.taker_account_a); + assert_eq!( + lamports(&es.svm, &es.taker.pubkey()), + taker_lamports_before_take - taker_ata_a_rent + ); +} + +#[test] +fn test_escrow_make_and_cancel() { + let mut es = setup(); + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + + let maker_lamports_before_make = lamports(&es.svm, &es.maker.pubkey()); + let maker_a_before_make = token_amount(&es.svm, &es.maker_account_a); + + // ---- Make Offer ---- + // The maker has no Mint B ATA yet; make_offer creates it, paid by the + // maker. + let make_ix = make_offer_instruction(&es); + send(&mut es.svm, &payer, &[make_ix], &[&maker]); + assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); + let maker_ata_b_rent = lamports(&es.svm, &es.maker_account_b); + assert!(maker_ata_b_rent > 0); + + // ---- Cancel Offer ---- + let cancel_ix = cancel_offer_instruction(&es, &es.maker.pubkey()); + send(&mut es.svm, &payer, &[cancel_ix], &[&maker]); + + // Offer + vault are closed. + assert_eq!(lamports(&es.svm, &es.offer), 0); + assert_eq!(lamports(&es.svm, &es.vault), 0); + + // The maker's Mint A tokens are back in full. + assert_eq!( + token_amount(&es.svm, &es.maker_account_a), + maker_a_before_make + ); + + // Rent destinations: the offer and vault rent return to the maker. The + // only lamports the maker is down is the rent of their still-open Mint B + // ATA, created during make_offer. + assert_eq!( + lamports(&es.svm, &es.maker.pubkey()), + maker_lamports_before_make - maker_ata_b_rent + ); +} + +#[test] +fn test_cancel_offer_rejects_non_maker() { + let mut es = setup(); + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + let taker = es.taker.insecure_clone(); + + let make_ix = make_offer_instruction(&es); + send(&mut es.svm, &payer, &[make_ix], &[&maker]); + + // The taker signs a cancel attempt. The offer's stored maker does not + // match the signer, so the program must reject it. + let cancel_ix = cancel_offer_instruction(&es, &es.taker.pubkey()); + let result = try_send(&mut es.svm, &payer, &[cancel_ix], &[&taker]); + assert!( + result.is_err(), + "non-maker must not be able to cancel the offer" + ); + + // The vault still holds the offered tokens. + assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); } diff --git a/finance/escrow/quasar/Cargo.toml b/finance/escrow/quasar/Cargo.toml index de27742c..8056b58b 100644 --- a/finance/escrow/quasar/Cargo.toml +++ b/finance/escrow/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-escrow" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/finance/escrow/quasar/README.md b/finance/escrow/quasar/README.md index b4f5ec1a..4df24634 100644 --- a/finance/escrow/quasar/README.md +++ b/finance/escrow/quasar/README.md @@ -6,8 +6,10 @@ See also: the [repository catalog](../../../README.md). ## Major concepts -- Escrow PDA -- See [Anchor variant](../anchor/README.md) for the full walkthrough +- **Offer**: a PDA with seeds `["offer", maker, id]` (the same seeds as the Anchor variant, so clients work against either build). It stores the maker, both mints, the maker's token B account, the vault address, the wanted `receive` amount, and the bump. `take_offer` and `cancel_offer` validate every passed account against this stored state via `has_one` bindings. +- **Vault**: a token account owned by the offer PDA holding the maker's offered token A while the offer is open. +- The maker pays the rent for the offer account and the vault in `make_offer`; both `take_offer` and `cancel_offer` close those accounts back to the maker. +- See the [Anchor variant](../anchor/README.md) for the full walkthrough. ## Setup diff --git a/finance/escrow/quasar/src/instructions/cancel_offer.rs b/finance/escrow/quasar/src/instructions/cancel_offer.rs index 11d22216..2bf4af6a 100644 --- a/finance/escrow/quasar/src/instructions/cancel_offer.rs +++ b/finance/escrow/quasar/src/instructions/cancel_offer.rs @@ -5,14 +5,19 @@ use { }; #[derive(Accounts)] -pub struct CancelOffer { +pub struct CancelOfferAccountConstraints { #[account(mut)] pub maker: Signer, + // Only the maker can cancel. The mint and vault are bound to the stored + // offer state, and the offer closes back to the maker, who paid its rent + // in make_offer. #[account( mut, has_one(maker), + has_one(token_mint_a), + has_one(vault), close(dest = maker), - address = Offer::seeds(maker.address()) + address = Offer::seeds(maker.address(), offer.id.into()) )] pub offer: Account, pub token_mint_a: Account, @@ -31,15 +36,21 @@ pub struct CancelOffer { } #[inline(always)] -pub fn handle_withdraw_tokens_and_close_cancel_offer(accounts: &mut CancelOffer, bumps: &CancelOfferBumps) -> Result<(), ProgramError> { +pub fn handle_withdraw_tokens_and_close_cancel_offer( + accounts: &mut CancelOfferAccountConstraints, + bumps: &CancelOfferAccountConstraintsBumps, +) -> Result<(), ProgramError> { + let id_bytes = u64::from(accounts.offer.id).to_le_bytes(); let bump = [bumps.offer]; let seeds = [ Seed::from(b"offer" as &[u8]), Seed::from(accounts.maker.address().as_ref()), + Seed::from(id_bytes.as_ref()), Seed::from(bump.as_ref()), ]; - accounts.token_program + accounts + .token_program .transfer( &accounts.vault, &accounts.maker_token_account_a, @@ -48,7 +59,8 @@ pub fn handle_withdraw_tokens_and_close_cancel_offer(accounts: &mut CancelOffer, ) .invoke_signed(&seeds)?; - accounts.token_program + accounts + .token_program .close_account(&accounts.vault, &accounts.maker, &accounts.offer) .invoke_signed(&seeds)?; Ok(()) diff --git a/finance/escrow/quasar/src/instructions/make_offer.rs b/finance/escrow/quasar/src/instructions/make_offer.rs index 7def1c21..451f5ca9 100644 --- a/finance/escrow/quasar/src/instructions/make_offer.rs +++ b/finance/escrow/quasar/src/instructions/make_offer.rs @@ -5,10 +5,11 @@ use { }; #[derive(Accounts)] -pub struct MakeOffer { +#[instruction(id: u64)] +pub struct MakeOfferAccountConstraints { #[account(mut)] pub maker: Signer, - #[account(mut, init, payer = maker, address = Offer::seeds(maker.address()))] + #[account(mut, init, payer = maker, address = Offer::seeds(maker.address(), id))] pub offer: Account, pub token_mint_a: Account, pub token_mint_b: Account, @@ -34,12 +35,19 @@ pub struct MakeOffer { } #[inline(always)] -pub fn handle_make_offer(accounts: &mut MakeOffer, receive: u64, bumps: &MakeOfferBumps) -> Result<(), ProgramError> { +pub fn handle_make_offer( + accounts: &mut MakeOfferAccountConstraints, + id: u64, + receive: u64, + bumps: &MakeOfferAccountConstraintsBumps, +) -> Result<(), ProgramError> { accounts.offer.set_inner(OfferInner { + id, maker: *accounts.maker.address(), token_mint_a: *accounts.token_mint_a.address(), token_mint_b: *accounts.token_mint_b.address(), maker_token_account_b: *accounts.maker_token_account_b.address(), + vault: *accounts.vault.address(), receive, bump: bumps.offer, }); @@ -47,8 +55,17 @@ pub fn handle_make_offer(accounts: &mut MakeOffer, receive: u64, bumps: &MakeOff } #[inline(always)] -pub fn handle_deposit_tokens(accounts: &mut MakeOffer, amount: u64) -> Result<(), ProgramError> { - accounts.token_program - .transfer(&accounts.maker_token_account_a, &accounts.vault, &accounts.maker, amount) +pub fn handle_deposit_tokens( + accounts: &mut MakeOfferAccountConstraints, + amount: u64, +) -> Result<(), ProgramError> { + accounts + .token_program + .transfer( + &accounts.maker_token_account_a, + &accounts.vault, + &accounts.maker, + amount, + ) .invoke() } diff --git a/finance/escrow/quasar/src/instructions/take_offer.rs b/finance/escrow/quasar/src/instructions/take_offer.rs index a6c0c907..9f036633 100644 --- a/finance/escrow/quasar/src/instructions/take_offer.rs +++ b/finance/escrow/quasar/src/instructions/take_offer.rs @@ -5,16 +5,23 @@ use { }; #[derive(Accounts)] -pub struct TakeOffer { +pub struct TakeOfferAccountConstraints { #[account(mut)] pub taker: Signer, + // Every account the offer recorded at make time is bound to the stored + // state: the maker, both mints, the maker's token B account, and the + // vault. The offer closes back to the maker, who paid its rent in + // make_offer. #[account( mut, has_one(maker), + has_one(token_mint_a), + has_one(token_mint_b), has_one(maker_token_account_b), + has_one(vault), constraints(offer.receive > 0), - close(dest = taker), - address = Offer::seeds(maker.address()) + close(dest = maker), + address = Offer::seeds(maker.address(), offer.id.into()) )] pub offer: Account, #[account(mut)] @@ -30,12 +37,7 @@ pub struct TakeOffer { pub taker_token_account_a: Account, #[account(mut)] pub taker_token_account_b: Account, - #[account( - mut, - init(idempotent), - payer = taker, - token(mint = token_mint_b, authority = maker, token_program = token_program), - )] + #[account(mut)] pub maker_token_account_b: Account, #[account(mut)] pub vault: Account, @@ -45,8 +47,11 @@ pub struct TakeOffer { } #[inline(always)] -pub fn handle_transfer_tokens(accounts: &mut TakeOffer) -> Result<(), ProgramError> { - accounts.token_program +pub fn handle_transfer_tokens( + accounts: &mut TakeOfferAccountConstraints, +) -> Result<(), ProgramError> { + accounts + .token_program .transfer( &accounts.taker_token_account_b, &accounts.maker_token_account_b, @@ -57,15 +62,21 @@ pub fn handle_transfer_tokens(accounts: &mut TakeOffer) -> Result<(), ProgramErr } #[inline(always)] -pub fn handle_withdraw_tokens_and_close_take(accounts: &mut TakeOffer, bumps: &TakeOfferBumps) -> Result<(), ProgramError> { +pub fn handle_withdraw_tokens_and_close_take( + accounts: &mut TakeOfferAccountConstraints, + bumps: &TakeOfferAccountConstraintsBumps, +) -> Result<(), ProgramError> { + let id_bytes = u64::from(accounts.offer.id).to_le_bytes(); let bump = [bumps.offer]; let seeds = [ Seed::from(b"offer" as &[u8]), Seed::from(accounts.maker.address().as_ref()), + Seed::from(id_bytes.as_ref()), Seed::from(bump.as_ref()), ]; - accounts.token_program + accounts + .token_program .transfer( &accounts.vault, &accounts.taker_token_account_a, @@ -74,8 +85,11 @@ pub fn handle_withdraw_tokens_and_close_take(accounts: &mut TakeOffer, bumps: &T ) .invoke_signed(&seeds)?; - accounts.token_program - .close_account(&accounts.vault, &accounts.taker, &accounts.offer) + // The maker paid the vault's rent in make_offer, so the vault closes + // back to the maker. + accounts + .token_program + .close_account(&accounts.vault, &accounts.maker, &accounts.offer) .invoke_signed(&seeds)?; Ok(()) } diff --git a/finance/escrow/quasar/src/lib.rs b/finance/escrow/quasar/src/lib.rs index bfb4f76a..6b92d6f3 100644 --- a/finance/escrow/quasar/src/lib.rs +++ b/finance/escrow/quasar/src/lib.rs @@ -8,7 +8,7 @@ mod state; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826"); /// Token escrow program: a maker deposits token A into a vault and specifies /// how much of token B they want in return. A taker fulfils the offer by @@ -18,19 +18,24 @@ mod quasar_escrow { use super::*; #[instruction(discriminator = 0)] - pub fn make_offer(ctx: Ctx, deposit: u64, receive: u64) -> Result<(), ProgramError> { - instructions::make_offer::handle_make_offer(&mut ctx.accounts, receive, &ctx.bumps)?; + pub fn make_offer( + ctx: Ctx, + id: u64, + deposit: u64, + receive: u64, + ) -> Result<(), ProgramError> { + instructions::make_offer::handle_make_offer(&mut ctx.accounts, id, receive, &ctx.bumps)?; instructions::make_offer::handle_deposit_tokens(&mut ctx.accounts, deposit) } #[instruction(discriminator = 1)] - pub fn take_offer(ctx: Ctx) -> Result<(), ProgramError> { + pub fn take_offer(ctx: Ctx) -> Result<(), ProgramError> { instructions::take_offer::handle_transfer_tokens(&mut ctx.accounts)?; instructions::take_offer::handle_withdraw_tokens_and_close_take(&mut ctx.accounts, &ctx.bumps) } #[instruction(discriminator = 2)] - pub fn cancel_offer(ctx: Ctx) -> Result<(), ProgramError> { + pub fn cancel_offer(ctx: Ctx) -> Result<(), ProgramError> { instructions::cancel_offer::handle_withdraw_tokens_and_close_cancel_offer(&mut ctx.accounts, &ctx.bumps) } } diff --git a/finance/escrow/quasar/src/state.rs b/finance/escrow/quasar/src/state.rs index 7357e181..f9a42fea 100644 --- a/finance/escrow/quasar/src/state.rs +++ b/finance/escrow/quasar/src/state.rs @@ -1,14 +1,18 @@ use quasar_lang::prelude::*; /// Offer state: records the maker's desired receive amount and the -/// associated mint/token-account addresses. +/// associated mint/token-account addresses. The `id` seed lets one maker +/// keep multiple offers open at once (matching the Anchor variant's +/// `["offer", maker, id]` seeds). #[account(discriminator = 1, set_inner)] -#[seeds(b"offer", maker: Address)] +#[seeds(b"offer", maker: Address, id: u64)] pub struct Offer { + pub id: u64, pub maker: Address, pub token_mint_a: Address, pub token_mint_b: Address, pub maker_token_account_b: Address, + pub vault: Address, pub receive: u64, pub bump: u8, } diff --git a/finance/escrow/quasar/src/tests.rs b/finance/escrow/quasar/src/tests.rs index ca81289a..b3c678dc 100644 --- a/finance/escrow/quasar/src/tests.rs +++ b/finance/escrow/quasar/src/tests.rs @@ -3,10 +3,17 @@ use { alloc::vec, alloc::vec::Vec, quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, std::println, }; +const OFFER_ID: u64 = 7; +const DEPOSIT_AMOUNT: u64 = 1337; +const RECEIVE_AMOUNT: u64 = 1337; +const STARTING_LAMPORTS: u64 = 1_000_000_000; +const OFFER_ACCOUNT_LAMPORTS: u64 = 2_000_000; + fn setup() -> QuasarSvm { let elf = std::fs::read("target/deploy/quasar_escrow.so").unwrap(); QuasarSvm::new() @@ -15,7 +22,7 @@ fn setup() -> QuasarSvm { } fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) + quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) } fn empty(address: Pubkey) -> Account { @@ -54,48 +61,75 @@ fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { ) } +fn token_amount(account: &Account) -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount +} + +fn derive_offer(maker: &Pubkey, id: u64) -> (Pubkey, u8) { + Pubkey::find_program_address(&[b"offer", maker.as_ref(), &id.to_le_bytes()], &crate::ID) +} + /// Build offer account data manually. /// Layout (from #[account] codegen): /// [disc: 1 byte = 1] +/// [id: 8 bytes (PodU64 LE)] /// [maker: 32 bytes (Address)] /// [token_mint_a: 32 bytes] /// [token_mint_b: 32 bytes] /// [maker_token_account_b: 32 bytes] +/// [vault: 32 bytes] /// [receive: 8 bytes (PodU64 LE)] /// [bump: 1 byte] -/// Total: 138 bytes +/// Total: 178 bytes +#[allow(clippy::too_many_arguments)] fn offer_data( + id: u64, maker: Pubkey, token_mint_a: Pubkey, token_mint_b: Pubkey, maker_token_account_b: Pubkey, + vault: Pubkey, receive: u64, bump: u8, ) -> Vec { - let mut data = Vec::with_capacity(138); + let mut data = Vec::with_capacity(178); data.push(1u8); // discriminator + data.extend_from_slice(&id.to_le_bytes()); data.extend_from_slice(maker.as_ref()); data.extend_from_slice(token_mint_a.as_ref()); data.extend_from_slice(token_mint_b.as_ref()); data.extend_from_slice(maker_token_account_b.as_ref()); + data.extend_from_slice(vault.as_ref()); data.extend_from_slice(&receive.to_le_bytes()); data.push(bump); data } +#[allow(clippy::too_many_arguments)] fn offer_account( address: Pubkey, + id: u64, maker: Pubkey, token_mint_a: Pubkey, token_mint_b: Pubkey, maker_token_account_b: Pubkey, + vault: Pubkey, receive: u64, bump: u8, ) -> Account { Account { address, - lamports: 2_000_000, - data: offer_data(maker, token_mint_a, token_mint_b, maker_token_account_b, receive, bump), + lamports: OFFER_ACCOUNT_LAMPORTS, + data: offer_data( + id, + maker, + token_mint_a, + token_mint_b, + maker_token_account_b, + vault, + receive, + bump, + ), owner: crate::ID, executable: false, } @@ -110,9 +144,10 @@ fn with_signers(mut ix: Instruction, indices: &[usize]) -> Instruction { } /// Build make_offer instruction data. -/// Wire format: [discriminator: u8 = 0] [deposit: u64 LE] [receive: u64 LE] -fn build_make_offer_data(deposit: u64, receive: u64) -> Vec { +/// Wire format: [discriminator: u8 = 0] [id: u64 LE] [deposit: u64 LE] [receive: u64 LE] +fn build_make_offer_data(id: u64, deposit: u64, receive: u64) -> Vec { let mut data = vec![0u8]; + data.extend_from_slice(&id.to_le_bytes()); data.extend_from_slice(&deposit.to_le_bytes()); data.extend_from_slice(&receive.to_le_bytes()); data @@ -130,6 +165,100 @@ fn build_cancel_offer_data() -> Vec { vec![2u8] } +struct TakeOfferFixture { + maker: Pubkey, + taker: Pubkey, + token_mint_a: Pubkey, + token_mint_b: Pubkey, + taker_token_account_a: Pubkey, + taker_token_account_b: Pubkey, + maker_token_account_b: Pubkey, + vault: Pubkey, + offer: Pubkey, + offer_bump: u8, +} + +fn take_offer_fixture() -> TakeOfferFixture { + let maker = Pubkey::new_unique(); + let (offer, offer_bump) = derive_offer(&maker, OFFER_ID); + TakeOfferFixture { + maker, + taker: Pubkey::new_unique(), + token_mint_a: Pubkey::new_unique(), + token_mint_b: Pubkey::new_unique(), + taker_token_account_a: Pubkey::new_unique(), + taker_token_account_b: Pubkey::new_unique(), + maker_token_account_b: Pubkey::new_unique(), + vault: Pubkey::new_unique(), + offer, + offer_bump, + } +} + +/// Build the take_offer instruction for the fixture, allowing the mint A and +/// vault metas to be overridden so attacks with substituted accounts can be +/// expressed. +fn build_take_offer_instruction( + fx: &TakeOfferFixture, + token_mint_a: Pubkey, + vault: Pubkey, +) -> Instruction { + let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; + with_signers( + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(fx.taker.into(), true), + solana_instruction::AccountMeta::new(fx.offer.into(), false), + solana_instruction::AccountMeta::new(fx.maker.into(), false), + solana_instruction::AccountMeta::new_readonly(token_mint_a.into(), false), + solana_instruction::AccountMeta::new_readonly(fx.token_mint_b.into(), false), + solana_instruction::AccountMeta::new(fx.taker_token_account_a.into(), false), + solana_instruction::AccountMeta::new(fx.taker_token_account_b.into(), false), + solana_instruction::AccountMeta::new(fx.maker_token_account_b.into(), false), + solana_instruction::AccountMeta::new(vault.into(), false), + solana_instruction::AccountMeta::new_readonly(rent.into(), false), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::SPL_TOKEN_PROGRAM_ID.into(), + false, + ), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::system_program::ID.into(), + false, + ), + ], + data: build_take_offer_data(), + }, + // taker_token_account_a signs the create_account CPI for its own + // initialization. + &[5], + ) +} + +fn take_offer_fixture_accounts(fx: &TakeOfferFixture) -> Vec { + vec![ + signer(fx.taker), + offer_account( + fx.offer, + OFFER_ID, + fx.maker, + fx.token_mint_a, + fx.token_mint_b, + fx.maker_token_account_b, + fx.vault, + RECEIVE_AMOUNT, + fx.offer_bump, + ), + signer(fx.maker), + mint(fx.token_mint_a, fx.maker), + mint(fx.token_mint_b, fx.maker), + empty(fx.taker_token_account_a), + token(fx.taker_token_account_b, fx.token_mint_b, fx.taker, 10_000), + token(fx.maker_token_account_b, fx.token_mint_b, fx.maker, 0), + token(fx.vault, fx.token_mint_a, fx.offer, DEPOSIT_AMOUNT), + ] +} + #[test] fn test_make_offer() { let mut svm = setup(); @@ -142,11 +271,10 @@ fn test_make_offer() { let maker_token_account_a = Pubkey::new_unique(); let maker_token_account_b = Pubkey::new_unique(); let vault = Pubkey::new_unique(); - let (offer, offer_bump) = - Pubkey::find_program_address(&[b"offer", maker.as_ref()], &crate::ID); + let (offer, offer_bump) = derive_offer(&maker, OFFER_ID); let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - let data = build_make_offer_data(1337, 1337); + let data = build_make_offer_data(OFFER_ID, DEPOSIT_AMOUNT, RECEIVE_AMOUNT); let instruction = with_signers( Instruction { @@ -183,12 +311,31 @@ fn test_make_offer() { assert!(result.is_ok(), "make_offer failed: {:?}", result.raw_result); - // Verify offer state + // Verify offer state (layout documented on offer_data above). let offer_data = &result.account(&offer).unwrap().data; assert_eq!(offer_data[0], 1, "discriminator"); - assert_eq!(&offer_data[1..33], maker.as_ref(), "maker"); - assert_eq!(&offer_data[129..137], &1337u64.to_le_bytes(), "receive"); - assert_eq!(offer_data[137], offer_bump, "bump"); + assert_eq!(&offer_data[1..9], &OFFER_ID.to_le_bytes(), "id"); + assert_eq!(&offer_data[9..41], maker.as_ref(), "maker"); + assert_eq!(&offer_data[41..73], token_mint_a.as_ref(), "token_mint_a"); + assert_eq!(&offer_data[73..105], token_mint_b.as_ref(), "token_mint_b"); + assert_eq!( + &offer_data[105..137], + maker_token_account_b.as_ref(), + "maker_token_account_b" + ); + assert_eq!(&offer_data[137..169], vault.as_ref(), "vault"); + assert_eq!( + &offer_data[169..177], + &RECEIVE_AMOUNT.to_le_bytes(), + "receive" + ); + assert_eq!(offer_data[177], offer_bump, "bump"); + + // The deposit landed in the vault. + assert_eq!( + token_amount(result.account(&vault).unwrap()), + DEPOSIT_AMOUNT + ); println!(" MAKE_OFFER CU: {}", result.compute_units_consumed); } @@ -196,111 +343,240 @@ fn test_make_offer() { #[test] fn test_take_offer() { let mut svm = setup(); + let fx = take_offer_fixture(); + let accounts = take_offer_fixture_accounts(&fx); + let vault_rent = accounts[8].lamports; + + let instruction = build_take_offer_instruction(&fx, fx.token_mint_a, fx.vault); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "take_offer failed: {:?}", result.raw_result); + + // Token balances: the taker received the vault's mint A, the maker + // received the wanted mint B. + assert_eq!( + token_amount(result.account(&fx.taker_token_account_a).unwrap()), + DEPOSIT_AMOUNT + ); + assert_eq!( + token_amount(result.account(&fx.maker_token_account_b).unwrap()), + RECEIVE_AMOUNT + ); + + // The offer and vault are closed. + let offer_lamports = result.account(&fx.offer).map(|a| a.lamports).unwrap_or(0); + let vault_lamports = result.account(&fx.vault).map(|a| a.lamports).unwrap_or(0); + assert_eq!(offer_lamports, 0, "offer must be closed"); + assert_eq!(vault_lamports, 0, "vault must be closed"); + + // Rent destinations: the maker recovers the rent of both accounts they + // paid for in make_offer; the taker gains no lamports from the close. + let maker_lamports = result.account(&fx.maker).unwrap().lamports; + let expected_maker_lamports = STARTING_LAMPORTS + .checked_add(OFFER_ACCOUNT_LAMPORTS) + .and_then(|lamports| lamports.checked_add(vault_rent)) + .unwrap(); + assert_eq!( + maker_lamports, expected_maker_lamports, + "maker must recover the offer and vault rent" + ); + let taker_lamports = result.account(&fx.taker).unwrap().lamports; + assert!( + taker_lamports <= STARTING_LAMPORTS, + "taker must not gain lamports from closing the maker's accounts" + ); + + println!(" TAKE_OFFER CU: {}", result.compute_units_consumed); +} + +#[test] +fn test_take_offer_rejects_wrong_mint() { + let mut svm = setup(); + let fx = take_offer_fixture(); + let mut accounts = take_offer_fixture_accounts(&fx); + + // The attacker substitutes a different mint for token_mint_a. The + // has_one(token_mint_a) binding to the offer state must reject it. + let wrong_mint = Pubkey::new_unique(); + accounts[3] = mint(wrong_mint, fx.maker); + + let instruction = build_take_offer_instruction(&fx, wrong_mint, fx.vault); + let result = svm.process_instruction(&instruction, &accounts); + assert!( + !result.is_ok(), + "take_offer must reject a mint that does not match the offer state" + ); +} + +#[test] +fn test_take_offer_rejects_wrong_vault() { + let mut svm = setup(); + let fx = take_offer_fixture(); + let mut accounts = take_offer_fixture_accounts(&fx); + + // The attacker substitutes a different token account (same mint, also + // owned by the offer PDA) for the vault. The has_one(vault) binding to + // the offer state must reject it. + let wrong_vault = Pubkey::new_unique(); + accounts[8] = token(wrong_vault, fx.token_mint_a, fx.offer, DEPOSIT_AMOUNT); + + let instruction = build_take_offer_instruction(&fx, fx.token_mint_a, wrong_vault); + let result = svm.process_instruction(&instruction, &accounts); + assert!( + !result.is_ok(), + "take_offer must reject a vault that does not match the offer state" + ); +} + +#[test] +fn test_cancel_offer() { + let mut svm = setup(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; let maker = Pubkey::new_unique(); - let taker = Pubkey::new_unique(); let token_mint_a = Pubkey::new_unique(); let token_mint_b = Pubkey::new_unique(); - let taker_token_account_a = Pubkey::new_unique(); - let taker_token_account_b = Pubkey::new_unique(); + let maker_token_account_a = Pubkey::new_unique(); let maker_token_account_b = Pubkey::new_unique(); let vault = Pubkey::new_unique(); - let (offer, offer_bump) = - Pubkey::find_program_address(&[b"offer", maker.as_ref()], &crate::ID); + let (offer, offer_bump) = derive_offer(&maker, OFFER_ID); let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - let data = build_take_offer_data(); - - let instruction = with_signers( - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(taker.into(), true), - solana_instruction::AccountMeta::new(offer.into(), false), - solana_instruction::AccountMeta::new(maker.into(), false), - solana_instruction::AccountMeta::new_readonly(token_mint_a.into(), false), - solana_instruction::AccountMeta::new_readonly(token_mint_b.into(), false), - solana_instruction::AccountMeta::new(taker_token_account_a.into(), false), - solana_instruction::AccountMeta::new(taker_token_account_b.into(), false), - solana_instruction::AccountMeta::new(maker_token_account_b.into(), false), - solana_instruction::AccountMeta::new(vault.into(), false), - solana_instruction::AccountMeta::new_readonly(rent.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }, - &[5, 7], // taker_token_account_a, maker_token_account_b as signers for create_account CPI - ); + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(maker.into(), true), + solana_instruction::AccountMeta::new(offer.into(), false), + solana_instruction::AccountMeta::new_readonly(token_mint_a.into(), false), + solana_instruction::AccountMeta::new(maker_token_account_a.into(), false), + solana_instruction::AccountMeta::new(vault.into(), false), + solana_instruction::AccountMeta::new_readonly(rent.into(), false), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::SPL_TOKEN_PROGRAM_ID.into(), + false, + ), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::system_program::ID.into(), + false, + ), + ], + data: build_cancel_offer_data(), + }; + let vault_account = token(vault, token_mint_a, offer, DEPOSIT_AMOUNT); + let vault_rent = vault_account.lamports; let result = svm.process_instruction( &instruction, &[ - signer(taker), - offer_account(offer, maker, token_mint_a, token_mint_b, maker_token_account_b, 1337, offer_bump), signer(maker), + offer_account( + offer, + OFFER_ID, + maker, + token_mint_a, + token_mint_b, + maker_token_account_b, + vault, + RECEIVE_AMOUNT, + offer_bump, + ), mint(token_mint_a, maker), - mint(token_mint_b, maker), - empty(taker_token_account_a), - token(taker_token_account_b, token_mint_b, taker, 10_000), - empty(maker_token_account_b), - token(vault, token_mint_a, offer, 1337), + // Pre-created with a zero balance so the maker's lamports can be + // compared exactly after the cancel. + token(maker_token_account_a, token_mint_a, maker, 0), + vault_account, ], ); - assert!(result.is_ok(), "take_offer failed: {:?}", result.raw_result); - println!(" TAKE_OFFER CU: {}", result.compute_units_consumed); + assert!( + result.is_ok(), + "cancel_offer failed: {:?}", + result.raw_result + ); + + // The maker got their mint A tokens back. + assert_eq!( + token_amount(result.account(&maker_token_account_a).unwrap()), + DEPOSIT_AMOUNT + ); + + // The offer and vault are closed and their rent returned to the maker. + let offer_lamports = result.account(&offer).map(|a| a.lamports).unwrap_or(0); + let vault_lamports = result.account(&vault).map(|a| a.lamports).unwrap_or(0); + assert_eq!(offer_lamports, 0, "offer must be closed"); + assert_eq!(vault_lamports, 0, "vault must be closed"); + + let maker_lamports = result.account(&maker).unwrap().lamports; + let expected_maker_lamports = STARTING_LAMPORTS + .checked_add(OFFER_ACCOUNT_LAMPORTS) + .and_then(|lamports| lamports.checked_add(vault_rent)) + .unwrap(); + assert_eq!( + maker_lamports, expected_maker_lamports, + "maker must recover the offer and vault rent" + ); + + println!(" CANCEL_OFFER CU: {}", result.compute_units_consumed); } #[test] -fn test_cancel_offer() { +fn test_cancel_offer_rejects_non_maker() { let mut svm = setup(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; let maker = Pubkey::new_unique(); + let attacker = Pubkey::new_unique(); let token_mint_a = Pubkey::new_unique(); let token_mint_b = Pubkey::new_unique(); - let maker_token_account_a = Pubkey::new_unique(); + let attacker_token_account_a = Pubkey::new_unique(); let maker_token_account_b = Pubkey::new_unique(); let vault = Pubkey::new_unique(); - let (offer, offer_bump) = - Pubkey::find_program_address(&[b"offer", maker.as_ref()], &crate::ID); + let (offer, offer_bump) = derive_offer(&maker, OFFER_ID); let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - let data = build_cancel_offer_data(); - - let instruction = with_signers( - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(maker.into(), true), - solana_instruction::AccountMeta::new(offer.into(), false), - solana_instruction::AccountMeta::new_readonly(token_mint_a.into(), false), - solana_instruction::AccountMeta::new(maker_token_account_a.into(), false), - solana_instruction::AccountMeta::new(vault.into(), false), - solana_instruction::AccountMeta::new_readonly(rent.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }, - &[3], // maker_token_account_a as signer for create_account CPI - ); + // The attacker signs as the "maker". has_one(maker) and the offer's PDA + // seeds both fail to match. + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(attacker.into(), true), + solana_instruction::AccountMeta::new(offer.into(), false), + solana_instruction::AccountMeta::new_readonly(token_mint_a.into(), false), + solana_instruction::AccountMeta::new(attacker_token_account_a.into(), false), + solana_instruction::AccountMeta::new(vault.into(), false), + solana_instruction::AccountMeta::new_readonly(rent.into(), false), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::SPL_TOKEN_PROGRAM_ID.into(), + false, + ), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::system_program::ID.into(), + false, + ), + ], + data: build_cancel_offer_data(), + }; let result = svm.process_instruction( &instruction, &[ - signer(maker), - offer_account(offer, maker, token_mint_a, token_mint_b, maker_token_account_b, 1337, offer_bump), + signer(attacker), + offer_account( + offer, + OFFER_ID, + maker, + token_mint_a, + token_mint_b, + maker_token_account_b, + vault, + RECEIVE_AMOUNT, + offer_bump, + ), mint(token_mint_a, maker), - empty(maker_token_account_a), - token(vault, token_mint_a, offer, 1337), + token(attacker_token_account_a, token_mint_a, attacker, 0), + token(vault, token_mint_a, offer, DEPOSIT_AMOUNT), ], ); - assert!(result.is_ok(), "cancel_offer failed: {:?}", result.raw_result); - println!(" CANCEL_OFFER CU: {}", result.compute_units_consumed); + assert!( + !result.is_ok(), + "cancel_offer must reject a signer who is not the offer's maker" + ); } diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index 0d1347ef..49be1f29 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -1,6 +1,6 @@ -# Order Book — Central Limit Order Book (CLOB) +# Order Book - Central Limit Order Book (CLOB) -This is an **[order book](https://www.investopedia.com/terms/o/order-book.asp)** — specifically, a **[central limit order +This is an **[order book](https://www.investopedia.com/terms/o/order-book.asp)** - specifically, a **[central limit order book (CLOB)](https://www.investopedia.com/terms/o/order-book.asp)**, the standard piece of market infrastructure used by NYSE, NASDAQ, LSE, CME, and every major crypto venue. An Anchor program that runs an onchain order book for a single pair of token mints: @@ -20,7 +20,7 @@ are, skip to [Accounts and PDAs](#2-accounts-and-pdas) or - [A real-world walkthrough: NVDAx/USDC](#a-real-world-walkthrough-nvdaxusdc) 2. [Accounts and PDAs](#2-accounts-and-pdas) 3. [Instruction lifecycle walkthrough](#3-instruction-lifecycle-walkthrough) -4. [The matching engine — step by step](#4-the-matching-engine--step-by-step) +4. [The matching engine - step by step](#4-the-matching-engine--step-by-step) - [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance) 5. [Full-lifecycle worked examples](#5-full-lifecycle-worked-examples) 6. [Safety and edge cases](#6-safety-and-edge-cases) @@ -33,52 +33,52 @@ are, skip to [Accounts and PDAs](#2-accounts-and-pdas) or Two users want to swap tokens at prices they each picked: -- Alice holds **USDC** (the *[quote](https://www.investopedia.com/terms/q/quotecurrency.asp)* mint — the pricing unit, the way USD +- Alice holds **USDC** (the *[quote](https://www.investopedia.com/terms/q/quotecurrency.asp)* mint - the pricing unit, the way USD is the pricing unit in "NVDAx is $950") and wants to buy **NVDAx** - (the *[base](https://www.investopedia.com/terms/b/basecurrency.asp)* mint — the asset being priced), but only if she can + (the *[base](https://www.investopedia.com/terms/b/basecurrency.asp)* mint - the asset being priced), but only if she can get NVDAx at 900 USDC per share or lower. - Bob holds **NVDAx** and wants USDC, but only if he can get at least 950 USDC per NVDAx share he sells. -They post their offers — Alice a *bid* (a buy offer at a limit price), -Bob an *ask* (a sell offer at a limit price) — and wait. Alice's bid +They post their offers - Alice a *bid* (a buy offer at a limit price), +Bob an *ask* (a sell offer at a limit price) - and wait. Alice's bid sits on the book. Bob's ask sits on the book. Neither crosses the other, so nothing happens yet. Later, Carol shows up holding NVDAx and willing to sell at any price ≥ 900 USDC. She posts an ask at 900. Now Alice's bid (900 USDC) *crosses* -Carol's new ask (900 USDC) — the bid is ≥ the ask. The program: +Carol's new ask (900 USDC) - the bid is ≥ the ask. The program: 1. Pairs them up. 2. Locks Carol's NVDAx in the program's base vault (Carol signed this transaction, so only her funds move). -3. Allocates Alice's USDC — already sitting in the quote vault since - Alice placed her bid — to Carol. +3. Allocates Alice's USDC - already sitting in the quote vault since + Alice placed her bid - to Carol. 4. Credits each party's unsettled balance with what they're owed, minus a fee for the market operator. Tokens don't leave the vaults yet; Alice and Carol each call `settle_funds` later to pull them out. -At no point does either of them transfer directly to the other — all +At no point does either of them transfer directly to the other - all token flows go through two program-owned vaults, and both users later call `settle_funds` to pull their balances out. ### The onchain pieces, in plain terms -- A **Market** PDA — one per base/quote pair. Stores fee rate, tick +- A **Market** PDA - one per base/quote pair. Stores fee rate, tick size, minimum order size, the addresses of the four related accounts (base vault, quote vault, fee vault, order book), and the pubkey that can withdraw accumulated fees. -- An **OrderBook** account — two stores: bids sorted highest-first, +- An **OrderBook** account - two stores: bids sorted highest-first, asks sorted lowest-first, each holding up to 1024 entries. Rather than a plain list of orders, each side uses a depth-bounded tree (a - critbit trie) for fast lookup — see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance). + critbit trie) for fast lookup - see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance). Each entry stores enough to drive matching (price, quantity, `order_id`); the full `Order` PDA holds the authoritative state. -- A **MarketUser** PDA — one per `(market, wallet)` pair. Tracks the +- A **MarketUser** PDA - one per `(market, wallet)` pair. Tracks the order_ids this user has open and two running tallies (`unsettled_base`, `unsettled_quote`) of tokens owed back to this user from fills or cancellations. -- An **Order** PDA — one per placed order. Stores price, quantity, +- An **Order** PDA - one per placed order. Stores price, quantity, side (bid or ask), fill status, and the owner. - Three token accounts held by the Market PDA: `base_vault` (all sellers' locked base + buyers' bought base waiting to be withdrawn), @@ -87,7 +87,7 @@ call `settle_funds` to pull their balances out. ### Finance background, briefly -For readers new to trading terms — these are the same concepts every +For readers new to trading terms - these are the same concepts every equity, futures, and crypto exchange uses. They're optional; everything above describes the program mechanically. @@ -102,9 +102,9 @@ everything above describes the program mechanically. book on the ask side is the lowest-priced sell offer. - **A [maker](https://www.investopedia.com/terms/m/marketmaker.asp)** is whoever posts an order that doesn't immediately - match — they "make" [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) by leaving their offer on the book + match - they "make" [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) by leaving their offer on the book for others to trade against. A **[taker](https://www.investopedia.com/terms/m/maker_taker.asp)** is whoever walks into the - book and hits the resting orders — they "take" liquidity. + book and hits the resting orders - they "take" liquidity. - **A [taker fee](https://www.investopedia.com/terms/m/maker_taker.asp)** is a cut of each trade taken by the venue from the taker's leg of the trade, expressed in *[basis points](https://www.investopedia.com/terms/b/basispoint.asp)* (bps). One @@ -124,7 +124,7 @@ everything above describes the program mechanically. - **Not deployed, not audited.** Treat as a learning example, not production-ready code. -- **No [immediate-or-cancel](https://www.investopedia.com/terms/i/immediateorcancel.asp) (IOC), [fill-or-kill](https://www.investopedia.com/terms/f/fill-or-kill.asp) (FOK), or post-only orders** — every +- **No [immediate-or-cancel](https://www.investopedia.com/terms/i/immediateorcancel.asp) (IOC), [fill-or-kill](https://www.investopedia.com/terms/f/fill-or-kill.asp) (FOK), or post-only orders** - every order matches what it can at the limit price and rests any remainder on the book. IOC would discard the remainder instead of resting it; FOK would reject the whole order unless it fills entirely; post-only @@ -139,7 +139,7 @@ being priced and the quote is the pricing unit. Bids spend quote and receive base; asks spend base and receive quote. **Limit price.** The worst price at which an order is allowed to -trade — for a bid, the *highest* the buyer will pay; for an ask, the +trade - for a bid, the *highest* the buyer will pay; for an ask, the *lowest* the seller will accept. A bid at 900 won't fill against an ask at 950. @@ -152,7 +152,7 @@ Keeps dust orders from polluting the book. **Match / fill / cross.** Two orders *cross* when the bid's price is ≥ the ask's price; they *match* (are paired up) and a *fill* is the -result — one crossing event with a fill quantity and a fill price. +result - one crossing event with a fill quantity and a fill price. One call to `place_order` can produce many fills. **[Price improvement](https://www.investopedia.com/terms/p/priceimprovement.asp).** When a taker's limit is better than the best @@ -167,7 +167,7 @@ tokens still sit in the market's vaults. `settle_funds` moves them to the user's own token accounts and zeroes the counters. **Fee vault.** A separate token account (quote mint) owned by the -Market PDA. Every taker fee — `gross * fee_bps / 10_000` per fill — +Market PDA. Every taker fee - `ceil(gross * fee_bps / 10_000)` per fill - moves here in one batched CPI at the end of `place_order`. **Remaining accounts.** Solana lets the caller pass a tail of extra @@ -187,23 +187,23 @@ This section walks through a complete sequence of trades using four real partici | Token | What it is | Role on this market | |---|---|---| -| **NVDAx** | An onchain NVIDIA share (xStock). Its price tracks the underlying stock. | **Base asset** — the thing being bought and sold | -| **USDC** | A stablecoin redeemable 1:1 for US dollars | **Quote asset** — the currency used for pricing and payment | +| **NVDAx** | An onchain NVIDIA share (xStock). Its price tracks the underlying stock. | **Base asset** - the thing being bought and sold | +| **USDC** | A stablecoin redeemable 1:1 for US dollars | **Quote asset** - the currency used for pricing and payment | -A price of **960** means "960 USDC per NVDAx". The same program logic — identical instruction handlers and account structure — works for any other pair, such as **TSLAx/USDC** (Tesla xStock). +A price of **960** means "960 USDC per NVDAx". The same program logic - identical instruction handlers and account structure - works for any other pair, such as **TSLAx/USDC** (Tesla xStock). ### The participants | Name | Role | Motivation | |---|---|---| | **Maria** | Market authority | Earns 0.25 % ([25 basis points](https://www.investopedia.com/terms/b/basispoint.asp)) on every fill. Her revenue scales with market volume, so she wants a liquid, trusted venue. | -| **Alice** | Retail investor — buyer | Bullish thesis: she expects NVDAx to rise from ~960 USDC to ~1 100 as demand for NVIDIA's AI chips grows. She wants to accumulate NVDAx at a good price before that move. | +| **Alice** | Retail investor - buyer | Bullish thesis: she expects NVDAx to rise from ~960 USDC to ~1 100 as demand for NVIDIA's AI chips grows. She wants to accumulate NVDAx at a good price before that move. | | **Bob** | [Market maker](https://www.investopedia.com/terms/m/marketmaker.asp) | No directional view on NVDAx. Profits from the [bid-ask spread](https://www.investopedia.com/terms/b/bid-askspread.asp): he simultaneously quotes a buy price (bid) below fair value and a sell price (ask) above it. If both sides fill, the difference is his gross revenue. He provides [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) to the market in exchange for that spread. | -| **Carol** | Retail investor — seller | Bought NVDAx at 800 USDC six months ago. It is now trading around 960. She wants to sell some to [realise her profit](https://www.investopedia.com/terms/r/realizedprofit.asp) in USDC. | +| **Carol** | Retail investor - seller | Bought NVDAx at 800 USDC six months ago. It is now trading around 960. She wants to sell some to [realise her profit](https://www.investopedia.com/terms/r/realizedprofit.asp) in USDC. | --- -### Step 1 — Maria creates the market +### Step 1 - Maria creates the market **Instruction: `initialize_market(fee_basis_points=25, tick_size=1, min_order_size=1)`** **Key accounts: `base_mint = NVDAx`, `quote_mint = USDC`** @@ -222,11 +222,11 @@ Maria's wallet signs. Five accounts are created: --- -### Step 2 — Alice, Bob, and Carol register as traders +### Step 2 - Alice, Bob, and Carol register as traders **Instruction: `create_market_user`** (called once by each trader) -Each call creates one `MarketUser` PDA — a per-(trader, market) account that tracks their open orders and any tokens owed to them: +Each call creates one `MarketUser` PDA - a per-(trader, market) account that tracks their open orders and any tokens owed to them: | Account | Seeds | State after | |---|---|---| @@ -236,11 +236,11 @@ Each call creates one `MarketUser` PDA — a per-(trader, market) account that t --- -### Step 3 — Bob posts a sell offer (ask) at 965 USDC +### Step 3 - Bob posts a sell offer (ask) at 965 USDC -Bob estimates NVDAx fair value at 960 USDC. He quotes a 10-USDC spread — ask at 965, bid at 955. He starts by posting the ask. +Bob estimates NVDAx fair value at 960 USDC. He quotes a 10-USDC spread - ask at 965, bid at 955. He starts by posting the ask. -**Instruction: `place_order(side=Ask, price=965, quantity=10)`** (no `remaining_accounts` — book is empty) +**Instruction: `place_order(side=Ask, price=965, quantity=10)`** (no `remaining_accounts` - book is empty) **Token flow:** ``` @@ -264,7 +264,7 @@ bids [] --- -### Step 4 — Alice places a buy offer (bid) at 950 USDC +### Step 4 - Alice places a buy offer (bid) at 950 USDC Alice places a [limit order](https://www.investopedia.com/terms/l/limitorder.asp): she will buy 5 NVDAx but pay no more than 950 USDC each. Her bid (950) does not cross Bob's ask (965), so nothing fills and her bid rests on the book. @@ -294,13 +294,13 @@ The [bid-ask spread](https://www.investopedia.com/terms/b/bid-askspread.asp) is --- -### Step 5 — Carol sells into Alice's bid +### Step 5 - Carol sells into Alice's bid -Carol wants to sell 3 NVDAx. Alice is bidding 950 USDC — above Carol's floor of 945. Carol sends an [ask](https://www.investopedia.com/terms/a/ask.asp) at 945 and passes Alice's resting order as a maker. +Carol wants to sell 3 NVDAx. Alice is bidding 950 USDC - above Carol's floor of 945. Carol sends an [ask](https://www.investopedia.com/terms/a/ask.asp) at 945 and passes Alice's resting order as a maker. **Instruction: `place_order(side=Ask, price=945, quantity=3, remaining_accounts=[alice_order_pda, alice_market_user_pda])`** -**Crossing check:** Carol's ask (945) ≤ Alice's bid (950) ✓ — the orders cross. Fill price = 950 (Alice's price — the resting [maker](https://www.investopedia.com/terms/m/marketmaker.asp) always sets the execution price). Carol named 945 but receives 950 — that is [price improvement](https://www.investopedia.com/terms/p/priceimprovement.asp). +**Crossing check:** Carol's ask (945) ≤ Alice's bid (950) ✓ - the orders cross. Fill price = 950 (Alice's price - the resting [maker](https://www.investopedia.com/terms/m/marketmaker.asp) always sets the execution price). Carol named 945 but receives 950 - that is [price improvement](https://www.investopedia.com/terms/p/priceimprovement.asp). **Token flow (Carol's NVDAx locked up front):** ``` @@ -312,8 +312,8 @@ carol_nvdax_ata --[3 NVDAx]--> base_vault | Line item | Calculation | Result | |---|---|---| | Gross quote exchanged | 950 × 3 | 2 850 USDC | -| Taker fee (25 bps) | 2 850 × 25 / 10 000 | 7 USDC | -| Carol's net proceeds | 2 850 − 7 | 2 843 USDC → `carol.MarketUser.unsettled_quote` | +| Taker fee (25 bps) | ceil(2 850 × 25 / 10 000) = ceil(7.125) | 8 USDC | +| Carol's net proceeds | 2 850 − 8 | 2 842 USDC → `carol.MarketUser.unsettled_quote` | | Alice's base received | 3 NVDAx | → `alice.MarketUser.unsettled_base` | **Accounts changed:** @@ -321,11 +321,11 @@ carol_nvdax_ata --[3 NVDAx]--> base_vault | Account | Change | |---|---| | `base_vault` | +3 NVDAx (Carol's lock) | -| `fee_vault` | +7 USDC (fee CPI from quote_vault) | +| `fee_vault` | +8 USDC (fee CPI from quote_vault) | | Alice's `Order` PDA (id=2) | `filled_quantity=3`, `status=PartiallyFilled` | | Alice's `MarketUser.unsettled_base` | +3 NVDAx | -| Alice's `MarketUser.open_orders` | `[2]` (still open — 2 of 5 NVDAx remain) | -| Carol's `MarketUser.unsettled_quote` | +2 843 USDC | +| Alice's `MarketUser.open_orders` | `[2]` (still open - 2 of 5 NVDAx remain) | +| Carol's `MarketUser.unsettled_quote` | +2 842 USDC | | New Carol's `Order` PDA (id=3) | `side=Ask, price=945, qty=3, status=Filled` | | `OrderBook.bids` | Alice's leaf quantity: 5 → 2 | @@ -335,11 +335,11 @@ asks [(id=1, price=965, qty=10)] ← Bob (untouched) bids [(id=2, price=950, qty=2)] ← Alice (3 filled, 2 still resting) ``` -Alice has 3 NVDAx credited to her (tracked in `unsettled_base`). Carol has 2 993 USDC credited (tracked in `unsettled_quote`). Neither amount has left the vaults yet — that happens on `settle_funds`. +Alice has 3 NVDAx credited to her (tracked in `unsettled_base`). Carol has 2 993 USDC credited (tracked in `unsettled_quote`). Neither amount has left the vaults yet - that happens on `settle_funds`. --- -### Step 6 — Settlement: tokens move to wallets +### Step 6 - Settlement: tokens move to wallets [Settlement](https://www.investopedia.com/terms/s/settlement.asp) is when the program pays out what it owes. @@ -351,17 +351,17 @@ base_vault --[3 NVDAx]--> alice_nvdax_ata **Carol calls `settle_funds`:** ``` -quote_vault --[2 843 USDC]--> carol_usdc_ata +quote_vault --[2 842 USDC]--> carol_usdc_ata ``` `carol.MarketUser.unsettled_quote = 0` --- -### Step 7 — Maria sweeps fees +### Step 7 - Maria sweeps fees **Maria calls `withdraw_fees`:** ``` -fee_vault --[7 USDC]--> maria_usdc_ata +fee_vault --[8 USDC]--> maria_usdc_ata ``` `fee_vault.balance = 0` @@ -372,9 +372,9 @@ fee_vault --[7 USDC]--> maria_usdc_ata | Participant | Paid / locked | Received | Outcome | |---|---|---|---| | **Alice** | 4 750 USDC (for 5 NVDAx) | 3 NVDAx + 1 900 USDC still in `quote_vault` (2-NVDAx bid resting at 950) | Thesis running; waiting for a seller at 950 to fill the rest | -| **Carol** | 3 NVDAx (cost 800 each) | 2 843 USDC | Locked in ≈ 148 USDC/NVDAx profit net of fee | -| **Bob** | 10 NVDAx locked | Nothing yet — ask at 965 unfilled | Earns the spread when a buyer at 965 arrives | -| **Maria** | — | 7 USDC | Fee revenue | +| **Carol** | 3 NVDAx (cost 800 each) | 2 842 USDC | Locked in ≈ 147 USDC/NVDAx profit net of fee | +| **Bob** | 10 NVDAx locked | Nothing yet - ask at 965 unfilled | Earns the spread when a buyer at 965 arrives | +| **Maria** | - | 8 USDC | Fee revenue | Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) stays on the book. The next seller willing to part with NVDAx at 950 or below will fill it automatically. A **TSLAx/USDC** market runs the same seven steps with different mint addresses. @@ -395,7 +395,7 @@ Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) st | Account | PDA? | Authority | Mint | Holds | |---|---|---|---|---| -| `base_vault` | no (regular token account) | Market PDA | base | bids' locked base IS NOT STORED HERE — only asks' locked base sits here pre-match, plus base owed to bid-takers waiting for `settle_funds` | +| `base_vault` | no (regular token account) | Market PDA | base | bids' locked base IS NOT STORED HERE - only asks' locked base sits here pre-match, plus base owed to bid-takers waiting for `settle_funds` | | `quote_vault` | no | Market PDA | quote | bids' locked quote pre-match, plus quote owed to ask-takers and bid-makers waiting for settlement | | `fee_vault` | no | Market PDA | quote | taker fees accumulated across all fills; drained by `withdraw_fees` | @@ -473,7 +473,7 @@ Three reasons: 1. **Unsettled balances are per-market by definition.** Different markets use different `base_mint` / `quote_mint` pairs, so the scalar `unsettled_base` / `unsettled_quote` fields can't be - shared across markets — they'd refer to different tokens. + shared across markets - they'd refer to different tokens. 2. **Open-order indexing is local to one book.** `open_orders` holds `order_id`s that index into a specific market's @@ -503,7 +503,7 @@ At any point in time: (Plus the bit of quote that the matching engine has already taken out as fee and batched into `fee_vault`.) -This is not a hard invariant the program enforces — it emerges from +This is not a hard invariant the program enforces - it emerges from the flows. The invariant worth caring about is the per-event balance: every fill moves tokens from the loser's locked pool to the winner's `unsettled_*`, plus the fee cut to `fee_vault`. The unit tests check @@ -516,12 +516,12 @@ this directly (`settle_funds_after_match_pays_out_both_unsettled_balances`). The program has six instruction handlers. The order a user encounters them is: -1. `initialize_market` (market operator — once) +1. `initialize_market` (market operator - once) 2. `create_market_user` (every user, once per market) -3. `place_order` (a user — as many times as they want) -4. `cancel_order` (a user — to remove a resting order) -5. `settle_funds` (a user — to collect winnings) -6. `withdraw_fees` (market authority — to collect protocol revenue) +3. `place_order` (a user - as many times as they want) +4. `cancel_order` (a user - to remove a resting order) +5. `settle_funds` (a user - to collect winnings) +6. `withdraw_fees` (market authority - to collect protocol revenue) For each, the shape is: who signs, what accounts go in, what PDAs get created, what token flows happen, what state mutates, what checks are @@ -552,10 +552,10 @@ pub fn initialize_market( **Accounts in:** -- `authority` (signer, mut — pays account rent for all five new +- `authority` (signer, mut - pays account rent for all five new accounts) - `market` (PDA, **init**, seeds `["market", base_mint, quote_mint]`) -- `order_book` (not a PDA — client calls `system_program::create_account` +- `order_book` (not a PDA - client calls `system_program::create_account` first, sized to `ORDER_BOOK_ACCOUNT_SIZE`; verified here with `#[account(zero)]`) - `base_mint`, `quote_mint` (read-only) @@ -576,7 +576,7 @@ the supplied parameters plus all the derived fields (`market.authority`, the vault pubkeys, `is_active = true`, `next_order_id = 1`). -The vaults are regular token accounts, *not* PDAs — their +The vaults are regular token accounts, *not* PDAs - their addresses are chosen by the caller (typically fresh keypairs) and captured on the market's state so later instruction handlers can validate them. @@ -590,7 +590,7 @@ trade on. **Accounts in:** -- `owner` (signer, mut — pays rent) +- `owner` (signer, mut - pays rent) - `market` (read-only) - `market_user` (PDA, **init**, seeds `["market_user", market, owner]`) - `system_program` @@ -625,7 +625,7 @@ pub fn place_order<'info>( `["order", market, next_order_id.to_le_bytes()]`) - `market_user` (mut, PDA seeds-checked) - `base_vault`, `quote_vault`, `fee_vault` (all mut, boxed) -- `user_base_account`, `user_quote_account` (mut — the caller's ATAs) +- `user_base_account`, `user_quote_account` (mut - the caller's ATAs) - `base_mint`, `quote_mint` (read-only) - `owner` (signer, mut) - `token_program`, `system_program` @@ -698,14 +698,14 @@ always holds *exactly* what's needed to fulfil every open trading position plus every unsettled balance. **Token movements (during matching, per fill):** see -[§4. The matching engine — step by step](#4-the-matching-engine--step-by-step). +[§4. The matching engine - step by step](#4-the-matching-engine--step-by-step). Summary: - For a taker bid crossing a resting ask at price `p`: ``` quote_vault --[p * fill_qty * fee_bps / 10_000]--> fee_vault (everything else stays in quote_vault as unsettled_quote for maker) - (base_vault provides the taker's base via unsettled_base — the base + (base_vault provides the taker's base via unsettled_base - the base was pre-locked when the maker placed their ask) ``` @@ -714,7 +714,7 @@ Summary: quote_vault --[p * fill_qty * fee_bps / 10_000]--> fee_vault ``` -No user's ATA is touched during matching — all movements happen +No user's ATA is touched during matching - all movements happen between vaults or inside `MarketUser` counters. Physical payouts wait for `settle_funds`. @@ -775,7 +775,7 @@ On the caller's new `order`: - `order.owner == owner.key()` → `Unauthorized` - `order.status ∈ {Open, PartiallyFilled}` → `OrderNotCancellable` - The order's `order_id` is present in `order_book` → `OrderNotFound` - (sanity — shouldn't normally fire since fully-filled orders aren't + (sanity - shouldn't normally fire since fully-filled orders aren't cancellable) **Token movements:** none. Cancellation is an accounting-only step. @@ -804,7 +804,7 @@ zero, so it is safe to call on a heartbeat/cron. - `market` (mut) - `market_user` (mut) - `base_vault`, `quote_vault` (mut, boxed) -- `user_base_account`, `user_quote_account` (mut, boxed — caller's +- `user_base_account`, `user_quote_account` (mut, boxed - caller's ATAs; caller must create them before calling) - `base_mint`, `quote_mint` (boxed, read-only) - `owner` (signer) @@ -846,7 +846,7 @@ double-withdraw. - `market` (mut, `has_one = fee_vault`) - `fee_vault` (mut, boxed) -- `authority_quote_account` (mut, boxed — destination) +- `authority_quote_account` (mut, boxed - destination) - `quote_mint` (boxed) - `authority` (signer) - `token_program` @@ -870,19 +870,19 @@ zero as a side effect of the transfer). --- -## 4. The matching engine — step by step +## 4. The matching engine - step by step This is the heart of the program. Everything in `place_order` after the initial fund lock is matching-engine work. Follow along with [`place_order.rs`](programs/order-book/src/instructions/place_order.rs) and -[`state/matching.rs`](programs/order-book/src/state/matching.rs) — it'll +[`state/matching.rs`](programs/order-book/src/state/matching.rs) - it'll read more easily once you've gone through this section. ### Ensuring fast order matching performance The book must find the best-priced resting order on every `place_order` call. Storing orders in a plain list (`Vec`) would work at small -scale, but finding the best price requires scanning every entry — in +scale, but finding the best price requires scanning every entry - in formal notation that's **O(n)**: double the number of open orders, double the work. @@ -894,7 +894,7 @@ instead of 1 024. The specific data structure used here is a [critbit tree](https://cr.yp.to/critbit.html) (short for *critical-bit -tree*) — a compact binary radix trie where each internal node splits on +tree*) - a compact binary radix trie where each internal node splits on the first bit where two keys disagree. Unlike a self-balancing BST it never rotates or recolours nodes; its depth is instead bounded by the *bit width of the key* rather than the number of orders, so it stays @@ -909,7 +909,7 @@ example. 1. Caller passes `(side, price, quantity)` and, in remaining_accounts, the maker pairs to cross against. 2. The handler locks the required funds into the vault (done up - front, before any matching — see §3.3). + front, before any matching - see §3.3). 3. **Plan the fills** (pure logic, no mutations): walk the opposite side of the book sorted by price (best price first). For each entry whose price @@ -932,7 +932,7 @@ example. `order_id` to the taker's `open_orders`, set status to `PartiallyFilled` (if any fills) or `Open` (if none). -### 4.2 Why bids spend quote, asks spend base — the full accounting +### 4.2 Why bids spend quote, asks spend base - the full accounting Pick a taker **bid** at price `bp` and quantity `bq`, crossing a resting **ask** at `ap ≤ bp` with remaining quantity `aq`. Let @@ -942,7 +942,7 @@ Per-fill quantities: ``` gross = fill_price * fill_qty (quote tokens) -fee = gross * fee_bps / 10_000 (quote tokens) +fee = ceil(gross * fee_bps / 10_000) (quote tokens) net_to_maker = gross - fee (quote tokens) locked = bp * fill_qty (quote tokens the taker had locked for this fill) rebate = locked - gross (quote the taker locked but doesn't need to spend) @@ -953,7 +953,7 @@ Token flows: ``` quote_vault --[fee]---------> fee_vault (CPI signed by Market PDA, batched across all fills) - # No physical transfer for the base and net-quote legs — they stay in the + # No physical transfer for the base and net-quote legs - they stay in the # vaults, accounted for via unsettled_* counters: maker.unsettled_quote += net_to_maker (maker collects gross - fee) @@ -961,21 +961,21 @@ Token flows: taker.unsettled_quote += rebate (price improvement refund) ``` -The *base* that the taker now owns was already in `base_vault` — +The *base* that the taker now owns was already in `base_vault` - remember, the maker locked it there when placing the ask. The *quote* -that the maker now owns was already in `quote_vault` — the taker +that the maker now owns was already in `quote_vault` - the taker locked `bp * bq` there at the top of this call. Nothing leaves the vaults except the fee. Everything else gets paid out later, on `settle_funds`. -For the opposite direction — a taker **ask** at `ap` crossing a +For the opposite direction - a taker **ask** at `ap` crossing a resting **bid** at `bp ≥ ap`: ``` fill_qty = min(taker_remaining, bp_remaining) fill_price = bp gross = bp * fill_qty -fee = gross * fee_bps / 10_000 +fee = ceil(gross * fee_bps / 10_000) net_to_taker = gross - fee Token flows: @@ -987,9 +987,9 @@ Token flows: No rebate on this side: the maker's bid locked exactly `bp * bid_original_qty` of quote up front, and of that, `bp * fill_qty` is -being spent right now at exactly that price — no leftover. +being spent right now at exactly that price - no leftover. -### 4.3 Worked example — taker bid crosses two resting asks +### 4.3 Worked example - taker bid crosses two resting asks Start with an empty book. Fees 10 bps (0.1%). Tick size 1. @@ -1005,21 +1005,21 @@ Start with an empty book. Fees 10 bps (0.1%). Tick size 1. makers as remaining_accounts: `(order_1, dan_user), (order_2, erin_user)`. - Step A — lock. Faye's quote ATA loses `1000 * 7 = 7000` quote; + Step A - lock. Faye's quote ATA loses `1000 * 7 = 7000` quote; `quote_vault.balance += 7000`. - Step B — plan: + Step B - plan: - Fill 0: resting index 0 (Dan's ask), order_id 1, qty = min(7, 5) = 5, price = 900. `taker_remaining = 7 - 5 = 2`. - Fill 1: resting index 1 (Erin's ask), order_id 2, qty = min(2, 5) = 2, price = 950. `taker_remaining = 0`. - Step C — apply fills: + Step C - apply fills: For Fill 0 (Dan): - - gross = 900 * 5 = 4500; fee = 4500 * 10 / 10 000 = 4; - net_to_maker = 4496. - - `dan_market_user.unsettled_quote += 4496` + - gross = 900 * 5 = 4500; fee = ceil(4500 * 10 / 10 000) = ceil(4.5) = 5; + net_to_maker = 4495. + - `dan_market_user.unsettled_quote += 4495` - `faye_market_user.unsettled_base += 5` - Faye's rebate = 1000*5 − 4500 = 500. `faye_market_user.unsettled_quote += 500` @@ -1027,15 +1027,15 @@ Start with an empty book. Fees 10 bps (0.1%). Tick size 1. remove from `dan_market_user.open_orders`. For Fill 1 (Erin): - - gross = 950 * 2 = 1900; fee = 1; net_to_maker = 1899. - - `erin_market_user.unsettled_quote += 1899` + - gross = 950 * 2 = 1900; fee = ceil(1.9) = 2; net_to_maker = 1898. + - `erin_market_user.unsettled_quote += 1898` - `faye_market_user.unsettled_base += 2` - Faye's rebate = 1000*2 − 1900 = 100. `faye_market_user.unsettled_quote += 100` - `erin_order.filled_quantity = 2`, status = PartiallyFilled (original 5, filled 2), **stays** in `erin_market_user.open_orders`. - Step D — clean book. Dan's ask was fully filled → leaf removed from + Step D - clean book. Dan's ask was fully filled → leaf removed from the asks critbit tree. Erin's ask was partially filled → leaf's `quantity` decremented in place to 3 (no tree rebalancing needed). The `Order` PDA carries `filled_quantity`; the leaf just holds the @@ -1043,27 +1043,27 @@ Start with an empty book. Fees 10 bps (0.1%). Tick size 1. The next taker who wants to hit Erin's ask will pass `order_2` as a maker and see `leaf.quantity = 3`. - Step E — pay the fee. `total_fee_quote = 4 + 1 = 5`. One CPI: + Step E - pay the fee. `total_fee_quote = 5 + 2 = 7`. One CPI: ``` - quote_vault --[5 quote]--> fee_vault + quote_vault --[7 quote]--> fee_vault ``` - Step F — apply Faye's deltas. `faye_market_user.unsettled_base = + Step F - apply Faye's deltas. `faye_market_user.unsettled_base = 0 + 7 = 7`. `faye_market_user.unsettled_quote = 0 + (500 + 100) = 600`. - Step G — rest the remainder. `taker_remaining = 0` → Faye's new + Step G - rest the remainder. `taker_remaining = 0` → Faye's new Order is marked `Filled` immediately, not added to the book. 4. Later, each user calls `settle_funds`: - Dan's settle: `base_vault` loses 0 base; `quote_vault` loses - 4496 quote → Dan's quote ATA gains 4496. - - Erin's settle: 1899 quote to Erin's ATA. + 4495 quote → Dan's quote ATA gains 4495. + - Erin's settle: 1898 quote to Erin's ATA. - Faye's settle: 7 base to Faye's base ATA; 600 quote refund to Faye's quote ATA (unused from her 7000 lock). 5. At some point the market authority calls `withdraw_fees`: - `fee_vault.balance = 5` → drained to authority's quote ATA. + `fee_vault.balance = 7` → drained to authority's quote ATA. **Post-settlement invariant check**: - `base_vault.balance` should equal sum of remaining ask quantities = @@ -1109,7 +1109,7 @@ Gael decides to cancel. `cancel_order` on order_id 4: - `order_book.bids` cleared. `gael_market_user.open_orders = []`. - `order.status = Cancelled`. -No tokens moved — `quote_vault.balance` still holds the 3640. +No tokens moved - `quote_vault.balance` still holds the 3640. Gael calls `settle_funds`: @@ -1140,9 +1140,9 @@ Market configuration: Cast: **Maria** (market authority + Alice/Bob's broker), **Alice** (seller), **Bob** (buyer). -1. `initialize_market` — Maria runs it. Rent for five accounts comes +1. `initialize_market` - Maria runs it. Rent for five accounts comes out of her wallet. Market is now `is_active`. -2. `create_market_user` — Alice and Bob each run it once. +2. `create_market_user` - Alice and Bob each run it once. 3. Alice posts an ask: `place_order(Ask, 1000, 5)`, no remaining_accounts (empty book). - Lock: `alice_base_account --[5 base]--> base_vault`. @@ -1247,14 +1247,14 @@ Cast: Alice (ask maker), Bob (bid maker, then remainder rests), Carol the bid side) - No rebate on ask-taker side. - Bob's order: filled_quantity 3 → 7, status PartiallyFilled - (still not fully filled — original 10, filled 7). + (still not fully filled - original 10, filled 7). - Clean book: Bob's book remaining = 10 − 7 = 3 > 0, so his entry stays. `order_book.bids = [(2, 1100)]`. - Fee CPI: 22 quote → fee_vault. - `taker_remaining = 0` → Carol's new Order marked Filled. Mid-state: `base_vault = 0 + 4 = 4` (from Carol's lock; was 0 - after Bob's settle made it flow — wait, no: Bob's base never + after Bob's settle made it flow - wait, no: Bob's base never settled yet. Let's re-check:) After step 4 Bob's `unsettled_base = 3` (from the 3-base fill @@ -1269,7 +1269,7 @@ Cast: Alice (ask maker), Bob (bid maker, then remainder rests), Carol Cast: Alice (bid maker), nobody else. 1. `initialize_market`, `create_market_user(Alice)`. -2. Alice posts `Bid, 900, 10` — rests on an empty book. +2. Alice posts `Bid, 900, 10` - rests on an empty book. - Lock: 9000 quote from Alice to quote_vault. - No fills. `alice.open_orders = [1]`. `bids = [(1, 900)]`. 3. Alice reconsiders and calls `cancel_order` on her bid. @@ -1285,7 +1285,7 @@ Cast: Alice (bid maker), nobody else. Net delta: Alice is exactly where she started. The vaults are empty. The Order account is still onchain in `Cancelled` state (one could -imagine a future instruction handler to reclaim its rent — see §8). +imagine a future instruction handler to reclaim its rent - see §8). --- @@ -1323,7 +1323,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): owe. - **Caller supplies maker pairs.** The matching engine does not - iterate the whole book looking for counterparties — the caller + iterate the whole book looking for counterparties - the caller tells it which resting orders to cross. This is what Openbook v2 does and it's the only way to fit the matching work within a transaction's account budget when the book is large. The cost is @@ -1331,7 +1331,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): first, pick the crossings, and pass the right accounts. The program still enforces order (price-time priority) and ownership on what the caller passes, so a malicious caller cannot cross a - non-top-of-book maker to hurt someone else — they can only *fail + non-top-of-book maker to hurt someone else - they can only *fail to cross* orders they should have crossed, which only hurts themselves. @@ -1344,8 +1344,8 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - **Fees come out of the gross.** The maker receives `gross - fee`, not `gross`; the fee lives on for a while in `quote_vault` before being moved to `fee_vault` in one batched CPI at the end of - `place_order`. An alternative model — the taker paying `gross + - fee` on top of the lock — is discussed in a comment in + `place_order`. An alternative model - the taker paying `gross + + fee` on top of the lock - is discussed in a comment in `place_order.rs` and left as an exercise. - **Unsettled balances are pure accounting.** No token physically @@ -1363,7 +1363,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - **Boxed InterfaceAccounts.** Several handlers use `Box< InterfaceAccount<...>>` for mint/token accounts. That's a BPF - stack-size workaround — each `InterfaceAccount` is ~1 KB on the + stack-size workaround - each `InterfaceAccount` is ~1 KB on the stack and the Solana VM gives handlers a tight budget. Don't unbox these without testing the compute output size. @@ -1375,7 +1375,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - **Book capacity check after matching.** The taker's remainder check happens at the end. A bid that clears enough asks to free up 3 slots can then rest its own 1-slot remainder even on a - previously-full book — matching the "liquidity-positive" spirit + previously-full book - matching the "liquidity-positive" spirit of an order book. ### 6.3 Things this example does *not* do @@ -1427,7 +1427,7 @@ run first. From `finance/order-book/anchor/`: ```bash -# 1. Build the .so — target/deploy/order_book.so +# 1. Build the .so - target/deploy/order_book.so anchor build # 2. Run the LiteSVM tests @@ -1555,8 +1555,8 @@ Ordered by difficulty. **Worst-case depth must be bounded, not assumed.** A plain binary search tree only keeps a roughly-balanced shape when its inputs arrive -in random order. In an order book an attacker chooses the inputs — the -prices of their orders — so nothing they choose can be allowed to +in random order. In an order book an attacker chooses the inputs - the +prices of their orders - so nothing they choose can be allowed to inflate the tree's depth. Two families of structure defend against this: *self-balancing* BSTs (red-black, AVL, …) that restore a bounded height with rotations on every insert and delete, and *radix tries* @@ -1580,10 +1580,10 @@ every operation cheap regardless of input, so the attack is structurally impossible. **Why critbit specifically.** Critbit is a binary radix trie keyed on -the order's sort bits — *not* a self-balancing BST, so it never rotates +the order's sort bits - *not* a self-balancing BST, so it never rotates or recolours nodes. Its shape is a deterministic function of which keys are present, and its depth can never exceed the *bit width of the sort -key* (128 bits here — price in the high 64, sequence number in the low +key* (128 bits here - price in the high 64, sequence number in the low 64), so it cannot degenerate into a long chain under any insert order. An insert splits exactly one leaf and adds exactly one inner node; a delete splices one out. This example uses the critbit slab from @@ -1591,7 +1591,7 @@ Openbook v2 (`src/state/slab/`). ### Harder -- **Event queue.** Mirror Openbook's `EventQueue` — `place_order` +- **Event queue.** Mirror Openbook's `EventQueue` - `place_order` writes "fill" events, and a separate `consume_events` instruction processes them in batches for the maker side. Makes matching O(1) in CU cost regardless of the taker's depth. diff --git a/finance/order-book/anchor/programs/order-book/Cargo.toml b/finance/order-book/anchor/programs/order-book/Cargo.toml index 55bb48d8..955f75d8 100644 --- a/finance/order-book/anchor/programs/order-book/Cargo.toml +++ b/finance/order-book/anchor/programs/order-book/Cargo.toml @@ -22,7 +22,7 @@ custom-panic = [] [dependencies] anchor-lang = "1.0.0" anchor-spl = "1.0.0" -# Used by the ported Openbook slab — `bytemuck::Pod` / `Zeroable` on every node +# Used by the ported Openbook slab - `bytemuck::Pod` / `Zeroable` on every node # variant + `min_const_generics` so `[AnyNode; 1024]` can derive Pod without # hitting bytemuck's default-32 array cap. `static_assertions` keeps the slab # layout asserts (node size, alignment) compile-time, matching upstream. diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs b/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs new file mode 100644 index 00000000..ef5bf86c --- /dev/null +++ b/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs @@ -0,0 +1,3 @@ +pub mod withdraw_fees; + +pub use withdraw_fees::*; diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/withdraw_fees.rs b/finance/order-book/anchor/programs/order-book/src/instructions/admin/withdraw_fees.rs similarity index 87% rename from finance/order-book/anchor/programs/order-book/src/instructions/withdraw_fees.rs rename to finance/order-book/anchor/programs/order-book/src/instructions/admin/withdraw_fees.rs index 2b44463f..2afc3183 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/withdraw_fees.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/admin/withdraw_fees.rs @@ -7,11 +7,11 @@ use crate::errors::ErrorCode; use crate::state::{Market, MARKET_SEED}; /// Drain the market's accumulated taker fees into the authority's token -/// account. Authority-only — arbitrary callers must not be able to siphon +/// account. Authority-only - arbitrary callers must not be able to siphon /// the fee vault. Transfers the current balance of the fee vault in full; /// a partial-withdraw flavour could take an amount parameter, left out here /// to keep the example focused. -pub fn handle_withdraw_fees(context: Context) -> Result<()> { +pub fn handle_withdraw_fees(context: Context) -> Result<()> { let market = &context.accounts.market; require!( @@ -21,7 +21,7 @@ pub fn handle_withdraw_fees(context: Context) -> Result<()> { let fee_balance = context.accounts.fee_vault.amount; if fee_balance == 0 { - // Nothing to do — exit quietly rather than failing, so this + // Nothing to do - exit quietly rather than failing, so this // instruction is safe to call on a cron/heartbeat even when there // haven't been any fills since the last run. return Ok(()); @@ -55,14 +55,14 @@ pub fn handle_withdraw_fees(context: Context) -> Result<()> { } #[derive(Accounts)] -pub struct WithdrawFees<'info> { +pub struct WithdrawFeesAccountConstraints<'info> { #[account( mut, has_one = fee_vault @ ErrorCode::InvalidFeeVault, )] pub market: Account<'info, Market>, - // Boxed to keep the struct under the BPF stack limit (see PlaceOrder). + // Boxed to keep the struct under the BPF stack limit (see PlaceOrderAccountConstraints). #[account(mut)] pub fee_vault: Box>, diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs b/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs index 984a10b7..e03064d1 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs @@ -6,7 +6,7 @@ use crate::state::{ MarketUser, ORDER_SEED, MARKET_USER_SEED, }; -pub fn handle_cancel_order(context: Context) -> Result<()> { +pub fn handle_cancel_order(context: Context) -> Result<()> { let order = &mut context.accounts.order; require!( @@ -56,7 +56,7 @@ pub fn handle_cancel_order(context: Context) -> Result<()> { } // Remove the leaf from the slab. The current cancel API doesn't tell us - // which side the order is on without reading the Order PDA — which we + // which side the order is on without reading the Order PDA - which we // already have, so use it. let mut order_book = context.accounts.order_book.load_mut()?; let removed = order_book.remove_from(order.side, order.order_id).is_some(); @@ -72,7 +72,7 @@ pub fn handle_cancel_order(context: Context) -> Result<()> { } #[derive(Accounts)] -pub struct CancelOrder<'info> { +pub struct CancelOrderAccountConstraints<'info> { #[account(has_one = order_book @ ErrorCode::InvalidOrderBook)] pub market: Account<'info, Market>, diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs b/finance/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs index 25d28665..11fcf316 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use crate::state::{Market, MarketUser, MARKET_USER_SEED}; -pub fn handle_create_market_user(context: Context) -> Result<()> { +pub fn handle_create_market_user(context: Context) -> Result<()> { let market_user = &mut context.accounts.market_user; market_user.market = context.accounts.market.key(); market_user.owner = context.accounts.owner.key(); @@ -15,7 +15,7 @@ pub fn handle_create_market_user(context: Context) -> Result<( } #[derive(Accounts)] -pub struct CreateMarketUser<'info> { +pub struct CreateMarketUserAccountConstraints<'info> { #[account( init, payer = owner, diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs b/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs index a2d925bd..99f56a5b 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs @@ -9,7 +9,7 @@ use crate::state::{Market, OrderBook, MARKET_SEED}; const MAX_FEE_BASIS_POINTS: u16 = 10_000; pub fn handle_initialize_market( - context: Context, + context: Context, fee_basis_points: u16, tick_size: u64, base_lot_size: u64, @@ -42,7 +42,7 @@ pub fn handle_initialize_market( market.bump = context.bumps.market; // Zero-copy account: initialize the slab in place. `load_init` is the - // first-write path — every subsequent handler uses `load` / `load_mut`. + // first-write path - every subsequent handler uses `load` / `load_mut`. // The order book is not a PDA (see the comment on the `order_book` // account below), so `bump` is unused and stored as 0. let mut order_book = context.accounts.order_book.load_init()?; @@ -52,7 +52,7 @@ pub fn handle_initialize_market( } #[derive(Accounts)] -pub struct InitializeMarket<'info> { +pub struct InitializeMarketAccountConstraints<'info> { #[account( init, payer = authority, @@ -64,7 +64,7 @@ pub struct InitializeMarket<'info> { // The order book is a zero-copy account (~180 KB: two 1024-slot critbit // slabs back to back). Solana's BPF runtime caps inner-CPI account - // allocations at 10 KB, so we can't use Anchor's `init` here — the + // allocations at 10 KB, so we can't use Anchor's `init` here - the // client must call system_program::create_account directly before this // instruction, sizing the account to ORDER_BOOK_ACCOUNT_SIZE, owned by // this program, and zero-initialized. @@ -77,7 +77,7 @@ pub struct InitializeMarket<'info> { // The account is not a PDA; it is a plain account whose keypair the // client generates. The README recommends deriving that keypair // deterministically (e.g. from `["order_book", market]`) so the address - // is predictable — but the program doesn't enforce the derivation. + // is predictable - but the program doesn't enforce the derivation. #[account(zero)] pub order_book: AccountLoader<'info, OrderBook>, diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/mod.rs b/finance/order-book/anchor/programs/order-book/src/instructions/mod.rs index 0b80b8b3..aa1118af 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/mod.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/mod.rs @@ -1,13 +1,13 @@ +pub mod admin; pub mod cancel_order; pub mod create_market_user; pub mod initialize_market; pub mod place_order; pub mod settle_funds; -pub mod withdraw_fees; +pub use admin::*; pub use cancel_order::*; pub use create_market_user::*; pub use initialize_market::*; pub use place_order::*; pub use settle_funds::*; -pub use withdraw_fees::*; diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs b/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs index 13a227be..a00ccfbf 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs @@ -10,23 +10,23 @@ use crate::state::{ }; // Mirror of MarketUser.open_orders max_len. Kept as a constant so the -// PlaceOrder check reads clearly and the limit is documented in one place. +// PlaceOrderAccountConstraints check reads clearly and the limit is documented in one place. const MAX_OPEN_ORDERS_PER_USER: usize = 20; -// Basis-points denominator. 10_000 bps == 100% — the universal rate convention +// Basis-points denominator. 10_000 bps == 100% - the universal rate convention // on every major exchange (NYSE, CME, Binance, Coinbase, ...). const BASIS_POINTS_DENOMINATOR: u128 = 10_000; // Remaining accounts are passed in groups of 2 per resting order we intend // to cross: [maker_order, maker_market_user]. We keep it at 2 (instead of // also threading the maker's ATAs) because fills land in the maker's -// unsettled_* balance — the maker drains them later via settle_funds. This +// unsettled_* balance - the maker drains them later via settle_funds. This // mirrors how Openbook v2 works and keeps the per-fill account footprint // small. const ACCOUNTS_PER_MAKER: usize = 2; pub fn handle_place_order<'info>( - context: Context<'info, PlaceOrder<'info>>, + context: Context<'info, PlaceOrderAccountConstraints<'info>>, side: OrderSide, price: u64, quantity: u64, @@ -47,12 +47,12 @@ pub fn handle_place_order<'info>( ); // Lock up the funds the order would need if filled. Bids lock quote - // (price * quantity); asks lock base (quantity). This always happens — + // (price * quantity); asks lock base (quantity). This always happens - // matching consumes from the locked pot (already in the vault), and any // unmatched remainder rests as a maker order with its lock still in place. // // The bid lock multiplies two u64s. A plain `u64::checked_mul` would - // refuse anything that overflows u64 (~1.8e19) — which is a perfectly + // refuse anything that overflows u64 (~1.8e19) - which is a perfectly // legal lock once you scale by token decimals (e.g. 18-decimal quote // mint * mid-cap price * mid-cap quantity). Promote to u128 for the // multiplication, then narrow back to u64 with try_into so the failure @@ -155,7 +155,7 @@ pub fn handle_place_order<'info>( let mut taker_base_received: u64 = 0; let mut taker_quote_rebate: u64 = 0; let mut taker_quote_received: u64 = 0; - // Aggregate the per-fill fee into a single transfer at the end — + // Aggregate the per-fill fee into a single transfer at the end - // halves CU cost vs one CPI per fill. let mut total_fee_quote: u64 = 0; @@ -178,14 +178,14 @@ pub fn handle_place_order<'info>( // Fee model (simple, maker-funded, no extra taker deposit): // // gross = fill_price * fill_quantity (quote tokens per fill) - // fee = gross * fee_bps / 10_000 (rounded down) + // fee = gross * fee_bps / 10_000 (rounded up) // maker gets gross - fee, // fee_vault gets fee, // taker pays 'gross' net (out of their pre-locked quote). // // Strictly "makers pay nothing" would require the taker to bring // (gross + fee) which means pulling more from the taker's ATA on - // every fill — a per-fill CPI that inflates CU cost and account + // every fill - a per-fill CPI that inflates CU cost and account // lists. Real CLOBs (Openbook v2, Phoenix) use a similar // deduct-from-gross pattern for simplicity; the fee can be thought // of as the maker pricing their ask a fraction higher to cover it. @@ -201,9 +201,14 @@ pub fn handle_place_order<'info>( .try_into() .map_err(|_| error!(ErrorCode::NumericalOverflow))?; + // Ceiling division: round the fee in the protocol's favour. Flooring + // would leak up to 1 minor unit of quote per fill to the maker, which + // an attacker could industrialise with many tiny fills. let fee_quote: u64 = (gross_quote as u128) .checked_mul(market.fee_basis_points as u128) .ok_or(ErrorCode::NumericalOverflow)? + .checked_add(BASIS_POINTS_DENOMINATOR - 1) + .ok_or(ErrorCode::NumericalOverflow)? .checked_div(BASIS_POINTS_DENOMINATOR) .ok_or(ErrorCode::NumericalOverflow)? .try_into() @@ -211,7 +216,7 @@ pub fn handle_place_order<'info>( // Defensive invariant: fees are a fraction of gross, never more. // `fee_basis_points <= 10_000` is enforced at market init, so this - // should be unreachable — but a stale assumption here would let a + // should be unreachable - but a stale assumption here would let a // misconfigured market overdraw the maker's net payout. Cheap check. require!(fee_quote <= gross_quote, ErrorCode::NumericalOverflow); @@ -238,7 +243,7 @@ pub fn handle_place_order<'info>( // Price improvement: taker locked (price * quantity) but // only needs (fill_price * fill_quantity) for this fill. // u128 intermediate for the same reason as the bid lock - // and gross_quote above — the original lock is already + // and gross_quote above - the original lock is already // bounded to u64, so this product narrows back cleanly. let locked_for_this_fill: u64 = (price as u128) .checked_mul(fill.fill_quantity as u128) @@ -424,7 +429,7 @@ pub fn handle_place_order<'info>( #[derive(Accounts)] #[instruction(side: OrderSide, price: u64, quantity: u64)] -pub struct PlaceOrder<'info> { +pub struct PlaceOrderAccountConstraints<'info> { // `has_one` ties every market-owned account on this struct to the // addresses recorded on the Market PDA. Crucially, without // has_one on base_vault / quote_vault / base_mint / quote_mint a caller @@ -444,7 +449,7 @@ pub struct PlaceOrder<'info> { // Zero-copy: AccountLoader streams the slab in/out without paying // borsh (de)serialization on every instruction. See order_book.rs for - // the layout. Not a PDA — the client created it directly via + // the layout. Not a PDA - the client created it directly via // system_program::create_account (see initialize_market.rs for why); // `has_one = order_book` on `market` is what ties this specific account // to this specific market. @@ -452,7 +457,7 @@ pub struct PlaceOrder<'info> { pub order_book: AccountLoader<'info, OrderBook>, // The order PDA seed uses the book's `next_order_id` *before* this - // instruction increments it — i.e. the id this new order will receive. + // instruction increments it - i.e. the id this new order will receive. // Read via `load()` so Anchor can derive the PDA at verification time. #[account( init, diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs b/finance/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs index c8fe64da..4a9f53ad 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs @@ -6,7 +6,7 @@ use anchor_spl::token_interface::{ use crate::errors::ErrorCode; use crate::state::{Market, MarketUser, MARKET_SEED, MARKET_USER_SEED}; -pub fn handle_settle_funds(context: Context) -> Result<()> { +pub fn handle_settle_funds(context: Context) -> Result<()> { let market_user = &mut context.accounts.market_user; let market = &context.accounts.market; @@ -71,7 +71,7 @@ pub fn handle_settle_funds(context: Context) -> Result<()> { } #[derive(Accounts)] -pub struct SettleFunds<'info> { +pub struct SettleFundsAccountConstraints<'info> { // `has_one` constraints bind these vaults/mints to the addresses stored // on the Market PDA at initialise_market time. Without them a caller // could substitute the fee_vault (same mint + same authority as @@ -94,7 +94,7 @@ pub struct SettleFunds<'info> { )] pub market_user: Account<'info, MarketUser>, - // Boxed for the same reason as in PlaceOrder — + // Boxed for the same reason as in PlaceOrderAccountConstraints - // InterfaceAccount is too large to keep on the BPF stack in bulk. #[account(mut)] pub base_vault: Box>, diff --git a/finance/order-book/anchor/programs/order-book/src/lib.rs b/finance/order-book/anchor/programs/order-book/src/lib.rs index d0ca768a..c1583185 100644 --- a/finance/order-book/anchor/programs/order-book/src/lib.rs +++ b/finance/order-book/anchor/programs/order-book/src/lib.rs @@ -16,7 +16,7 @@ pub mod order_book { /// the order book PDA, and the two PDA-authority vaults that hold locked /// funds while orders are open. pub fn initialize_market( - context: Context, + context: Context, fee_basis_points: u16, tick_size: u64, base_lot_size: u64, @@ -35,7 +35,7 @@ pub mod order_book { /// Create a per-user, per-market account that tracks a user's open orders /// and unsettled balances. - pub fn create_market_user(context: Context) -> Result<()> { + pub fn create_market_user(context: Context) -> Result<()> { instructions::create_market_user::handle_create_market_user(context) } @@ -51,7 +51,7 @@ pub mod order_book { /// `(maker_order_pda, maker_user_account_pda)`, ordered by the /// book's price-time priority (i.e. best ask first for a taker bid). pub fn place_order<'info>( - context: Context<'info, PlaceOrder<'info>>, + context: Context<'info, PlaceOrderAccountConstraints<'info>>, side: state::OrderSide, price: u64, quantity: u64, @@ -62,19 +62,19 @@ pub mod order_book { /// Cancel an open (or partially filled) order. Credits the remaining /// locked amount back to the owner's unsettled balance; the actual token /// transfer happens on settle_funds. - pub fn cancel_order(context: Context) -> Result<()> { + pub fn cancel_order(context: Context) -> Result<()> { instructions::cancel_order::handle_cancel_order(context) } /// Move accumulated unsettled balances out of the market vault and into /// the user's token accounts. No-op if both balances are zero. - pub fn settle_funds(context: Context) -> Result<()> { + pub fn settle_funds(context: Context) -> Result<()> { instructions::settle_funds::handle_settle_funds(context) } /// Drain the fee vault into the market authority's token account. - /// Authority-gated — only the market's stored `authority` may call this. - pub fn withdraw_fees(context: Context) -> Result<()> { + /// Authority-gated - only the market's stored `authority` may call this. + pub fn withdraw_fees(context: Context) -> Result<()> { instructions::withdraw_fees::handle_withdraw_fees(context) } } diff --git a/finance/order-book/anchor/programs/order-book/src/state/matching.rs b/finance/order-book/anchor/programs/order-book/src/state/matching.rs index 86b2c85a..11b32e99 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/matching.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/matching.rs @@ -29,7 +29,7 @@ pub struct Fill { pub fill_quantity: u64, /// Price at which the fill clears. Always the resting (maker) order's - /// price — standard order-book rule: maker's posted price wins; the taker + /// price - standard order-book rule: maker's posted price wins; the taker /// gets price improvement vs their limit on bids, and a higher payout /// vs their limit on asks. Also the high 64 bits of the tree key when /// we look the leaf up again at apply time. @@ -39,7 +39,7 @@ pub struct Fill { /// Walk the opposite side of the book and produce the list of fills that /// should occur for the incoming taker order. Does not mutate the book. /// -/// Returns `(fills, taker_remaining)` — `taker_remaining` is what's left +/// Returns `(fills, taker_remaining)` - `taker_remaining` is what's left /// over after crossing, to be rested on the book at the taker's limit price. pub fn plan_fills( order_book: &OrderBook, @@ -65,7 +65,7 @@ pub fn plan_fills( // ask's price; ask takes when its limit is <= the resting bid's // price. The tree walk is in best-price-first order on the resting // side, so the first leaf that fails to cross means every - // subsequent leaf also fails — break, don't continue. + // subsequent leaf also fails - break, don't continue. let resting_price = leaf.price(); let crosses = match incoming_side { OrderSide::Bid => incoming_price >= resting_price, diff --git a/finance/order-book/anchor/programs/order-book/src/state/order_book.rs b/finance/order-book/anchor/programs/order-book/src/state/order_book.rs index 8963651b..1786d5ac 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/order_book.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/order_book.rs @@ -11,7 +11,7 @@ pub const ORDER_BOOK_SEED: &[u8] = b"order_book"; /// Per-side capacity. 1024 leaves is enough for any realistic depth a single /// market quotes; at 88 bytes per node that's ~90 KB per side, so the whole -/// OrderBook account fits in ~180 KB — well under Solana's per-account ceiling +/// OrderBook account fits in ~180 KB - well under Solana's per-account ceiling /// and well within the rent budget a market authority is happy to fund once. pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES; @@ -21,7 +21,7 @@ pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES; /// /// Stored as one `AccountLoader` (zero-copy). The account is far /// larger than Anchor's borsh `Account` would happily deserialize on every -/// instruction — zero-copy gives us per-field memory access without paying +/// instruction - zero-copy gives us per-field memory access without paying /// the (de)serialization cost. #[account(zero_copy(unsafe))] #[repr(C)] @@ -85,7 +85,7 @@ impl OrderBook { self._padding = [0; 7]; // Slab regions arrive zeroed (Anchor zero_copy guarantees that on - // first init). We only need to write the side tag — every other + // first init). We only need to write the side tag - every other // field already reads as "empty" (bump_index=0, free_list_len=0, // free_list_head=0, all node slots Uninitialized). self.bids.order_tree_type = OrderTreeType::Bids as u8; @@ -130,8 +130,9 @@ impl OrderBook { /// and removed on either side. pub fn remove(&mut self, order_id: u64) -> bool { // We don't know which side the order is on without scanning, so try - // both. Tree lookup is O(log N) — much cheaper than the linear Vec - // scan the previous implementation did. + // both. Each side does a linear scan over its node arena to find the + // full key (price is not known at cancellation time), then removes + // by key - see `remove_from`. if self.remove_from(OrderSide::Bid, order_id).is_some() { return true; } @@ -145,7 +146,7 @@ impl OrderBook { /// if found, so callers can read its quantity/owner without re-fetching /// the Order account. pub fn remove_from(&mut self, side: OrderSide, order_id: u64) -> Option { - // Tree keys embed price in the high 64 bits — we don't have the price + // Tree keys embed price in the high 64 bits - we don't have the price // at cancellation time, so we can't reconstruct the exact key without // scanning. Linear scan to find the full key, then remove by key. let (root, nodes) = match side { @@ -154,7 +155,7 @@ impl OrderBook { }; // Linear scan to find the full key (price + seq_num) for this - // order_id. Cheap relative to a CPI — and only happens at + // order_id. Cheap relative to a CPI - and only happens at // cancellation, not in the hot matching path. let mut found_key: Option = None; for (_, leaf) in OrderTreeIter::new(nodes, root) { @@ -195,7 +196,7 @@ impl OrderBook { /// against the maker side of the book without reinserting leaves. /// /// Leaves are looked up by (price, order_id) instead of a cached slab - /// handle because removing any leaf rebalances the tree — every other + /// handle because removing any leaf rebalances the tree - every other /// handle in the same plan would be stale after the first removal. /// (price, order_id) reconstructs the exact tree key the leaf was /// inserted with, so the tree walk lands on the right slot every time. diff --git a/finance/order-book/anchor/programs/order-book/src/state/slab/iterator.rs b/finance/order-book/anchor/programs/order-book/src/state/slab/iterator.rs index e52f1a72..1fcfa733 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/slab/iterator.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/slab/iterator.rs @@ -2,7 +2,7 @@ // MIT-licensed. See LICENSE-OPENBOOK in this directory. // // This iterator yields (handle, leaf) pairs in best-price-first order. -// Matching uses pure price-time priority — no oracle peg or time-in-force +// Matching uses pure price-time priority - no oracle peg or time-in-force // filtering needed here. use super::nodes::{InnerNode, LeafNode, NodeHandle, NodeRef}; @@ -15,7 +15,7 @@ use super::ordertree::{OrderTreeNodes, OrderTreeRoot, OrderTreeType}; /// - bids: descending (highest price first) /// /// Within a single price level, earlier orders come first (price-time -/// priority) — that ordering is encoded in the leaf key, so it falls out of +/// priority) - that ordering is encoded in the leaf key, so it falls out of /// the in-order walk automatically. pub struct OrderTreeIter<'a> { nodes: &'a OrderTreeNodes, @@ -27,7 +27,7 @@ pub struct OrderTreeIter<'a> { /// Cached next leaf so `peek` and `next` can share work. next_leaf: Option<(NodeHandle, &'a LeafNode)>, - /// Child indexes to walk: (first, second). For asks we go (0, 1) — i.e. + /// Child indexes to walk: (first, second). For asks we go (0, 1) - i.e. /// down the left child first, then right; for bids we go (1, 0). first: usize, second: usize, diff --git a/finance/order-book/anchor/programs/order-book/src/state/slab/nodes.rs b/finance/order-book/anchor/programs/order-book/src/state/slab/nodes.rs index 1caae6c0..2067f6ee 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/slab/nodes.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/slab/nodes.rs @@ -19,7 +19,7 @@ use crate::state::OrderSide; /// is kept wide to match upstream so the layout stays compatible. pub type NodeHandle = u32; -/// Every node — Inner, Leaf, Free — is padded to the same 88 bytes so the +/// Every node - Inner, Leaf, Free - is padded to the same 88 bytes so the /// underlying `[AnyNode; N]` array is a true slab: we can swap a Leaf for an /// Inner in place without reallocating. Matches the upstream Openbook layout. pub const NODE_SIZE: usize = 88; @@ -79,8 +79,8 @@ pub fn price_from_key(key: u128) -> u64 { /// holding the shared prefix bits, and `prefix_len` telling consumers how many /// of the high bits of `key` are meaningful. /// -/// `tag` is the first byte at offset 0 — same offset as on `LeafNode` and -/// `FreeNode` — so `AnyNode::tag` reads the variant tag from a fixed offset +/// `tag` is the first byte at offset 0 - same offset as on `LeafNode` and +/// `FreeNode` - so `AnyNode::tag` reads the variant tag from a fixed offset /// regardless of which variant is in the slot. /// /// `repr(C, packed(8))` caps field alignment at 8 bytes. Without this, u128 @@ -99,7 +99,7 @@ pub struct InnerNode { /// Number of high `key` bits that all descendants share. pub prefix_len: u32, - /// Only the top `prefix_len` bits of `key` are meaningful — the rest is + /// Only the top `prefix_len` bits of `key` are meaningful - the rest is /// whichever leaf happened to be inserted first below this node. pub key: u128, @@ -143,7 +143,7 @@ impl InnerNode { /// One resting order in the slab. /// /// All the per-order metadata callers care about lives on the corresponding -/// `Order` PDA — the slab leaf only stores what the matching engine needs: +/// `Order` PDA - the slab leaf only stores what the matching engine needs: /// the tree key (price + tie-break), the remaining quantity, the owner, and /// the order_id (which the handler uses to verify the matching `Order` /// account the caller passed in). @@ -175,7 +175,7 @@ pub struct LeafNode { pub order_id: u64, /// Unix timestamp at which the order rested. Not used by matching (the - /// seq_num inside `key` is the tie-break) — kept so offchain tooling + /// seq_num inside `key` is the tie-break) - kept so offchain tooling /// can show an "age" without re-deriving it from a different account. pub timestamp: i64, @@ -205,7 +205,7 @@ impl LeafNode { } } - /// Price half of the tree key — convenience for callers. + /// Price half of the tree key - convenience for callers. #[inline(always)] pub fn price(&self) -> u64 { price_from_key(self.key) diff --git a/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs b/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs index 5ec08653..ea6e983e 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs @@ -20,7 +20,7 @@ pub const MAX_TREE_NODES: usize = 1024; /// Root pointer + leaf count for one side of the book. /// -/// `maybe_node` is only meaningful when `leaf_count > 0` — a freshly-zeroed +/// `maybe_node` is only meaningful when `leaf_count > 0` - a freshly-zeroed /// root represents an empty tree. #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[repr(C)] @@ -160,7 +160,7 @@ impl OrderTreeNodes { search_key: u128, ) -> Option { // Stack of (handle, critbit) pairs so we can walk back up to the - // root after splicing the leaf out — same trick the upstream uses. + // root after splicing the leaf out - same trick the upstream uses. let mut stack: Vec<(NodeHandle, bool)> = vec![]; let mut parent_h = root.node()?; @@ -255,7 +255,7 @@ impl OrderTreeNodes { /// Returns the handle of the new leaf and, when a duplicate key collided, /// the leaf that got overwritten. (Callers in this order book embed a /// monotonically increasing seq_num in every key, so collisions cannot - /// actually happen — the case is kept just to match the upstream API.) + /// actually happen - the case is kept just to match the upstream API.) pub fn insert_leaf( &mut self, root: &mut OrderTreeRoot, @@ -294,7 +294,7 @@ impl OrderTreeNodes { if let Some(NodeRef::Inner(inner)) = parent_contents.case() { if shared_prefix_len >= inner.prefix_len { - // The new key shares at least this node's prefix — + // The new key shares at least this node's prefix - // descend. let (child, crit_bit) = inner.walk_down(new_leaf.key); stack.push((parent_handle, crit_bit)); @@ -325,7 +325,7 @@ impl OrderTreeNodes { // replacing it with a freshly-built InnerNode that has the new // leaf and the moved-aside old node as children. We can't go via // `node_mut().as_inner_mut()` here because that would refuse the - // slot when its tag is still LeafNode — instead, write a complete + // slot when its tag is still LeafNode - instead, write a complete // new InnerNode bit-pattern into the slot via AnyNode. let mut new_inner = InnerNode::new(shared_prefix_len, new_leaf.key); new_inner.children[new_leaf_crit_bit as usize] = new_leaf_handle; diff --git a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs index e3b0a252..00daf4c2 100644 --- a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -4,7 +4,7 @@ //! create user accounts, place bids/asks (locking the appropriate vault), //! reject invalid prices / tick-aligned prices / undersized quantities, //! cancel orders (which credits unsettled balances), settle funds out of -//! the vaults, and — in the matching block near the bottom — cross incoming +//! the vaults, and - in the matching block near the bottom - cross incoming //! orders against resting orders using price-time priority, charge the //! configured taker fee to a fee vault, and drain the fee vault via //! `withdraw_fees`. @@ -36,14 +36,14 @@ const ORDER_SEED: &[u8] = b"order"; const MARKET_USER_SEED: &[u8] = b"market_user"; // Size of the zero-copy OrderBook account, including Anchor's 8-byte -// discriminator. Mirrors `order_book::state::ORDER_BOOK_ACCOUNT_SIZE` — duplicated +// discriminator. Mirrors `order_book::state::ORDER_BOOK_ACCOUNT_SIZE` - duplicated // here so tests are self-contained and stay closer to what an SDK does. // Two 1024-leaf critbit slabs at 88 bytes per node, plus header. If you -// change this, bump the constant in `state/order_book.rs` too — the +// change this, bump the constant in `state/order_book.rs` too - the // `#[account(zero)]` check fails if the account size is wrong. const ORDER_BOOK_ACCOUNT_SIZE: u64 = order_book::state::ORDER_BOOK_ACCOUNT_SIZE as u64; -// NVDAx has 8 decimals on-chain; USDC has 6. +// NVDAx has 8 decimals onchain; USDC has 6. const BASE_DECIMALS: u8 = 8; // NVDAx (https://explorer.solana.com/address/Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh) const QUOTE_DECIMALS: u8 = 6; // USDC @@ -54,6 +54,12 @@ const QUOTE_DECIMALS: u8 = 6; // USDC // raw_quote = price × quantity × 1 (= human USDC/share × lots) // tick_size = 1 → $1.00 minimum price increment const FEE_BASIS_POINTS: u16 = 10; + +// Mirror of the program's fee rounding: ceiling division so the fee rounds +// in the protocol's favour (flooring would leak dust to the maker per fill). +const fn fee_ceil(gross: u64) -> u64 { + ((gross as u128 * FEE_BASIS_POINTS as u128 + 9_999) / 10_000) as u64 +} const TICK_SIZE: u64 = 1; const BASE_LOT_SIZE: u64 = 100; const QUOTE_LOT_SIZE: u64 = 1; @@ -63,7 +69,7 @@ const MIN_ORDER_SIZE: u64 = 1; // order placed in the tests with room to spare. const TRADER_STARTING_BALANCE: u64 = 1_000_000_000; -// Shared order sizing — chosen so price * quantity stays well inside u64 +// Shared order sizing - chosen so price * quantity stays well inside u64 // and the seller's ask sits at the same price as the buyer's bid (matching // is not implemented, they just coexist in the book). const BID_PRICE: u64 = 100; @@ -127,7 +133,7 @@ struct Scenario { fee_vault: Keypair, market: Pubkey, // The order book is a ~180 KB zero-copy account owned by the program. - // It's NOT a PDA — the BPF runtime caps inner-CPI allocations at 10 KB, + // It's NOT a PDA - the BPF runtime caps inner-CPI allocations at 10 KB, // so the client must allocate it directly via system_program::CreateAccount // and pass it in as a signer. See `build_initialize_market_tx` for the // full setup. @@ -219,7 +225,7 @@ fn full_setup() -> Scenario { } // --------------------------------------------------------------------------- -// Instruction builders — one per program entry point. +// Instruction builders - one per program entry point. // --------------------------------------------------------------------------- /// Build the `system_program::CreateAccount` instruction the client must run @@ -235,7 +241,7 @@ fn build_create_order_book_account_ix( payer: &Pubkey, ) -> Instruction { // LiteSVM uses the default rent schedule; minimum_balance() on the - // 180 KB account is around 1.25 SOL — well within the 100 SOL we fund + // 180 KB account is around 1.25 SOL - well within the 100 SOL we fund // the test payer with in `full_setup`. let rent_lamports = sc .svm @@ -267,7 +273,7 @@ fn build_initialize_market_ix( min_order_size, } .data(), - order_book::accounts::InitializeMarket { + order_book::accounts::InitializeMarketAccountConstraints { market: sc.market, order_book: sc.order_book.pubkey(), base_mint: sc.base_mint, @@ -288,7 +294,7 @@ fn build_create_market_user_ix(sc: &Scenario, owner: &Pubkey) -> Instruction { Instruction::new_with_bytes( sc.program_id, &order_book::instruction::CreateMarketUser {}.data(), - order_book::accounts::CreateMarketUser { + order_book::accounts::CreateMarketUserAccountConstraints { market_user, market: sc.market, owner: *owner, @@ -319,7 +325,7 @@ fn build_place_order_ix( quantity, } .data(), - order_book::accounts::PlaceOrder { + order_book::accounts::PlaceOrderAccountConstraints { market: sc.market, order_book: sc.order_book.pubkey(), order, @@ -341,7 +347,7 @@ fn build_place_order_ix( /// Build a `place_order` instruction with maker (order, market_user) PDA /// pairs appended as remaining accounts. The order-book program expects them in the same -/// order the resting book will be walked — best-priced first (lowest ask +/// order the resting book will be walked - best-priced first (lowest ask /// for a taker bid, highest bid for a taker ask), and within a price level /// earliest-first. Every maker pair must be writable: the program mutates /// the maker's Order (filled_quantity, status) and their MarketUser @@ -389,7 +395,7 @@ fn build_withdraw_fees_ix( Instruction::new_with_bytes( sc.program_id, &order_book::instruction::WithdrawFees {}.data(), - order_book::accounts::WithdrawFees { + order_book::accounts::WithdrawFeesAccountConstraints { market: sc.market, fee_vault: sc.fee_vault.pubkey(), authority_quote_account, @@ -411,7 +417,7 @@ fn build_cancel_order_ix( Instruction::new_with_bytes( sc.program_id, &order_book::instruction::CancelOrder {}.data(), - order_book::accounts::CancelOrder { + order_book::accounts::CancelOrderAccountConstraints { market: sc.market, order_book: sc.order_book.pubkey(), order, @@ -432,7 +438,7 @@ fn build_settle_funds_ix( Instruction::new_with_bytes( sc.program_id, &order_book::instruction::SettleFunds {}.data(), - order_book::accounts::SettleFunds { + order_book::accounts::SettleFundsAccountConstraints { market: sc.market, market_user, base_vault: sc.base_vault.pubkey(), @@ -452,7 +458,7 @@ fn build_settle_funds_ix( // both user-account creations so tests that just want a ready-to-trade // market do not have to repeat the boilerplate. fn initialize_market_and_users(sc: &mut Scenario) { - // Allocate the OrderBook account first — it has to exist (owned by the + // Allocate the OrderBook account first - it has to exist (owned by the // program, zero-initialized) before initialize_market's `#[account(zero)]` // check passes. let create_ix = build_create_order_book_account_ix(sc, &sc.authority.pubkey()); @@ -610,7 +616,7 @@ fn place_bid_locks_quote_in_vault() { get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), TRADER_STARTING_BALANCE - locked_quote ); - // Base vault untouched — bids never move base tokens. + // Base vault untouched - bids never move base tokens. assert_eq!( get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), 0 @@ -719,7 +725,7 @@ fn place_order_rejects_unaligned_tick() { ) .unwrap(); - // 75 is not a multiple of 50 — must be rejected by the tick check. + // 75 is not a multiple of 50 - must be rejected by the tick check. let unaligned_price: u64 = 75; let ix = build_place_order_ix( &sc, @@ -841,7 +847,7 @@ fn cancel_ask_credits_unsettled_base() { ) .unwrap(); - // Funds are still in the vault — cancel does not move tokens, it only + // Funds are still in the vault - cancel does not move tokens, it only // updates the unsettled balance. Settlement is a separate step. assert_eq!( get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), @@ -1007,7 +1013,7 @@ fn cancel_and_settle_bid_refunds_full_quote() { } // Regression test for the fee-drain attack on settle_funds. Pre-fix, -// `SettleFunds` did not bind `quote_vault` to `market.quote_vault` via +// `SettleFundsAccountConstraints` did not bind `quote_vault` to `market.quote_vault` via // `has_one`, so a caller could pass `market.fee_vault` (same mint and // same authority) where `quote_vault` was expected and drain accumulated // taker fees while spending their own unsettled_quote credit. The @@ -1055,7 +1061,7 @@ fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { let attack_ix = Instruction::new_with_bytes( sc.program_id, &order_book::instruction::SettleFunds {}.data(), - order_book::accounts::SettleFunds { + order_book::accounts::SettleFundsAccountConstraints { market: sc.market, market_user: sc.buyer_market_user, base_vault: sc.base_vault.pubkey(), @@ -1258,7 +1264,7 @@ fn taker_bid_fully_crosses_best_ask() { const MAKER_ASK_ID: u64 = 1; // 1000 * 100 = 100_000 quote flows, and 100_000 * 10 bps / 10_000 = 100 - // fee — big enough to be non-zero after integer division, tiny enough + // fee - big enough to be non-zero after integer division, tiny enough // that trader starting balances easily cover it. const PRICE: u64 = 1000; const QUANTITY: u64 = 100; @@ -1286,7 +1292,7 @@ fn taker_bid_fully_crosses_best_ask() { ) .unwrap(); - // Buyer's taker bid at the same price, same qty — fully crosses. + // Buyer's taker bid at the same price, same qty - fully crosses. const TAKER_BID_ID: u64 = 2; let taker_bid_ix = build_place_order_with_makers_ix( &sc, @@ -1316,7 +1322,7 @@ fn taker_bid_fully_crosses_best_ask() { let (buyer_base, buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); assert_eq!(buyer_base, QUANTITY * BASE_LOT_SIZE); - // No price improvement here — buyer's limit == maker's price — so no + // No price improvement here - buyer's limit == maker's price - so no // quote rebate lands in the taker's unsettled_quote. assert_eq!(buyer_quote, 0); @@ -1457,7 +1463,7 @@ fn taker_partially_fills_resting_order_rest_stays_on_book() { assert_eq!(status, ORDER_STATUS_PARTIALLY_FILLED); // Base vault still holds the un-filled portion (seller's lock, minus - // what was delivered to the taker's unsettled_base — which never left + // what was delivered to the taker's unsettled_base - which never left // the vault, just got re-tagged as owed to the buyer). // // Total base in vault stays == MAKER_ASK_QUANTITY * BASE_LOT_SIZE, because @@ -1540,7 +1546,7 @@ fn taker_partially_filled_remainder_rests_on_book() { // The taker's own Order PDA holds the true remaining-on-book quantity // (original_quantity - filled_quantity). On-book quantity isn't stored - // on OrderEntry directly — see state/order_book.rs — so this is the + // on OrderEntry directly - see state/order_book.rs - so this is the // source of truth both here and at runtime. assert_eq!( TAKER_BID_QUANTITY - taker_filled, @@ -1569,7 +1575,7 @@ fn taker_crosses_multiple_resting_orders_best_price_first() { const TAKER_BID_PRICE: u64 = 1000; const TAKER_BID_QUANTITY: u64 = BEST_ASK_QUANTITY + SECOND_ASK_QUANTITY; - // Need to post both asks and both rest — seller places two in sequence. + // Need to post both asks and both rest - seller places two in sequence. let ask_one_ix = build_place_order_ix( &sc, &sc.seller, @@ -1649,8 +1655,8 @@ fn taker_crosses_multiple_resting_orders_best_price_first() { // Seller's net unsettled_quote = sum of (fill_price * fill_qty * quote_lot_size - fee). let gross_one: u64 = BEST_ASK_PRICE * BEST_ASK_QUANTITY * QUOTE_LOT_SIZE; let gross_two: u64 = SECOND_ASK_PRICE * SECOND_ASK_QUANTITY * QUOTE_LOT_SIZE; - let fee_one: u64 = gross_one * FEE_BASIS_POINTS as u64 / 10_000; - let fee_two: u64 = gross_two * FEE_BASIS_POINTS as u64 / 10_000; + let fee_one: u64 = fee_ceil(gross_one); + let fee_two: u64 = fee_ceil(gross_two); let expected_seller_quote = (gross_one - fee_one) + (gross_two - fee_two); let (_, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_market_user); assert_eq!(seller_quote, expected_seller_quote); @@ -1783,7 +1789,7 @@ fn taker_bid_gets_price_improvement_from_resting_ask() { send_transaction_from_instructions(&mut sc.svm, vec![__ix4], &[&sc.seller], &sc.seller.pubkey()).unwrap(); - // Taker bid — limit 1000. + // Taker bid - limit 1000. const TAKER_BID_ID: u64 = 2; let taker_ix = build_place_order_with_makers_ix( &sc, @@ -1807,7 +1813,7 @@ fn taker_bid_gets_price_improvement_from_resting_ask() { // Maker got 900-per-unit (minus fee), not 1000. let gross_to_maker: u64 = MAKER_ASK_PRICE * QUANTITY * QUOTE_LOT_SIZE; - let fee: u64 = gross_to_maker * FEE_BASIS_POINTS as u64 / 10_000; + let fee: u64 = fee_ceil(gross_to_maker); let expected_net_to_maker: u64 = gross_to_maker - fee; let (_, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_market_user); assert_eq!(seller_quote, expected_net_to_maker); @@ -1820,10 +1826,67 @@ fn taker_bid_gets_price_improvement_from_resting_ask() { assert_eq!(buyer_quote, expected_rebate); } +#[test] +fn fee_rounds_up_when_gross_is_not_a_bps_multiple() { + // Rounding regression: with fee_bps = 10, a gross of 501 quote tokens + // gives 501 * 10 / 10_000 = 0.501, which must round UP to 1 (protocol- + // favouring ceiling), not down to 0. A floor here would let makers + // fill fee-free with many small orders. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const PRICE: u64 = 501; + const QUANTITY: u64 = 1; + const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; + const EXPECTED_FEE: u64 = fee_ceil(GROSS); + // Prove this case actually exercises the rounding edge. + assert!(GROSS * FEE_BASIS_POINTS as u64 % 10_000 != 0); + assert_eq!(EXPECTED_FEE, GROSS * FEE_BASIS_POINTS as u64 / 10_000 + 1); + + let maker_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![maker_ix], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + + const TAKER_BID_ID: u64 = 2; + let taker_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![taker_ix], &[&sc.buyer], + &sc.buyer.pubkey()).unwrap(); + + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + EXPECTED_FEE + ); + // Maker's unsettled quote is gross minus the rounded-up fee. + let (_, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_market_user); + assert_eq!(seller_quote, GROSS - EXPECTED_FEE); +} + #[test] fn fee_vault_receives_exactly_bps_of_taker_gross() { // Simpler standalone check of the fee maths: fee_vault must equal - // (taker gross quote) * fee_bps / 10_000 after a single fill. + // ceil((taker gross quote) * fee_bps / 10_000) after a single fill. let mut sc = full_setup(); initialize_market_and_users(&mut sc); @@ -1831,7 +1894,7 @@ fn fee_vault_receives_exactly_bps_of_taker_gross() { const PRICE: u64 = 500; const QUANTITY: u64 = 200; const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; - const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; + const EXPECTED_FEE: u64 = fee_ceil(GROSS); let __ix5 = build_place_order_ix( &sc, @@ -1888,7 +1951,7 @@ fn authority_can_withdraw_fees_after_match() { const PRICE: u64 = 2000; const QUANTITY: u64 = 50; const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; - const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; + const EXPECTED_FEE: u64 = fee_ceil(GROSS); let __ix7 = build_place_order_ix( &sc, @@ -1957,7 +2020,7 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { const PRICE: u64 = 1000; const QUANTITY: u64 = 100; const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; - const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; + const EXPECTED_FEE: u64 = fee_ceil(GROSS); const EXPECTED_NET_QUOTE_TO_SELLER: u64 = GROSS - EXPECTED_FEE; // Maker posts and taker crosses. @@ -2010,23 +2073,24 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { send_transaction_from_instructions(&mut sc.svm, vec![__ix12], &[&sc.seller], &sc.seller.pubkey()).unwrap(); - // Buyer should now hold `QUANTITY` extra base tokens and have paid the - // gross quote (starting balance minus gross). No price improvement - // here, so nothing else to refund. + // Buyer should now hold `QUANTITY` lots of extra base tokens + // (QUANTITY * BASE_LOT_SIZE raw minor units) and have paid the gross + // quote (starting balance minus gross). No price improvement here, so + // nothing else to refund. assert_eq!( get_token_account_balance(&sc.svm, &sc.buyer_base_ata).unwrap(), - QUANTITY + QUANTITY * BASE_LOT_SIZE ); assert_eq!( get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), TRADER_STARTING_BALANCE - GROSS ); - // Seller should now hold (starting - QUANTITY) base and + // Seller should now hold (starting - QUANTITY lots) base and // EXPECTED_NET_QUOTE_TO_SELLER quote. assert_eq!( get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), - TRADER_STARTING_BALANCE - QUANTITY + TRADER_STARTING_BALANCE - QUANTITY * BASE_LOT_SIZE ); assert_eq!( get_token_account_balance(&sc.svm, &sc.seller_quote_ata).unwrap(), diff --git a/finance/token-fundraiser/anchor/Anchor.toml b/finance/token-fundraiser/anchor/Anchor.toml index af7c2798..04138348 100644 --- a/finance/token-fundraiser/anchor/Anchor.toml +++ b/finance/token-fundraiser/anchor/Anchor.toml @@ -11,8 +11,6 @@ fundraiser = "Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC" [programs.devnet] fundraiser = "Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/finance/token-fundraiser/anchor/README.md b/finance/token-fundraiser/anchor/README.md index 73c17984..cdb82130 100644 --- a/finance/token-fundraiser/anchor/README.md +++ b/finance/token-fundraiser/anchor/README.md @@ -1,6 +1,6 @@ # Token Fundraiser -Create a fundraiser that collects tokens. A user creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration. Other users contribute. If the target is reached, the maker can claim the funds; if it isn't reached within the duration, contributors can refund. +Create a fundraiser that collects tokens. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds; if it is not reached by the deadline, contributors can refund. ## Architecture @@ -22,13 +22,13 @@ pub struct Fundraiser { Fields: -- `maker` — the person starting the fundraiser. -- `mint_to_raise` — the mint the maker wants to receive. -- `amount_to_raise` — the target amount. -- `current_amount` — total amount currently contributed. -- `time_started` — when the fundraiser was created. -- `duration` — fundraising window in days. -- `bump` — canonical bump for the Fundraiser [PDA](https://solana.com/docs/terminology#program-derived-address-pda). +- `maker` - the person starting the fundraiser. +- `mint_to_raise` - the mint the maker wants to receive. +- `amount_to_raise` - the target amount, in minor units. +- `current_amount` - total amount contributed through the `contribute` handler. This tracked total, not the vault balance, is what `check_contributions` and `refund` compare against the target, so tokens sent directly to the vault cannot trigger an early release or block refunds. +- `time_started` - when the fundraiser was created. +- `duration` - fundraising window in days. +- `bump` - canonical bump for the Fundraiser [PDA](https://solana.com/docs/terminology#program-derived-address-pda). The `InitSpace` derive macro implements the `Space` trait, which calculates the size of the account (not counting the [Anchor](https://solana.com/docs/terminology#anchor) discriminator). @@ -43,8 +43,8 @@ pub struct Contributor { } ``` -- `amount` — total amount contributed by this contributor. -- `bump` — canonical bump for the Contributor PDA. +- `amount` - total amount contributed by this contributor. +- `bump` - canonical bump for the Contributor PDA. The Contributor PDA uses `init_if_needed`, which only runs the init branch on first call. The handler stores `bumps.contributor_account` into `bump` on first init (when `bump == 0`); see [`instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). @@ -59,90 +59,75 @@ pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; pub const PERCENTAGE_SCALER: u64 = 100; ``` -`MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` = 10%, the per-contributor cap. +`MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` = 10%, the per-contributor cap. `MIN_AMOUNT_TO_RAISE` is the minimum target in major units. ### Code layout -Each [instruction handler](https://solana.com/docs/terminology#instruction-handler) is a free function (`pub fn handle_(accounts: &mut , ...)`) called from the `#[program]` module in `lib.rs`. Account-validation structs sit in the same file as the handler. +Each [instruction handler](https://solana.com/docs/terminology#instruction-handler) is a free function (`pub fn handle_(accounts: &mut , ...)`) called from the `#[program]` module in `lib.rs`. The matching `#[derive(Accounts)]` struct (named `AccountConstraints`) sits in the same file as the handler. -## Instruction handlers +### Token program compatibility -### `initialize` +All token accounts use `anchor_spl::token_interface` types (`InterfaceAccount`, `InterfaceAccount`, `Interface`), and every token movement uses `transfer_checked`, which carries the mint and decimals through the [CPI](https://solana.com/docs/terminology#cross-program-invocation-cpi). The same code works against the Classic Token Program and the Token Extensions Program. -[`programs/fundraiser/src/instructions/initialize.rs`](programs/fundraiser/src/instructions/initialize.rs). +### Onchain math -```rust -#[derive(Accounts)] -pub struct Initialize<'info> { - #[account(mut)] - pub maker: Signer<'info>, - pub mint_to_raise: Account<'info, Mint>, - #[account( - init, - payer = maker, - seeds = [b"fundraiser", maker.key().as_ref()], - bump, - space = Fundraiser::DISCRIMINATOR.len() + Fundraiser::INIT_SPACE, - )] - pub fundraiser: Account<'info, Fundraiser>, - #[account( - init, - payer = maker, - associated_token::mint = mint_to_raise, - associated_token::authority = fundraiser, - )] - pub vault: Account<'info, TokenAccount>, - pub system_program: Program<'info, System>, - pub token_program: Program<'info, Token>, - pub associated_token_program: Program<'info, AssociatedToken>, -} -``` +All balance arithmetic uses `checked_*` operations and returns `FundraiserError::MathOverflow` on overflow. The per-contributor cap is computed in `u128` so the percentage product cannot overflow `u64`. Both handlers that move tokens out of the vault update program state before issuing the transfer CPI (checks-effects-interactions). + +## Lifecycle -Account breakdown: +### `initialize` -- `maker` — the person starting the fundraiser. Signs; mutable so we can deduct [lamports](https://solana.com/docs/terminology#lamport). -- `mint_to_raise` — the mint the maker wants to receive. -- `fundraiser` — the state account. Derived from `b"fundraiser"` and the maker's public key; Anchor calculates the canonical bump and stores it in the struct. -- `vault` — the [ATA](https://solana.com/docs/terminology#associated-token-account-ata) that receives contributions, owned by the Fundraiser PDA. -- `system_program`, `token_program`, `associated_token_program` — needed to initialize the new accounts. +[`programs/fundraiser/src/instructions/initialize.rs`](programs/fundraiser/src/instructions/initialize.rs), account constraints `InitializeAccountConstraints`. -The handler requires `amount >= MIN_AMOUNT_TO_RAISE.pow(mint.decimals)` and initializes the Fundraiser state. +The maker signs and pays for two new accounts: + +- `fundraiser` - the state account, derived from `b"fundraiser"` and the maker's public key. Anchor calculates the canonical bump and the handler stores it. +- `vault` - the [ATA](https://solana.com/docs/terminology#associated-token-account-ata) that receives contributions, owned by the Fundraiser PDA. + +The handler requires `amount >= MIN_AMOUNT_TO_RAISE * 10^decimals` (the target must be at least 3 major units of the mint, expressed in minor units), then initializes the Fundraiser state with `current_amount = 0` and `time_started` from the `Clock` sysvar. A target below the minimum fails with `InvalidAmount`. ### `contribute` -[`programs/fundraiser/src/instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs). +[`programs/fundraiser/src/instructions/contribute.rs`](programs/fundraiser/src/instructions/contribute.rs), account constraints `ContributeAccountConstraints`. -Account-validation struct: see source. The handler performs four `require!` checks in order: +A contributor signs and the handler performs four checks in order: -1. `amount >= 1_u64.pow(mint.decimals)` — minimum contribution (this is `1`, since `1.pow(n) == 1`; effectively contributions just need to be non-zero). -2. `amount <= amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` — per-call cap of 10% of the target. -3. `fundraiser.duration <= (current_time - time_started) / SECONDS_TO_DAYS` — see the [duration semantics note](#duration-check-semantics) below. -4. Cumulative contributor cap: this contributor's running total (existing + new) must not exceed 10% of the target. +1. Minimum contribution: `amount >= 10^decimals` (one major unit of the mint), else `ContributionTooSmall`. +2. Per-call cap: `amount <= amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE / PERCENTAGE_SCALER` (10% of the target), else `ContributionTooBig`. +3. Time window: contributions are allowed while `elapsed_days < duration`, where `elapsed_days = (now - time_started) / SECONDS_TO_DAYS`. Once `elapsed_days` reaches `duration` the handler fails with `FundraiserEnded`. +4. Cumulative cap: the contributor's running total (existing + new) must not exceed the same 10% cap, else `MaximumContributionsReached`. -If all four checks pass, tokens are transferred from `contributor_ata` to `vault` via a [CPI](https://solana.com/docs/terminology#cross-program-invocation-cpi) to the [Classic Token Program](https://solana.com/docs/terminology#token-program), and both `Fundraiser.current_amount` and `Contributor.amount` are updated. +If all checks pass, `Fundraiser.current_amount` and `Contributor.amount` are updated, then `amount` is transferred from `contributor_ata` to `vault` with `transfer_checked`. ### `check_contributions` -[`programs/fundraiser/src/instructions/checker.rs`](programs/fundraiser/src/instructions/checker.rs). +[`programs/fundraiser/src/instructions/checker.rs`](programs/fundraiser/src/instructions/checker.rs), account constraints `CheckContributionsAccountConstraints`. + +Lets the maker claim the funds once the target is met. Requires `fundraiser.current_amount >= amount_to_raise` (the state-tracked total, so direct donations to the vault cannot unlock the claim early), else `TargetNotMet`. The handler then, signing both CPIs with the Fundraiser PDA's seeds: -Lets the maker claim the funds. Requires `vault.amount >= amount_to_raise`. The CPI uses `new_with_signer` with the Fundraiser PDA's seeds because the vault is owned by the PDA. The Fundraiser account is closed (via the `close = maker` constraint) and its [rent](https://solana.com/docs/terminology#rent) is refunded to the maker. +1. Transfers the entire vault balance (including any direct donations) to `maker_ata` with `transfer_checked`. +2. Closes the empty vault token account with `close_account`, returning its rent to the maker. + +The Fundraiser state account is closed via the `close = maker` constraint, so the maker also recovers that [rent](https://solana.com/docs/terminology#rent). ### `refund` -[`programs/fundraiser/src/instructions/refund.rs`](programs/fundraiser/src/instructions/refund.rs). +[`programs/fundraiser/src/instructions/refund.rs`](programs/fundraiser/src/instructions/refund.rs), account constraints `RefundAccountConstraints`. -Lets a contributor reclaim their contribution if the target wasn't met. Two checks: +Lets a contributor reclaim their contribution after a failed fundraiser. Two checks: -1. `fundraiser.duration >= (current_time - time_started) / SECONDS_TO_DAYS` — see the [duration semantics note](#duration-check-semantics) below. -2. `vault.amount < amount_to_raise` — target not met. +1. Refunds are allowed only after the fundraiser has ended: `elapsed_days >= duration`, else `FundraiserNotEnded`. +2. The target was not met: `fundraiser.current_amount < amount_to_raise` (again the state-tracked total, so donated tokens cannot block refunds), else `TargetMet`. -Then the vault's tokens are transferred back to the contributor's ATA (CPI with PDA signer seeds) and the Contributor account is closed (via `close = contributor`), refunding its rent to the contributor. +The handler subtracts the contributor's recorded amount from `current_amount` and zeroes the Contributor record before the transfer CPI, then sends the tokens from the vault back to `contributor_ata` with `transfer_checked` (PDA signer). The Contributor account is closed via `close = contributor`, refunding its rent to the contributor. -## Duration check semantics +## Testing -The `contribute` and `refund` handlers compare `fundraiser.duration` (a `u16` in *days*) against elapsed days since `time_started`. The two checks use opposite comparison operators, which is worth reading carefully: +The tests are Rust integration tests using [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://crates.io/crates/solana-kite), in [`programs/fundraiser/tests/test_fundraiser.rs`](programs/fundraiser/tests/test_fundraiser.rs). They load the compiled program with `include_bytes!`, so build the program first and rebuild after every program change: -- `contribute`: `require!(duration <= elapsed_days, FundraiserEnded)` — fails (with `FundraiserEnded`) when `elapsed_days < duration`. -- `refund`: `require!(duration >= elapsed_days, FundraiserNotEnded)` — fails (with `FundraiserNotEnded`) when `elapsed_days > duration`. +```sh +cargo build-sbf +cargo test +``` -> ⚠️ Both comparisons look inverted relative to their error names. If you adapt this code, audit the duration logic carefully before relying on it. +The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It also verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, and asserts token balances and decoded account state rather than just transaction success. diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/Cargo.toml b/finance/token-fundraiser/anchor/programs/fundraiser/Cargo.toml index de7b3bf0..47145c5f 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/Cargo.toml +++ b/finance/token-fundraiser/anchor/programs/fundraiser/Cargo.toml @@ -29,6 +29,12 @@ solana-signer = "3.0.0" solana-keypair = "3.0.1" solana-kite = "0.3.0" borsh = "1.6.1" +# solana-kite depends on these SPL program crates without "no-entrypoint", +# so the host test binary would link their `entrypoint` symbols alongside +# this program's and fail with a duplicate-symbol error. Enabling +# "no-entrypoint" here removes theirs via feature unification. +spl-token = { version = "9.0.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs index 0c7ee948..9a13092f 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/error.rs @@ -16,6 +16,8 @@ pub enum FundraiserError { FundraiserNotEnded, #[msg("The fundraiser has ended")] FundraiserEnded, - #[msg("Invalid total amount. i should be bigger than 3")] - InvalidAmount -} \ No newline at end of file + #[msg("The amount to raise is below the minimum of 3 major units")] + InvalidAmount, + #[msg("Arithmetic overflow")] + MathOverflow, +} diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs index a0549585..333bae49 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/checker.rs @@ -1,25 +1,21 @@ use anchor_lang::prelude::*; use anchor_spl::{ - associated_token::AssociatedToken, - token::{ - transfer, - Mint, - Token, - TokenAccount, - Transfer - } + associated_token::AssociatedToken, + token_interface::{ + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, + }, }; -use crate::{ - state::Fundraiser, - FundraiserError -}; +use crate::{state::Fundraiser, FundraiserError}; #[derive(Accounts)] -pub struct CheckContributions<'info> { +pub struct CheckContributionsAccountConstraints<'info> { #[account(mut)] pub maker: Signer<'info>, - pub mint_to_raise: Account<'info, Mint>, + + pub mint_to_raise: InterfaceAccount<'info, Mint>, + #[account( mut, seeds = [b"fundraiser".as_ref(), maker.key().as_ref()], @@ -27,55 +23,79 @@ pub struct CheckContributions<'info> { close = maker, )] pub fundraiser: Account<'info, Fundraiser>, + #[account( mut, associated_token::mint = mint_to_raise, associated_token::authority = fundraiser, + associated_token::token_program = token_program, )] - pub vault: Account<'info, TokenAccount>, + pub vault: InterfaceAccount<'info, TokenAccount>, + #[account( init_if_needed, payer = maker, associated_token::mint = mint_to_raise, associated_token::authority = maker, + associated_token::token_program = token_program, )] - pub maker_ata: Account<'info, TokenAccount>, - pub token_program: Program<'info, Token>, + pub maker_ata: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + pub associated_token_program: Program<'info, AssociatedToken>, } -pub fn handle_check_contributions(accounts: &mut CheckContributions) -> Result<()> { - - // Check if the target amount has been met - require!( - accounts.vault.amount >= accounts.fundraiser.amount_to_raise, - FundraiserError::TargetNotMet - ); - - // Transfer the funds to the maker - // CPI to the token program to transfer the funds - let cpi_program = accounts.token_program.key(); +pub fn handle_check_contributions( + accounts: &mut CheckContributionsAccountConstraints, +) -> Result<()> { + // Compare the state-tracked total, not the vault balance, so tokens + // donated directly to the vault cannot trigger an early release. + require!( + accounts.fundraiser.current_amount >= accounts.fundraiser.amount_to_raise, + FundraiserError::TargetNotMet + ); - // Transfer the funds from the vault to the maker - let cpi_accounts = Transfer { - from: accounts.vault.to_account_info(), - to: accounts.maker_ata.to_account_info(), - authority: accounts.fundraiser.to_account_info(), - }; + // The vault is owned by the fundraiser PDA, so both CPIs are signed with + // its seeds. + let signer_seeds: [&[&[u8]]; 1] = [&[ + b"fundraiser".as_ref(), + accounts.maker.to_account_info().key.as_ref(), + &[accounts.fundraiser.bump], + ]]; - // Signer seeds to sign the CPI on behalf of the fundraiser account - let signer_seeds: [&[&[u8]]; 1] = [&[ - b"fundraiser".as_ref(), - accounts.maker.to_account_info().key.as_ref(), - &[accounts.fundraiser.bump], - ]]; + // Drain the whole vault (including any direct donations) to the maker. + let transfer_accounts = TransferChecked { + from: accounts.vault.to_account_info(), + mint: accounts.mint_to_raise.to_account_info(), + to: accounts.maker_ata.to_account_info(), + authority: accounts.fundraiser.to_account_info(), + }; + let transfer_context = CpiContext::new_with_signer( + accounts.token_program.key(), + transfer_accounts, + &signer_seeds, + ); + transfer_checked( + transfer_context, + accounts.vault.amount, + accounts.mint_to_raise.decimals, + )?; - // CPI context with signer since the fundraiser account is a PDA - let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, &signer_seeds); + // Close the empty vault so its rent goes back to the maker. + let close_accounts = CloseAccount { + account: accounts.vault.to_account_info(), + destination: accounts.maker.to_account_info(), + authority: accounts.fundraiser.to_account_info(), + }; + let close_context = CpiContext::new_with_signer( + accounts.token_program.key(), + close_accounts, + &signer_seeds, + ); + close_account(close_context)?; - // Transfer the funds from the vault to the maker - transfer(cpi_ctx, accounts.vault.amount)?; - - Ok(()) - } + Ok(()) +} diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs index e26c94e9..67e73d2f 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/contribute.rs @@ -1,26 +1,20 @@ use anchor_lang::prelude::*; -use anchor_spl::token::{ - Mint, - transfer, - Token, - TokenAccount, - Transfer +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, }; use crate::{ - state::{ - Contributor, - Fundraiser - }, FundraiserError, - MAX_CONTRIBUTION_PERCENTAGE, - PERCENTAGE_SCALER, SECONDS_TO_DAYS + state::{Contributor, Fundraiser}, + FundraiserError, MAX_CONTRIBUTION_PERCENTAGE, PERCENTAGE_SCALER, SECONDS_TO_DAYS, }; #[derive(Accounts)] -pub struct Contribute<'info> { +pub struct ContributeAccountConstraints<'info> { #[account(mut)] pub contributor: Signer<'info>, - pub mint_to_raise: Account<'info, Mint>, + + pub mint_to_raise: InterfaceAccount<'info, Mint>, + #[account( mut, has_one = mint_to_raise, @@ -28,6 +22,7 @@ pub struct Contribute<'info> { bump = fundraiser.bump, )] pub fundraiser: Account<'info, Fundraiser>, + #[account( init_if_needed, payer = contributor, @@ -36,77 +31,106 @@ pub struct Contribute<'info> { space = Contributor::DISCRIMINATOR.len() + Contributor::INIT_SPACE, )] pub contributor_account: Account<'info, Contributor>, + #[account( mut, associated_token::mint = mint_to_raise, - associated_token::authority = contributor + associated_token::authority = contributor, + associated_token::token_program = token_program, )] - pub contributor_ata: Account<'info, TokenAccount>, + pub contributor_ata: InterfaceAccount<'info, TokenAccount>, + #[account( mut, associated_token::mint = fundraiser.mint_to_raise, - associated_token::authority = fundraiser + associated_token::authority = fundraiser, + associated_token::token_program = token_program, )] - pub vault: Account<'info, TokenAccount>, - pub token_program: Program<'info, Token>, + pub vault: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, } -pub fn handle_contribute(accounts: &mut Contribute, amount: u64, bumps: &ContributeBumps) -> Result<()> { - - // Check if the amount to contribute meets the minimum amount required - require!( - amount >= 1_u64.pow(accounts.mint_to_raise.decimals as u32), - FundraiserError::ContributionTooSmall - ); - - // Check if the amount to contribute is less than the maximum allowed contribution - require!( - amount <= (accounts.fundraiser.amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE) / PERCENTAGE_SCALER, - FundraiserError::ContributionTooBig - ); - - // Check if the fundraising duration has been reached - let current_time = Clock::get()?.unix_timestamp; - require!( - accounts.fundraiser.duration <= ((current_time - accounts.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, - crate::FundraiserError::FundraiserEnded - ); - - // Check if the maximum contributions per contributor have been reached - require!( - (accounts.contributor_account.amount <= (accounts.fundraiser.amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE) / PERCENTAGE_SCALER) - && (accounts.contributor_account.amount + amount <= (accounts.fundraiser.amount_to_raise * MAX_CONTRIBUTION_PERCENTAGE) / PERCENTAGE_SCALER), - FundraiserError::MaximumContributionsReached - ); - - // Transfer the funds to the vault - // CPI to the token program to transfer the funds - let cpi_program = accounts.token_program.key(); - - // Transfer the funds from the contributor to the vault - let cpi_accounts = Transfer { - from: accounts.contributor_ata.to_account_info(), - to: accounts.vault.to_account_info(), - authority: accounts.contributor.to_account_info(), - }; - - // Crete a CPI context - let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); - - // Transfer the funds from the contributor to the vault - transfer(cpi_ctx, amount)?; - - // Update the fundraiser and contributor accounts with the new amounts - accounts.fundraiser.current_amount += amount; - - accounts.contributor_account.amount += amount; - - // Save the contributor PDA bump on first init (init_if_needed only - // runs the init branch once; stored bump is zero until set). - if accounts.contributor_account.bump == 0 { - accounts.contributor_account.bump = bumps.contributor_account; - } - - Ok(()) +/// Caps a single contributor at MAX_CONTRIBUTION_PERCENTAGE percent of the +/// target. Multiplies in u128 so the product cannot overflow u64. +fn calculate_max_contribution(amount_to_raise: u64) -> Result { + (amount_to_raise as u128) + .checked_mul(MAX_CONTRIBUTION_PERCENTAGE as u128) + .ok_or(FundraiserError::MathOverflow)? + .checked_div(PERCENTAGE_SCALER as u128) + .ok_or(FundraiserError::MathOverflow)? + .try_into() + .map_err(|_| error!(FundraiserError::MathOverflow)) +} + +pub fn handle_contribute( + accounts: &mut ContributeAccountConstraints, + amount: u64, + bumps: &ContributeAccountConstraintsBumps, +) -> Result<()> { + // The minimum contribution is one major unit, which is 10^decimals minor units. + let one_major_unit = 10_u64 + .checked_pow(accounts.mint_to_raise.decimals as u32) + .ok_or(FundraiserError::MathOverflow)?; + require!( + amount >= one_major_unit, + FundraiserError::ContributionTooSmall + ); + + let max_contribution = calculate_max_contribution(accounts.fundraiser.amount_to_raise)?; + require!( + amount <= max_contribution, + FundraiserError::ContributionTooBig + ); + + // Contributions are allowed while elapsed_days < duration. + let current_time = Clock::get()?.unix_timestamp; + let elapsed_days = current_time + .checked_sub(accounts.fundraiser.time_started) + .ok_or(FundraiserError::MathOverflow)? + .checked_div(SECONDS_TO_DAYS) + .ok_or(FundraiserError::MathOverflow)?; + require!( + elapsed_days < accounts.fundraiser.duration as i64, + FundraiserError::FundraiserEnded + ); + + // The contributor's cumulative total must also stay within the cap. + let cumulative_contribution = accounts + .contributor_account + .amount + .checked_add(amount) + .ok_or(FundraiserError::MathOverflow)?; + require!( + cumulative_contribution <= max_contribution, + FundraiserError::MaximumContributionsReached + ); + + // Checks-effects-interactions: update state before the transfer CPI. + accounts.fundraiser.current_amount = accounts + .fundraiser + .current_amount + .checked_add(amount) + .ok_or(FundraiserError::MathOverflow)?; + accounts.contributor_account.amount = cumulative_contribution; + + // Save the contributor PDA bump on first init (init_if_needed only + // runs the init branch once; stored bump is zero until set). + if accounts.contributor_account.bump == 0 { + accounts.contributor_account.bump = bumps.contributor_account; } + + // Transfer the funds from the contributor to the vault. + let cpi_accounts = TransferChecked { + from: accounts.contributor_ata.to_account_info(), + mint: accounts.mint_to_raise.to_account_info(), + to: accounts.vault.to_account_info(), + authority: accounts.contributor.to_account_info(), + }; + let cpi_context = CpiContext::new(accounts.token_program.key(), cpi_accounts); + transfer_checked(cpi_context, amount, accounts.mint_to_raise.decimals)?; + + Ok(()) +} diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/initialize.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/initialize.rs index f91dec4b..63d126d2 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/initialize.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/initialize.rs @@ -1,22 +1,18 @@ use anchor_lang::prelude::*; use anchor_spl::{ - associated_token::AssociatedToken, - token::{ - Mint, - Token, - TokenAccount - } + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, }; -use crate::{ - state::Fundraiser, FundraiserError, MIN_AMOUNT_TO_RAISE -}; +use crate::{state::Fundraiser, FundraiserError, MIN_AMOUNT_TO_RAISE}; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub maker: Signer<'info>, - pub mint_to_raise: Account<'info, Mint>, + + pub mint_to_raise: InterfaceAccount<'info, Mint>, + #[account( init, payer = maker, @@ -25,36 +21,51 @@ pub struct Initialize<'info> { space = Fundraiser::DISCRIMINATOR.len() + Fundraiser::INIT_SPACE, )] pub fundraiser: Account<'info, Fundraiser>, + #[account( init, payer = maker, associated_token::mint = mint_to_raise, associated_token::authority = fundraiser, + associated_token::token_program = token_program, )] - pub vault: Account<'info, TokenAccount>, + pub vault: InterfaceAccount<'info, TokenAccount>, + pub system_program: Program<'info, System>, - pub token_program: Program<'info, Token>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, } -pub fn handle_initialize(accounts: &mut Initialize, amount: u64, duration: u16, bumps: &InitializeBumps) -> Result<()> { - - // Check if the amount to raise meets the minimum amount required - require!( - amount >= MIN_AMOUNT_TO_RAISE.pow(accounts.mint_to_raise.decimals as u32), - FundraiserError::InvalidAmount - ); - - // Initialize the fundraiser account - accounts.fundraiser.set_inner(Fundraiser { - maker: accounts.maker.key(), - mint_to_raise: accounts.mint_to_raise.key(), - amount_to_raise: amount, - current_amount: 0, - time_started: Clock::get()?.unix_timestamp, - duration, - bump: bumps.fundraiser - }); - - Ok(()) - } +pub fn handle_initialize( + accounts: &mut InitializeAccountConstraints, + amount: u64, + duration: u16, + bumps: &InitializeAccountConstraintsBumps, +) -> Result<()> { + // The target must be at least MIN_AMOUNT_TO_RAISE major units, expressed + // in minor units: MIN_AMOUNT_TO_RAISE * 10^decimals. + let one_major_unit = 10_u64 + .checked_pow(accounts.mint_to_raise.decimals as u32) + .ok_or(FundraiserError::MathOverflow)?; + let minimum_amount_to_raise = MIN_AMOUNT_TO_RAISE + .checked_mul(one_major_unit) + .ok_or(FundraiserError::MathOverflow)?; + require!( + amount >= minimum_amount_to_raise, + FundraiserError::InvalidAmount + ); + + accounts.fundraiser.set_inner(Fundraiser { + maker: accounts.maker.key(), + mint_to_raise: accounts.mint_to_raise.key(), + amount_to_raise: amount, + current_amount: 0, + time_started: Clock::get()?.unix_timestamp, + duration, + bump: bumps.fundraiser, + }); + + Ok(()) +} diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs index 7bd69728..61fb2bab 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/instructions/refund.rs @@ -1,26 +1,22 @@ use anchor_lang::prelude::*; -use anchor_spl::token::{ - transfer, - Mint, - Token, - TokenAccount, - Transfer +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, }; use crate::{ - state::{ - Contributor, - Fundraiser - }, - SECONDS_TO_DAYS + state::{Contributor, Fundraiser}, + FundraiserError, SECONDS_TO_DAYS, }; #[derive(Accounts)] -pub struct Refund<'info> { +pub struct RefundAccountConstraints<'info> { #[account(mut)] pub contributor: Signer<'info>, + pub maker: SystemAccount<'info>, - pub mint_to_raise: Account<'info, Mint>, + + pub mint_to_raise: InterfaceAccount<'info, Mint>, + #[account( mut, has_one = mint_to_raise, @@ -28,70 +24,90 @@ pub struct Refund<'info> { bump = fundraiser.bump, )] pub fundraiser: Account<'info, Fundraiser>, + #[account( mut, seeds = [b"contributor", fundraiser.key().as_ref(), contributor.key().as_ref()], - bump, + bump = contributor_account.bump, close = contributor, )] pub contributor_account: Account<'info, Contributor>, + #[account( mut, associated_token::mint = mint_to_raise, - associated_token::authority = contributor + associated_token::authority = contributor, + associated_token::token_program = token_program, )] - pub contributor_ata: Account<'info, TokenAccount>, + pub contributor_ata: InterfaceAccount<'info, TokenAccount>, + #[account( mut, associated_token::mint = mint_to_raise, - associated_token::authority = fundraiser + associated_token::authority = fundraiser, + associated_token::token_program = token_program, )] - pub vault: Account<'info, TokenAccount>, - pub token_program: Program<'info, Token>, - pub system_program: Program<'info, System>, -} - -pub fn handle_refund(accounts: &mut Refund) -> Result<()> { - - // Check if the fundraising duration has been reached - let current_time = Clock::get()?.unix_timestamp; - - require!( - accounts.fundraiser.duration >= ((current_time - accounts.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, - crate::FundraiserError::FundraiserNotEnded - ); - - require!( - accounts.vault.amount < accounts.fundraiser.amount_to_raise, - crate::FundraiserError::TargetMet - ); + pub vault: InterfaceAccount<'info, TokenAccount>, - // Transfer the funds back to the contributor - // CPI to the token program to transfer the funds - let cpi_program = accounts.token_program.key(); + pub token_program: Interface<'info, TokenInterface>, - // Transfer the funds from the vault to the contributor - let cpi_accounts = Transfer { - from: accounts.vault.to_account_info(), - to: accounts.contributor_ata.to_account_info(), - authority: accounts.fundraiser.to_account_info(), - }; + pub system_program: Program<'info, System>, +} - // Signer seeds to sign the CPI on behalf of the fundraiser account - let signer_seeds: [&[&[u8]]; 1] = [&[ - b"fundraiser".as_ref(), - accounts.maker.to_account_info().key.as_ref(), - &[accounts.fundraiser.bump], - ]]; +pub fn handle_refund(accounts: &mut RefundAccountConstraints) -> Result<()> { + // Refunds are allowed only after the fundraiser has ended: + // elapsed_days >= duration. + let current_time = Clock::get()?.unix_timestamp; + let elapsed_days = current_time + .checked_sub(accounts.fundraiser.time_started) + .ok_or(FundraiserError::MathOverflow)? + .checked_div(SECONDS_TO_DAYS) + .ok_or(FundraiserError::MathOverflow)?; + require!( + elapsed_days >= accounts.fundraiser.duration as i64, + FundraiserError::FundraiserNotEnded + ); - // CPI context with signer since the fundraiser account is a PDA - let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, &signer_seeds); + // Refunds are allowed only when the target was not met. Compare the + // state-tracked total, not the vault balance, so tokens donated directly + // to the vault cannot block refunds. + require!( + accounts.fundraiser.current_amount < accounts.fundraiser.amount_to_raise, + FundraiserError::TargetMet + ); - // Transfer the funds from the vault to the contributor - transfer(cpi_ctx, accounts.contributor_account.amount)?; + // Checks-effects-interactions: update state before the transfer CPI. + let refund_amount = accounts.contributor_account.amount; + accounts.fundraiser.current_amount = accounts + .fundraiser + .current_amount + .checked_sub(refund_amount) + .ok_or(FundraiserError::MathOverflow)?; + accounts.contributor_account.amount = 0; - // Update the fundraiser state by reducing the amount contributed - accounts.fundraiser.current_amount -= accounts.contributor_account.amount; + // Transfer the funds from the vault back to the contributor. The vault is + // owned by the fundraiser PDA, so the CPI is signed with its seeds. + let cpi_accounts = TransferChecked { + from: accounts.vault.to_account_info(), + mint: accounts.mint_to_raise.to_account_info(), + to: accounts.contributor_ata.to_account_info(), + authority: accounts.fundraiser.to_account_info(), + }; + let signer_seeds: [&[&[u8]]; 1] = [&[ + b"fundraiser".as_ref(), + accounts.maker.to_account_info().key.as_ref(), + &[accounts.fundraiser.bump], + ]]; + let cpi_context = CpiContext::new_with_signer( + accounts.token_program.key(), + cpi_accounts, + &signer_seeds, + ); + transfer_checked( + cpi_context, + refund_amount, + accounts.mint_to_raise.decimals, + )?; - Ok(()) - } + Ok(()) +} diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs b/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs index 4eff6823..6da075ec 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/src/lib.rs @@ -15,25 +15,34 @@ use instructions::*; pub mod fundraiser { use super::*; - pub fn initialize(mut context: Context, amount: u64, duration: u16) -> Result<()> { + pub fn initialize( + mut context: Context, + amount: u64, + duration: u16, + ) -> Result<()> { handle_initialize(&mut context.accounts, amount, duration, &context.bumps)?; Ok(()) } - pub fn contribute(mut context: Context, amount: u64) -> Result<()> { + pub fn contribute( + mut context: Context, + amount: u64, + ) -> Result<()> { handle_contribute(&mut context.accounts, amount, &context.bumps)?; Ok(()) } - pub fn check_contributions(mut context: Context) -> Result<()> { + pub fn check_contributions( + mut context: Context, + ) -> Result<()> { handle_check_contributions(&mut context.accounts)?; Ok(()) } - pub fn refund(mut context: Context) -> Result<()> { + pub fn refund(mut context: Context) -> Result<()> { handle_refund(&mut context.accounts)?; Ok(()) diff --git a/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs b/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs index c17869de..679eb976 100644 --- a/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/token-fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs @@ -1,8 +1,10 @@ use { anchor_lang::{ - solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + solana_program::{clock::Clock, instruction::Instruction, pubkey::Pubkey, system_program}, InstructionData, ToAccountMetas, }, + borsh::BorshDeserialize, + fundraiser::SECONDS_TO_DAYS, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -12,6 +14,16 @@ use { solana_signer::Signer, }; +const MINT_DECIMALS: u8 = 6; +/// One major unit of the test mint in minor units (10^MINT_DECIMALS). +const ONE_TOKEN: u64 = 1_000_000; +/// Comfortably above the program's 3-major-unit minimum target. +const AMOUNT_TO_RAISE: u64 = 30 * ONE_TOKEN; +/// The per-contributor cap is 10% of the target. +const MAX_CONTRIBUTION: u64 = AMOUNT_TO_RAISE / 10; +const DURATION_DAYS: u16 = 7; +const CONTRIBUTOR_STARTING_BALANCE: u64 = 10 * ONE_TOKEN; + fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -32,15 +44,43 @@ fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { ata } -fn setup() -> (LiteSVM, Pubkey, Keypair) { - let program_id = fundraiser::id(); - let mut svm = LiteSVM::new(); +/// Mirror of the onchain Fundraiser struct for borsh-decoding account data +/// in tests. Pubkeys are read as raw 32-byte arrays. +#[derive(BorshDeserialize)] +struct FundraiserState { + _maker: [u8; 32], + _mint_to_raise: [u8; 32], + amount_to_raise: u64, + current_amount: u64, + _time_started: i64, + duration: u16, + _bump: u8, +} - let program_bytes = include_bytes!("../../../target/deploy/fundraiser.so"); - svm.add_program(program_id, program_bytes).unwrap(); +/// Mirror of the onchain Contributor struct. +#[derive(BorshDeserialize)] +struct ContributorState { + amount: u64, + _bump: u8, +} - let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); - (svm, program_id, payer) +const ANCHOR_DISCRIMINATOR_LENGTH: usize = 8; + +fn read_fundraiser_state(svm: &LiteSVM, fundraiser_pda: &Pubkey) -> FundraiserState { + let account = svm.get_account(fundraiser_pda).unwrap(); + FundraiserState::try_from_slice(&account.data[ANCHOR_DISCRIMINATOR_LENGTH..]).unwrap() +} + +fn read_contributor_state(svm: &LiteSVM, contributor_pda: &Pubkey) -> ContributorState { + let account = svm.get_account(contributor_pda).unwrap(); + ContributorState::try_from_slice(&account.data[ANCHOR_DISCRIMINATOR_LENGTH..]).unwrap() +} + +/// Moves the LiteSVM clock forward by the given number of days. +fn warp_days_forward(svm: &mut LiteSVM, days: i64) { + let mut clock: Clock = svm.get_sysvar(); + clock.unix_timestamp += days * SECONDS_TO_DAYS; + svm.set_sysvar(&clock); } struct FundraiserSetup { @@ -54,20 +94,22 @@ struct FundraiserSetup { } fn full_setup() -> FundraiserSetup { - let (mut svm, program_id, payer) = setup(); + let program_id = fundraiser::id(); + let mut svm = LiteSVM::new(); + + let program_bytes = include_bytes!("../../../target/deploy/fundraiser.so"); + svm.add_program(program_id, program_bytes).unwrap(); + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let maker = create_wallet(&mut svm, 10_000_000_000).unwrap(); - // Create mint (6 decimals) — payer is mint authority - let mint = create_token_mint(&mut svm, &payer, 6, None).unwrap(); + // The payer is the mint authority. + let mint = create_token_mint(&mut svm, &payer, MINT_DECIMALS, None).unwrap(); - // Derive the fundraiser PDA - let (fundraiser_pda, _bump) = Pubkey::find_program_address( - &[b"fundraiser", maker.pubkey().as_ref()], - &program_id, - ); + let (fundraiser_pda, _bump) = + Pubkey::find_program_address(&[b"fundraiser", maker.pubkey().as_ref()], &program_id); - // Vault is the ATA of the fundraiser PDA for the mint + // The vault is the ATA of the fundraiser PDA for the mint. let vault = derive_ata(&fundraiser_pda, &mint); FundraiserSetup { @@ -81,344 +123,524 @@ fn full_setup() -> FundraiserSetup { } } -#[test] -fn test_initialize_fundraiser() { - let mut fs = full_setup(); - - let amount_to_raise: u64 = 30_000_000; - let duration: u16 = 0; - - let init_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Initialize { - amount: amount_to_raise, - duration, - } - .data(), - fundraiser::accounts::Initialize { - maker: fs.maker.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - vault: fs.vault, +fn initialize_fundraiser(setup: &mut FundraiserSetup, amount: u64, duration: u16) { + let initialize_instruction = Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::Initialize { amount, duration }.data(), + fundraiser::accounts::InitializeAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, system_program: system_program::id(), token_program: token_program_id(), associated_token_program: ata_program_id(), } .to_account_metas(None), ); - send_transaction_from_instructions( - &mut fs.svm, - vec![init_ix], - &[&fs.maker], - &fs.maker.pubkey(), + &mut setup.svm, + vec![initialize_instruction], + &[&setup.maker], + &setup.maker.pubkey(), ) .unwrap(); +} - // Verify fundraiser account exists - let fundraiser_data = fs - .svm - .get_account(&fs.fundraiser_pda) - .expect("Fundraiser account should exist"); - assert!(!fundraiser_data.data.is_empty()); +/// Creates a contributor wallet with a funded ATA and returns +/// (contributor keypair, contributor ATA, contributor account PDA). +fn create_funded_contributor(setup: &mut FundraiserSetup) -> (Keypair, Pubkey, Pubkey) { + let contributor = create_wallet(&mut setup.svm, 10_000_000_000).unwrap(); - // Verify vault exists with zero balance - assert_eq!(get_token_account_balance(&fs.svm, &fs.vault).unwrap(), 0); -} + let contributor_ata = create_associated_token_account( + &mut setup.svm, + &contributor.pubkey(), + &setup.mint, + &setup.payer, + ) + .unwrap(); -#[test] -fn test_contribute_and_refund() { - let mut fs = full_setup(); + mint_tokens_to_token_account( + &mut setup.svm, + &setup.mint, + &contributor_ata, + CONTRIBUTOR_STARTING_BALANCE, + &setup.payer, + ) + .unwrap(); - let amount_to_raise: u64 = 30_000_000; - let duration: u16 = 0; + let (contributor_account_pda, _bump) = Pubkey::find_program_address( + &[ + b"contributor", + setup.fundraiser_pda.as_ref(), + contributor.pubkey().as_ref(), + ], + &setup.program_id, + ); - // Initialize fundraiser - let init_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Initialize { - amount: amount_to_raise, - duration, + (contributor, contributor_ata, contributor_account_pda) +} + +fn build_contribute_instruction( + setup: &FundraiserSetup, + contributor: &Pubkey, + contributor_ata: &Pubkey, + contributor_account_pda: &Pubkey, + amount: u64, +) -> Instruction { + Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::Contribute { amount }.data(), + fundraiser::accounts::ContributeAccountConstraints { + contributor: *contributor, + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + contributor_account: *contributor_account_pda, + contributor_ata: *contributor_ata, + vault: setup.vault, + token_program: token_program_id(), + system_program: system_program::id(), } - .data(), - fundraiser::accounts::Initialize { - maker: fs.maker.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - vault: fs.vault, + .to_account_metas(None), + ) +} + +fn build_refund_instruction( + setup: &FundraiserSetup, + contributor: &Pubkey, + contributor_ata: &Pubkey, + contributor_account_pda: &Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::Refund {}.data(), + fundraiser::accounts::RefundAccountConstraints { + contributor: *contributor, + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + contributor_account: *contributor_account_pda, + contributor_ata: *contributor_ata, + vault: setup.vault, + token_program: token_program_id(), system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_check_contributions_instruction( + setup: &FundraiserSetup, + maker_ata: &Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::CheckContributions {}.data(), + fundraiser::accounts::CheckContributionsAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, + maker_ata: *maker_ata, token_program: token_program_id(), + system_program: system_program::id(), associated_token_program: ata_program_id(), } .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut fs.svm, - vec![init_ix], - &[&fs.maker], - &fs.maker.pubkey(), ) - .unwrap(); +} - // Setup contributor using Kite - let contributor = create_wallet(&mut fs.svm, 10_000_000_000).unwrap(); +#[test] +fn test_initialize_fundraiser() { + let mut setup = full_setup(); - let contributor_ata = - create_associated_token_account(&mut fs.svm, &contributor.pubkey(), &fs.mint, &fs.payer) - .unwrap(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let mint_amount: u64 = 10_000_000; - mint_tokens_to_token_account(&mut fs.svm, &fs.mint, &contributor_ata, mint_amount, &fs.payer) - .unwrap(); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.amount_to_raise, AMOUNT_TO_RAISE); + assert_eq!(fundraiser_state.current_amount, 0); + assert_eq!(fundraiser_state.duration, DURATION_DAYS); - // Derive contributor account PDA - let (contributor_account_pda, _bump) = Pubkey::find_program_address( - &[ - b"contributor", - fs.fundraiser_pda.as_ref(), - contributor.pubkey().as_ref(), - ], - &fs.program_id, - ); + assert_eq!(get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0); +} - // Contribute 1_000_000 - let contribute_amount: u64 = 1_000_000; - let contribute_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Contribute { - amount: contribute_amount, +#[test] +fn test_initialize_below_minimum_target_fails() { + let mut setup = full_setup(); + + // 3 major units is the minimum; one minor unit below it must fail. + let below_minimum_target = 3 * ONE_TOKEN - 1; + let initialize_instruction = Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::Initialize { + amount: below_minimum_target, + duration: DURATION_DAYS, } .data(), - fundraiser::accounts::Contribute { - contributor: contributor.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - contributor_account: contributor_account_pda, - contributor_ata, - vault: fs.vault, - token_program: token_program_id(), + fundraiser::accounts::InitializeAccountConstraints { + maker: setup.maker.pubkey(), + mint_to_raise: setup.mint, + fundraiser: setup.fundraiser_pda, + vault: setup.vault, system_program: system_program::id(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), } .to_account_metas(None), ); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![initialize_instruction], + &[&setup.maker], + &setup.maker.pubkey(), + ); + assert!(result.is_err(), "Target below 3 major units must be rejected"); + assert!( + setup.svm.get_account(&setup.fundraiser_pda).is_none(), + "Fundraiser account must not exist after a failed initialize" + ); +} + +#[test] +fn test_contribute_inside_window_succeeds() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + + // One day in: well inside the 7-day window. + warp_days_forward(&mut setup.svm, 1); + + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, + ); send_transaction_from_instructions( - &mut fs.svm, - vec![contribute_ix], + &mut setup.svm, + vec![contribute_instruction], &[&contributor], &contributor.pubkey(), ) .unwrap(); - // Verify vault balance assert_eq!( - get_token_account_balance(&fs.svm, &fs.vault).unwrap(), - contribute_amount + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + MAX_CONTRIBUTION + ); + assert_eq!( + get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE - MAX_CONTRIBUTION ); - // Expire blockhash to avoid AlreadyProcessed error (same accounts, same amount = same tx hash) - fs.svm.expire_blockhash(); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, MAX_CONTRIBUTION); - // Contribute again - let contribute_ix2 = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Contribute { - amount: contribute_amount, - } - .data(), - fundraiser::accounts::Contribute { - contributor: contributor.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - contributor_account: contributor_account_pda, - contributor_ata, - vault: fs.vault, - token_program: token_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), + let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); + assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); +} + +#[test] +fn test_contribute_after_deadline_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + + // One day past the deadline. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); + + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + ONE_TOKEN, ); - send_transaction_from_instructions( - &mut fs.svm, - vec![contribute_ix2], + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], &[&contributor], &contributor.pubkey(), - ) - .unwrap(); + ); + assert!(result.is_err(), "Contributing after the deadline must fail"); - // Verify vault balance is now 2_000_000 + assert_eq!(get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0); assert_eq!( - get_token_account_balance(&fs.svm, &fs.vault).unwrap(), - contribute_amount * 2 + get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE ); +} - fs.svm.expire_blockhash(); +#[test] +fn test_contribute_below_one_major_unit_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - // Refund - let refund_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Refund {}.data(), - fundraiser::accounts::Refund { - contributor: contributor.pubkey(), - maker: fs.maker.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - contributor_account: contributor_account_pda, - contributor_ata, - vault: fs.vault, - token_program: token_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + ONE_TOKEN - 1, + ); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], + &contributor.pubkey(), + ); + assert!( + result.is_err(), + "Contributions below one major unit must fail" + ); + assert_eq!(get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0); +} + +#[test] +fn test_refund_before_deadline_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + ONE_TOKEN, ); send_transaction_from_instructions( - &mut fs.svm, - vec![refund_ix], + &mut setup.svm, + vec![contribute_instruction], &[&contributor], &contributor.pubkey(), ) .unwrap(); - // Verify vault is empty after refund - assert_eq!(get_token_account_balance(&fs.svm, &fs.vault).unwrap(), 0); - - // Verify contributor got tokens back - assert_eq!( - get_token_account_balance(&fs.svm, &contributor_ata).unwrap(), - mint_amount + // Still inside the window: refund must fail with FundraiserNotEnded. + let refund_instruction = build_refund_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + ); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[&contributor], + &contributor.pubkey(), ); + assert!(result.is_err(), "Refunding before the deadline must fail"); - // Contributor account PDA should be closed - assert!( - fs.svm.get_account(&contributor_account_pda).is_none(), - "Contributor account should be closed after refund" + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + ONE_TOKEN ); + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, ONE_TOKEN); } #[test] -fn test_check_contributions_success() { - let mut fs = full_setup(); +fn test_refund_after_deadline_target_not_met_succeeds() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let amount_to_raise: u64 = 1_000; - let duration: u16 = 0; + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); - // Initialize fundraiser - let init_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Initialize { - amount: amount_to_raise, - duration, - } - .data(), - fundraiser::accounts::Initialize { - maker: fs.maker.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - vault: fs.vault, - system_program: system_program::id(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - } - .to_account_metas(None), + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, ); send_transaction_from_instructions( - &mut fs.svm, - vec![init_ix], - &[&fs.maker], - &fs.maker.pubkey(), + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], + &contributor.pubkey(), ) .unwrap(); - // Need 10 contributors each contributing 100 (10% of 1000) to reach goal - for _ in 0..10 { - let contributor = create_wallet(&mut fs.svm, 10_000_000_000).unwrap(); + // Past the deadline, target not met: refund must succeed. + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - let contributor_ata = create_associated_token_account( - &mut fs.svm, + let refund_instruction = build_refund_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[&contributor], + &contributor.pubkey(), + ) + .unwrap(); + + assert_eq!(get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 0); + assert_eq!( + get_token_account_balance(&setup.svm, &contributor_ata).unwrap(), + CONTRIBUTOR_STARTING_BALANCE + ); + + let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); + assert_eq!(fundraiser_state.current_amount, 0); + + assert!( + setup.svm.get_account(&contributor_account_pda).is_none(), + "Contributor account must be closed after refund" + ); +} + +#[test] +fn test_refund_when_target_met_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + // 10 contributors at the 10% cap reach the target exactly. + let mut contributors = Vec::new(); + for _ in 0..10 { + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], &contributor.pubkey(), - &fs.mint, - &fs.payer, ) .unwrap(); + contributors.push((contributor, contributor_ata, contributor_account_pda)); + } - mint_tokens_to_token_account(&mut fs.svm, &fs.mint, &contributor_ata, 10_000, &fs.payer) - .unwrap(); + warp_days_forward(&mut setup.svm, DURATION_DAYS as i64 + 1); - let (contributor_pda, _) = Pubkey::find_program_address( - &[ - b"contributor", - fs.fundraiser_pda.as_ref(), - contributor.pubkey().as_ref(), - ], - &fs.program_id, - ); + let (contributor, contributor_ata, contributor_account_pda) = &contributors[0]; + let refund_instruction = build_refund_instruction( + &setup, + &contributor.pubkey(), + contributor_ata, + contributor_account_pda, + ); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![refund_instruction], + &[contributor], + &contributor.pubkey(), + ); + assert!( + result.is_err(), + "Refunding must fail once the target has been met" + ); + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + AMOUNT_TO_RAISE + ); +} + +#[test] +fn test_check_contributions_success_pays_maker_and_closes_vault() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); - let contribute_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::Contribute { amount: 100 }.data(), - fundraiser::accounts::Contribute { - contributor: contributor.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - contributor_account: contributor_pda, - contributor_ata, - vault: fs.vault, - token_program: token_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), + // 10 contributors at the 10% cap reach the target exactly. + for _ in 0..10 { + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, ); send_transaction_from_instructions( - &mut fs.svm, - vec![contribute_ix], + &mut setup.svm, + vec![contribute_instruction], &[&contributor], &contributor.pubkey(), ) .unwrap(); - - // Check if we've hit the goal - let current = get_token_account_balance(&fs.svm, &fs.vault).unwrap(); - if current >= amount_to_raise { - break; - } } - // Verify vault has enough - assert!(get_token_account_balance(&fs.svm, &fs.vault).unwrap() >= amount_to_raise); - - // Check contributions (maker claims the funds) - let maker_ata = derive_ata(&fs.maker.pubkey(), &fs.mint); - - let check_ix = Instruction::new_with_bytes( - fs.program_id, - &fundraiser::instruction::CheckContributions {}.data(), - fundraiser::accounts::CheckContributions { - maker: fs.maker.pubkey(), - mint_to_raise: fs.mint, - fundraiser: fs.fundraiser_pda, - vault: fs.vault, - maker_ata, - token_program: token_program_id(), - system_program: system_program::id(), - associated_token_program: ata_program_id(), - } - .to_account_metas(None), + assert_eq!( + get_token_account_balance(&setup.svm, &setup.vault).unwrap(), + AMOUNT_TO_RAISE ); + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); send_transaction_from_instructions( - &mut fs.svm, - vec![check_ix], - &[&fs.maker], - &fs.maker.pubkey(), + &mut setup.svm, + vec![check_instruction], + &[&setup.maker], + &setup.maker.pubkey(), ) .unwrap(); - // Verify maker received the funds + assert_eq!( + get_token_account_balance(&setup.svm, &maker_ata).unwrap(), + AMOUNT_TO_RAISE + ); assert!( - get_token_account_balance(&fs.svm, &maker_ata).unwrap() >= amount_to_raise + setup.svm.get_account(&setup.vault).is_none(), + "Vault token account must be closed after a successful claim" ); + assert!( + setup.svm.get_account(&setup.fundraiser_pda).is_none(), + "Fundraiser account must be closed after a successful claim" + ); +} - // Fundraiser account should be closed +#[test] +fn test_check_contributions_ignores_direct_vault_donations() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + // Mint the full target straight into the vault, bypassing contribute. + // The state-tracked current_amount stays 0, so the claim must fail. + mint_tokens_to_token_account( + &mut setup.svm, + &setup.mint, + &setup.vault, + AMOUNT_TO_RAISE, + &setup.payer, + ) + .unwrap(); + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&setup.maker], + &setup.maker.pubkey(), + ); + assert!( + result.is_err(), + "Direct donations to the vault must not unlock the claim" + ); assert!( - fs.svm.get_account(&fs.fundraiser_pda).is_none(), - "Fundraiser account should be closed after check_contributions" + setup.svm.get_account(&setup.fundraiser_pda).is_some(), + "Fundraiser account must stay open after a failed claim" ); } diff --git a/finance/token-fundraiser/quasar/Cargo.toml b/finance/token-fundraiser/quasar/Cargo.toml index 3053b856..69ed0eca 100644 --- a/finance/token-fundraiser/quasar/Cargo.toml +++ b/finance/token-fundraiser/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-token-fundraiser" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. [workspace] [lints.rust.unexpected_cfgs] @@ -32,6 +32,9 @@ quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } solana-instruction = { version = "3.2.0" } [dev-dependencies] +# Generated by `quasar build` (see [clients] in Quasar.toml); gives tests +# typed *Instruction builders instead of hand-built account metas. +quasar-token-fundraiser-client = { path = "target/client/rust/quasar-token-fundraiser-client" } quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } spl-token-interface = { version = "2.0.0" } solana-program-pack = { version = "3.1.0" } diff --git a/finance/token-fundraiser/quasar/README.md b/finance/token-fundraiser/quasar/README.md index c0d47666..03116dad 100644 --- a/finance/token-fundraiser/quasar/README.md +++ b/finance/token-fundraiser/quasar/README.md @@ -1,13 +1,24 @@ # Token Fundraiser (Quasar) -Onchain crowdfunding toward a target amount in a chosen token. +Onchain crowdfunding toward a target amount in a chosen token, written with [Quasar](https://quasar-lang.com/docs). A **maker** opens a fundraiser with a target amount and a deadline; **contributors** deposit tokens into a program-controlled vault. If the target is met the maker withdraws everything; if the deadline passes without the target being met, each contributor reclaims exactly what they put in. -See also: the [repository catalog](../../../README.md). +See also: the [repository catalog](../../../README.md) and the [Anchor variant](../anchor/) of the same program. ## Major concepts -- Fundraiser PDA -- Contributor deposits +- The **Fundraiser** account is a PDA at `["fundraiser", maker]`. It stores the maker, the token's mint, the vault address, the target (`amount_to_raise`), the running total (`current_amount`), the Clock timestamp captured at creation (`time_started`), the window length in days (`duration`), and the PDA bump. Storing the vault address lets every later instruction bind the passed vault to this fundraiser with a `has_one(vault)` constraint. +- A **Contributor** account is a PDA at `["contributor", fundraiser, contributor]`. It records how much that signer has given to that fundraiser, plus its bump. The seeds bind the record to one (fundraiser, contributor) pair, so one contributor's record can never be spent by another signer or against another fundraiser. +- The **vault** is a token account whose authority is the Fundraiser PDA. All deposits, the maker payout, and refunds flow through it, with the PDA signing outbound transfers via its seeds. +- The **fundraising window** runs from `time_started` for `duration` days. Contributions are allowed while `now < time_started + duration`; refunds are allowed once `now >= time_started + duration` and only if the target was not met. `now` is the Clock sysvar's unix timestamp. + +## Lifecycle + +- `initialize` (maker signs): rejects a zero target (`InvalidAmount`) or zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`. +- `contribute` (contributor signs): rejects a zero amount and, after the deadline, fails with `FundraiserEnded`. Creates the contributor's Contributor PDA on first use (idempotent init, contributor pays the rent), adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise). +- `check_contributions` (maker signs): fails with `TargetNotMet` unless `current_amount >= amount_to_raise`. Transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing, then closes the vault and the Fundraiser account, returning their rent to the maker. +- `refund` (contributor signs): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault with the PDA signing, subtracts it from `current_amount`, verifies the vault lost exactly that amount, and closes the Contributor account back to the contributor. + +Errors are defined in `src/error.rs` as a `#[error_code]` enum starting at code 6000. ## Setup @@ -19,16 +30,14 @@ quasar build Prerequisites: [Quasar](https://quasar-lang.com/docs) CLI and [Agave](https://docs.anza.xyz/) toolchain (see `Quasar.toml`). +`quasar build` also regenerates the Rust client crate under `target/client/rust/`, which the tests use for typed instruction builders. + ## Testing In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): ```bash -cargo test +quasar test ``` -Tests invoke instruction handlers and assert onchain state. No local validator. - -## Usage - -Read `src/` and `Quasar.toml`. Compare with the [Anchor](../anchor/) variant in the same example where present. +The tests in `src/tests.rs` drive the real instruction handlers end to end (initialize, contribute, check_contributions, refund), assert vault and contributor token balances plus account state after every step, and use `QuasarSvm::warp_to_timestamp` to test both sides of the deadline. They also cover the rejection paths: contributing after the deadline, refunding early or after a successful raise, paying out below target, passing a vault not bound to the fundraiser, and refunding against another contributor's record. No local validator is needed. diff --git a/finance/token-fundraiser/quasar/src/error.rs b/finance/token-fundraiser/quasar/src/error.rs new file mode 100644 index 00000000..1599b5ba --- /dev/null +++ b/finance/token-fundraiser/quasar/src/error.rs @@ -0,0 +1,25 @@ +use quasar_lang::prelude::*; + +#[error_code] +pub enum FundraiserError { + /// The target amount has not been raised, so the maker cannot withdraw. + // 6000 is the conventional Anchor-compatible starting offset for + // program-specific error codes (Quasar's #[error_code] starts at 0 + // unless told otherwise; framework errors occupy 3000+). + TargetNotMet = 6000, + /// The target amount was raised, so contributors cannot claim refunds. + TargetMet, + /// The fundraising window has closed, so contributions are rejected. + FundraiserEnded, + /// The fundraising window is still open, so refunds are rejected. + FundraiserNotEnded, + /// An amount argument was zero or otherwise unusable. + InvalidAmount, + /// A duration argument was zero, which would create a fundraiser that + /// could never accept contributions. + InvalidDuration, + /// Checked arithmetic overflowed or underflowed. + MathOverflow, + /// A token balance after a transfer did not match the expected value. + BalanceMismatch, +} diff --git a/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs b/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs index 61e1bee7..a78d770d 100644 --- a/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs +++ b/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs @@ -1,38 +1,45 @@ use { - crate::state::Fundraiser, + crate::{error::FundraiserError, state::Fundraiser}, quasar_lang::prelude::*, quasar_spl::prelude::*, }; #[derive(Accounts)] -pub struct CheckContributions { +pub struct CheckContributionsAccountConstraints { #[account(mut)] pub maker: Signer, + #[account( mut, has_one(maker), + has_one(vault), close(dest = maker), address = Fundraiser::seeds(maker.address()), )] pub fundraiser: Account, + #[account(mut)] pub vault: Account, + #[account(mut)] pub maker_ta: Account, + pub token_program: Program, } #[inline(always)] -pub fn handle_check_contributions(accounts: &mut CheckContributions, bumps: &CheckContributionsBumps) -> Result<(), ProgramError> { - // Verify the target was met +pub fn handle_check_contributions( + accounts: &mut CheckContributionsAccountConstraints, + bumps: &CheckContributionsAccountConstraintsBumps, +) -> Result<(), ProgramError> { + let current_amount: u64 = accounts.fundraiser.current_amount.into(); + let amount_to_raise: u64 = accounts.fundraiser.amount_to_raise.into(); require!( - accounts.fundraiser.current_amount >= accounts.fundraiser.amount_to_raise, - ProgramError::Custom(0) // TargetNotMet + current_amount >= amount_to_raise, + FundraiserError::TargetNotMet ); - // Build PDA signer seeds for the fundraiser: - // ["fundraiser", maker, bump]. Inline rather than via a helper because - // post-PR-#195 the derive no longer emits a `_seeds()` method. + // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. let bump = [bumps.fundraiser]; let seeds = [ Seed::from(b"fundraiser" as &[u8]), @@ -40,14 +47,24 @@ pub fn handle_check_contributions(accounts: &mut CheckContributions, bumps: &Che Seed::from(bump.as_ref()), ]; - // Transfer all vault funds to the maker + // Transfer all vault funds to the maker. let vault_amount = accounts.vault.amount(); - accounts.token_program - .transfer(&accounts.vault, &accounts.maker_ta, &accounts.fundraiser, vault_amount) + accounts + .token_program + .transfer( + &accounts.vault, + &accounts.maker_ta, + &accounts.fundraiser, + vault_amount, + ) .invoke_signed(&seeds)?; - // Close the vault token account - accounts.token_program + // Token conservation: the vault was fully drained. + require!(accounts.vault.amount() == 0, FundraiserError::BalanceMismatch); + + // Close the vault token account, returning its rent to the maker. + accounts + .token_program .close_account(&accounts.vault, &accounts.maker, &accounts.fundraiser) .invoke_signed(&seeds)?; diff --git a/finance/token-fundraiser/quasar/src/instructions/contribute.rs b/finance/token-fundraiser/quasar/src/instructions/contribute.rs index 8fcc0218..7aa78ddf 100644 --- a/finance/token-fundraiser/quasar/src/instructions/contribute.rs +++ b/finance/token-fundraiser/quasar/src/instructions/contribute.rs @@ -1,40 +1,98 @@ use { - crate::state::{Contributor, Fundraiser}, - quasar_lang::prelude::*, + crate::{ + error::FundraiserError, + state::{fundraiser_deadline, Contributor, Fundraiser}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, quasar_spl::prelude::*, }; #[derive(Accounts)] -pub struct Contribute { +pub struct ContributeAccountConstraints { #[account(mut)] pub contributor: Signer, - #[account(mut)] + + pub maker: UncheckedAccount, + + #[account( + mut, + has_one(maker), + has_one(vault), + address = Fundraiser::seeds(maker.address()), + )] pub fundraiser: Account, - #[account(mut)] + + #[account( + mut, + init(idempotent), + payer = contributor, + address = Contributor::seeds(fundraiser.address(), contributor.address()), + )] pub contributor_account: Account, + #[account(mut)] pub contributor_ta: Account, + #[account(mut)] pub vault: Account, + pub token_program: Program, + + pub system_program: Program, } #[inline(always)] -pub fn handle_contribute(accounts: &mut Contribute, amount: u64) -> Result<(), ProgramError> { - require!(amount > 0, ProgramError::InvalidArgument); +pub fn handle_contribute( + accounts: &mut ContributeAccountConstraints, + amount: u64, + bumps: &ContributeAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require!(amount > 0, FundraiserError::InvalidAmount); - // Transfer tokens from contributor to vault - accounts.token_program - .transfer(&accounts.contributor_ta, &accounts.vault, &accounts.contributor, amount) - .invoke()?; + // Contributions are allowed while now < start + duration. + let now: i64 = Clock::get()?.unix_timestamp.into(); + let deadline = fundraiser_deadline( + accounts.fundraiser.time_started.into(), + accounts.fundraiser.duration.into(), + )?; + require!(now < deadline, FundraiserError::FundraiserEnded); + + // Update state before the transfer CPI (checks-effects-interactions). + let current_amount: u64 = accounts.fundraiser.current_amount.into(); + accounts.fundraiser.current_amount = PodU64::from( + current_amount + .checked_add(amount) + .ok_or(FundraiserError::MathOverflow)?, + ); - // Update fundraiser state - accounts.fundraiser.current_amount = accounts.fundraiser.current_amount.checked_add(amount) - .ok_or(ProgramError::ArithmeticOverflow)?; + let contributed_so_far: u64 = accounts.contributor_account.amount.into(); + accounts.contributor_account.amount = PodU64::from( + contributed_so_far + .checked_add(amount) + .ok_or(FundraiserError::MathOverflow)?, + ); + accounts.contributor_account.bump = bumps.contributor_account; + + let vault_balance_before = accounts.vault.amount(); + + accounts + .token_program + .transfer( + &accounts.contributor_ta, + &accounts.vault, + &accounts.contributor, + amount, + ) + .invoke()?; - // Update contributor tracking - accounts.contributor_account.amount = accounts.contributor_account.amount.checked_add(amount) - .ok_or(ProgramError::ArithmeticOverflow)?; + // Token conservation: the vault gained exactly the contributed amount. + let expected_vault_balance = vault_balance_before + .checked_add(amount) + .ok_or(FundraiserError::MathOverflow)?; + require!( + accounts.vault.amount() == expected_vault_balance, + FundraiserError::BalanceMismatch + ); Ok(()) } diff --git a/finance/token-fundraiser/quasar/src/instructions/initialize.rs b/finance/token-fundraiser/quasar/src/instructions/initialize.rs index 4c7d0bc4..5e3a49fc 100644 --- a/finance/token-fundraiser/quasar/src/instructions/initialize.rs +++ b/finance/token-fundraiser/quasar/src/instructions/initialize.rs @@ -1,16 +1,22 @@ use { - crate::state::{Fundraiser, FundraiserInner}, - quasar_lang::prelude::*, + crate::{ + error::FundraiserError, + state::{Fundraiser, FundraiserInner}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, quasar_spl::prelude::*, }; #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub maker: Signer, + pub mint_to_raise: Account, + #[account(mut, init, payer = maker, address = Fundraiser::seeds(maker.address()))] pub fundraiser: Account, + #[account( mut, init(idempotent), @@ -18,27 +24,34 @@ pub struct Initialize { token(mint = mint_to_raise, authority = fundraiser, token_program = token_program), )] pub vault: Account, + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, } #[inline(always)] pub fn handle_initialize( - accounts: &mut Initialize, + accounts: &mut InitializeAccountConstraints, amount_to_raise: u64, duration: u16, bump: u8, ) -> Result<(), ProgramError> { - // Validate minimum raise amount - require!(amount_to_raise > 0, ProgramError::InvalidArgument); + require!(amount_to_raise > 0, FundraiserError::InvalidAmount); + // A zero-day window would close before any contribution could land. + require!(duration > 0, FundraiserError::InvalidDuration); + + let time_started: i64 = Clock::get()?.unix_timestamp.into(); accounts.fundraiser.set_inner(FundraiserInner { maker: *accounts.maker.address(), mint_to_raise: *accounts.mint_to_raise.address(), + vault: *accounts.vault.address(), amount_to_raise, current_amount: 0, - time_started: 0, + time_started, duration, bump, }); diff --git a/finance/token-fundraiser/quasar/src/instructions/refund.rs b/finance/token-fundraiser/quasar/src/instructions/refund.rs index c6e000ad..02023dcd 100644 --- a/finance/token-fundraiser/quasar/src/instructions/refund.rs +++ b/finance/token-fundraiser/quasar/src/instructions/refund.rs @@ -1,35 +1,70 @@ use { - crate::state::{Contributor, ContributorInner, Fundraiser}, - quasar_lang::prelude::*, + crate::{ + error::FundraiserError, + state::{fundraiser_deadline, Contributor, Fundraiser}, + }, + quasar_lang::{prelude::*, sysvars::Sysvar as _}, quasar_spl::prelude::*, }; #[derive(Accounts)] -pub struct Refund { +pub struct RefundAccountConstraints { #[account(mut)] pub contributor: Signer, + pub maker: UncheckedAccount, + #[account( mut, has_one(maker), + has_one(vault), address = Fundraiser::seeds(maker.address()), )] pub fundraiser: Account, - #[account(mut)] + + #[account( + mut, + close(dest = contributor), + address = Contributor::seeds(fundraiser.address(), contributor.address()), + )] pub contributor_account: Account, + #[account(mut)] pub contributor_ta: Account, + #[account(mut)] pub vault: Account, + pub token_program: Program, } #[inline(always)] -pub fn handle_refund(accounts: &mut Refund, bumps: &RefundBumps) -> Result<(), ProgramError> { - let refund_amount = accounts.contributor_account.amount; +pub fn handle_refund(accounts: &mut RefundAccountConstraints, bumps: &RefundAccountConstraintsBumps) -> Result<(), ProgramError> { + // Refunds are allowed only after the deadline (now >= start + duration). + let now: i64 = Clock::get()?.unix_timestamp.into(); + let deadline = fundraiser_deadline( + accounts.fundraiser.time_started.into(), + accounts.fundraiser.duration.into(), + )?; + require!(now >= deadline, FundraiserError::FundraiserNotEnded); + + // Refunds are allowed only when the target was not met. A successful + // fundraiser pays out to the maker via check_contributions instead. + let current_amount: u64 = accounts.fundraiser.current_amount.into(); + let amount_to_raise: u64 = accounts.fundraiser.amount_to_raise.into(); + require!(current_amount < amount_to_raise, FundraiserError::TargetMet); + + let refund_amount: u64 = accounts.contributor_account.amount.into(); - // Build PDA signer seeds inline; see comment in check_contributions.rs - // for why we no longer use a struct helper method. + // Update state before the transfer CPI (checks-effects-interactions). + accounts.fundraiser.current_amount = PodU64::from( + current_amount + .checked_sub(refund_amount) + .ok_or(FundraiserError::MathOverflow)?, + ); + accounts.contributor_account.amount = PodU64::from(0); + + // Fundraiser PDA signer seeds: ["fundraiser", maker, bump]. let bump = [bumps.fundraiser]; let seeds = [ Seed::from(b"fundraiser" as &[u8]), @@ -37,18 +72,26 @@ pub fn handle_refund(accounts: &mut Refund, bumps: &RefundBumps) -> Result<(), P Seed::from(bump.as_ref()), ]; - // Transfer contributor's tokens back from vault - accounts.token_program - .transfer(&accounts.vault, &accounts.contributor_ta, &accounts.fundraiser, refund_amount) + let vault_balance_before = accounts.vault.amount(); + + accounts + .token_program + .transfer( + &accounts.vault, + &accounts.contributor_ta, + &accounts.fundraiser, + refund_amount, + ) .invoke_signed(&seeds)?; - // Update fundraiser state - accounts.fundraiser.current_amount = accounts.fundraiser.current_amount + // Token conservation: the vault lost exactly the refunded amount. + let expected_vault_balance = vault_balance_before .checked_sub(refund_amount) - .ok_or(ProgramError::ArithmeticOverflow)?; - - // Zero out contributor amount - accounts.contributor_account.set_inner(ContributorInner { amount: 0 }); + .ok_or(FundraiserError::MathOverflow)?; + require!( + accounts.vault.amount() == expected_vault_balance, + FundraiserError::BalanceMismatch + ); Ok(()) } diff --git a/finance/token-fundraiser/quasar/src/lib.rs b/finance/token-fundraiser/quasar/src/lib.rs index cb00e6c1..630876cd 100644 --- a/finance/token-fundraiser/quasar/src/lib.rs +++ b/finance/token-fundraiser/quasar/src/lib.rs @@ -2,13 +2,14 @@ use quasar_lang::prelude::*; +mod error; mod instructions; use instructions::*; mod state; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC"); /// Token crowdfunding program: a maker creates a fundraiser targeting a specific /// SPL token. Contributors deposit tokens into a vault. If the target is met, @@ -20,28 +21,30 @@ mod quasar_token_fundraiser { /// Create a new fundraiser with a target amount and duration. #[instruction(discriminator = 0)] pub fn initialize( - ctx: Ctx, + ctx: Ctx, amount_to_raise: u64, duration: u16, ) -> Result<(), ProgramError> { instructions::handle_initialize(&mut ctx.accounts, amount_to_raise, duration, ctx.bumps.fundraiser) } - /// Contribute tokens to the fundraiser. + /// Contribute tokens to the fundraiser while its window is open. Creates + /// the contributor's tracking account on first contribution. #[instruction(discriminator = 1)] - pub fn contribute(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { - instructions::handle_contribute(&mut ctx.accounts, amount) + pub fn contribute(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + instructions::handle_contribute(&mut ctx.accounts, amount, &ctx.bumps) } /// Maker withdraws all funds once the target is met. #[instruction(discriminator = 2)] - pub fn check_contributions(ctx: Ctx) -> Result<(), ProgramError> { + pub fn check_contributions(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_check_contributions(&mut ctx.accounts, &ctx.bumps) } - /// Contributors reclaim their tokens if the fundraiser fails. + /// Contributors reclaim their tokens after the deadline if the target + /// was not met. #[instruction(discriminator = 3)] - pub fn refund(ctx: Ctx) -> Result<(), ProgramError> { + pub fn refund(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_refund(&mut ctx.accounts, &ctx.bumps) } } diff --git a/finance/token-fundraiser/quasar/src/state.rs b/finance/token-fundraiser/quasar/src/state.rs index 9bbe003d..e3724cf0 100644 --- a/finance/token-fundraiser/quasar/src/state.rs +++ b/finance/token-fundraiser/quasar/src/state.rs @@ -1,20 +1,47 @@ -use quasar_lang::prelude::*; +use {crate::error::FundraiserError, quasar_lang::prelude::*}; -/// State for the fundraiser: records the maker, target mint, amounts, and timing. +/// Number of seconds in one day. `Fundraiser::duration` is denominated in +/// days; deadline math converts it to seconds with this factor. +pub const SECONDS_PER_DAY: i64 = 86_400; + +/// State for the fundraiser: records the maker, target mint, vault, amounts, +/// and timing. #[account(discriminator = 1, set_inner)] #[seeds(b"fundraiser", maker: Address)] pub struct Fundraiser { pub maker: Address, pub mint_to_raise: Address, + /// The token account holding contributions. Stored so every later + /// instruction can bind the passed vault to this fundraiser via + /// `has_one(vault)`. + pub vault: Address, pub amount_to_raise: u64, pub current_amount: u64, + /// Clock unix timestamp captured when the fundraiser was created. pub time_started: i64, + /// Fundraising window length in days, counted from `time_started`. pub duration: u16, pub bump: u8, } -/// Tracks how much a specific contributor has given. +/// Tracks how much a specific contributor has given to a specific fundraiser. +/// The seeds bind this record to one (fundraiser, contributor) pair, so it +/// can never be spent by another signer or against another fundraiser. #[account(discriminator = 2, set_inner)] +#[seeds(b"contributor", fundraiser: Address, contributor: Address)] pub struct Contributor { pub amount: u64, + pub bump: u8, +} + +/// The unix timestamp at which the fundraising window closes. Contributions +/// are allowed while `now < deadline`; refunds are allowed once +/// `now >= deadline`. +pub fn fundraiser_deadline(time_started: i64, duration_days: u16) -> Result { + let window_seconds = (duration_days as i64) + .checked_mul(SECONDS_PER_DAY) + .ok_or(FundraiserError::MathOverflow)?; + Ok(time_started + .checked_add(window_seconds) + .ok_or(FundraiserError::MathOverflow)?) } diff --git a/finance/token-fundraiser/quasar/src/tests.rs b/finance/token-fundraiser/quasar/src/tests.rs index e68f8dba..6e489c67 100644 --- a/finance/token-fundraiser/quasar/src/tests.rs +++ b/finance/token-fundraiser/quasar/src/tests.rs @@ -1,17 +1,38 @@ extern crate std; use { - alloc::vec, - alloc::vec::Vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + crate::state::SECONDS_PER_DAY, + quasar_lang::error::QuasarError, + quasar_svm::{Account, Instruction, ProgramError, Pubkey, QuasarSvm}, + quasar_token_fundraiser_client::{ + CheckContributionsInstruction, ContributeInstruction, InitializeInstruction, + QuasarTokenFundraiserError, RefundInstruction, + }, + solana_program_pack::Pack, spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + std::{vec, vec::Vec}, }; +/// Fundraising target in minor units of the raised token. +const TARGET_AMOUNT: u64 = 10_000; +/// Fundraising window length in days. +const DURATION_DAYS: u16 = 30; +/// Arbitrary fixed unix timestamp the SVM clock is warped to before +/// initialize, so deadline math in tests is deterministic. +const START_TIME: i64 = 1_750_000_000; +/// First timestamp at which the fundraising window is closed. +const DEADLINE: i64 = START_TIME + DURATION_DAYS as i64 * SECONDS_PER_DAY; +/// Token balance each contributor's token account starts with. +const CONTRIBUTOR_STARTING_BALANCE: u64 = 100_000; +/// A contribution below the target, used by the refund-path tests. +const PARTIAL_CONTRIBUTION: u64 = 500; + fn setup() -> QuasarSvm { let elf = std::fs::read("target/deploy/quasar_token_fundraiser.so").unwrap(); - QuasarSvm::new() + let mut svm = QuasarSvm::new() .with_program(&crate::ID, &elf) - .with_token_program() + .with_token_program(); + svm.warp_to_timestamp(START_TIME); + svm } fn signer(address: Pubkey) -> Account { @@ -54,277 +75,483 @@ fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { ) } -/// Build Fundraiser account data. -/// Layout: [disc:1] [maker:32] [mint_to_raise:32] [amount_to_raise:8] -/// [current_amount:8] [time_started:8] [duration:2] [bump:1] -fn fundraiser_data( +fn token_balance(svm: &QuasarSvm, address: &Pubkey) -> u64 { + let account = svm.get_account(address).unwrap(); + TokenAccount::unpack(&account.data).unwrap().amount +} + +fn find_fundraiser(maker: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[b"fundraiser", maker.as_ref()], &crate::ID) +} + +fn find_contributor_account(fundraiser: &Pubkey, contributor: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[b"contributor", fundraiser.as_ref(), contributor.as_ref()], + &crate::ID, + ) +} + +/// Deserialized Fundraiser account state, parsed from the zero-copy layout: +/// [disc:1] [maker:32] [mint_to_raise:32] [vault:32] [amount_to_raise:8] +/// [current_amount:8] [time_started:8] [duration:2] [bump:1] +struct FundraiserState { maker: Pubkey, mint_to_raise: Pubkey, + vault: Pubkey, amount_to_raise: u64, current_amount: u64, time_started: i64, duration: u16, bump: u8, -) -> Vec { - let mut data = Vec::with_capacity(92); - data.push(1u8); // discriminator - data.extend_from_slice(maker.as_ref()); - data.extend_from_slice(mint_to_raise.as_ref()); - data.extend_from_slice(&amount_to_raise.to_le_bytes()); - data.extend_from_slice(¤t_amount.to_le_bytes()); - data.extend_from_slice(&time_started.to_le_bytes()); - data.extend_from_slice(&duration.to_le_bytes()); - data.push(bump); - data -} - -fn fundraiser_account( - address: Pubkey, - maker: Pubkey, - mint_to_raise: Pubkey, - amount_to_raise: u64, - current_amount: u64, +} + +fn parse_fundraiser(data: &[u8]) -> FundraiserState { + assert_eq!(data[0], 1, "Fundraiser discriminator"); + let mut cursor = Cursor { + data, + offset: 1usize, + }; + FundraiserState { + maker: Pubkey::new_from_array(cursor.take()), + mint_to_raise: Pubkey::new_from_array(cursor.take()), + vault: Pubkey::new_from_array(cursor.take()), + amount_to_raise: u64::from_le_bytes(cursor.take()), + current_amount: u64::from_le_bytes(cursor.take()), + time_started: i64::from_le_bytes(cursor.take()), + duration: u16::from_le_bytes(cursor.take()), + bump: cursor.take::<1>()[0], + } +} + +/// Deserialized Contributor account state, parsed from the zero-copy layout: +/// [disc:1] [amount:8] [bump:1] +struct ContributorState { + amount: u64, bump: u8, -) -> Account { - Account { - address, - lamports: 2_000_000, - data: fundraiser_data(maker, mint_to_raise, amount_to_raise, current_amount, 0, 30, bump), - owner: crate::ID, - executable: false, +} + +fn parse_contributor(data: &[u8]) -> ContributorState { + assert_eq!(data[0], 2, "Contributor discriminator"); + let mut cursor = Cursor { + data, + offset: 1usize, + }; + ContributorState { + amount: u64::from_le_bytes(cursor.take()), + bump: cursor.take::<1>()[0], } } -/// Build Contributor account data. -/// Layout: [disc:1=2] [amount:8] -fn contributor_data(amount: u64) -> Vec { - let mut data = Vec::with_capacity(9); - data.push(2u8); // discriminator - data.extend_from_slice(&amount.to_le_bytes()); - data +struct Cursor<'a> { + data: &'a [u8], + offset: usize, } -fn contributor_account(address: Pubkey, amount: u64) -> Account { - Account { - address, - lamports: 1_000_000, - data: contributor_data(amount), - owner: crate::ID, - executable: false, +impl Cursor<'_> { + fn take(&mut self) -> [u8; N] { + let bytes: [u8; N] = self.data[self.offset..self.offset + N].try_into().unwrap(); + self.offset += N; + bytes + } +} + +/// Addresses for one fundraiser plus one contributor, shared by every test. +struct Fixture { + maker: Pubkey, + mint: Pubkey, + fundraiser: Pubkey, + vault: Pubkey, + contributor: Pubkey, + contributor_ta: Pubkey, + contributor_account: Pubkey, +} + +fn fixture() -> Fixture { + let maker = Pubkey::new_unique(); + let contributor = Pubkey::new_unique(); + let (fundraiser, _) = find_fundraiser(&maker); + let (contributor_account, _) = find_contributor_account(&fundraiser, &contributor); + Fixture { + maker, + mint: Pubkey::new_unique(), + fundraiser, + vault: Pubkey::new_unique(), + contributor, + contributor_ta: Pubkey::new_unique(), + contributor_account, + } +} + +fn initialize_instruction(fixture: &Fixture, amount_to_raise: u64, duration: u16) -> Instruction { + let mut instruction: Instruction = InitializeInstruction { + maker: fixture.maker, + mint_to_raise: fixture.mint, + fundraiser: fixture.fundraiser, + vault: fixture.vault, + rent: quasar_svm::solana_sdk_ids::sysvar::rent::ID, + token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, + system_program: quasar_svm::system_program::ID, + amount_to_raise, + duration, } + .into(); + // The vault is a fresh keypair account, so it must sign its own + // system-program creation inside the init CPI. + instruction.accounts[3].is_signer = true; + instruction } -/// Build initialize instruction data. -/// Wire format: [disc: u8 = 0] [amount_to_raise: u64 LE] [duration: u16 LE] -fn build_init_data(amount_to_raise: u64, duration: u16) -> Vec { - let mut data = vec![0u8]; - data.extend_from_slice(&amount_to_raise.to_le_bytes()); - data.extend_from_slice(&duration.to_le_bytes()); - data +fn initialize_accounts(fixture: &Fixture) -> Vec { + vec![ + signer(fixture.maker), + mint(fixture.mint, fixture.maker), + empty(fixture.fundraiser), + empty(fixture.vault), + ] +} + +/// Run initialize through the program and assert it succeeded. +fn initialize_fundraiser(svm: &mut QuasarSvm, fixture: &Fixture) { + let result = svm.process_instruction( + &initialize_instruction(fixture, TARGET_AMOUNT, DURATION_DAYS), + &initialize_accounts(fixture), + ); + result.assert_success(); } -/// Build contribute instruction data. -/// Wire format: [disc: u8 = 1] [amount: u64 LE] -fn build_contribute_data(amount: u64) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data +fn contribute_instruction(fixture: &Fixture, amount: u64) -> Instruction { + ContributeInstruction { + contributor: fixture.contributor, + maker: fixture.maker, + fundraiser: fixture.fundraiser, + contributor_account: fixture.contributor_account, + contributor_ta: fixture.contributor_ta, + vault: fixture.vault, + token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, + system_program: quasar_svm::system_program::ID, + amount, + } + .into() } -/// Build check_contributions instruction data. -/// Wire format: [disc: u8 = 2] -fn build_check_data() -> Vec { - vec![2u8] +/// Accounts a first-time contributor brings to contribute. The fundraiser +/// and vault already live in the SVM's database after initialize. +fn first_contribution_accounts(fixture: &Fixture) -> Vec { + vec![ + signer(fixture.contributor), + empty(fixture.contributor_account), + token( + fixture.contributor_ta, + fixture.mint, + fixture.contributor, + CONTRIBUTOR_STARTING_BALANCE, + ), + ] } -/// Build refund instruction data. -/// Wire format: [disc: u8 = 3] -fn build_refund_data() -> Vec { - vec![3u8] +/// Run contribute through the program and assert it succeeded. +fn contribute(svm: &mut QuasarSvm, fixture: &Fixture, amount: u64) { + let result = svm.process_instruction( + &contribute_instruction(fixture, amount), + &first_contribution_accounts(fixture), + ); + result.assert_success(); } -fn with_signers(mut ix: Instruction, indices: &[usize]) -> Instruction { - for &i in indices { - ix.accounts[i].is_signer = true; +fn refund_instruction(fixture: &Fixture) -> Instruction { + RefundInstruction { + contributor: fixture.contributor, + maker: fixture.maker, + fundraiser: fixture.fundraiser, + contributor_account: fixture.contributor_account, + contributor_ta: fixture.contributor_ta, + vault: fixture.vault, + token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, } - ix + .into() +} + +fn fundraiser_error(error: QuasarTokenFundraiserError) -> ProgramError { + ProgramError::Custom(error as u32) +} + +fn framework_error(error: QuasarError) -> ProgramError { + ProgramError::Custom(error as u32) } #[test] -fn test_initialize() { +fn test_initialize_records_state_and_clock_time() { let mut svm = setup(); + let fixture = fixture(); + + initialize_fundraiser(&mut svm, &fixture); + + let state = parse_fundraiser(&svm.get_account(&fixture.fundraiser).unwrap().data); + assert_eq!(state.maker, fixture.maker); + assert_eq!(state.mint_to_raise, fixture.mint); + assert_eq!(state.vault, fixture.vault); + assert_eq!(state.amount_to_raise, TARGET_AMOUNT); + assert_eq!(state.current_amount, 0); + assert_eq!(state.time_started, START_TIME); + assert_eq!(state.duration, DURATION_DAYS); + let (_, expected_bump) = find_fundraiser(&fixture.maker); + assert_eq!(state.bump, expected_bump); + + assert_eq!(token_balance(&svm, &fixture.vault), 0); +} - let maker = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let vault = Pubkey::new_unique(); - let (fundraiser_pda, _) = - Pubkey::find_program_address(&[b"fundraiser", maker.as_ref()], &crate::ID); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - - let data = build_init_data(10_000, 30); - - let instruction = with_signers( - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(maker.into(), true), - solana_instruction::AccountMeta::new_readonly(mint_addr.into(), false), - solana_instruction::AccountMeta::new(fundraiser_pda.into(), false), - solana_instruction::AccountMeta::new(vault.into(), false), - solana_instruction::AccountMeta::new_readonly(rent.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }, - &[3], // vault as signer for create_account CPI +#[test] +fn test_initialize_rejects_zero_amount() { + let mut svm = setup(); + let fixture = fixture(); + + let result = svm.process_instruction( + &initialize_instruction(&fixture, 0, DURATION_DAYS), + &initialize_accounts(&fixture), ); + result.assert_error(fundraiser_error(QuasarTokenFundraiserError::InvalidAmount)); +} + +#[test] +fn test_initialize_rejects_zero_duration() { + let mut svm = setup(); + let fixture = fixture(); let result = svm.process_instruction( - &instruction, - &[ - signer(maker), - mint(mint_addr, maker), - empty(fundraiser_pda), - empty(vault), - ], + &initialize_instruction(&fixture, TARGET_AMOUNT, 0), + &initialize_accounts(&fixture), ); + result.assert_error(fundraiser_error( + QuasarTokenFundraiserError::InvalidDuration, + )); +} + +#[test] +fn test_contribute_creates_contributor_account_and_moves_tokens() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); + assert_eq!(token_balance(&svm, &fixture.vault), PARTIAL_CONTRIBUTION); + assert_eq!( + token_balance(&svm, &fixture.contributor_ta), + CONTRIBUTOR_STARTING_BALANCE - PARTIAL_CONTRIBUTION + ); + + let fundraiser_state = parse_fundraiser(&svm.get_account(&fixture.fundraiser).unwrap().data); + assert_eq!(fundraiser_state.current_amount, PARTIAL_CONTRIBUTION); + + let contributor_state = + parse_contributor(&svm.get_account(&fixture.contributor_account).unwrap().data); + assert_eq!(contributor_state.amount, PARTIAL_CONTRIBUTION); + let (_, expected_bump) = find_contributor_account(&fixture.fundraiser, &fixture.contributor); + assert_eq!(contributor_state.bump, expected_bump); } #[test] -fn test_contribute() { +fn test_contribute_accumulates_across_calls() { let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); + + // Second contribution reuses the contributor account created by the + // first; everything already lives in the SVM database. + let result = + svm.process_instruction(&contribute_instruction(&fixture, PARTIAL_CONTRIBUTION), &[]); + result.assert_success(); + + let expected_total = PARTIAL_CONTRIBUTION * 2; + assert_eq!(token_balance(&svm, &fixture.vault), expected_total); + let contributor_state = + parse_contributor(&svm.get_account(&fixture.contributor_account).unwrap().data); + assert_eq!(contributor_state.amount, expected_total); + let fundraiser_state = parse_fundraiser(&svm.get_account(&fixture.fundraiser).unwrap().data); + assert_eq!(fundraiser_state.current_amount, expected_total); +} - let contributor = Pubkey::new_unique(); - let maker = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let contributor_ta = Pubkey::new_unique(); - let vault_ta = Pubkey::new_unique(); - let contributor_acct = Pubkey::new_unique(); - let (fundraiser_pda, fundraiser_bump) = - Pubkey::find_program_address(&[b"fundraiser", maker.as_ref()], &crate::ID); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - - let amount = 500u64; - let data = build_contribute_data(amount); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(contributor.into(), true), - solana_instruction::AccountMeta::new(fundraiser_pda.into(), false), - solana_instruction::AccountMeta::new(contributor_acct.into(), false), - solana_instruction::AccountMeta::new(contributor_ta.into(), false), - solana_instruction::AccountMeta::new(vault_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; +#[test] +fn test_contribute_rejected_after_deadline() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + + svm.warp_to_timestamp(DEADLINE); let result = svm.process_instruction( - &instruction, - &[ - signer(contributor), - fundraiser_account(fundraiser_pda, maker, mint_addr, 10_000, 0, fundraiser_bump), - contributor_account(contributor_acct, 0), - token(contributor_ta, mint_addr, contributor, 100_000), - token(vault_ta, mint_addr, fundraiser_pda, 0), - ], + &contribute_instruction(&fixture, PARTIAL_CONTRIBUTION), + &first_contribution_accounts(&fixture), ); + result.assert_error(fundraiser_error( + QuasarTokenFundraiserError::FundraiserEnded, + )); +} - assert!(result.is_ok(), "contribute failed: {:?}", result.raw_result); - println!(" CONTRIBUTE CU: {}", result.compute_units_consumed); +#[test] +fn test_contribute_allowed_just_before_deadline() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + + svm.warp_to_timestamp(DEADLINE - 1); + + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); + assert_eq!(token_balance(&svm, &fixture.vault), PARTIAL_CONTRIBUTION); } #[test] -fn test_check_contributions() { +fn test_contribute_rejects_vault_not_bound_to_fundraiser() { let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); - let maker = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let vault_ta = Pubkey::new_unique(); - let maker_ta = Pubkey::new_unique(); - let (fundraiser_pda, fundraiser_bump) = - Pubkey::find_program_address(&[b"fundraiser", maker.as_ref()], &crate::ID); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - - let data = build_check_data(); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(maker.into(), true), - solana_instruction::AccountMeta::new(fundraiser_pda.into(), false), - solana_instruction::AccountMeta::new(vault_ta.into(), false), - solana_instruction::AccountMeta::new(maker_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; + // The attacker tries to credit the fundraiser while depositing into a + // decoy token account instead of the fundraiser's stored vault. + let decoy_vault = Pubkey::new_unique(); + let mut accounts = first_contribution_accounts(&fixture); + accounts.push(token(decoy_vault, fixture.mint, fixture.fundraiser, 0)); - // Target was 10_000, current is 10_000 — should succeed - let result = svm.process_instruction( - &instruction, - &[ - signer(maker), - fundraiser_account(fundraiser_pda, maker, mint_addr, 10_000, 10_000, fundraiser_bump), - token(vault_ta, mint_addr, fundraiser_pda, 10_000), - token(maker_ta, mint_addr, maker, 0), - ], + let mut instruction = contribute_instruction(&fixture, PARTIAL_CONTRIBUTION); + // Account index 5 is the vault (see ContributeInstruction ordering). + instruction.accounts[5].pubkey = decoy_vault; + + let result = svm.process_instruction(&instruction, &accounts); + result.assert_error(framework_error(QuasarError::HasOneMismatch)); +} + +#[test] +fn test_refund_returns_tokens_after_failed_fundraiser() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); + + svm.warp_to_timestamp(DEADLINE); + + let result = svm.process_instruction(&refund_instruction(&fixture), &[]); + result.assert_success(); + + assert_eq!(token_balance(&svm, &fixture.vault), 0); + assert_eq!( + token_balance(&svm, &fixture.contributor_ta), + CONTRIBUTOR_STARTING_BALANCE ); + let fundraiser_state = parse_fundraiser(&svm.get_account(&fixture.fundraiser).unwrap().data); + assert_eq!(fundraiser_state.current_amount, 0); - assert!(result.is_ok(), "check_contributions failed: {:?}", result.raw_result); - println!(" CHECK CONTRIBUTIONS CU: {}", result.compute_units_consumed); + // The contributor account was closed and its rent returned. + let closed = svm.get_account(&fixture.contributor_account).unwrap(); + assert_eq!(closed.lamports, 0, "contributor account rent reclaimed"); } #[test] -fn test_refund() { +fn test_refund_rejected_before_deadline() { let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); - let contributor = Pubkey::new_unique(); - let maker = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let contributor_ta = Pubkey::new_unique(); - let vault_ta = Pubkey::new_unique(); - let contributor_acct = Pubkey::new_unique(); - let (fundraiser_pda, fundraiser_bump) = - Pubkey::find_program_address(&[b"fundraiser", maker.as_ref()], &crate::ID); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - - let refund_amount = 500u64; - let data = build_refund_data(); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(contributor.into(), true), - solana_instruction::AccountMeta::new_readonly(maker.into(), false), - solana_instruction::AccountMeta::new(fundraiser_pda.into(), false), - solana_instruction::AccountMeta::new(contributor_acct.into(), false), - solana_instruction::AccountMeta::new(contributor_ta.into(), false), - solana_instruction::AccountMeta::new(vault_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; + svm.warp_to_timestamp(DEADLINE - 1); + + let result = svm.process_instruction(&refund_instruction(&fixture), &[]); + result.assert_error(fundraiser_error( + QuasarTokenFundraiserError::FundraiserNotEnded, + )); +} + +#[test] +fn test_refund_rejected_when_target_met() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, TARGET_AMOUNT); + + svm.warp_to_timestamp(DEADLINE); + + let result = svm.process_instruction(&refund_instruction(&fixture), &[]); + result.assert_error(fundraiser_error(QuasarTokenFundraiserError::TargetMet)); +} + +#[test] +fn test_refund_rejects_another_contributors_account() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); + + svm.warp_to_timestamp(DEADLINE); + + // The attacker signs as themselves but passes the victim's contributor + // record and their own token account, trying to drain the vault. + let attacker = Pubkey::new_unique(); + let attacker_ta = Pubkey::new_unique(); + let mut instruction = refund_instruction(&fixture); + // Account indices follow RefundInstruction ordering: + // 0 contributor (signer), 3 contributor_account, 4 contributor_ta. + instruction.accounts[0].pubkey = attacker; + instruction.accounts[4].pubkey = attacker_ta; let result = svm.process_instruction( &instruction, &[ - signer(contributor), - signer(maker), - fundraiser_account(fundraiser_pda, maker, mint_addr, 10_000, refund_amount, fundraiser_bump), - contributor_account(contributor_acct, refund_amount), - token(contributor_ta, mint_addr, contributor, 0), - token(vault_ta, mint_addr, fundraiser_pda, refund_amount), + signer(attacker), + token(attacker_ta, fixture.mint, attacker, 0), ], ); + // The contributor_account PDA check derives ["contributor", fundraiser, + // attacker], which does not match the victim's record. + result.assert_error(framework_error(QuasarError::InvalidPda)); + // The vault still holds the victim's contribution. + assert_eq!(token_balance(&svm, &fixture.vault), PARTIAL_CONTRIBUTION); +} + +#[test] +fn test_check_contributions_pays_maker_when_target_met() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, TARGET_AMOUNT); + + let maker_ta = Pubkey::new_unique(); + let instruction: Instruction = CheckContributionsInstruction { + maker: fixture.maker, + fundraiser: fixture.fundraiser, + vault: fixture.vault, + maker_ta, + token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, + } + .into(); + + let result = + svm.process_instruction(&instruction, &[token(maker_ta, fixture.mint, fixture.maker, 0)]); + result.assert_success(); + + assert_eq!(token_balance(&svm, &maker_ta), TARGET_AMOUNT); + // The vault and fundraiser accounts were closed. + assert_eq!(svm.get_account(&fixture.vault).unwrap().lamports, 0); + assert_eq!(svm.get_account(&fixture.fundraiser).unwrap().lamports, 0); +} + +#[test] +fn test_check_contributions_rejected_below_target() { + let mut svm = setup(); + let fixture = fixture(); + initialize_fundraiser(&mut svm, &fixture); + contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); + + let maker_ta = Pubkey::new_unique(); + let instruction: Instruction = CheckContributionsInstruction { + maker: fixture.maker, + fundraiser: fixture.fundraiser, + vault: fixture.vault, + maker_ta, + token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, + } + .into(); - assert!(result.is_ok(), "refund failed: {:?}", result.raw_result); - println!(" REFUND CU: {}", result.compute_units_consumed); + let result = + svm.process_instruction(&instruction, &[token(maker_ta, fixture.mint, fixture.maker, 0)]); + result.assert_error(fundraiser_error(QuasarTokenFundraiserError::TargetNotMet)); } diff --git a/finance/token-swap/README.md b/finance/token-swap/README.md index 4fd1e13e..79e0e537 100644 --- a/finance/token-swap/README.md +++ b/finance/token-swap/README.md @@ -1,6 +1,6 @@ # Token Swap (AMM) -A Constant Product [Automated Market Maker (AMM)](https://www.investopedia.com/terms/a/automated-market-maker-amm.asp) in [Anchor](https://solana.com/docs/terminology#anchor) — the model popularized by Uniswap V2. +A Constant Product [Automated Market Maker (AMM)](https://www.investopedia.com/terms/a/automated-market-maker-amm.asp) in [Anchor](https://solana.com/docs/terminology#anchor) - the model popularized by Uniswap V2. The pool keeps `x * y = K` invariant: if `x` is the reserve of token A and `y` is the reserve of token B, then `x * y` stays constant for a given [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) quantity. @@ -27,14 +27,14 @@ Other bonding-curve designs exist: - **Uniswap V3 Concentrated Liquidity AMM (CLAMM):** splits the curve into buckets; LPs supply liquidity to specific price ranges. - **Trader Joe CLAMM:** like Uniswap V3, but each bucket is a CSAMM. -A CPAMM is the simplest and the cheapest to keep in [account](https://solana.com/docs/terminology#account) state — one pool, one [mint](https://solana.com/docs/terminology#token-mint), easy to reason about. That's what this example implements. +A CPAMM is the simplest and the cheapest to keep in [account](https://solana.com/docs/terminology#account) state - one pool, one [mint](https://solana.com/docs/terminology#token-mint), easy to reason about. That's what this example implements. ## Design Requirements: - **Fee distribution.** Every pool charges a trading fee, paid in the traded token, that rewards [liquidity providers (LPs)](https://www.investopedia.com/terms/l/liquidity-provider.asp). To stay consistent across pools, the fee is shared. -- **Single pool per asset pair.** Avoids liquidity fragmentation. Without a single canonical pool per pair, a [decentralised exchange (DEX)](https://www.investopedia.com/terms/d/decentralized-exchange-dex.asp) would fragment volume across multiple pools, widening spreads — the same problem that motivated the shift away from [order books](https://www.investopedia.com/terms/o/order-book.asp) onchain. +- **Single pool per asset pair.** Avoids liquidity fragmentation. Without a single canonical pool per pair, a [decentralised exchange (DEX)](https://www.investopedia.com/terms/d/decentralized-exchange-dex.asp) would fragment volume across multiple pools, widening spreads - the same problem that motivated the shift away from [order books](https://www.investopedia.com/terms/o/order-book.asp) onchain. - **LP accounting.** The program tracks each LP's deposits. Implementation choices: @@ -74,21 +74,21 @@ programs/token-swap/src/ ### `Config` -Shared configuration for the AMM. **Singleton** — one per deployed program, at PDA seeds `[b"config"]`. +Shared configuration for the AMM. **Singleton** - one per deployed program, at PDA seeds `[b"config"]`. -- `admin: Pubkey` — the admin authority. Only this address can call `claim_admin_fees`. -- `fee: u16` — total trading fee in [basis points (bps)](https://www.investopedia.com/terms/b/basispoint.asp) (must be < 10000). Split between LPs and the admin according to `admin_share_bps`. -- `admin_share_bps: u16` — fraction of the trading fee that goes to the admin, in basis points (must be < 10000). The remainder goes to LPs. Modelled on Uniswap V2 / Raydium: the AMM operator takes a slice of every fee, LPs keep the rest. +- `admin: Pubkey` - the admin authority. Only this address can call `claim_admin_fees`. +- `fee: u16` - total trading fee in [basis points (bps)](https://www.investopedia.com/terms/b/basispoint.asp) (must be < 10000). Split between LPs and the admin according to `admin_share_bps`. +- `admin_share_bps: u16` - fraction of the trading fee that goes to the admin, in basis points (must be < 10000). The remainder goes to LPs. Modelled on Uniswap V2 / Raydium: the AMM operator takes a slice of every fee, LPs keep the rest. ### `PoolConfig` -Per-pool configuration / identity record. Identifies a single pool by which `Config` it belongs to and which two mints it trades, and tracks the admin's accumulated trading-fee claim for each side. The actual pool reserves live in separate token accounts (`pool_a`, `pool_b`) owned by `pool_authority` — they are *not* stored here. +Per-pool configuration / identity record. Identifies a single pool by which `Config` it belongs to and which two mints it trades, and tracks the admin's accumulated trading-fee claim for each side. The actual pool reserves live in separate token accounts (`pool_a`, `pool_b`) owned by `pool_authority` - they are *not* stored here. -- `config: Pubkey` — the parent `Config` account. -- `mint_a: Pubkey` — mint of token A. -- `mint_b: Pubkey` — mint of token B. -- `admin_fees_owed_a: u64` — admin's accumulated fee claim on token A, in base units. Sits physically in `pool_a` but is excluded from the LP curve and from LP-withdrawable amounts. Swept by `claim_admin_fees`. -- `admin_fees_owed_b: u64` — same for token B. +- `config: Pubkey` - the parent `Config` account. +- `mint_a: Pubkey` - mint of token A. +- `mint_b: Pubkey` - mint of token B. +- `admin_fees_owed_a: u64` - admin's accumulated fee claim on token A, in base units. Sits physically in `pool_a` but is excluded from the LP curve and from LP-withdrawable amounts. Swept by `claim_admin_fees`. +- `admin_fees_owed_b: u64` - same for token B. The admin's fees are tracked as *virtual* claims on the existing `pool_a` / `pool_b` reserves rather than as separate vaults. LP-facing math uses **effective reserves** = `pool_X.amount - admin_fees_owed_X` so the admin's owed slice doesn't grow LP [yield](https://www.investopedia.com/terms/y/yield.asp). @@ -106,17 +106,17 @@ Initializes a `PoolConfig` account, an LP mint (`liquidity_provider_mint`), and ### `deposit_liquidity` -Transfers token A and token B from the depositor to the pool, then mints LP tokens to the depositor. `amount_a` and `amount_b` are treated as **upper bounds** — the caller's maximum willingness on each side. The contract clamps both numbers down to the largest pair that lies on the current price line, then pulls exactly that pair. `minimum_lp_tokens_out` is the caller's **lower bound** on what they're willing to receive in LP tokens; the handler reverts with `DepositBelowMinimum` if the post-clamp LP mint amount falls below it. Pass `0` to opt out (any non-zero mint is acceptable). +Transfers token A and token B from the depositor to the pool, then mints LP tokens to the depositor. `amount_a` and `amount_b` are treated as **upper bounds** - the caller's maximum willingness on each side. The contract clamps both numbers down to the largest pair that lies on the current price line, then pulls exactly that pair. `minimum_lp_tokens_out` is the caller's **lower bound** on what they're willing to receive in LP tokens; the handler reverts with `DepositBelowMinimum` if the post-clamp LP mint amount falls below it. Pass `0` to opt out (any non-zero mint is acceptable). -- For the first deposit, both amounts are used as-is and the LP amount is `sqrt(amount_a * amount_b)` — computed with a `u128` integer-sqrt (Newton's method), no floats — with `MINIMUM_LIQUIDITY` locked away forever (to prevent the empty-pool edge case). No admin fees can be owed yet, so this case is unchanged by the admin-fee mechanism. +- For the first deposit, both amounts are used as-is and the LP amount is `sqrt(amount_a * amount_b)` - computed with a `u128` integer-sqrt (Newton's method), no floats - with `MINIMUM_LIQUIDITY` locked away forever (to prevent the empty-pool edge case). No admin fees can be owed yet, so this case is unchanged by the admin-fee mechanism. - For later deposits, the amounts are clamped to the current pool ratio (Uniswap V2's `mint()` pattern): 1. Compute `amount_b_required = amount_a * effective_pool_b / effective_pool_a`. - 2. If `amount_b_required ≤ amount_b`, use `(amount_a, amount_b_required)` — the depositor offered enough B, so we take the full A and clamp B down. - 3. Otherwise, compute `amount_a_required = amount_b * effective_pool_a / effective_pool_b` and use `(amount_a_required, amount_b)` — B is the binding side, so we take the full B and clamp A down. + 2. If `amount_b_required ≤ amount_b`, use `(amount_a, amount_b_required)` - the depositor offered enough B, so we take the full A and clamp B down. + 3. Otherwise, compute `amount_a_required = amount_b * effective_pool_a / effective_pool_b` and use `(amount_a_required, amount_b)` - B is the binding side, so we take the full B and clamp A down. - All ratio math runs in `u128` with checked arithmetic. No floats are used for money; rounding is always toward the pool (the depositor never gets a sub-base-unit advantage). - The ratio is computed on the **effective reserves** (`pool_X.amount - admin_fees_owed_X`). The admin's owed slice isn't LP-claimable capital, so it doesn't shift the deposit ratio. - If the clamp rounds one of the amounts down to zero (e.g. a depositor offering a sub-base-unit fraction against a thick pool), the handler reverts with `DepositAmountTooSmall` rather than minting LP shares against a zero contribution. -- If the computed LP-token amount falls below `minimum_lp_tokens_out`, the handler reverts with `DepositBelowMinimum`. This is the depositor's slippage guard for cases where the pool ratio shifted between off-chain quote time and tx landing. +- If the computed LP-token amount falls below `minimum_lp_tokens_out`, the handler reverts with `DepositBelowMinimum`. This is the depositor's slippage guard for cases where the pool ratio shifted between offchain quote time and tx landing. ### `swap_tokens` @@ -124,17 +124,17 @@ Swaps a fixed `input_amount` of one token for as much of the other as possible ( - The total trading fee is taken off the input first: `fee_amount = input * fee / 10_000`. - The fee is split between LPs and the admin: - - `admin_portion = fee_amount * admin_share_bps / 10_000` — accumulates as a virtual claim on the input-side reserve (`admin_fees_owed_a` or `admin_fees_owed_b`). Not transferred immediately, swept later by `claim_admin_fees`. Saves a CPI per swap. - - `lp_portion = fee_amount - admin_portion` — stays physically in the reserves and boosts LP yield ("less output for the same input"). + - `admin_portion = fee_amount * admin_share_bps / 10_000` - accumulates as a virtual claim on the input-side reserve (`admin_fees_owed_a` or `admin_fees_owed_b`). Not transferred immediately, swept later by `claim_admin_fees`. Saves a CPI per swap. + - `lp_portion = fee_amount - admin_portion` - stays physically in the reserves and boosts LP yield ("less output for the same input"). - `taxed_input = input - fee_amount` is what enters the curve. - The output is computed against the **effective reserves** (`pool_X.amount - admin_fees_owed_X`), so the admin's outstanding fees do not contribute to the price. The curve math runs in `u128` with checked arithmetic, multiplying before dividing to keep precision; floor rounding favours the pool (Uniswap V2 convention). -- The [price impact](https://www.investopedia.com/terms/p/price-impact.asp) of a swap — the difference between the quoted mid-price and the effective execution price — is determined by the size of the trade relative to the pool's effective reserves. Larger trades move the curve further, resulting in higher price impact. +- The [price impact](https://www.investopedia.com/terms/p/price-impact.asp) of a swap - the difference between the quoted mid-price and the effective execution price - is determined by the size of the trade relative to the pool's effective reserves. Larger trades move the curve further, resulting in higher price impact. - If `output < min_output_amount`, the handler reverts with `SlippageExceeded`. This is the trader's slippage guard for cases where the pool shifted between quote time and tx landing. - After the transfers, the handler reloads the pool accounts and re-verifies that `effective_pool_a * effective_pool_b` is at least as high as before the trade. This is defence in depth: if the curve math were ever wrong in a way that gave the trader too much, the invariant check would fail and revert the trade. Reverts with `InvariantViolated`. ### `withdraw_liquidity` -Burns LP tokens and returns a proportional share of the **effective reserves** (`pool_X.amount - admin_fees_owed_X`) to the LP. The proportion is `amount / (liquidity_provider_mint.supply + MINIMUM_LIQUIDITY)`. The admin's owed slice physically remains in the vaults but is not distributed to exiting LPs — it's claimed separately via `claim_admin_fees`. All math is `u128` with checked arithmetic, multiplying before dividing; floor rounding leaves sub-base-unit dust with the pool (grows LP value for everyone still in). +Burns LP tokens and returns a proportional share of the **effective reserves** (`pool_X.amount - admin_fees_owed_X`) to the LP. The proportion is `amount / (liquidity_provider_mint.supply + MINIMUM_LIQUIDITY)`. The admin's owed slice physically remains in the vaults but is not distributed to exiting LPs - it's claimed separately via `claim_admin_fees`. All math is `u128` with checked arithmetic, multiplying before dividing; floor rounding leaves sub-base-unit dust with the pool (grows LP value for everyone still in). - `minimum_token_a_out` and `minimum_token_b_out` are the LP's per-side slippage floors. If either computed amount falls below its floor, the handler reverts with `WithdrawalBelowMinimum` *before* any tokens move. Pass `0` on either side to opt out. This protects LPs from withdrawing during a pool imbalance (e.g. a large swap landed just before this tx and skewed the mix). @@ -143,66 +143,66 @@ Burns LP tokens and returns a proportional share of the **effective reserves** ( Lets the address stored in `Config.admin` sweep their accumulated trading-fee claim out of a pool. Transfers `admin_fees_owed_a` from `pool_a` to the admin's token-A account and `admin_fees_owed_b` from `pool_b` to the admin's token-B account, signed by `pool_authority`. Then resets both accumulators to zero. - Authorisation: enforced by Anchor's `has_one = admin` constraint on `config` plus the `Signer` constraint on `admin`. Calls from any other signer are rejected. -- The admin's token accounts (`admin_token_a`, `admin_token_b`) must already exist — this handler doesn't auto-create them (keeps the example small). +- The admin's token accounts (`admin_token_a`, `admin_token_b`) must already exist - this handler doesn't auto-create them (keeps the example small). - Idempotent: calling again with the accumulators at zero is a successful no-op (transfers are skipped when owed = 0). ## Program flow: Alice, Bob, Carol, and Dave A worked example, end to end, using this program. The example uses three tokens: -- **NVDAx** — an NVIDIA share (xStock), priced at ~5 USDC offchain. -- **TSLAx** — a Tesla share (xStock), priced at ~180 USDC offchain. -- **USDC** — a USD-pegged [stablecoin](https://www.investopedia.com/terms/s/stablecoin.asp) used as the quote currency in both pools. +- **NVDAx** - an NVIDIA share (xStock), priced at ~5 USDC offchain. +- **TSLAx** - a Tesla share (xStock), priced at ~180 USDC offchain. +- **USDC** - a USD-pegged [stablecoin](https://www.investopedia.com/terms/s/stablecoin.asp) used as the quote currency in both pools. **Cast:** -- **Alice** — AMM operator. Deploys and runs the exchange. Earns a slice of every trading fee via the admin protocol-fee mechanism; also earns LP [yield](https://www.investopedia.com/terms/y/yield.asp) on her own initial deposits. Wants real usage so fee income compounds. She calls `create_config` to fix the trading fee at 0.3% and sets `admin_share_bps = 1667` so she earns ~1/6 of every trading fee (LPs keep the other ~5/6). She seeds both the NVDAx/USDC pool and the TSLAx/USDC pool herself (eating the locked `MINIMUM_LIQUIDITY` cost) so users have something to trade from day one. -- **Bob** — yield farmer / [liquidity provider](https://www.investopedia.com/terms/l/liquidity-provider.asp). Has idle capital (NVDAx and USDC) earning nothing. Wants to earn [passive income](https://www.investopedia.com/terms/p/passiveincome.asp) from the swap fees the pool collects, without actively trading. -- **Carol** — retail trader. Holds USDC and has a bullish [thesis](https://www.investopedia.com/terms/i/investmentthesis.asp) on NVIDIA: she believes NVDAx will appreciate. She wants to swap USDC for NVDAx quickly, without a centralised exchange account. She also later buys TSLAx on the TSLAx/USDC pool. -- **Dave** — [arbitrageur](https://www.investopedia.com/terms/a/arbitrage.asp). Profits by trading the gap between the pool's mid-price and the offchain market price. Side effect: his trades drag the pool price back toward fair value. +- **Alice** - AMM operator. Deploys and runs the exchange. Earns a slice of every trading fee via the admin protocol-fee mechanism; also earns LP [yield](https://www.investopedia.com/terms/y/yield.asp) on her own initial deposits. Wants real usage so fee income compounds. She calls `create_config` to fix the trading fee at 0.3% and sets `admin_share_bps = 1667` so she earns ~1/6 of every trading fee (LPs keep the other ~5/6). She seeds both the NVDAx/USDC pool and the TSLAx/USDC pool herself (eating the locked `MINIMUM_LIQUIDITY` cost) so users have something to trade from day one. +- **Bob** - yield farmer / [liquidity provider](https://www.investopedia.com/terms/l/liquidity-provider.asp). Has idle capital (NVDAx and USDC) earning nothing. Wants to earn [passive income](https://www.investopedia.com/terms/p/passiveincome.asp) from the swap fees the pool collects, without actively trading. +- **Carol** - retail trader. Holds USDC and has a bullish [thesis](https://www.investopedia.com/terms/i/investmentthesis.asp) on NVIDIA: she believes NVDAx will appreciate. She wants to swap USDC for NVDAx quickly, without a centralised exchange account. She also later buys TSLAx on the TSLAx/USDC pool. +- **Dave** - [arbitrageur](https://www.investopedia.com/terms/a/arbitrage.asp). Profits by trading the gap between the pool's mid-price and the offchain market price. Side effect: his trades drag the pool price back toward fair value. -### Step 1 — Alice creates the `Config` +### Step 1 - Alice creates the `Config` The singleton `Config` account is set once per deployed program. Every pool inherits its `fee` and `admin_share_bps`. - **Handler:** `create_config` - **Accounts (`CreateConfigAccounts`):** - - `config` (PDA, created) — seeds `[b"config"]`; stores `admin`, `fee`, `admin_share_bps`, `bump` + - `config` (PDA, created) - seeds `[b"config"]`; stores `admin`, `fee`, `admin_share_bps`, `bump` - `admin` = Alice - `payer` = Alice - `system_program` -- **Args:** `fee = 30` (0.3%), `admin_share_bps = 1667` (Uniswap V2's classic 1/6 default — Alice keeps 1/6 of the trading fee; LPs keep 5/6) +- **Args:** `fee = 30` (0.3%), `admin_share_bps = 1667` (Uniswap V2's classic 1/6 default - Alice keeps 1/6 of the trading fee; LPs keep 5/6) `Config` exists. No pools yet, no liquidity yet. -### Step 2 — Alice creates the NVDAx/USDC pool +### Step 2 - Alice creates the NVDAx/USDC pool - **Handler:** `create_pool` - **Accounts (`CreatePoolAccounts`):** - - `config` — Alice's `Config` - - `pool_config` (PDA, created) — seeds `[config, mint_a, mint_b]`; stores `config`, `mint_a`, `mint_b`, `bump` - - `pool_authority` (PDA) — signs for the pool reserves - - `liquidity_provider_mint` (created) — the LP-token mint, authority = `pool_authority` + - `config` - Alice's `Config` + - `pool_config` (PDA, created) - seeds `[config, mint_a, mint_b]`; stores `config`, `mint_a`, `mint_b`, `bump` + - `pool_authority` (PDA) - signs for the pool reserves + - `liquidity_provider_mint` (created) - the LP-token mint, authority = `pool_authority` - `mint_a` = NVDAx mint, `mint_b` = USDC mint (with `mint_a < mint_b`) - - `pool_a`, `pool_b` (created, ATAs owned by `pool_authority`) — the NVDAx and USDC reserves + - `pool_a`, `pool_b` (created, ATAs owned by `pool_authority`) - the NVDAx and USDC reserves - `payer` = Alice - token, ATA, system programs - **Args:** none NVDAx/USDC pool exists; reserves are empty. No one can swap yet. -### Step 2b — Alice creates the TSLAx/USDC pool +### Step 2b - Alice creates the TSLAx/USDC pool Alice immediately creates a second pool for TSLAx (Tesla xStock, ~180 USDC each). The handler and account shape are identical to Step 2; only the mints differ. - **Handler:** `create_pool` - **Accounts (`CreatePoolAccounts`):** - - `config` — Alice's `Config` (same singleton) - - `pool_config` (PDA, created) — seeds `[config, mint_a, mint_b]`; stores `config`, `mint_a` = TSLAx mint, `mint_b` = USDC mint, `bump` - - `pool_authority` (PDA) — signs for this pool's reserves - - `liquidity_provider_mint` (created) — a separate LP-token mint for this pool + - `config` - Alice's `Config` (same singleton) + - `pool_config` (PDA, created) - seeds `[config, mint_a, mint_b]`; stores `config`, `mint_a` = TSLAx mint, `mint_b` = USDC mint, `bump` + - `pool_authority` (PDA) - signs for this pool's reserves + - `liquidity_provider_mint` (created) - a separate LP-token mint for this pool - `mint_a` = TSLAx mint, `mint_b` = USDC mint (with `mint_a < mint_b`) - - `pool_a`, `pool_b` (created, ATAs owned by `pool_authority`) — the TSLAx and USDC reserves + - `pool_a`, `pool_b` (created, ATAs owned by `pool_authority`) - the TSLAx and USDC reserves - `payer` = Alice - token, ATA, system programs - **Args:** none @@ -213,7 +213,7 @@ Alice seeds the TSLAx/USDC pool with **1 TSLAx and 180 USDC** (a 1:180 ratio mat TSLAx/USDC pool state: **1 TSLAx, 180 USDC**. Mid-price = 180. Alice owns 100% of withdrawable LP supply on this pool. -### Step 3 — Alice seeds initial liquidity in the NVDAx/USDC pool +### Step 3 - Alice seeds initial liquidity in the NVDAx/USDC pool Alice picks a 1:5 ratio so the NVDAx/USDC pool launches at ~5 USDC per NVDAx. She deposits **20 NVDAx and 100 USDC**. @@ -223,42 +223,42 @@ Alice picks a 1:5 ratio so the NVDAx/USDC pool launches at ~5 USDC per NVDAx. Sh - `depositor` = Alice (signer) - `mint_a`, `mint_b` - `pool_a`, `pool_b` (the pool's reserves) - - `liquidity_provider_token` — Alice's LP-token ATA (created) - - `token_a` — Alice's NVDAx ATA, `token_b` — Alice's USDC ATA + - `liquidity_provider_token` - Alice's LP-token ATA (created) + - `token_a` - Alice's NVDAx ATA, `token_b` - Alice's USDC ATA - `payer` = Alice - token, ATA, system programs -- **Args:** `amount_a = 20`, `amount_b = 100`, `minimum_lp_tokens_out = 0` (initial deposit — Alice is the only LP, no slippage risk; production code should still set a floor to guard against frontrun pool-creations) +- **Args:** `amount_a = 20`, `amount_b = 100`, `minimum_lp_tokens_out = 0` (initial deposit - Alice is the only LP, no slippage risk; production code should still set a floor to guard against frontrun pool-creations) Math: - LP tokens minted on the first deposit: `sqrt(20 × 100) = sqrt(2000) ≈ 44.72`. -- Minus the locked `MINIMUM_LIQUIDITY = 100` floor (base units — negligible at major-unit scale). +- Minus the locked `MINIMUM_LIQUIDITY = 100` floor (base units - negligible at major-unit scale). - Alice receives ~44.72 LP tokens. The 100 base-unit dust is locked forever, owned by no one. Alice eats that cost as the price of bootstrapping. NVDAx/USDC pool state: **20 NVDAx, 100 USDC**. Mid-price = 5. Alice owns 100% of withdrawable LP supply on this pool. -### Step 4 — Bob adds liquidity +### Step 4 - Bob adds liquidity At the current 1:5 ratio, Bob deposits **100 NVDAx and 500 USDC**. - **Handler:** `deposit_liquidity` - **Accounts:** same shape as Step 3, `depositor` = Bob -- **Args:** `amount_a = 100`, `amount_b = 500`, `minimum_lp_tokens_out = 223_000_000` (Bob quoted ~223.6 LP off-chain and is unwilling to accept less than ~223.0 if the pool shifts before his tx lands; units here are LP base units at the LP mint's decimals) +- **Args:** `amount_a = 100`, `amount_b = 500`, `minimum_lp_tokens_out = 223_000_000` (Bob quoted ~223.6 LP offchain and is unwilling to accept less than ~223.0 if the pool shifts before his tx lands; units here are LP base units at the LP mint's decimals) Math: subsequent deposits get `min(amount_a / pool_a, amount_b / pool_b) × current_lp_supply = min(100/20, 500/100) × 44.72 ≈ 223.6` LP tokens. NVDAx/USDC pool state: **120 NVDAx, 600 USDC**. LP supply ~268.32. Bob owns ~83%, Alice ~17%. -### Step 5 — Carol buys NVDAx with USDC +### Step 5 - Carol buys NVDAx with USDC - **Handler:** `swap_tokens` - **Accounts (`SwapTokensAccounts`):** - - `config` — for the fee + - `config` - for the fee - `pool_config`, `pool_authority` - `trader` = Carol (signer) - `mint_a`, `mint_b` - `pool_a`, `pool_b` (the pool's reserves) - - `token_a` — Carol's NVDAx ATA (created if missing), `token_b` — Carol's USDC ATA + - `token_a` - Carol's NVDAx ATA (created if missing), `token_b` - Carol's USDC ATA - `payer` = Carol - token, ATA, system programs - **Args:** `input_is_token_a = false` (input is token B = USDC), `input_amount = 11`, `min_output_amount = 1.9` @@ -267,19 +267,19 @@ Math (constant product, 0.3% fee from `Config.fee`, fee split per `Config.admin_ - Total fee on the input: `11 × 0.003 = 0.033 USDC`. - Fee split: - - Admin slice (`admin_share_bps = 1667`): `0.033 × 0.1667 ≈ 0.0055 USDC` — added to `admin_fees_owed_b`. - - LP slice: `0.033 − 0.0055 ≈ 0.0275 USDC` — stays in the reserves, boosts LP yield. + - Admin slice (`admin_share_bps = 1667`): `0.033 × 0.1667 ≈ 0.0055 USDC` - added to `admin_fees_owed_b`. + - LP slice: `0.033 − 0.0055 ≈ 0.0275 USDC` - stays in the reserves, boosts LP yield. - Input into the curve: `11 − 0.033 = 10.967 USDC`. - Effective reserves before the trade: `effective_pool_a = 120`, `effective_pool_b = 600` (admin owes nothing yet). - New effective B: `600 + 10.967 = 610.967` (raw `pool_b.amount` is `611`, minus the new admin slice `0.0055`). - New effective A: `(120 × 600) / 610.967 ≈ 117.844`. - NVDAx out: `120 − 117.844 ≈ 2.156`. -Carol gets ~2.156 NVDAx. Effective price ~5.10 USDC/NVDAx — worse than mid-price because of the fee plus her own price impact. +Carol gets ~2.156 NVDAx. Effective price ~5.10 USDC/NVDAx - worse than mid-price because of the fee plus her own price impact. NVDAx/USDC pool state: **117.844 NVDAx, 611 USDC raw** (`admin_fees_owed_a = 0`, `admin_fees_owed_b ≈ 0.0055`). Mid-price on the effective reserves drifted up to ~5.18. -### Step 6 — Dave arbitrages the NVDAx/USDC pool +### Step 6 - Dave arbitrages the NVDAx/USDC pool NVDAx still trades at 5.00 offchain; the NVDAx/USDC pool now says 5.18. There's a profitable trade: buy NVDAx offchain at 5.00, sell it into the pool at ~5.18. Dave does it. @@ -291,8 +291,8 @@ Math: - Total fee on the input: `2.15 × 0.003 ≈ 0.00645 NVDAx`. - Fee split: - - Admin slice: `0.00645 × 0.1667 ≈ 0.001075 NVDAx` — added to `admin_fees_owed_a`. - - LP slice: `≈ 0.005375 NVDAx` — stays in the reserves. + - Admin slice: `0.00645 × 0.1667 ≈ 0.001075 NVDAx` - added to `admin_fees_owed_a`. + - LP slice: `≈ 0.005375 NVDAx` - stays in the reserves. - Input into the curve: `2.15 − 0.00645 ≈ 2.14355 NVDAx`. - Effective reserves before the trade: `effective_pool_a = 117.844` (no A-side admin claim yet), `effective_pool_b ≈ 611 − 0.0055 ≈ 610.9945`. - New effective A: `117.844 + 2.14355 ≈ 119.9876`. @@ -301,21 +301,21 @@ Math: Dave paid ~10.75 USDC offchain for 2.15 NVDAx, sold into the pool for ~10.92 USDC. Profit ~0.17 USDC, minus gas. -NVDAx/USDC pool state: **119.987 NVDAx, 600.07 USDC raw**, with `admin_fees_owed_a ≈ 0.001075` and `admin_fees_owed_b ≈ 0.0055`. Mid-price on the effective reserves back to ~5.00 — *because* that's the price at which Dave's profit hit zero and he stopped. +NVDAx/USDC pool state: **119.987 NVDAx, 600.07 USDC raw**, with `admin_fees_owed_a ≈ 0.001075` and `admin_fees_owed_b ≈ 0.0055`. Mid-price on the effective reserves back to ~5.00 - *because* that's the price at which Dave's profit hit zero and he stopped. -### Step 7 — Carol buys TSLAx with USDC +### Step 7 - Carol buys TSLAx with USDC Separately, Carol decides to add TSLAx exposure on top of her NVDAx purchase. She swaps USDC for TSLAx on the TSLAx/USDC pool Alice created in Step 2b. - **Handler:** `swap_tokens` - **Accounts (`SwapTokensAccounts`):** - - `config` — the same singleton `Config` (fee and admin_share_bps apply to all pools) - - `pool_config` — the TSLAx/USDC `PoolConfig` PDA - - `pool_authority` — the TSLAx/USDC pool authority PDA + - `config` - the same singleton `Config` (fee and admin_share_bps apply to all pools) + - `pool_config` - the TSLAx/USDC `PoolConfig` PDA + - `pool_authority` - the TSLAx/USDC pool authority PDA - `trader` = Carol (signer) - `mint_a` = TSLAx mint, `mint_b` = USDC mint - - `pool_a`, `pool_b` — the TSLAx/USDC reserves (1 TSLAx, 180 USDC after Alice's seed deposit) - - `token_a` — Carol's TSLAx ATA (created if missing), `token_b` — Carol's USDC ATA + - `pool_a`, `pool_b` - the TSLAx/USDC reserves (1 TSLAx, 180 USDC after Alice's seed deposit) + - `token_a` - Carol's TSLAx ATA (created if missing), `token_b` - Carol's USDC ATA - `payer` = Carol - token, ATA, system programs - **Args:** `input_is_token_a = false` (input is token B = USDC), `input_amount = 180`, `min_output_amount = 0.9` @@ -324,39 +324,39 @@ Math (constant product, same 0.3% fee and 1667 bps admin share): - Total fee on the input: `180 × 0.003 = 0.54 USDC`. - Fee split: - - Admin slice: `0.54 × 0.1667 ≈ 0.09 USDC` — added to `admin_fees_owed_b` on this pool. - - LP slice: `0.54 − 0.09 ≈ 0.45 USDC` — stays in the TSLAx/USDC reserves. + - Admin slice: `0.54 × 0.1667 ≈ 0.09 USDC` - added to `admin_fees_owed_b` on this pool. + - LP slice: `0.54 − 0.09 ≈ 0.45 USDC` - stays in the TSLAx/USDC reserves. - Input into the curve: `180 − 0.54 = 179.46 USDC`. - Effective reserves before the trade: `effective_pool_a = 1 TSLAx`, `effective_pool_b = 180 USDC`. - New effective B: `180 + 179.46 = 359.46` (raw `pool_b.amount` ≈ `360`, minus the new admin slice `≈ 0.09`). - New effective A: `(1 × 180) / 359.46 ≈ 0.5008`. - TSLAx out: `1 − 0.5008 ≈ 0.4992`. -Carol gets ~0.4992 TSLAx. The large price impact (~50% of the pool's TSLAx reserve) reflects the shallow pool depth at this early stage — in a real deployment, Alice would seed the pool with more liquidity to reduce price impact for traders of this size. +Carol gets ~0.4992 TSLAx. The large price impact (~50% of the pool's TSLAx reserve) reflects the shallow pool depth at this early stage - in a real deployment, Alice would seed the pool with more liquidity to reduce price impact for traders of this size. TSLAx/USDC pool state: **~0.5008 TSLAx, ~360 USDC raw** (`admin_fees_owed_b ≈ 0.09`). Mid-price on the effective reserves has roughly doubled to ~358 USDC per TSLAx, illustrating why deep liquidity matters for minimising price impact. -### Step 8 — Alice claims her admin fees +### Step 8 - Alice claims her admin fees After trading activity on both pools, Alice sweeps her accumulated slice from the NVDAx/USDC pool. - **Handler:** `claim_admin_fees` - **Accounts (`ClaimAdminFeesAccounts`):** - - `config` — Alice's `Config` (the `has_one = admin` constraint enforces that only she can call this) + - `config` - Alice's `Config` (the `has_one = admin` constraint enforces that only she can call this) - `pool_config`, `pool_authority` - `mint_a`, `mint_b` - - `pool_a`, `pool_b` (the pool's reserves — the source of the transfers) + - `pool_a`, `pool_b` (the pool's reserves - the source of the transfers) - `admin` = Alice (signer) - - `admin_token_a` — Alice's NVDAx ATA (must already exist) - - `admin_token_b` — Alice's USDC ATA (must already exist) + - `admin_token_a` - Alice's NVDAx ATA (must already exist) + - `admin_token_b` - Alice's USDC ATA (must already exist) - `token_program` - **Args:** none -She receives her accumulated `admin_fees_owed_a` of NVDAx and `admin_fees_owed_b` of USDC from the NVDAx/USDC pool. Both accumulators reset to zero on the same instruction. From this example's two swaps that's only `~0.001075 NVDAx` and `~0.0055 USDC` — small, because the fee is small and only two trades have happened, but real volume would compound it. She can call `claim_admin_fees` again against the TSLAx/USDC pool (same handler, different `pool_config`) to sweep her ~0.09 USDC slice from Carol's TSLAx trade. +She receives her accumulated `admin_fees_owed_a` of NVDAx and `admin_fees_owed_b` of USDC from the NVDAx/USDC pool. Both accumulators reset to zero on the same instruction. From this example's two swaps that's only `~0.001075 NVDAx` and `~0.0055 USDC` - small, because the fee is small and only two trades have happened, but real volume would compound it. She can call `claim_admin_fees` again against the TSLAx/USDC pool (same handler, different `pool_config`) to sweep her ~0.09 USDC slice from Carol's TSLAx trade. NVDAx/USDC pool state: **119.986 NVDAx, 600.065 USDC raw**, with `admin_fees_owed_a = 0` and `admin_fees_owed_b = 0`. -### Step 9 — Bob withdraws +### Step 9 - Bob withdraws Later on, Bob exits. @@ -375,7 +375,7 @@ He receives his proportional share of the **effective reserves** (`pool_X.amount - **Alice** calls `claim_admin_fees` on NVDAx/USDC, then `claim_admin_fees` on TSLAx/USDC (sweeps her accumulated fee slices from both pools) - **Bob** later calls `withdraw_liquidity` on NVDAx/USDC (exits with his fee income) -What makes this work: `x × y = K` on the effective reserves keeps the pool solvent on every swap without anyone quoting prices. LPs are paid in growing effective reserves (their share of the fee, parameterised by `Config.fee` and `Config.admin_share_bps`); the admin earns the other share, accumulated lazily and swept on demand; profit-chasing arbitrageurs incidentally keep the mid-price honest; traders get instant fills against a passive counterparty (the pool). The same `create_pool` handler and the same `swap_tokens` handler work identically for both the NVDAx/USDC and TSLAx/USDC pools — only the mint accounts differ. +What makes this work: `x × y = K` on the effective reserves keeps the pool solvent on every swap without anyone quoting prices. LPs are paid in growing effective reserves (their share of the fee, parameterised by `Config.fee` and `Config.admin_share_bps`); the admin earns the other share, accumulated lazily and swept on demand; profit-chasing arbitrageurs incidentally keep the mid-price honest; traders get instant fills against a passive counterparty (the pool). The same `create_pool` handler and the same `swap_tokens` handler work identically for both the NVDAx/USDC and TSLAx/USDC pools - only the mint accounts differ. ## Tests diff --git a/finance/token-swap/anchor/Anchor.toml b/finance/token-swap/anchor/Anchor.toml index 8cc67cb9..43d123db 100644 --- a/finance/token-swap/anchor/Anchor.toml +++ b/finance/token-swap/anchor/Anchor.toml @@ -8,7 +8,6 @@ skip-lint = false [programs.devnet] swap_example = "AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn" -# [registry] section removed — no longer used in Anchor 1.0 [provider] cluster = "localnet" diff --git a/finance/token-swap/anchor/programs/token-swap/src/errors.rs b/finance/token-swap/anchor/programs/token-swap/src/errors.rs index 69d67a63..58c52044 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/errors.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/errors.rs @@ -74,7 +74,7 @@ pub enum AmmError { // Returned by `create_pool` when `mint_a >= mint_b`. Requiring a strict // ascending order ensures each (mint_a, mint_b) pair has exactly one - // canonical pool PDA — without it, a (X, Y) pool and a (Y, X) pool would + // canonical pool PDA - without it, a (X, Y) pool and a (Y, X) pool would // both be valid, fragmenting liquidity. #[msg("mint_a must be less than mint_b for canonical pool ordering")] InvalidMintOrder, diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs index d894414c..a6d97ff2 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs @@ -53,14 +53,14 @@ pub fn handle_deposit_liquidity( // is scaled down to match the current price. This mirrors Uniswap V2's // `mint()` pattern (UniswapV2Router._addLiquidity): try the first side at // its requested amount, compute what the other side needs at the current - // ratio, and if it fits we're done — otherwise swap roles and try the + // ratio, and if it fits we're done - otherwise swap roles and try the // other side. // // We use the *effective* (LP-claimable) reserves, not the raw vault // balances, so the admin's accumulated fees don't drag the deposit ratio // off the LP-relevant price. // - // All ratio math is in u128 with checked arithmetic — no floats for + // All ratio math is in u128 with checked arithmetic - no floats for // money. The intermediate `amount_a * pool_b` can overflow u64 (both // factors are u64), but u128 absorbs that with room to spare. let pool_a = &context.accounts.pool_a; @@ -123,9 +123,15 @@ pub fn handle_deposit_liquidity( // LP-mint math. Two branches: // - Initial deposit (pool creation): `liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY`. - // One-time bootstrap; the `MINIMUM_LIQUIDITY` floor is locked - // forever and prevents the first depositor from later draining the - // pool to a sub-base-unit ratio. + // The `MINIMUM_LIQUIDITY` floor is never minted to anyone: the first + // depositor receives `sqrt(a * b) - MINIMUM_LIQUIDITY` LP tokens, and + // withdraw_liquidity adds the floor back into its supply denominator, + // so the floor's share of the reserves stays in the pool, claimable by + // nobody while any LP supply exists. This stops the first depositor + // from draining the pool to a sub-minor-unit ratio. (Uniswap V2 + // instead mints the floor to the zero address; here, if every LP + // token is burned, the floor's leftover reserves simply seed the next + // bootstrap deposit.) // - Subsequent deposit: `liquidity = min(a * supply / pool_a, b * supply / pool_b)`. // This is the canonical Uniswap V2 formula: mint LP tokens in // proportion to the depositor's share of each reserve, taking the diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs index 11c35961..6cf30340 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs @@ -51,7 +51,7 @@ pub fn handle_swap_tokens( .ok_or(AmmError::MathOverflow)?; // Narrow back to u64 for storage / transfer. The fee can never exceed // `input` (`fee_amount <= input * 9999 / 10_000 < input`, and `input` - // is u64), so the cast is safe — but use try_into anyway to make the + // is u64), so the cast is safe - but use try_into anyway to make the // invariant explicit in the type system. let fee_amount: u64 = u64::try_from(fee_amount).map_err(|_| AmmError::MathOverflow)?; let admin_portion: u64 = @@ -88,7 +88,7 @@ pub fn handle_swap_tokens( // u128 + checked: the numerator `taxed_input * reserve` can fill the // full u128 (both factors are u64). Multiply before divide to keep // precision. Floor on the divide is protocol-favouring (the pool keeps - // sub-base-unit rounding, the trader gets slightly less output) — same + // sub-base-unit rounding, the trader gets slightly less output) - same // direction as Uniswap V2. let (this_reserve, other_reserve) = if input_is_token_a { (effective_pool_a, effective_pool_b) @@ -213,7 +213,7 @@ pub fn handle_swap_tokens( authority: context.accounts.trader.to_account_info(), }, ), - output, + input_amount, context.accounts.mint_b.decimals, )?; } @@ -229,7 +229,7 @@ pub fn handle_swap_tokens( // Verify the invariant still holds on the LP-claimable (effective) // reserves. This is THE most important defensive check: it catches // "I screwed up the swap math and accidentally gave the user too much" - // bugs that no other test would catch. Defence in depth — runs *after* + // bugs that no other test would catch. Defence in depth - runs *after* // the math (and after the transfers, once balances have been reloaded). // // We tolerate the new invariant being higher because it means a diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs index f587c58d..db8ef01e 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs @@ -51,7 +51,7 @@ pub fn handle_withdraw_liquidity( // The `+ MINIMUM_LIQUIDITY` accounts for the bootstrap floor that was // locked away on the first deposit and is *not* part of the LP supply // counter (mint::supply doesn't include it) but *is* part of the - // reserves — so the divisor needs the same adjustment to keep shares + // reserves - so the divisor needs the same adjustment to keep shares // honest. // // u128 + checked: `lp_amount * reserve` can fill the full u128 (both diff --git a/finance/token-swap/anchor/programs/token-swap/src/state/pool_config.rs b/finance/token-swap/anchor/programs/token-swap/src/state/pool_config.rs index 60b8867c..d39a93e5 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/state/pool_config.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/state/pool_config.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; /// Holds the metadata that identifies a single pool: which `Config` it belongs /// to, which two mints it trades, and its canonical bump. The actual pool /// reserves live in separate token accounts (`pool_a`, `pool_b`) owned by the -/// pool authority PDA — they are not stored here. This struct is the pool's +/// pool authority PDA - they are not stored here. This struct is the pool's /// *configuration*, not its state. /// /// In addition to the identity fields, this account tracks the admin's diff --git a/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs b/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs index a0adb092..531b109b 100644 --- a/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs +++ b/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs @@ -802,7 +802,7 @@ fn deposit_ix(ts: &TestSetup, amount_a: u64, amount_b: u64) -> Instruction { /// care about the success payload (`Ok` only signals "tx landed"), and the /// concrete error type is `solana_kite::SolanaKiteError`. Returning a /// `Result<(), String>` keeps tests insulated from the kite crate's error -/// type — they just need success/failure plus a message for `.expect()`. +/// type - they just need success/failure plus a message for `.expect()`. fn send_deposit(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Result<(), String> { let ix = deposit_ix(ts, amount_a, amount_b); send_transaction_from_instructions( @@ -1018,7 +1018,7 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { let used_a = holder_a_before - holder_a_after; let used_b = holder_b_before - holder_b_after; assert_eq!(used_b, deposit_b, "amount_b should be fully used"); - // used_a must be close to deposit_a — never the unbounded raw value + // used_a must be close to deposit_a - never the unbounded raw value // (deposit_a + 10). If the bug were still here we'd see something // wildly off (or a transaction failure on transfer_checked). assert!( @@ -1036,7 +1036,7 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { fn test_deposit_too_small_for_ratio_reverts() { let mut ts = full_setup(); - // Seed at 4M:1M (A is "cheaper" — 4 A per 1 B). To force amount_b to + // Seed at 4M:1M (A is "cheaper" - 4 A per 1 B). To force amount_b to // round down to zero, the depositor must offer < 4 base units of A // (so amount_b_required = amount_a * 1M / 4M = 0). We offer 1 base unit // of A and a large amount_b. @@ -1282,7 +1282,7 @@ fn test_swap_reverts_when_output_below_min() { // Reset and try the same swap with `min_output_amount = actual + 1`. It // must revert because the pool can't beat the previous output (in fact - // it can't even match it — the first swap shifted the ratio). + // it can't even match it - the first swap shifted the ratio). let mut ts = full_setup(); send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); let too_high = actual_output + 1; @@ -1320,7 +1320,7 @@ fn test_deposit_reverts_when_lp_below_min() { let lp_from_b = (1_000_000u128 * lp_supply as u128) / pool_b_amount as u128; let achievable_lp = lp_from_a.min(lp_from_b) as u64; - // Require *strictly more* than that — the deposit must revert. + // Require *strictly more* than that - the deposit must revert. let strict_ix = deposit_ix_with_min_lp(&ts, 4_000_000, 1_000_000, achievable_lp + 1); let result = send_transaction_from_instructions( @@ -1357,7 +1357,7 @@ fn test_withdraw_reverts_when_below_min() { // Burning half the LP at a 4M:4M pool returns ~2M of each side, but the // exact amount is `lp/2 * 4_000_000 / (lp_supply + MINIMUM_LIQUIDITY)`. - // Demand 4M of A out of a half-burn — clearly impossible, must revert. + // Demand 4M of A out of a half-burn - clearly impossible, must revert. let strict_ix = withdraw_ix_with_min(&ts, lp / 2, 4_000_000, 0); let result = send_transaction_from_instructions( &mut ts.svm, @@ -1387,7 +1387,7 @@ fn test_withdraw_reverts_when_below_min() { } /// Slippage test: passing `min_output_amount = 0` is the explicit -/// "I accept any non-zero output" signal — this is the documented escape +/// "I accept any non-zero output" signal - this is the documented escape /// hatch and must still succeed. #[test] fn test_swap_with_zero_min_output_still_succeeds() { @@ -1407,10 +1407,136 @@ fn test_swap_with_zero_min_output_still_succeeds() { assert!(after_b > before_b, "B balance should increase"); } +/// Helper: build a `swap_tokens` ix that trades token B in for token A. +fn swap_b_to_a_ix(ts: &TestSetup, input_amount: u64, min_output_amount: u64) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::SwapTokens { + input_is_token_a: false, + input_amount, + min_output_amount, + } + .data(), + swap_example::accounts::SwapTokensAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + trader: ts.admin.pubkey(), + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + token_a: ts.holder_account_a, + token_b: ts.holder_account_b, + payer: ts.payer.pubkey(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +/// B→A regression test: the trader must pay the pool exactly `input_amount` +/// of token B, not some other quantity. This pins the trader-to-pool transfer +/// amount in the `input_is_token_a = false` branch, which once shipped a +/// wrong-variable bug (it transferred `output` of token B instead of +/// `input_amount`, so the trader underpaid for every B→A swap). +#[test] +fn test_swap_b_to_a_trader_pays_full_input() { + let mut ts = full_setup(); + // Seed at 4:1 so the B side is the scarce asset and a B→A swap returns a + // multiple of its input in A. + send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + + let holder_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + + let input_amount = 100_000u64; + let swap_ix = swap_b_to_a_ix(&ts, input_amount, 1); + send_transaction_from_instructions( + &mut ts.svm, + vec![swap_ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("B→A swap should succeed"); + + let holder_a_after = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let holder_b_after = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + + // The trader pays exactly input_amount of B... + assert_eq!( + holder_b_before - holder_b_after, + input_amount, + "trader must pay the pool the full input_amount of token B" + ); + assert_eq!( + pool_b_after - pool_b_before, + input_amount, + "pool must receive the full input_amount of token B" + ); + + // ...and receives a positive amount of A, conserved against the pool. + let output = holder_a_after - holder_a_before; + assert!(output > 0, "trader should receive token A"); + assert_eq!( + pool_a_before - pool_a_after, + output, + "token A out of the pool must equal token A received by the trader" + ); +} + +/// B→A invariant test: after a fee-paying B→A swap, the effective +/// (LP-claimable) `k = x * y` must not decrease. Run alongside the A→B +/// variant so both directions of the symmetric flow are exercised. +#[test] +fn test_invariant_holds_after_b_to_a_swap() { + let mut ts = full_setup(); + send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + + let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + let k_before = (pool_a_before as u128) * (pool_b_before as u128); + + let swap_ix = swap_b_to_a_ix(&ts, 100_000, 1); + send_transaction_from_instructions( + &mut ts.svm, + vec![swap_ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("B→A swap should succeed"); + + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + // The swap input was on the B side, so the admin fee accrued on B. + // PoolConfig layout: 8 (discriminator) + 32*3 (config, mint_a, mint_b) + // + 8 (admin_fees_owed_a) → admin_fees_owed_b starts at byte 112. + let admin_owed_b: u64 = { + let account = ts.svm.get_account(&ts.pool_config_key).unwrap(); + let start = 8 + 32 * 3 + 8; + u64::from_le_bytes(account.data[start..start + 8].try_into().unwrap()) + }; + let effective_a_after = pool_a_after; + let effective_b_after = pool_b_after - admin_owed_b; + let k_after = (effective_a_after as u128) * (effective_b_after as u128); + + assert!( + k_after >= k_before, + "effective invariant must not decrease across a B→A swap: \ + before={k_before}, after={k_after}" + ); +} + /// Invariant-check test: a normal swap leaves the effective `k = x * y` /// at least as high as before (LP fee adds to LP-claimable reserves; admin /// slice is excluded). This is the runtime guard that catches "the math -/// gave away too much" bugs — verify the happy path doesn't trip it. +/// gave away too much" bugs - verify the happy path doesn't trip it. #[test] fn test_invariant_holds_after_normal_swap() { let mut ts = full_setup(); diff --git a/finance/token-swap/quasar/Cargo.toml b/finance/token-swap/quasar/Cargo.toml index ac95c368..23000d57 100644 --- a/finance/token-swap/quasar/Cargo.toml +++ b/finance/token-swap/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-token-swap" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/finance/token-swap/quasar/README.md b/finance/token-swap/quasar/README.md index 00936b0d..e1621f4e 100644 --- a/finance/token-swap/quasar/README.md +++ b/finance/token-swap/quasar/README.md @@ -9,6 +9,28 @@ See also: [Token Swap overview](../README.md) and the [repository catalog](../.. - Pool PDA and LP tokens - See [finance/token-swap/README.md](../token-swap/README.md) +## Slippage protection + +All three money-moving flows take a caller-supplied floor and revert with a +named `AmmError` if the floor is not met: + +- `deposit_liquidity(amount_a, amount_b, minimum_lp_tokens_out)` treats + `amount_a` / `amount_b` as upper bounds: one side is used in full and the + other is scaled down to the current pool ratio (never up). If the LP tokens + minted would fall below `minimum_lp_tokens_out`, the deposit reverts with + `DepositBelowMinimum`. +- `withdraw_liquidity(amount, minimum_token_a_out, minimum_token_b_out)` + reverts with `WithdrawalBelowMinimum` if either side of the proportional + payout falls below its floor. +- `swap_tokens(input_is_token_a, input_amount, min_output_amount)` reverts + with `SlippageExceeded` if the constant-product output falls below + `min_output_amount`. + +Requesting more than the caller's token balance fails fast with +`InsufficientBalance` rather than clamping, so the caller's slippage math +always refers to the amounts actually moved. Error codes live in +`src/error.rs` and start at 6000, matching the Anchor variant's offset. + ## Setup From `finance/token-swap/quasar/`: diff --git a/finance/token-swap/quasar/src/error.rs b/finance/token-swap/quasar/src/error.rs new file mode 100644 index 00000000..3f713d5c --- /dev/null +++ b/finance/token-swap/quasar/src/error.rs @@ -0,0 +1,45 @@ +use quasar_lang::prelude::*; + +#[error_code] +pub enum AmmError { + /// `create_config` was called with `fee >= 10_000` basis points (a fee of + /// 100% or more would consume the whole input). + // 6000 is the conventional Anchor-compatible starting offset for + // program-specific error codes (Quasar's #[error_code] starts at 0 + // unless told otherwise; framework errors occupy 3000+). + InvalidFee = 6000, + /// `create_config` was called with `admin_share_bps >= 10_000`. The admin + /// share is a basis-points fraction of the trading fee, so the admin + /// cannot take more than the whole fee. + AdminShareTooHigh, + /// The initial deposit's geometric mean is below `MINIMUM_LIQUIDITY`, or a + /// subsequent deposit is too small to mint any LP tokens. + DepositTooSmall, + /// Clamping the caller's amounts to the current pool ratio rounded one + /// side down to zero; the pool cannot issue meaningful LP shares. + DepositAmountTooSmall, + /// The swap output is below the trader's `min_output_amount`. This is the + /// trader's slippage guard against the pool shifting between quote and + /// landing. + SlippageExceeded, + /// One side of the proportional withdrawal fell below the LP's specified + /// minimum (`minimum_token_a_out` / `minimum_token_b_out`). + WithdrawalBelowMinimum, + /// The LP-token amount minted by a deposit fell below the depositor's + /// `minimum_lp_tokens_out`. This is the lower-bound slippage guard; the + /// ratio clamp is the upper-bound guard. + DepositBelowMinimum, + /// The constant-product invariant decreased across a swap. + InvariantViolated, + /// The caller asked to deposit or swap more tokens than they hold. The + /// program fails fast instead of clamping to the balance, because + /// clamping would invalidate the caller's slippage math. + InsufficientBalance, + /// `claim_admin_fees` was called while both fee accumulators are zero. + NothingToClaim, + /// A checked arithmetic operation overflowed or a u128 result did not fit + /// back into u64. + MathOverflow, + /// The signer of `claim_admin_fees` does not match `Config.admin`. + Unauthorized, +} diff --git a/finance/token-swap/quasar/src/instructions/claim_admin_fees.rs b/finance/token-swap/quasar/src/instructions/claim_admin_fees.rs index 7c83f4c8..2ab330b0 100644 --- a/finance/token-swap/quasar/src/instructions/claim_admin_fees.rs +++ b/finance/token-swap/quasar/src/instructions/claim_admin_fees.rs @@ -1,5 +1,6 @@ use { crate::{ + error::AmmError, state::{Config, PoolConfig, PoolConfigInner}, ConfigPda, PoolAuthorityPda, PoolPda, }, @@ -11,7 +12,7 @@ use { /// enforce that explicitly in the handler since quasar doesn't have an /// Anchor-style `has_one` constraint. #[derive(Accounts)] -pub struct ClaimAdminFeesAccounts { +pub struct ClaimAdminFeesAccountConstraints { #[account(address = ConfigPda::seeds())] pub config: Account, #[account( @@ -19,7 +20,7 @@ pub struct ClaimAdminFeesAccounts { address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()), )] pub pool_config: Account, - /// Pool authority PDA — signs the outbound transfers. + /// Pool authority PDA - signs the outbound transfers. #[account(address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()))] pub pool_authority: UncheckedAccount, pub mint_a: Account, @@ -46,17 +47,22 @@ pub struct ClaimAdminFeesAccounts { #[inline(always)] pub fn handle_claim_admin_fees( - accounts: &mut ClaimAdminFeesAccounts, - bumps: &ClaimAdminFeesAccountsBumps, + accounts: &mut ClaimAdminFeesAccountConstraints, + bumps: &ClaimAdminFeesAccountConstraintsBumps, ) -> Result<(), ProgramError> { // Authorisation: only the address stored in `Config.admin` may call this. if *accounts.admin.address() != *accounts.config.admin() { - return Err(ProgramError::Custom(6)); // Unauthorized + return Err(AmmError::Unauthorized.into()); } let owed_a = accounts.pool_config.admin_fees_owed_a(); let owed_b = accounts.pool_config.admin_fees_owed_b(); + // Revert (rather than silently no-op) when there is nothing to sweep, so + // the admin gets a clear signal the call was wasted. Matches the Anchor + // variant's behaviour. + require!(owed_a > 0 || owed_b > 0, AmmError::NothingToClaim); + // Seed order matches PoolAuthorityPda: [b"authority", config, mint_a, mint_b, bump]. let bump = [bumps.pool_authority]; let seeds: &[Seed] = &[ diff --git a/finance/token-swap/quasar/src/instructions/create_config.rs b/finance/token-swap/quasar/src/instructions/create_config.rs index a5e7101d..b986ff1c 100644 --- a/finance/token-swap/quasar/src/instructions/create_config.rs +++ b/finance/token-swap/quasar/src/instructions/create_config.rs @@ -1,13 +1,13 @@ use { - crate::{state::{Config, ConfigInner}, ConfigPda, BASIS_POINTS_DIVISOR}, + crate::{error::AmmError, state::{Config, ConfigInner}, ConfigPda, BASIS_POINTS_DIVISOR}, quasar_lang::prelude::*, }; /// `Config` is a global singleton: one account per deployed program, derived -/// at the fixed seed `b"config"`. There is no `id` parameter — calling this +/// at the fixed seed `b"config"`. There is no `id` parameter - calling this /// twice for the same program will fail because the account already exists. #[derive(Accounts)] -pub struct CreateConfigAccounts { +pub struct CreateConfigAccountConstraints { #[account(mut, init, payer = payer, address = ConfigPda::seeds())] pub config: Account, /// Admin authority for the AMM. @@ -19,19 +19,18 @@ pub struct CreateConfigAccounts { #[inline(always)] pub fn handle_create_config( - accounts: &mut CreateConfigAccounts, + accounts: &mut CreateConfigAccountConstraints, fee: u16, admin_share_bps: u16, ) -> Result<(), ProgramError> { - if fee as u64 >= BASIS_POINTS_DIVISOR { - return Err(ProgramError::InvalidArgument); - } + require!((fee as u64) < BASIS_POINTS_DIVISOR, AmmError::InvalidFee); // `admin_share_bps` is the basis-points slice of the trading fee that // goes to the admin (rest goes to LPs). Anything >= 10_000 is nonsensical // (admin can't take more than the whole fee). - if admin_share_bps as u64 >= BASIS_POINTS_DIVISOR { - return Err(ProgramError::InvalidArgument); - } + require!( + (admin_share_bps as u64) < BASIS_POINTS_DIVISOR, + AmmError::AdminShareTooHigh + ); accounts.config.set_inner(ConfigInner { admin: *accounts.admin.address(), fee: fee.into(), diff --git a/finance/token-swap/quasar/src/instructions/create_pool.rs b/finance/token-swap/quasar/src/instructions/create_pool.rs index 1d37907d..5f1d9042 100644 --- a/finance/token-swap/quasar/src/instructions/create_pool.rs +++ b/finance/token-swap/quasar/src/instructions/create_pool.rs @@ -13,10 +13,10 @@ use { /// - `liquidity_provider_mint = [b"liquidity", config, mint_a, mint_b]` /// /// `pool_authority` and `liquidity_provider_mint` derive at different -/// on-chain addresses than the Anchor sibling because `#[derive(Seeds)]` +/// onchain addresses than the Anchor sibling because `#[derive(Seeds)]` /// emits the literal prefix first. Internally consistent within this program. #[derive(Accounts)] -pub struct CreatePoolAccounts { +pub struct CreatePoolAccountConstraints { #[account(address = ConfigPda::seeds())] pub config: Account, #[account( @@ -26,12 +26,12 @@ pub struct CreatePoolAccounts { address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()), )] pub pool_config: Account, - /// Pool authority PDA — signs for pool token operations. + /// Pool authority PDA - signs for pool token operations. #[account( address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()), )] pub pool_authority: UncheckedAccount, - /// Liquidity token mint — created at a PDA. + /// Liquidity token mint - created at a PDA. #[account( mut, init, @@ -66,7 +66,7 @@ pub struct CreatePoolAccounts { } #[inline(always)] -pub fn handle_create_pool(accounts: &mut CreatePoolAccounts) -> Result<(), ProgramError> { +pub fn handle_create_pool(accounts: &mut CreatePoolAccountConstraints) -> Result<(), ProgramError> { accounts.pool_config.set_inner(PoolConfigInner { config: *accounts.config.address(), mint_a: *accounts.mint_a.address(), diff --git a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs index b476d2e3..881e5173 100644 --- a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -1,5 +1,6 @@ use { crate::{ + error::AmmError, state::{Config, PoolConfig}, ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, @@ -10,7 +11,7 @@ use { /// Seeds reference the `config`, `mint_a`, and `mint_b` account addresses, /// which must be provided as separate account inputs. #[derive(Accounts)] -pub struct DepositLiquidityAccounts { +pub struct DepositLiquidityAccountConstraints { #[account(address = ConfigPda::seeds())] pub config: Account, #[account(address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()))] @@ -57,8 +58,10 @@ pub struct DepositLiquidityAccounts { pub system_program: Program, } -/// Integer square root via Newton's method. -fn isqrt(n: u128) -> u64 { +/// Integer square root via Newton's method. Operates on and returns `u128`; +/// callers narrow with `try_from` so an out-of-range result is a named error +/// instead of a silent truncation. +fn isqrt(n: u128) -> u128 { if n == 0 { return 0; } @@ -68,15 +71,16 @@ fn isqrt(n: u128) -> u64 { x = y; y = (x + n / x) / 2; } - x as u64 + x } #[inline(always)] pub fn handle_deposit_liquidity( - accounts: &mut DepositLiquidityAccounts, + accounts: &mut DepositLiquidityAccountConstraints, amount_a: u64, amount_b: u64, - bumps: &DepositLiquidityAccountsBumps, + minimum_lp_tokens_out: u64, + bumps: &DepositLiquidityAccountConstraintsBumps, ) -> Result<(), ProgramError> { // Fail fast if the depositor lacks the requested balance. Never silently // clamp to the available balance: callers expect their requested amount to @@ -84,10 +88,8 @@ pub fn handle_deposit_liquidity( let depositor_a = accounts.token_a.amount(); let depositor_b = accounts.token_b.amount(); if amount_a > depositor_a || amount_b > depositor_b { - return Err(ProgramError::InsufficientFunds); + return Err(AmmError::InsufficientBalance.into()); } - let mut amount_a = amount_a; - let mut amount_b = amount_b; // LP curve runs on *effective* reserves (vault balance minus admin's // accumulated fee claim). The admin's owed slice is a fixed obligation, @@ -98,29 +100,65 @@ pub fn handle_deposit_liquidity( .pool_a .amount() .checked_sub(accounts.pool_config.admin_fees_owed_a()) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let pool_b_amount = accounts .pool_b .amount() .checked_sub(accounts.pool_config.admin_fees_owed_b()) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let pool_creation = pool_a_amount == 0 && pool_b_amount == 0; - if !pool_creation { - // Adjust amounts to maintain the pool ratio. - if pool_a_amount > pool_b_amount { - amount_a = (amount_b as u128) + // Clamp the caller's (amount_a, amount_b) to the current pool ratio. + // + // The caller's amounts are *upper bounds*: at most one side can be used in + // full, and the other is scaled DOWN to match the current price. This is + // Uniswap V2's `_addLiquidity` pattern: try the full `amount_a` first and + // compute the token B it requires; if that fits within the caller's + // `amount_b`, done - otherwise `amount_b` is the binding side, so use it + // in full and scale `amount_a` down. Branching on which USER amount is + // binding (never on reserve sizes) guarantees neither side is ever scaled + // UP past what the caller offered and the balance check above verified. + // + // All ratio math is u128 with checked arithmetic: `amount * reserve` can + // overflow u64, and the final narrowing uses try_from so an oversized + // result is a named error, not a truncation. + let (amount_a, amount_b) = if pool_creation { + // First deposit sets the initial price; both amounts are used as is. + (amount_a, amount_b) + } else { + // Round down: this can only ask the depositor for *less* of the other + // token than perfect-ratio, which favours the pool by a sub-minor-unit + // amount and matches Uniswap V2. + let amount_b_required = (amount_a as u128) + .checked_mul(pool_b_amount as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(pool_a_amount as u128) + .ok_or(AmmError::MathOverflow)?; + if amount_b_required <= amount_b as u128 { + // The caller's `amount_b` covers the ratio: use the full + // `amount_a` and clamp `amount_b` down. + let amount_b_required = + u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a, amount_b_required) + } else { + // `amount_b` is the binding side: use it in full and clamp + // `amount_a` down to what the ratio needs. + let amount_a_required = (amount_b as u128) .checked_mul(pool_a_amount as u128) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(pool_b_amount as u128) - .ok_or(ProgramError::ArithmeticOverflow)? as u64; - } else { - amount_b = (amount_a as u128) - .checked_mul(pool_b_amount as u128) - .ok_or(ProgramError::ArithmeticOverflow)? - .checked_div(pool_a_amount as u128) - .ok_or(ProgramError::ArithmeticOverflow)? as u64; + .ok_or(AmmError::MathOverflow)?; + let amount_a_required = + u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a_required, amount_b) } + }; + + // After clamping, both sides must contribute something. If either side + // rounds to zero the deposit is too small to register at the current + // ratio. Fail rather than mint zero-priced LP shares. + if !pool_creation && (amount_a == 0 || amount_b == 0) { + return Err(AmmError::DepositAmountTooSmall.into()); } // LP-mint math, two branches: @@ -129,39 +167,50 @@ pub fn handle_deposit_liquidity( // forever to prevent the first depositor draining the pool later. // - Subsequent deposit: liquidity = min(a * supply / pool_a, // b * supply / pool_b), proportional to the depositor's share of each - // reserve. Using sqrt(a * b) for *every* deposit (the previous - // behaviour) breaks proportionality on subsequent deposits. + // reserve. The geometric mean must not be used here: it breaks + // proportionality once the pool has existing supply. let liquidity: u64 = if pool_creation { let product = (amount_a as u128) .checked_mul(amount_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; - let sqrt = isqrt(product); + .ok_or(AmmError::MathOverflow)?; + let sqrt = u64::try_from(isqrt(product)).map_err(|_| AmmError::MathOverflow)?; if sqrt < crate::MINIMUM_LIQUIDITY { - return Err(ProgramError::InsufficientFunds); + return Err(AmmError::DepositTooSmall.into()); } sqrt.checked_sub(crate::MINIMUM_LIQUIDITY) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? } else { let total_supply = accounts.liquidity_provider_mint.supply() as u128; let from_a = (amount_a as u128) .checked_mul(total_supply) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(pool_a_amount as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let from_b = (amount_b as u128) .checked_mul(total_supply) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(pool_b_amount as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; - u64::try_from(from_a.min(from_b)).map_err(|_| ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)?; + u64::try_from(from_a.min(from_b)).map_err(|_| AmmError::MathOverflow)? }; // Reject deposits too small to mint any LP tokens (skill: never mint // zero-priced shares). if liquidity == 0 { - return Err(ProgramError::InsufficientFunds); + return Err(AmmError::DepositTooSmall.into()); } + // Depositor's slippage protection: the caller passes the lowest LP amount + // they will accept (computed offchain at quote time). If the pool ratio + // shifted between quoting and landing, the clamp above used smaller + // amounts and the LP mint drops; revert rather than mint fewer LP tokens + // than the caller expects. This is the lower-bound guard; the ratio clamp + // is the upper-bound guard (caps how much of each token can be spent). + require!( + liquidity >= minimum_lp_tokens_out, + AmmError::DepositBelowMinimum + ); + // Transfer token A to the pool. accounts.token_program .transfer(&accounts.token_a, &accounts.pool_a, &accounts.depositor, amount_a) diff --git a/finance/token-swap/quasar/src/instructions/swap_tokens.rs b/finance/token-swap/quasar/src/instructions/swap_tokens.rs index 897a9016..2dcc4319 100644 --- a/finance/token-swap/quasar/src/instructions/swap_tokens.rs +++ b/finance/token-swap/quasar/src/instructions/swap_tokens.rs @@ -1,5 +1,6 @@ use { crate::{ + error::AmmError, state::{Config, PoolConfig, PoolConfigInner}, ConfigPda, PoolAuthorityPda, PoolPda, BASIS_POINTS_DIVISOR, }, @@ -10,7 +11,7 @@ use { /// `pool_config` is mutable because each swap accumulates the admin's slice /// of the trading fee into `admin_fees_owed_a` / `admin_fees_owed_b`. #[derive(Accounts)] -pub struct SwapTokensAccounts { +pub struct SwapTokensAccountConstraints { #[account(address = ConfigPda::seeds())] pub config: Account, #[account( @@ -50,11 +51,11 @@ pub struct SwapTokensAccounts { #[inline(always)] pub fn handle_swap_tokens( - accounts: &mut SwapTokensAccounts, + accounts: &mut SwapTokensAccountConstraints, input_is_token_a: bool, input_amount: u64, min_output_amount: u64, - bumps: &SwapTokensAccountsBumps, + bumps: &SwapTokensAccountConstraintsBumps, ) -> Result<(), ProgramError> { // Never silently clamp the input to the trader's balance: the trader's // min_output_amount is computed against the input they requested, so @@ -66,7 +67,7 @@ pub fn handle_swap_tokens( accounts.token_b.amount() }; if input_amount > trader_balance { - return Err(ProgramError::InsufficientFunds); + return Err(AmmError::InsufficientBalance.into()); } let input = input_amount; @@ -81,19 +82,21 @@ pub fn handle_swap_tokens( // intermediate `input * fee` can overflow u64; multiply before divide. let fee = accounts.config.fee() as u128; let admin_share_bps = accounts.config.admin_share_bps() as u128; - let fee_amount = (input as u128) + let fee_amount_u128 = (input as u128) .checked_mul(fee) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(ProgramError::ArithmeticOverflow)? as u64; - let admin_portion = (fee_amount as u128) + .ok_or(AmmError::MathOverflow)?; + let fee_amount = u64::try_from(fee_amount_u128).map_err(|_| AmmError::MathOverflow)?; + let admin_portion_u128 = (fee_amount as u128) .checked_mul(admin_share_bps) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(ProgramError::ArithmeticOverflow)? as u64; + .ok_or(AmmError::MathOverflow)?; + let admin_portion = u64::try_from(admin_portion_u128).map_err(|_| AmmError::MathOverflow)?; let taxed_input = input .checked_sub(fee_amount) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; // Effective reserves = raw vault balance - admin's accumulated claim. // The constant-product curve runs on the LP-claimable portion only, so @@ -106,43 +109,44 @@ pub fn handle_swap_tokens( let owed_b = accounts.pool_config.admin_fees_owed_b(); let effective_pool_a = pool_a_raw .checked_sub(owed_a) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let effective_pool_b = pool_b_raw .checked_sub(owed_b) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; - let output = if input_is_token_a { + let output_u128 = if input_is_token_a { (taxed_input as u128) .checked_mul(effective_pool_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div( (effective_pool_a as u128) .checked_add(taxed_input as u128) - .ok_or(ProgramError::ArithmeticOverflow)?, + .ok_or(AmmError::MathOverflow)?, ) - .ok_or(ProgramError::ArithmeticOverflow)? as u64 + .ok_or(AmmError::MathOverflow)? } else { (taxed_input as u128) .checked_mul(effective_pool_a as u128) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div( (effective_pool_b as u128) .checked_add(taxed_input as u128) - .ok_or(ProgramError::ArithmeticOverflow)?, + .ok_or(AmmError::MathOverflow)?, ) - .ok_or(ProgramError::ArithmeticOverflow)? as u64 + .ok_or(AmmError::MathOverflow)? }; + let output = u64::try_from(output_u128).map_err(|_| AmmError::MathOverflow)?; - if output < min_output_amount { - return Err(ProgramError::Custom(4)); // OutputTooSmall - } + // Trader's slippage protection: revert if the pool moved between quote + // and landing and the output dropped below the trader's floor. + require!(output >= min_output_amount, AmmError::SlippageExceeded); // Record invariant on the *effective* reserves before the trade. Using // raw balances would let the admin's accumulated fees count toward LP // yield (wrong). let invariant = (effective_pool_a as u128) .checked_mul(effective_pool_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; // Effects (Checks-Effects-Interactions): accumulate the admin's slice on // the *input* side before any transfer CPI. The fee always comes off the @@ -152,13 +156,13 @@ pub fn handle_swap_tokens( // transfer. let (new_owed_a, new_owed_b) = if input_is_token_a { ( - owed_a.checked_add(admin_portion).ok_or(ProgramError::ArithmeticOverflow)?, + owed_a.checked_add(admin_portion).ok_or(AmmError::MathOverflow)?, owed_b, ) } else { ( owed_a, - owed_b.checked_add(admin_portion).ok_or(ProgramError::ArithmeticOverflow)?, + owed_b.checked_add(admin_portion).ok_or(AmmError::MathOverflow)?, ) }; let config_addr = *accounts.pool_config.config(); @@ -207,27 +211,25 @@ pub fn handle_swap_tokens( // u128 + checked throughout - a raw `+`/`-` could wrap on extreme values. let new_pool_a_raw = (pool_a_raw as u128) .checked_add(if input_is_token_a { input as u128 } else { 0 }) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_sub(if !input_is_token_a { output as u128 } else { 0 }) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let new_pool_b_raw = (pool_b_raw as u128) .checked_add(if !input_is_token_a { input as u128 } else { 0 }) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_sub(if input_is_token_a { output as u128 } else { 0 }) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let new_effective_a = new_pool_a_raw .checked_sub(new_owed_a as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let new_effective_b = new_pool_b_raw .checked_sub(new_owed_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let new_invariant = new_effective_a .checked_mul(new_effective_b) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; - if new_invariant < invariant { - return Err(ProgramError::Custom(5)); // InvariantViolated - } + require!(new_invariant >= invariant, AmmError::InvariantViolated); Ok(()) } diff --git a/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs b/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs index c5354245..8bfc03df 100644 --- a/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs +++ b/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs @@ -1,5 +1,6 @@ use { crate::{ + error::AmmError, state::{Config, PoolConfig}, ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, @@ -8,7 +9,7 @@ use { }; #[derive(Accounts)] -pub struct WithdrawLiquidityAccounts { +pub struct WithdrawLiquidityAccountConstraints { #[account(address = ConfigPda::seeds())] pub config: Account, #[account(address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()))] @@ -58,9 +59,11 @@ pub struct WithdrawLiquidityAccounts { #[inline(always)] pub fn handle_withdraw_liquidity( - accounts: &mut WithdrawLiquidityAccounts, + accounts: &mut WithdrawLiquidityAccountConstraints, amount: u64, - bumps: &WithdrawLiquidityAccountsBumps, + minimum_token_a_out: u64, + minimum_token_b_out: u64, + bumps: &WithdrawLiquidityAccountConstraintsBumps, ) -> Result<(), ProgramError> { // Seed order matches PoolAuthorityPda: [b"authority", config, mint_a, mint_b, bump]. let bump = [bumps.pool_authority]; @@ -83,29 +86,44 @@ pub fn handle_withdraw_liquidity( .pool_a .amount() .checked_sub(accounts.pool_config.admin_fees_owed_a()) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let effective_pool_b = accounts .pool_b .amount() .checked_sub(accounts.pool_config.admin_fees_owed_b()) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; let total_liquidity = accounts .liquidity_provider_mint .supply() .checked_add(crate::MINIMUM_LIQUIDITY) - .ok_or(ProgramError::ArithmeticOverflow)?; + .ok_or(AmmError::MathOverflow)?; - let amount_a = (amount as u128) + let amount_a_u128 = (amount as u128) .checked_mul(effective_pool_a as u128) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(total_liquidity as u128) - .ok_or(ProgramError::ArithmeticOverflow)? as u64; + .ok_or(AmmError::MathOverflow)?; + let amount_a = u64::try_from(amount_a_u128).map_err(|_| AmmError::MathOverflow)?; - let amount_b = (amount as u128) + let amount_b_u128 = (amount as u128) .checked_mul(effective_pool_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)? + .ok_or(AmmError::MathOverflow)? .checked_div(total_liquidity as u128) - .ok_or(ProgramError::ArithmeticOverflow)? as u64; + .ok_or(AmmError::MathOverflow)?; + let amount_b = u64::try_from(amount_b_u128).map_err(|_| AmmError::MathOverflow)?; + + // LP's slippage protection: if the pool ratio shifted between the LP + // quoting their exit and this transaction landing (e.g. a big swap + // drained one side), the proportional share comes back with a different + // mix than expected. Revert so the LP can requote. + require!( + amount_a >= minimum_token_a_out, + AmmError::WithdrawalBelowMinimum + ); + require!( + amount_b >= minimum_token_b_out, + AmmError::WithdrawalBelowMinimum + ); // Transfer token A from pool to depositor. accounts.token_program diff --git a/finance/token-swap/quasar/src/lib.rs b/finance/token-swap/quasar/src/lib.rs index ba3271de..197d63bf 100644 --- a/finance/token-swap/quasar/src/lib.rs +++ b/finance/token-swap/quasar/src/lib.rs @@ -2,13 +2,14 @@ use quasar_lang::prelude::*; +pub mod error; mod instructions; use instructions::*; pub mod state; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("GahM6PrXesrBkHiGJ5no4EskLNnVBCaSwVKbM4UtzyK6"); /// Minimum liquidity locked on first deposit to prevent manipulation. pub const MINIMUM_LIQUIDITY: u64 = 100; @@ -32,7 +33,7 @@ pub const LIQUIDITY_SEED: &[u8] = b"liquidity"; #[seeds(b"config")] pub struct ConfigPda; -/// `PoolConfig` PDA at seeds = [config, mint_a, mint_b] — no string prefix. +/// `PoolConfig` PDA at seeds = [config, mint_a, mint_b] - no string prefix. #[derive(Seeds)] #[seeds(b"", config: Address, mint_a: Address, mint_b: Address)] pub struct PoolPda; @@ -41,8 +42,8 @@ pub struct PoolPda; /// Modelled with prefix b"authority" + the three Address args; the /// rendered slice list ends up [config, mint_a, mint_b, b"authority"] when /// you use `with_bump`. Note: the new \`#[seeds]\` puts the literal -/// prefix first, so the on-chain derivation order is -/// [b"authority", config, mint_a, mint_b] — different from the original +/// prefix first, so the onchain derivation order is +/// [b"authority", config, mint_a, mint_b] - different from the original /// Anchor scheme. Programs are independent so this is consistent and /// correct on its own; the addresses just won't match the Anchor copy. #[derive(Seeds)] @@ -57,20 +58,20 @@ pub struct LiquidityMintPda; /// Simple constant-product AMM (token swap). /// /// Six instructions: -/// 1. `create_config` — initialise the singleton AMM config (admin, fee, +/// 1. `create_config` - initialise the singleton AMM config (admin, fee, /// admin share) -/// 2. `create_pool` — create a liquidity pool for a token pair -/// 3. `deposit_liquidity` — add liquidity and receive LP tokens -/// 4. `withdraw_liquidity` — burn LP tokens and receive pool tokens -/// 5. `swap_tokens` — swap one token for another -/// 6. `claim_admin_fees` — admin sweeps accumulated fee slice from a pool +/// 2. `create_pool` - create a liquidity pool for a token pair +/// 3. `deposit_liquidity` - add liquidity and receive LP tokens +/// 4. `withdraw_liquidity` - burn LP tokens and receive pool tokens +/// 5. `swap_tokens` - swap one token for another +/// 6. `claim_admin_fees` - admin sweeps accumulated fee slice from a pool #[program] mod quasar_token_swap { use super::*; #[instruction(discriminator = 0)] pub fn create_config( - ctx: Ctx, + ctx: Ctx, fee: u16, admin_share_bps: u16, ) -> Result<(), ProgramError> { @@ -78,30 +79,45 @@ mod quasar_token_swap { } #[instruction(discriminator = 1)] - pub fn create_pool(ctx: Ctx) -> Result<(), ProgramError> { + pub fn create_pool(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_create_pool(&mut ctx.accounts) } #[instruction(discriminator = 2)] pub fn deposit_liquidity( - ctx: Ctx, + ctx: Ctx, amount_a: u64, amount_b: u64, + minimum_lp_tokens_out: u64, ) -> Result<(), ProgramError> { - instructions::handle_deposit_liquidity(&mut ctx.accounts, amount_a, amount_b, &ctx.bumps) + instructions::handle_deposit_liquidity( + &mut ctx.accounts, + amount_a, + amount_b, + minimum_lp_tokens_out, + &ctx.bumps, + ) } #[instruction(discriminator = 3)] pub fn withdraw_liquidity( - ctx: Ctx, + ctx: Ctx, amount: u64, + minimum_token_a_out: u64, + minimum_token_b_out: u64, ) -> Result<(), ProgramError> { - instructions::handle_withdraw_liquidity(&mut ctx.accounts, amount, &ctx.bumps) + instructions::handle_withdraw_liquidity( + &mut ctx.accounts, + amount, + minimum_token_a_out, + minimum_token_b_out, + &ctx.bumps, + ) } #[instruction(discriminator = 4)] pub fn swap_tokens( - ctx: Ctx, + ctx: Ctx, input_is_token_a: bool, input_amount: u64, min_output_amount: u64, @@ -117,7 +133,7 @@ mod quasar_token_swap { #[instruction(discriminator = 5)] pub fn claim_admin_fees( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { instructions::handle_claim_admin_fees(&mut ctx.accounts, &ctx.bumps) } diff --git a/finance/token-swap/quasar/src/state.rs b/finance/token-swap/quasar/src/state.rs index 0c6f83e4..7b4871ff 100644 --- a/finance/token-swap/quasar/src/state.rs +++ b/finance/token-swap/quasar/src/state.rs @@ -25,7 +25,7 @@ pub struct Config { /// /// Holds the metadata that identifies a single pool: which `Config` it belongs /// to and which two mints it trades. The actual pool reserves live in separate -/// token accounts (`pool_a`, `pool_b`) owned by the pool authority PDA — they +/// token accounts (`pool_a`, `pool_b`) owned by the pool authority PDA - they /// are not stored here. This struct is the pool's *configuration*, not its /// state. /// diff --git a/finance/token-swap/quasar/src/tests.rs b/finance/token-swap/quasar/src/tests.rs index ddc0989a..182926d7 100644 --- a/finance/token-swap/quasar/src/tests.rs +++ b/finance/token-swap/quasar/src/tests.rs @@ -1,13 +1,33 @@ extern crate std; use { + crate::error::AmmError, alloc::vec, quasar_svm::{ token::{create_keyed_associated_token_account, create_keyed_mint_account, Mint}, - Account, Instruction, Pubkey, QuasarSvm, SPL_TOKEN_PROGRAM_ID, + Account, Instruction, ProgramError, Pubkey, QuasarSvm, SPL_TOKEN_PROGRAM_ID, }, std::println, }; +/// Quasar reports program errors as `ProgramError::Custom(code)`; this maps a +/// named `AmmError` to that wire form for assertions. +fn amm_error(error: AmmError) -> ProgramError { + ProgramError::Custom(error as u32) +} + +/// `amount * numerator / denominator` in u128 with checked ops, narrowed back +/// to u64. Mirrors the program's ratio math for computing expected values. +fn mul_div(amount: u64, numerator: u64, denominator: u64) -> u64 { + u64::try_from( + (amount as u128) + .checked_mul(numerator as u128) + .expect("mul_div: product overflow") + .checked_div(denominator as u128) + .expect("mul_div: divide by zero"), + ) + .expect("mul_div: result exceeds u64") +} + // ── SVM setup ──────────────────────────────────────────────────────────────── fn setup() -> QuasarSvm { @@ -50,11 +70,6 @@ fn funded_ata(wallet: Pubkey, mint: Pubkey, amount: u64) -> Account { create_keyed_associated_token_account(&wallet, &mint, amount) } -/// ATA address derived from wallet + mint (same formula as SPL ATA program). -fn ata_addr(wallet: Pubkey, mint: Pubkey) -> Pubkey { - create_keyed_associated_token_account(&wallet, &mint, 0).address -} - /// Read the `amount` field (bytes 64–72) from a packed token account. fn token_amount(account: &Account) -> u64 { u64::from_le_bytes(account.data[64..72].try_into().unwrap()) @@ -99,16 +114,19 @@ fn build_create_config_data(fee: u16, admin_share_bps: u16) -> Vec { data } -fn build_deposit_data(amount_a: u64, amount_b: u64) -> Vec { +fn build_deposit_data(amount_a: u64, amount_b: u64, minimum_lp_tokens_out: u64) -> Vec { let mut data = vec![2u8]; // discriminator = 2 data.extend_from_slice(&amount_a.to_le_bytes()); data.extend_from_slice(&amount_b.to_le_bytes()); + data.extend_from_slice(&minimum_lp_tokens_out.to_le_bytes()); data } -fn build_withdraw_data(amount: u64) -> Vec { +fn build_withdraw_data(amount: u64, minimum_token_a_out: u64, minimum_token_b_out: u64) -> Vec { let mut data = vec![3u8]; // discriminator = 3 data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&minimum_token_a_out.to_le_bytes()); + data.extend_from_slice(&minimum_token_b_out.to_le_bytes()); data } @@ -186,6 +204,7 @@ fn ix_deposit( payer: Pubkey, amount_a: u64, amount_b: u64, + minimum_lp_tokens_out: u64, ) -> Instruction { Instruction { program_id: crate::ID, @@ -208,7 +227,7 @@ fn ix_deposit( solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), ], - data: build_deposit_data(amount_a, amount_b), + data: build_deposit_data(amount_a, amount_b, minimum_lp_tokens_out), } } @@ -227,6 +246,8 @@ fn ix_withdraw( token_b: Pubkey, payer: Pubkey, amount: u64, + minimum_token_a_out: u64, + minimum_token_b_out: u64, ) -> Instruction { Instruction { program_id: crate::ID, @@ -249,7 +270,7 @@ fn ix_withdraw( solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), ], - data: build_withdraw_data(amount), + data: build_withdraw_data(amount, minimum_token_a_out, minimum_token_b_out), } } @@ -354,7 +375,7 @@ fn setup_pool() -> PoolEnv { ); assert!(r.is_ok(), "setup_pool/create_config: {:?}", r.raw_result); - // Pre-populate mint accounts (no on-chain minting needed for tests). + // Pre-populate mint accounts (no onchain minting needed for tests). let mint_a = Pubkey::new_unique(); let mint_b = Pubkey::new_unique(); svm.set_account(test_mint(mint_a, 6)); @@ -368,7 +389,7 @@ fn setup_pool() -> PoolEnv { let pool_a = Pubkey::new_unique(); let pool_b = Pubkey::new_unique(); - // create_pool — pass empty PDA slots (pool_config, lp_mint) and signer + // create_pool - pass empty PDA slots (pool_config, lp_mint) and signer // slots for non-PDA token accounts (pool_a, pool_b). The SVM commits // all accounts from the merged list, so every new account must appear here. let r = svm.process_instruction( @@ -404,7 +425,7 @@ fn do_deposit(env: &mut PoolEnv, amount_a: u64, amount_b: u64) -> (Pubkey, Pubke env.svm.set_account(ta); env.svm.set_account(tb); - // LP token account will be created by init(idempotent) — pass as signer + // LP token account will be created by init(idempotent) - pass as signer // because system::create_account CPI requires the new account to sign. let lp_token = Pubkey::new_unique(); @@ -413,7 +434,8 @@ fn do_deposit(env: &mut PoolEnv, amount_a: u64, amount_b: u64) -> (Pubkey, Pubke env.config, env.pool_config, env.pool_authority, depositor, env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, lp_token, token_a, token_b, env.payer, - amount_a, amount_b, + // Pool-setup helper, not a slippage test: no LP floor. + amount_a, amount_b, 0, ), &[signer(lp_token), signer(depositor)], ); @@ -423,7 +445,7 @@ fn do_deposit(env: &mut PoolEnv, amount_a: u64, amount_b: u64) -> (Pubkey, Pubke } // ═══════════════════════════════════════════════════════════════════════════════ -// Tests — create_config (existing) +// Tests - create_config (existing) // ═══════════════════════════════════════════════════════════════════════════════ #[test] @@ -502,7 +524,7 @@ fn test_create_config_invalid_admin_share() { } // ═══════════════════════════════════════════════════════════════════════════════ -// Tests — create_pool +// Tests - create_pool // ═══════════════════════════════════════════════════════════════════════════════ #[test] @@ -518,7 +540,7 @@ fn test_create_pool() { } // ═══════════════════════════════════════════════════════════════════════════════ -// Tests — deposit_liquidity +// Tests - deposit_liquidity // ═══════════════════════════════════════════════════════════════════════════════ #[test] @@ -584,16 +606,178 @@ fn test_deposit_insufficient_funds_rejected() { env.config, env.pool_config, env.pool_authority, depositor, env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, lp_token, token_a, token_b, env.payer, - 1_000_000, 1_000_000, + 1_000_000, 1_000_000, 0, ), &[empty(lp_token), signer(depositor)], ); - assert!(!r.is_ok(), "deposit with insufficient funds should fail"); + r.assert_error(amm_error(AmmError::InsufficientBalance)); println!(" DEPOSIT insufficient funds correctly rejected"); } +/// Regression test for the ratio-clamp direction bug: with reserves at +/// pool_a > pool_b, logic that branches on RESERVE sizes (instead of which +/// USER amount is binding) scales `amount_a` UP to +/// `amount_b * pool_a / pool_b`, past both the user's stated amount and the +/// balance check. The correct try-A-then-B clamp scales token B DOWN instead. +#[test] +fn test_deposit_clamps_down_never_up() { + let mut env = setup_pool(); + + // Seed at a 4:1 ratio so pool_a > pool_b. + let (pool_seed_a, pool_seed_b) = (4_000_000u64, 1_000_000u64); + let (_, lp_seed_token) = do_deposit(&mut env, pool_seed_a, pool_seed_b); + let lp_supply = token_amount(&env.svm.get_account(&lp_seed_token).unwrap()); + + // Depositor offers 1_000_000 of each and holds exactly that much. The + // old logic would try to pull 4_000_000 token A (scaling A UP); the + // correct clamp uses all 1_000_000 A and scales B down to 250_000. + let depositor = Pubkey::new_unique(); + let (stated_a, stated_b) = (1_000_000u64, 1_000_000u64); + let ta = funded_ata(depositor, env.mint_a, stated_a); + let tb = funded_ata(depositor, env.mint_b, stated_b); + let (token_a, token_b) = (ta.address, tb.address); + env.svm.set_account(ta); + env.svm.set_account(tb); + let lp_token = Pubkey::new_unique(); + + let expected_b_pulled = mul_div(stated_a, pool_seed_b, pool_seed_a); + let expected_lp = mul_div(stated_a, lp_supply, pool_seed_a); + + let r = env.svm.process_instruction( + &ix_deposit( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, token_a, token_b, env.payer, + stated_a, stated_b, expected_lp, + ), + &[signer(lp_token), signer(depositor)], + ); + assert!(r.is_ok(), "clamped deposit failed: {:?}", r.raw_result); + + // Exact amounts pulled: all of A, ratio-clamped B, nothing more. + let depositor_a = token_amount(&env.svm.get_account(&token_a).unwrap()); + let depositor_b = token_amount(&env.svm.get_account(&token_b).unwrap()); + assert_eq!(depositor_a, 0, "all stated token A must be pulled"); + assert_eq!( + depositor_b, + stated_b - expected_b_pulled, + "token B must be clamped down to the pool ratio" + ); + let pool_a_after = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pool_b_after = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!(pool_a_after, pool_seed_a + stated_a); + assert_eq!(pool_b_after, pool_seed_b + expected_b_pulled); + + let lp_minted = token_amount(&env.svm.get_account(&lp_token).unwrap()); + assert_eq!(lp_minted, expected_lp, "LP mint must be proportional"); + println!( + " DEPOSIT clamp: pulled_a={}, pulled_b={}, lp={}", + stated_a, expected_b_pulled, lp_minted + ); +} + +/// Mirror of `test_deposit_clamps_down_never_up` with the reserves reversed +/// (pool_b > pool_a), so the binding side is token A's counterpart: the full +/// `amount_b` is used and `amount_a` is the side that covers the ratio. +#[test] +fn test_deposit_clamps_down_other_side() { + let mut env = setup_pool(); + + // Seed at a 1:4 ratio so pool_b > pool_a. + let (pool_seed_a, pool_seed_b) = (1_000_000u64, 4_000_000u64); + let (_, lp_seed_token) = do_deposit(&mut env, pool_seed_a, pool_seed_b); + let lp_supply = token_amount(&env.svm.get_account(&lp_seed_token).unwrap()); + + let depositor = Pubkey::new_unique(); + let (stated_a, stated_b) = (1_000_000u64, 1_000_000u64); + let ta = funded_ata(depositor, env.mint_a, stated_a); + let tb = funded_ata(depositor, env.mint_b, stated_b); + let (token_a, token_b) = (ta.address, tb.address); + env.svm.set_account(ta); + env.svm.set_account(tb); + let lp_token = Pubkey::new_unique(); + + // amount_b_required for the full stated_a would be 4_000_000 > stated_b, + // so amount_b binds: all of B is used and A is clamped down. + let expected_a_pulled = mul_div(stated_b, pool_seed_a, pool_seed_b); + let expected_lp = mul_div(stated_b, lp_supply, pool_seed_b); + + let r = env.svm.process_instruction( + &ix_deposit( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, token_a, token_b, env.payer, + stated_a, stated_b, expected_lp, + ), + &[signer(lp_token), signer(depositor)], + ); + assert!(r.is_ok(), "clamped deposit failed: {:?}", r.raw_result); + + let depositor_a = token_amount(&env.svm.get_account(&token_a).unwrap()); + let depositor_b = token_amount(&env.svm.get_account(&token_b).unwrap()); + assert_eq!( + depositor_a, + stated_a - expected_a_pulled, + "token A must be clamped down to the pool ratio" + ); + assert_eq!(depositor_b, 0, "all stated token B must be pulled"); + let pool_a_after = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pool_b_after = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!(pool_a_after, pool_seed_a + expected_a_pulled); + assert_eq!(pool_b_after, pool_seed_b + stated_b); + + let lp_minted = token_amount(&env.svm.get_account(&lp_token).unwrap()); + assert_eq!(lp_minted, expected_lp, "LP mint must be proportional"); + println!( + " DEPOSIT clamp (B binding): pulled_a={}, pulled_b={}, lp={}", + expected_a_pulled, stated_b, lp_minted + ); +} + +#[test] +fn test_deposit_slippage_rejected() { + let mut env = setup_pool(); + + let (pool_seed_a, pool_seed_b) = (1_000_000u64, 1_000_000u64); + let (_, lp_seed_token) = do_deposit(&mut env, pool_seed_a, pool_seed_b); + let lp_supply = token_amount(&env.svm.get_account(&lp_seed_token).unwrap()); + + let depositor = Pubkey::new_unique(); + let (stated_a, stated_b) = (500_000u64, 500_000u64); + let ta = funded_ata(depositor, env.mint_a, stated_a); + let tb = funded_ata(depositor, env.mint_b, stated_b); + let (token_a, token_b) = (ta.address, tb.address); + env.svm.set_account(ta); + env.svm.set_account(tb); + let lp_token = Pubkey::new_unique(); + + // The pool will mint exactly this much; ask for one more. + let exact_lp = mul_div(stated_a, lp_supply, pool_seed_a); + let r = env.svm.process_instruction( + &ix_deposit( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, token_a, token_b, env.payer, + stated_a, stated_b, exact_lp + 1, + ), + &[signer(lp_token), signer(depositor)], + ); + r.assert_error(amm_error(AmmError::DepositBelowMinimum)); + + // Nothing moved: depositor balances and pool reserves are unchanged. + let depositor_a = token_amount(&env.svm.get_account(&token_a).unwrap()); + let depositor_b = token_amount(&env.svm.get_account(&token_b).unwrap()); + assert_eq!(depositor_a, stated_a, "token A must be untouched after revert"); + assert_eq!(depositor_b, stated_b, "token B must be untouched after revert"); + let pa = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pb = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!(pa, pool_seed_a, "pool_a must be untouched after revert"); + assert_eq!(pb, pool_seed_b, "pool_b must be untouched after revert"); + println!(" DEPOSIT slippage guard correctly rejected"); +} + // ═══════════════════════════════════════════════════════════════════════════════ -// Tests — withdraw_liquidity +// Tests - withdraw_liquidity // ═══════════════════════════════════════════════════════════════════════════════ #[test] @@ -609,6 +793,15 @@ fn test_withdraw_liquidity() { // Withdraw half the LP tokens. let withdraw_amount = lp_balance / 2; + // Expected proportional share, mirroring the program's formula: + // amount_out = lp_amount * reserve / (lp_supply + MINIMUM_LIQUIDITY) + // The depositor holds the entire LP supply, so supply == lp_balance. + let divisor = lp_balance + .checked_add(crate::MINIMUM_LIQUIDITY) + .expect("divisor overflow"); + let expected_a = mul_div(withdraw_amount, amount_a, divisor); + let expected_b = mul_div(withdraw_amount, amount_b, divisor); + // Output token accounts are created by init(idempotent) → pass as empty. let recv_a = Pubkey::new_unique(); let recv_b = Pubkey::new_unique(); @@ -618,18 +811,28 @@ fn test_withdraw_liquidity() { env.config, env.pool_config, env.pool_authority, depositor, env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, lp_token, recv_a, recv_b, env.payer, - withdraw_amount, + // Pass the exact expected amounts as the slippage floors: the + // pool hasn't moved since the quote, so the floors must be met. + withdraw_amount, expected_a, expected_b, ), // recv_a / recv_b are non-PDA accounts init(idempotent) → signer required. &[signer(recv_a), signer(recv_b), signer(depositor)], ); assert!(r.is_ok(), "withdraw failed: {:?}", r.raw_result); - // Verify the depositor received tokens. + // Verify the depositor received exactly the proportional share. let ra = env.svm.get_account(&recv_a).expect("recv_a missing after withdraw"); let rb = env.svm.get_account(&recv_b).expect("recv_b missing after withdraw"); - assert!(token_amount(&ra) > 0, "recv_a should have tokens after withdraw"); - assert!(token_amount(&rb) > 0, "recv_b should have tokens after withdraw"); + assert_eq!(token_amount(&ra), expected_a, "token A withdrawal mismatch"); + assert_eq!(token_amount(&rb), expected_b, "token B withdrawal mismatch"); + + // LP tokens were burned. + let lp_after = token_amount(&env.svm.get_account(&lp_token).unwrap()); + assert_eq!( + lp_after, + lp_balance - withdraw_amount, + "LP balance should drop by the burned amount" + ); println!( " WITHDRAW: lp_burned={}, recv_a={}, recv_b={}", @@ -637,69 +840,161 @@ fn test_withdraw_liquidity() { ); } +#[test] +fn test_withdraw_slippage_rejected() { + let mut env = setup_pool(); + let (depositor, lp_token) = do_deposit(&mut env, 2_000_000, 2_000_000); + let lp_balance = token_amount(&env.svm.get_account(&lp_token).unwrap()); + + let withdraw_amount = lp_balance / 2; + let divisor = lp_balance + .checked_add(crate::MINIMUM_LIQUIDITY) + .expect("divisor overflow"); + let expected_a = mul_div(withdraw_amount, 2_000_000, divisor); + + let recv_a = Pubkey::new_unique(); + let recv_b = Pubkey::new_unique(); + + // Floor on token A set just above what the pool will pay out. + let r = env.svm.process_instruction( + &ix_withdraw( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, recv_a, recv_b, env.payer, + withdraw_amount, expected_a + 1, 0, + ), + &[signer(recv_a), signer(recv_b), signer(depositor)], + ); + r.assert_error(amm_error(AmmError::WithdrawalBelowMinimum)); + + // Nothing moved: pool reserves and the LP balance are unchanged. + let pa = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pb = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!(pa, 2_000_000, "pool_a must be untouched after revert"); + assert_eq!(pb, 2_000_000, "pool_b must be untouched after revert"); + let lp_after = token_amount(&env.svm.get_account(&lp_token).unwrap()); + assert_eq!(lp_after, lp_balance, "LP balance must be untouched after revert"); + println!(" WITHDRAW slippage guard correctly rejected"); +} + // ═══════════════════════════════════════════════════════════════════════════════ -// Tests — swap_tokens +// Tests - swap_tokens // ═══════════════════════════════════════════════════════════════════════════════ +/// Constant-product quote mirroring the program's swap math, on effective +/// reserves: output = taxed_input * pool_out / (pool_in + taxed_input), where +/// taxed_input = input - input * fee_bps / 10_000. All products in u128. +fn expected_swap_output(input: u64, fee_bps: u64, pool_in: u64, pool_out: u64) -> u64 { + let fee_amount = mul_div(input, fee_bps, crate::BASIS_POINTS_DIVISOR); + let taxed_input = input.checked_sub(fee_amount).expect("fee exceeds input"); + let divisor = pool_in.checked_add(taxed_input).expect("reserve overflow"); + mul_div(taxed_input, pool_out, divisor) +} + +/// Trading fee passed to `create_config` in `setup_pool`, in basis points. +const POOL_FEE_BPS: u64 = 30; + #[test] -fn test_swap_a_to_b() { +fn test_swap_a_to_b_conserves_balances() { let mut env = setup_pool(); // Seed the pool with liquidity first. - do_deposit(&mut env, 10_000_000, 10_000_000); + let (pool_seed_a, pool_seed_b) = (10_000_000u64, 10_000_000u64); + do_deposit(&mut env, pool_seed_a, pool_seed_b); // Trader swaps 100_000 token A for token B. let trader = Pubkey::new_unique(); - let ta = funded_ata(trader, env.mint_a, 1_000_000); + let trader_funding = 1_000_000u64; + let ta = funded_ata(trader, env.mint_a, trader_funding); let token_a = ta.address; let token_b_out = Pubkey::new_unique(); // created by init(idempotent) env.svm.set_account(ta); let input = 100_000u64; + let expected_output = expected_swap_output(input, POOL_FEE_BPS, pool_seed_a, pool_seed_b); let r = env.svm.process_instruction( &ix_swap( env.config, env.pool_config, env.pool_authority, trader, env.mint_a, env.mint_b, env.pool_a, env.pool_b, token_a, token_b_out, env.payer, - true, input, 1, // input_is_token_a=true, min_output=1 + true, input, expected_output, // floor = exact quote; pool hasn't moved ), // token_b_out is a new non-PDA account → signer required for init. &[signer(token_b_out), signer(trader)], ); assert!(r.is_ok(), "swap A→B failed: {:?}", r.raw_result); - let out_acct = env.svm.get_account(&token_b_out).expect("token_b_out missing after swap"); - let received = token_amount(&out_acct); - assert!(received > 0, "expected non-zero token B output"); + // Conservation: the trader pays exactly `input` and receives exactly what + // the pool sent; nothing is minted or lost in transit. + let trader_a_after = token_amount(&env.svm.get_account(&token_a).unwrap()); + let received = token_amount(&env.svm.get_account(&token_b_out).unwrap()); + let pool_a_after = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pool_b_after = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!( + trader_a_after, + trader_funding - input, + "trader must pay exactly the input amount" + ); + assert_eq!(received, expected_output, "trader output mismatch"); + assert_eq!( + pool_a_after, + pool_seed_a + input, + "pool_a must gain exactly the input" + ); + assert_eq!( + pool_b_after, + pool_seed_b - received, + "pool_b must lose exactly what the trader received" + ); println!(" SWAP A→B: input={}, output={}", input, received); } #[test] -fn test_swap_b_to_a() { +fn test_swap_b_to_a_conserves_balances() { let mut env = setup_pool(); - do_deposit(&mut env, 10_000_000, 10_000_000); + let (pool_seed_a, pool_seed_b) = (10_000_000u64, 10_000_000u64); + do_deposit(&mut env, pool_seed_a, pool_seed_b); let trader = Pubkey::new_unique(); - let tb = funded_ata(trader, env.mint_b, 1_000_000); + let trader_funding = 1_000_000u64; + let tb = funded_ata(trader, env.mint_b, trader_funding); let token_b = tb.address; let token_a_out = Pubkey::new_unique(); env.svm.set_account(tb); let input = 100_000u64; + let expected_output = expected_swap_output(input, POOL_FEE_BPS, pool_seed_b, pool_seed_a); let r = env.svm.process_instruction( &ix_swap( env.config, env.pool_config, env.pool_authority, trader, env.mint_a, env.mint_b, env.pool_a, env.pool_b, token_a_out, token_b, env.payer, - false, input, 1, // input_is_token_a=false + false, input, expected_output, // input_is_token_a=false ), &[signer(token_a_out), signer(trader)], ); assert!(r.is_ok(), "swap B→A failed: {:?}", r.raw_result); - let out_acct = env.svm.get_account(&token_a_out).expect("token_a_out missing"); - let received = token_amount(&out_acct); - assert!(received > 0, "expected non-zero token A output"); + let trader_b_after = token_amount(&env.svm.get_account(&token_b).unwrap()); + let received = token_amount(&env.svm.get_account(&token_a_out).unwrap()); + let pool_a_after = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pool_b_after = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!( + trader_b_after, + trader_funding - input, + "trader must pay exactly the input amount" + ); + assert_eq!(received, expected_output, "trader output mismatch"); + assert_eq!( + pool_b_after, + pool_seed_b + input, + "pool_b must gain exactly the input" + ); + assert_eq!( + pool_a_after, + pool_seed_a - received, + "pool_a must lose exactly what the trader received" + ); println!(" SWAP B→A: input={}, output={}", input, received); } @@ -714,22 +1009,32 @@ fn test_swap_slippage_rejected() { let token_b_out = Pubkey::new_unique(); env.svm.set_account(ta); - // min_output set absurdly high (more than pool can deliver). + // min_output set one above the exact quote, so the floor cannot be met. + let input = 100_000u64; + let quote = expected_swap_output(input, POOL_FEE_BPS, 10_000_000, 10_000_000); let r = env.svm.process_instruction( &ix_swap( env.config, env.pool_config, env.pool_authority, trader, env.mint_a, env.mint_b, env.pool_a, env.pool_b, token_a, token_b_out, env.payer, - true, 100_000, 999_999_999, + true, input, quote + 1, ), &[empty(token_b_out), signer(trader)], ); - assert!(!r.is_ok(), "swap with impossible slippage should fail"); + r.assert_error(amm_error(AmmError::SlippageExceeded)); + + // Nothing moved: the trader keeps their input and the pool is untouched. + let trader_a = token_amount(&env.svm.get_account(&token_a).unwrap()); + assert_eq!(trader_a, 1_000_000, "trader balance must be untouched after revert"); + let pa = token_amount(&env.svm.get_account(&env.pool_a).unwrap()); + let pb = token_amount(&env.svm.get_account(&env.pool_b).unwrap()); + assert_eq!(pa, 10_000_000, "pool_a must be untouched after revert"); + assert_eq!(pb, 10_000_000, "pool_b must be untouched after revert"); println!(" SWAP slippage guard correctly rejected"); } // ═══════════════════════════════════════════════════════════════════════════════ -// Tests — claim_admin_fees +// Tests - claim_admin_fees // ═══════════════════════════════════════════════════════════════════════════════ #[test] diff --git a/finance/vault-strategy/anchor/README.md b/finance/vault-strategy/anchor/README.md index d97fcc03..436c8c3b 100644 --- a/finance/vault-strategy/anchor/README.md +++ b/finance/vault-strategy/anchor/README.md @@ -2,7 +2,7 @@ A manager-run investment vault on Solana. Users deposit [USDC](https://www.investopedia.com/terms/u/usd-coin-usdc.asp) and receive shares representing proportional ownership of a basket of assets. The manager allocates funds across the basket, earns a fee, and depositors withdraw their proportional slice when they choose. -The example uses two stocks as the basket assets: **TSLAx** (Tesla) and **NVDAx** (Nvidia) — [xStocks](https://backed.fi/xstocks) issued on Solana by Backed Finance. In tests these are mock [tokens](https://solana.com/docs/terminology#token). +The example uses two stocks as the basket assets: **TSLAx** (Tesla) and **NVDAx** (Nvidia) - [xStocks](https://backed.fi/xstocks) issued on Solana by Backed Finance. In tests these are mock [tokens](https://solana.com/docs/terminology#token). --- @@ -27,21 +27,21 @@ NAV = vault_usdc_balance + vault_nvda_balance × nvda_price_in_usdc ``` -NAV answers: *"if we liquidated the entire vault at today's prices, how many USDC would we get?"* It is used to price new deposits fairly — every depositor pays the same per-share price regardless of when they join. +NAV answers: *"if we liquidated the entire vault at today's prices, how many USDC would we get?"* It is used to price new deposits fairly - every depositor pays the same per-share price regardless of when they join. -Prices come from [Pyth Network](https://pyth.network/) oracle accounts (`PriceUpdateV2`). A staleness window of 60 seconds is enforced — deposits fail if either price is older than that. +Prices come from [Pyth Network](https://pyth.network/) oracle accounts (`PriceUpdateV2`). A staleness window of 60 seconds is enforced - deposits fail if either price is older than that. ### Shares A [share](https://www.investopedia.com/terms/s/shares.asp) (also called an LP token or vault token) represents a fraction of the total vault. If you hold 1% of all shares, you own 1% of every asset in the vault. - **First deposit**: shares are issued 1:1 with USDC base units (sets an initial share price of 1 USDC). -- **Later deposits**: `shares_to_mint = deposit_usdc × total_shares / NAV`. If the vault has grown, each new USDC buys fewer shares — correctly reflecting that the vault is worth more per share than when it started. +- **Later deposits**: `shares_to_mint = deposit_usdc × total_shares / NAV`. If the vault has grown, each new USDC buys fewer shares - correctly reflecting that the vault is worth more per share than when it started. - Shares are [SPL tokens](https://solana.com/docs/terminology#token) stored in the depositor's [associated token account (ATA)](https://solana.com/docs/terminology#associated-token-account). ### Management Fee -A [management fee](https://www.investopedia.com/terms/m/managementfee.asp) is charged annually as a percentage of assets under management. This vault uses [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (bps) — 100 bps = 1%. +A [management fee](https://www.investopedia.com/terms/m/managementfee.asp) is charged annually as a percentage of assets under management. This vault uses [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (bps) - 100 bps = 1%. The fee is collected by *minting new shares to the manager*, which dilutes existing holders proportionally. This avoids the need to know the current price at fee-collection time: @@ -49,7 +49,7 @@ The fee is collected by *minting new shares to the manager*, which dilutes exist fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) ``` -Anyone can call `collect_fees` — it is permissionless. +Anyone can call `collect_fees` - it is permissionless. ### Basket Allocation and Rebalancing @@ -57,11 +57,11 @@ A [basket](https://www.investopedia.com/terms/b/basket.asp) is a group of assets ### Slippage -[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the difference between the price you expected and the price you actually received. Every instruction that moves tokens accepts a `minimum_*` parameter — the transaction reverts if the output would fall below that floor. +[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the difference between the price you expected and the price you actually received. Every instruction that moves tokens accepts a `minimum_*` parameter - the transaction reverts if the output would fall below that floor. ### In-Kind Withdrawal -An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) means you receive the underlying assets themselves, not cash. When you withdraw from this vault you receive a proportional slice of whatever the vault holds at that moment — some USDC, some TSLAx, some NVDAx — rather than a forced conversion to USDC. You can then sell those assets on a DEX yourself. +An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) means you receive the underlying assets themselves, not cash. When you withdraw from this vault you receive a proportional slice of whatever the vault holds at that moment - some USDC, some TSLAx, some NVDAx - rather than a forced conversion to USDC. You can then sell those assets on a DEX yourself. --- @@ -75,19 +75,21 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) mean | **Bob** | Early depositor | Gain diversified exposure to TSLAx + NVDAx without managing individual positions | | **Carol** | Later depositor | Join the same strategy after it has been running for a while | -Alice's `manager` key can be a [Squads](https://squads.so/) multisig address — the vault stores it as a plain `Pubkey` and checks only that the transaction is signed by it. No code change is needed to use a multisig. +Alice's `manager` key can be a [Squads](https://squads.so/) multisig address - the vault stores it as a plain `Pubkey` and checks only that the transaction is signed by it. No code change is needed to use a multisig. --- -### Step 1 — Alice initialises the vault +### Step 1 - Alice initialises the vault **Instruction:** `initialize_strategy(weight_bps_a=4000, weight_bps_b=6000, fee_bps=100, swap_router, price_feed_a, price_feed_b)` +The weights must sum to 10,000 bps, and `fee_bps` must not exceed `MAX_FEE_BPS` (1,000 bps = 10% per year). Because `collect_fees` mints shares to the manager and dilutes every depositor, an uncapped fee would let a manager drain the vault by configuration, so unsafe fees are rejected at creation time (`FeeTooHigh`). + **Accounts created:** | Account | Seeds / Derivation | What it stores | |---------|--------------------|----------------| -| `Strategy` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) | `["strategy", alice_pubkey]` | manager, mint addresses, weights, fee, total shares, fee timestamp, Pyth feed pubkeys | +| `Strategy` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) | `["strategy", alice_pubkey]` | manager, mint addresses, weights, fee, total shares, fee timestamp, swap router program pubkey, Pyth feed pubkeys | | `share_mint` PDA | `["share_mint", strategy_pubkey]` | The SPL mint for vault shares. Strategy PDA is mint authority. | | `vault_usdc` ATA | Associated token account of strategy PDA for USDC | Holds deposited USDC | | `vault_asset_a` ATA | Associated token account of strategy PDA for TSLAx | Holds TSLAx after investing | @@ -95,7 +97,7 @@ Alice's `manager` key can be a [Squads](https://squads.so/) multisig address — --- -### Step 2 — Bob deposits 1,000 USDC +### Step 2 - Bob deposits 1,000 USDC **Instruction:** `deposit(usdc_amount=1_000_000_000, minimum_shares=990_000_000)` @@ -114,11 +116,11 @@ Bob now holds 100% of the vault. His motivation: rather than buying TSLAx and NV --- -### Step 3 — Alice invests: USDC → TSLAx and NVDAx +### Step 3 - Alice invests: USDC → TSLAx and NVDAx Alice calls `invest` twice, once per asset, to deploy the deposited USDC into the basket according to the 40/60 target. -**Instruction (call 1):** `invest(usdc_amount=400_000_000, minimum_asset_out=1_550_000)` — buys TSLAx at $250 +**Instruction (call 1):** `invest(usdc_amount=400_000_000, minimum_asset_out=1_550_000)` - buys TSLAx at $250 **Accounts modified (call 1):** @@ -128,7 +130,7 @@ Alice calls `invest` twice, once per asset, to deploy the deposited USDC into th | `vault_asset_a` (TSLAx) | +1,600,000 base units (1.6 TSLAx @ $250) | | `router_usdc_treasury` | +400 USDC | -**Instruction (call 2):** `invest(usdc_amount=600_000_000, minimum_asset_out=3_300_000)` — buys NVDAx at $180 +**Instruction (call 2):** `invest(usdc_amount=600_000_000, minimum_asset_out=3_300_000)` - buys NVDAx at $180 **Accounts modified (call 2):** @@ -138,11 +140,11 @@ Alice calls `invest` twice, once per asset, to deploy the deposited USDC into th | `vault_asset_b` (NVDAx) | +3,333,333 base units (3.33 NVDAx @ $180) | | `router_usdc_treasury` | +600 USDC | -After both calls the vault holds: ~0 USDC, 1.6 TSLAx, 3.33 NVDAx — all worth ~1,000 USDC at current prices. +After both calls the vault holds: ~0 USDC, 1.6 TSLAx, 3.33 NVDAx - all worth ~1,000 USDC at current prices. --- -### Step 4 — Carol deposits 1,000 USDC (after investing) +### Step 4 - Carol deposits 1,000 USDC (after investing) **Instruction:** `deposit(usdc_amount=1_000_000_000, minimum_shares=990_000_000)` @@ -163,7 +165,7 @@ Bob and Carol now each own ~50% of the vault. --- -### Step 5 — Alice rebalances (optional) +### Step 5 - Alice rebalances (optional) Suppose TSLAx has risen and the allocation has drifted to 45% TSLAx / 55% NVDAx. Alice calls `rebalance` to sell some TSLAx and buy more NVDAx, restoring the 40/60 target. @@ -182,11 +184,11 @@ Two CPI legs execute atomically: | `vault_asset_b` (NVDAx) | +1,111,111 base units | | `router_usdc_treasury` | net: +USDC from TSLAx sale, −USDC for NVDAx purchase | -If either slippage check fails, both legs revert — no partial rebalance. +If either slippage check fails, both legs revert - no partial rebalance. --- -### Step 6 — Alice collects fees +### Step 6 - Alice collects fees Six months have elapsed. Anyone calls `collect_fees` (it is permissionless). @@ -209,7 +211,7 @@ Bob and Carol are each diluted by ~0.5%. Alice now holds ~0.5% of the vault. --- -### Step 7 — Bob withdraws +### Step 7 - Bob withdraws Bob burns all his shares and receives his proportional slice of the vault in-kind. @@ -239,7 +241,7 @@ Bob receives TSLAx and NVDAx directly in his own ATAs. He can sell them on a DEX | Instruction | Signer | Key Accounts Read | Key Accounts Written | |------------|--------|-------------------|----------------------| -| `initialize_strategy` | manager | — | Strategy PDA, share_mint, vault_usdc, vault_asset_a, vault_asset_b | +| `initialize_strategy` | manager | - | Strategy PDA, share_mint, vault_usdc, vault_asset_a, vault_asset_b | | `deposit` | depositor | vault_usdc, vault_asset_a, vault_asset_b, price_feed_a, price_feed_b | vault_usdc (+), depositor_usdc_ata (−), depositor_share_ata (+), strategy.total_shares (+) | | `invest` | manager | strategy | vault_usdc (−), vault_asset (+), router_usdc_treasury (+) | | `rebalance` | manager | strategy | vault_sell (−), vault_buy (+), vault_usdc (net 0), router_usdc_treasury | @@ -270,24 +272,38 @@ The `mock-swap-router` exists only for testing. It: - `swap_usdc_for_asset`: receives USDC into its treasury, mints basket tokens to caller - `swap_asset_for_usdc`: burns basket tokens from caller, releases USDC from its treasury +The `Strategy` account stores the router's program pubkey (`swap_router`) at creation time, and `invest` and `rebalance` require the swap router program account they are given to match it (`InvalidSwapRouter`). A manager cannot route vault funds through a program the strategy did not register. + In production, replace the router CPIs in `invest` and `rebalance` with [Jupiter](https://jup.ag) CPI calls. The strategy PDA still signs; only the target program ID and account list change. --- +## Account Validation + +Every account a caller passes is checked against state the program controls, never trusted: + +- **Mints are bound to the strategy.** `deposit` and `withdraw` enforce `has_one` on `usdc_mint`, `asset_mint_a`, and `asset_mint_b` against the pubkeys stored in the `Strategy` account (`InvalidUsdcMint` / `InvalidAssetMint`). Without this, a caller could pass an unregistered mint whose strategy-owned vault is empty, understating NAV to mint inflated shares on deposit or skewing the proportional payout on withdraw. `invest` and `rebalance` enforce `has_one` on `usdc_mint` and require their asset mints to be one of the two registered basket mints. +- **Vault token accounts are derived, not supplied.** Each vault account must be the associated token account of the strategy PDA for the corresponding bound mint. +- **Price feeds are bound to the strategy.** The Pyth accounts passed to `deposit` must equal the feed pubkeys stored at creation (`InvalidPriceFeed`). +- **The swap router is bound to the strategy.** `invest` and `rebalance` require the router program account to equal the stored `swap_router` (`InvalidSwapRouter`). +- **Config is validated at creation.** Weights must sum to 10,000 bps and the fee is capped at `MAX_FEE_BPS`. + +--- + ## Custody and Trust This is a **manager-custodial** vault. The strategy [PDA](https://solana.com/docs/terminology#program-derived-address-pda) holds all assets; the manager controls `invest` and `rebalance` with no onchain constraint that they follow the stated allocation. Depositors trust the manager to act in their interest. -The `manager` field is a plain `Pubkey`. It can be a [Squads](https://squads.so/) multisig address — the vault checks only that the transaction carries a valid signature from that key. Squads handles threshold approval before the transaction reaches the vault. No program changes are required. +The `manager` field is a plain `Pubkey`. It can be a [Squads](https://squads.so/) multisig address - the vault checks only that the transaction carries a valid signature from that key. Squads handles threshold approval before the transaction reaches the vault. No program changes are required. --- ## Financial Math Implementation -- No floating point — integer arithmetic only throughout +- No floating point - integer arithmetic only throughout - All intermediate products use `u128` to prevent overflow (`u64 × u64` overflows at ~1.8 × 10¹⁹) - Multiply before divide to preserve precision -- All arithmetic uses `checked_*` methods — raw `+ - * /` are never used on token amounts +- All arithmetic uses `checked_*` methods - raw `+ - * /` are never used on token amounts - The user always receives floor division; the protocol retains the rounding remainder - `transfer_checked` is used for all SPL token transfers (carries decimals through the CPI to catch wrong-mint errors) @@ -296,11 +312,16 @@ The `manager` field is a plain `Pubkey`. It can be a [Squads](https://squads.so/ ## Build and Test ```bash -# Build both programs (requires anchor-cli and solana toolchain) -anchor build +# Build the vault (requires the Solana toolchain). This also compiles the +# router, but with the vault's `cpi` feature enabled, which strips the +# router's entrypoint and leaves a stub .so: +cargo build-sbf + +# So build the router again on its own to get a deployable .so: +cargo build-sbf --manifest-path programs/mock-swap-router/Cargo.toml -# Run tests (LiteSVM — no local validator needed) +# Run tests (LiteSVM, no local validator needed) cargo test ``` -Tests use [LiteSVM](https://github.com/LiteSVM/litesvm) for fast, self-contained program simulation. Both `.so` files are loaded from `target/deploy/`. The test suite covers all eight instructions including slippage rejection and time-based fee accrual. +Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm) for fast, self-contained program simulation. Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite exercises all six instruction handlers and the rejection paths: slippage limits, unregistered mints on deposit and withdraw, an over-cap management fee, and an unregistered swap router on invest and rebalance. diff --git a/finance/vault-strategy/anchor/programs/mock-swap-router/src/error.rs b/finance/vault-strategy/anchor/programs/mock-swap-router/src/error.rs index a92f8c5a..c57935c6 100644 --- a/finance/vault-strategy/anchor/programs/mock-swap-router/src/error.rs +++ b/finance/vault-strategy/anchor/programs/mock-swap-router/src/error.rs @@ -2,9 +2,9 @@ use anchor_lang::prelude::*; #[error_code] pub enum RouterError { - #[msg("Rate is zero — cannot compute swap")] + #[msg("Rate is zero - cannot compute swap")] ZeroRate, - #[msg("Output is below minimum — slippage exceeded")] + #[msg("Output is below minimum - slippage exceeded")] SlippageExceeded, #[msg("Asset mint does not match rate record")] InvalidAssetMint, diff --git a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs index 8583a450..125ea422 100644 --- a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs +++ b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs @@ -19,7 +19,7 @@ pub struct InitializeRouterAccountConstraints<'info> { )] pub router_config: Account<'info, RouterConfig>, - /// CHECK: PDA used as mint authority only — no data stored + /// CHECK: PDA used as mint authority only - no data stored #[account( seeds = [b"router_authority"], bump diff --git a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs index af3664e0..0fdcb429 100644 --- a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs +++ b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -30,7 +30,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { #[account(mut)] pub asset_mint: InterfaceAccount<'info, Mint>, - /// Caller's asset token account — asset tokens are burned from here + /// Caller's asset token account - asset tokens are burned from here #[account( mut, associated_token::mint = asset_mint, @@ -39,7 +39,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { )] pub caller_asset_account: InterfaceAccount<'info, TokenAccount>, - /// Caller's USDC account — receives the USDC + /// Caller's USDC account - receives the USDC #[account( mut, associated_token::mint = usdc_mint, @@ -48,7 +48,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { )] pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>, - /// Router's USDC treasury — sends the USDC + /// Router's USDC treasury - sends the USDC #[account( mut, associated_token::mint = usdc_mint, @@ -57,7 +57,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { )] pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, - /// CHECK: PDA used as treasury authority — validated by seeds constraint + /// CHECK: PDA used as treasury authority - validated by seeds constraint #[account( seeds = [b"router_authority"], bump @@ -96,7 +96,7 @@ pub fn handle_swap_asset_for_usdc( asset_amount_in, )?; - // Transfer USDC from router treasury to caller — router_authority PDA signs + // Transfer USDC from router treasury to caller - router_authority PDA signs let router_authority_bump = context.bumps.router_authority; let signer_seeds: &[&[&[u8]]] = &[&[b"router_authority", &[router_authority_bump]]]; diff --git a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs index a1da0640..54b0b78a 100644 --- a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs +++ b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -11,7 +11,7 @@ use crate::state::{AssetRate, RouterConfig}; #[derive(Accounts)] pub struct SwapUsdcForAssetAccountConstraints<'info> { - /// The caller — e.g. the vault strategy PDA (can be a signer or a PDA signer via CPI) + /// The caller - e.g. the vault strategy PDA (can be a signer or a PDA signer via CPI) pub caller: Signer<'info>, #[account( @@ -30,7 +30,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { #[account(mut)] pub asset_mint: InterfaceAccount<'info, Mint>, - /// Caller's USDC token account — USDC flows from here to the treasury + /// Caller's USDC token account - USDC flows from here to the treasury #[account( mut, associated_token::mint = usdc_mint, @@ -39,7 +39,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { )] pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>, - /// Caller's asset token account — minted asset tokens land here + /// Caller's asset token account - minted asset tokens land here #[account( mut, associated_token::mint = asset_mint, @@ -48,7 +48,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { )] pub caller_asset_account: InterfaceAccount<'info, TokenAccount>, - /// Router's USDC treasury — receives the USDC payment + /// Router's USDC treasury - receives the USDC payment #[account( mut, associated_token::mint = usdc_mint, @@ -57,7 +57,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { )] pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, - /// CHECK: PDA used as mint authority — validated by seeds constraint + /// CHECK: PDA used as mint authority - validated by seeds constraint #[account( seeds = [b"router_authority"], bump @@ -99,7 +99,7 @@ pub fn handle_swap_usdc_for_asset( let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), transfer_accounts); transfer_checked(cpi_ctx, usdc_amount_in, context.accounts.usdc_mint.decimals)?; - // Mint asset tokens to caller — router_authority PDA signs + // Mint asset tokens to caller - router_authority PDA signs let router_authority_bump = context.bumps.router_authority; let signer_seeds: &[&[&[u8]]] = &[&[b"router_authority", &[router_authority_bump]]]; diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs index e30ee13e..29a29739 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs @@ -4,13 +4,13 @@ use anchor_lang::prelude::*; pub enum VaultError { #[msg("Weights must sum to 10000 basis points")] InvalidWeights, - #[msg("Shares minted are below the minimum — slippage exceeded")] + #[msg("Shares minted are below the minimum - slippage exceeded")] SlippageTooHigh, - #[msg("USDC out is below minimum — slippage exceeded")] + #[msg("USDC out is below minimum - slippage exceeded")] UsdcSlippage, - #[msg("Asset A out is below minimum — slippage exceeded")] + #[msg("Asset A out is below minimum - slippage exceeded")] AssetASlippage, - #[msg("Asset B out is below minimum — slippage exceeded")] + #[msg("Asset B out is below minimum - slippage exceeded")] AssetBSlippage, #[msg("Asset mint is neither asset_a nor asset_b")] InvalidAssetMint, @@ -22,7 +22,7 @@ pub enum VaultError { ZeroShares, #[msg("Cannot deposit zero USDC")] ZeroDeposit, - #[msg("Total shares are zero — cannot compute proportional withdraw")] + #[msg("Total shares are zero - cannot compute proportional withdraw")] ZeroTotalShares, #[msg("Price feed account does not match the strategy's registered feed")] InvalidPriceFeed, @@ -32,4 +32,10 @@ pub enum VaultError { StalePriceFeed, #[msg("Sell and buy mints must be different")] SameMint, + #[msg("USDC mint does not match the strategy's registered USDC mint")] + InvalidUsdcMint, + #[msg("Swap router program does not match the strategy's registered swap router")] + InvalidSwapRouter, + #[msg("Management fee exceeds the maximum allowed")] + FeeTooHigh, } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs index 2c9708db..086decf8 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs @@ -29,7 +29,7 @@ pub struct CollectFeesAccountConstraints<'info> { )] pub share_mint: InterfaceAccount<'info, Mint>, - /// Manager's share token account — receives fee shares + /// Manager's share token account - receives fee shares #[account( init_if_needed, payer = payer, @@ -85,7 +85,7 @@ pub fn handle_collect_fees(context: Context) -> R .checked_add(fee_shares) .ok_or(VaultError::MathOverflow)?; - // Mint fee shares to manager — strategy PDA signs + // Mint fee shares to manager - strategy PDA signs let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; let mint_accounts = MintTo { diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs index 7659238b..b2de73e5 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs @@ -40,23 +40,26 @@ pub struct DepositAccountConstraints<'info> { #[account( mut, + has_one = usdc_mint @ VaultError::InvalidUsdcMint, + has_one = asset_mint_a @ VaultError::InvalidAssetMint, + has_one = asset_mint_b @ VaultError::InvalidAssetMint, seeds = [b"strategy", strategy.manager.as_ref()], bump = strategy.bump )] - pub strategy: Account<'info, Strategy>, + pub strategy: Box>, #[account( mut, seeds = [b"share_mint", strategy.key().as_ref()], bump )] - pub share_mint: InterfaceAccount<'info, Mint>, + pub share_mint: Box>, - pub usdc_mint: InterfaceAccount<'info, Mint>, + pub usdc_mint: Box>, - pub asset_mint_a: InterfaceAccount<'info, Mint>, + pub asset_mint_a: Box>, - pub asset_mint_b: InterfaceAccount<'info, Mint>, + pub asset_mint_b: Box>, #[account( mut, @@ -64,7 +67,7 @@ pub struct DepositAccountConstraints<'info> { associated_token::authority = depositor, associated_token::token_program = token_program )] - pub depositor_usdc_account: InterfaceAccount<'info, TokenAccount>, + pub depositor_usdc_account: Box>, #[account( init_if_needed, @@ -73,7 +76,7 @@ pub struct DepositAccountConstraints<'info> { associated_token::authority = depositor, associated_token::token_program = token_program )] - pub depositor_share_account: InterfaceAccount<'info, TokenAccount>, + pub depositor_share_account: Box>, #[account( mut, @@ -81,29 +84,29 @@ pub struct DepositAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + pub vault_usdc: Box>, #[account( associated_token::mint = asset_mint_a, associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_asset_a: InterfaceAccount<'info, TokenAccount>, + pub vault_asset_a: Box>, #[account( associated_token::mint = asset_mint_b, associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_asset_b: InterfaceAccount<'info, TokenAccount>, + pub vault_asset_b: Box>, - /// CHECK: Pyth PriceUpdateV2 for asset_a — key validated against strategy.price_feed_a + /// CHECK: Pyth PriceUpdateV2 for asset_a - key validated against strategy.price_feed_a #[account( constraint = price_feed_a.key() == strategy.price_feed_a @ VaultError::InvalidPriceFeed )] pub price_feed_a: UncheckedAccount<'info>, - /// CHECK: Pyth PriceUpdateV2 for asset_b — key validated against strategy.price_feed_b + /// CHECK: Pyth PriceUpdateV2 for asset_b - key validated against strategy.price_feed_b #[account( constraint = price_feed_b.key() == strategy.price_feed_b @ VaultError::InvalidPriceFeed )] @@ -224,7 +227,7 @@ pub fn handle_deposit( let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), transfer_accounts); transfer_checked(cpi_ctx, usdc_amount, usdc_decimals)?; - // Mint shares to depositor — strategy PDA signs + // Mint shares to depositor - strategy PDA signs let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; let mint_accounts = MintTo { diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs index 9726d584..fd1b7072 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs @@ -7,6 +7,12 @@ use anchor_spl::{ use crate::error::VaultError; use crate::state::Strategy; +/// Highest annual management fee a manager may set, in basis points (10%). +/// `collect_fees` mints shares to the manager and dilutes every depositor, +/// so an uncapped fee would let a manager drain the vault by configuration; +/// 10% per year is already far above typical fund management fees. +pub const MAX_FEE_BPS: u16 = 1_000; + #[derive(Accounts)] pub struct InitializeStrategyAccountConstraints<'info> { #[account(mut)] @@ -39,7 +45,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { )] pub share_mint: InterfaceAccount<'info, Mint>, - /// Vault's USDC token account — strategy PDA is the authority + /// Vault's USDC token account - strategy PDA is the authority #[account( init, payer = manager, @@ -49,7 +55,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { )] pub vault_usdc: InterfaceAccount<'info, TokenAccount>, - /// Vault's asset_a token account — strategy PDA is the authority + /// Vault's asset_a token account - strategy PDA is the authority #[account( init, payer = manager, @@ -59,7 +65,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { )] pub vault_asset_a: InterfaceAccount<'info, TokenAccount>, - /// Vault's asset_b token account — strategy PDA is the authority + /// Vault's asset_b token account - strategy PDA is the authority #[account( init, payer = manager, @@ -91,6 +97,8 @@ pub fn handle_initialize_strategy( VaultError::InvalidWeights ); + require!(fee_bps <= MAX_FEE_BPS, VaultError::FeeTooHigh); + let clock = Clock::get()?; context.accounts.strategy.set_inner(Strategy { diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs index 8893ade0..91a6ff33 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs @@ -18,16 +18,17 @@ pub struct InvestAccountConstraints<'info> { #[account( mut, has_one = manager, + has_one = usdc_mint @ VaultError::InvalidUsdcMint, seeds = [b"strategy", strategy.manager.as_ref()], bump = strategy.bump )] - pub strategy: Account<'info, Strategy>, + pub strategy: Box>, - pub usdc_mint: InterfaceAccount<'info, Mint>, + pub usdc_mint: Box>, - /// The asset mint to buy — must be asset_mint_a or asset_mint_b + /// The asset mint to buy - must be asset_mint_a or asset_mint_b #[account(mut)] - pub asset_mint: InterfaceAccount<'info, Mint>, + pub asset_mint: Box>, #[account( mut, @@ -35,7 +36,7 @@ pub struct InvestAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + pub vault_usdc: Box>, /// Vault's asset token account for the asset being bought #[account( @@ -44,7 +45,7 @@ pub struct InvestAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_asset: InterfaceAccount<'info, TokenAccount>, + pub vault_asset: Box>, pub asset_rate: Account<'info, AssetRate>, @@ -60,6 +61,9 @@ pub struct InvestAccountConstraints<'info> { #[account(mut)] pub router_authority: UncheckedAccount<'info>, + #[account( + constraint = swap_router_program.key() == strategy.swap_router @ VaultError::InvalidSwapRouter + )] pub swap_router_program: Program<'info, mock_swap_router::program::MockSwapRouter>, pub associated_token_program: Program<'info, AssociatedToken>, diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs index 619c7d50..ef087342 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs @@ -18,20 +18,21 @@ pub struct RebalanceAccountConstraints<'info> { #[account( mut, has_one = manager, + has_one = usdc_mint @ VaultError::InvalidUsdcMint, seeds = [b"strategy", strategy.manager.as_ref()], bump = strategy.bump )] - pub strategy: Account<'info, Strategy>, + pub strategy: Box>, - pub usdc_mint: InterfaceAccount<'info, Mint>, + pub usdc_mint: Box>, /// The basket token being sold #[account(mut)] - pub sell_mint: InterfaceAccount<'info, Mint>, + pub sell_mint: Box>, /// The basket token being bought #[account(mut)] - pub buy_mint: InterfaceAccount<'info, Mint>, + pub buy_mint: Box>, /// Vault's token account for the asset being sold #[account( @@ -40,7 +41,7 @@ pub struct RebalanceAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_sell: InterfaceAccount<'info, TokenAccount>, + pub vault_sell: Box>, /// Vault's token account for the asset being bought #[account( @@ -49,7 +50,7 @@ pub struct RebalanceAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_buy: InterfaceAccount<'info, TokenAccount>, + pub vault_buy: Box>, #[account( mut, @@ -57,7 +58,7 @@ pub struct RebalanceAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + pub vault_usdc: Box>, pub sell_rate: Account<'info, AssetRate>, @@ -75,6 +76,9 @@ pub struct RebalanceAccountConstraints<'info> { #[account(mut)] pub router_authority: UncheckedAccount<'info>, + #[account( + constraint = swap_router_program.key() == strategy.swap_router @ VaultError::InvalidSwapRouter + )] pub swap_router_program: Program<'info, mock_swap_router::program::MockSwapRouter>, pub associated_token_program: Program<'info, AssociatedToken>, diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs index 0338a7b7..67c725e5 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs @@ -16,23 +16,26 @@ pub struct WithdrawAccountConstraints<'info> { #[account( mut, + has_one = usdc_mint @ VaultError::InvalidUsdcMint, + has_one = asset_mint_a @ VaultError::InvalidAssetMint, + has_one = asset_mint_b @ VaultError::InvalidAssetMint, seeds = [b"strategy", strategy.manager.as_ref()], bump = strategy.bump )] - pub strategy: Account<'info, Strategy>, + pub strategy: Box>, #[account( mut, seeds = [b"share_mint", strategy.key().as_ref()], bump )] - pub share_mint: InterfaceAccount<'info, Mint>, + pub share_mint: Box>, - pub usdc_mint: InterfaceAccount<'info, Mint>, + pub usdc_mint: Box>, - pub asset_mint_a: InterfaceAccount<'info, Mint>, + pub asset_mint_a: Box>, - pub asset_mint_b: InterfaceAccount<'info, Mint>, + pub asset_mint_b: Box>, #[account( mut, @@ -40,7 +43,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = user, associated_token::token_program = token_program )] - pub user_share_account: InterfaceAccount<'info, TokenAccount>, + pub user_share_account: Box>, #[account( init_if_needed, @@ -49,7 +52,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = user, associated_token::token_program = token_program )] - pub user_usdc_account: InterfaceAccount<'info, TokenAccount>, + pub user_usdc_account: Box>, #[account( init_if_needed, @@ -58,7 +61,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = user, associated_token::token_program = token_program )] - pub user_asset_a_account: InterfaceAccount<'info, TokenAccount>, + pub user_asset_a_account: Box>, #[account( init_if_needed, @@ -67,7 +70,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = user, associated_token::token_program = token_program )] - pub user_asset_b_account: InterfaceAccount<'info, TokenAccount>, + pub user_asset_b_account: Box>, #[account( mut, @@ -75,7 +78,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + pub vault_usdc: Box>, #[account( mut, @@ -83,7 +86,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_asset_a: InterfaceAccount<'info, TokenAccount>, + pub vault_asset_a: Box>, #[account( mut, @@ -91,7 +94,7 @@ pub struct WithdrawAccountConstraints<'info> { associated_token::authority = strategy, associated_token::token_program = token_program )] - pub vault_asset_b: InterfaceAccount<'info, TokenAccount>, + pub vault_asset_b: Box>, pub associated_token_program: Program<'info, AssociatedToken>, pub token_program: Interface<'info, TokenInterface>, @@ -123,7 +126,7 @@ pub fn handle_withdraw( let shares_u128 = shares_to_burn as u128; let total_u128 = total_shares as u128; - // Proportional amounts — floor division (user gets floor) + // Proportional amounts - floor division (user gets floor) let amount_usdc: u64 = (vault_usdc_amount as u128) .checked_mul(shares_u128) .ok_or(VaultError::MathOverflow)? diff --git a/finance/vault-strategy/anchor/tests/vault_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs similarity index 67% rename from finance/vault-strategy/anchor/tests/vault_strategy.rs rename to finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs index 128c039d..45d568a6 100644 --- a/finance/vault-strategy/anchor/tests/vault_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs @@ -3,12 +3,14 @@ use { solana_program::{clock::Clock, instruction::Instruction, pubkey::Pubkey, system_program}, InstructionData, ToAccountMetas, }, + anchor_spl::token::spl_token, litesvm::LiteSVM, solana_account::Account as SolanaAccount, solana_keypair::Keypair, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, - get_token_account_balance, mint_tokens_to_token_account, send_transaction_from_instructions, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, }, solana_signer::Signer, }; @@ -58,7 +60,7 @@ fn build_mock_price_update_account(price: i64, exponent: i32, publish_time: i64) let mut data = Vec::with_capacity(133); data.extend_from_slice(&discriminator); data.extend_from_slice(&[0u8; 32]); // write_authority placeholder - data.push(1u8); // verification_level: Full + data.push(1u8); // verification_level: Full data.extend_from_slice(&[0xEFu8; 32]); // feed_id data.extend_from_slice(&price.to_le_bytes()); data.extend_from_slice(&100_000u64.to_le_bytes()); // conf @@ -74,6 +76,9 @@ fn build_mock_price_update_account(price: i64, exponent: i32, publish_time: i64) /// Fixed publish time matching the test clock const PUBLISH_TIME: i64 = 1_700_000_000; +/// All test mints (USDC and the basket assets) use 6 decimals, matching real USDC. +const TOKEN_DECIMALS: u8 = 6; + struct TestContext { svm: LiteSVM, vault_program_id: Pubkey, @@ -121,30 +126,40 @@ fn setup_full() -> TestContext { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let decimals: u8 = 6; - - // Create mints — payer is initial mint authority (we'll transfer TSLAx/NVDAx to router_authority) - let usdc_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + // Create mints with payer as the initial mint authority for all three + let usdc_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); + let tsla_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); + let nvda_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); - // Derive router_authority PDA before creating mints let (router_authority_pda, _) = Pubkey::find_program_address(&[b"router_authority"], &router_program_id); - // TSLAx and NVDAx have router_authority as mint authority - let tsla_mint = - create_token_mint(&mut svm, &payer, decimals, Some(&router_authority_pda)).unwrap(); - let nvda_mint = - create_token_mint(&mut svm, &payer, decimals, Some(&router_authority_pda)).unwrap(); + // The router pays out swap_usdc_for_asset by minting, so the basket asset + // mints must have router_authority as their mint authority + for basket_mint in [&tsla_mint, &nvda_mint] { + let set_authority_instruction = spl_token::instruction::set_authority( + &spl_token::ID, + basket_mint, + Some(&router_authority_pda), + spl_token::instruction::AuthorityType::MintTokens, + &payer.pubkey(), + &[], + ) + .unwrap(); + send_transaction_from_instructions( + &mut svm, + vec![set_authority_instruction], + &[&payer], + &payer.pubkey(), + ) + .unwrap(); + } // Derive PDAs - let (strategy_pda, _) = Pubkey::find_program_address( - &[b"strategy", manager.pubkey().as_ref()], - &vault_program_id, - ); - let (share_mint_pda, _) = Pubkey::find_program_address( - &[b"share_mint", strategy_pda.as_ref()], - &vault_program_id, - ); + let (strategy_pda, _) = + Pubkey::find_program_address(&[b"strategy", manager.pubkey().as_ref()], &vault_program_id); + let (share_mint_pda, _) = + Pubkey::find_program_address(&[b"share_mint", strategy_pda.as_ref()], &vault_program_id); let (router_config_pda, _) = Pubkey::find_program_address(&[b"router_config"], &router_program_id); let (tsla_rate_pda, _) = @@ -197,10 +212,7 @@ fn setup_full() -> TestContext { // Step 1: Initialize router let init_router_ix = Instruction::new_with_bytes( router_program_id, - &mock_swap_router::instruction::InitializeRouter { - usdc_mint, - } - .data(), + &mock_swap_router::instruction::InitializeRouter { usdc_mint }.data(), mock_swap_router::accounts::InitializeRouterAccountConstraints { authority: payer.pubkey(), usdc_mint, @@ -211,13 +223,8 @@ fn setup_full() -> TestContext { } .to_account_metas(None), ); - send_transaction_from_instructions( - &mut svm, - vec![init_router_ix], - &[&payer], - &payer.pubkey(), - ) - .unwrap(); + send_transaction_from_instructions(&mut svm, vec![init_router_ix], &[&payer], &payer.pubkey()) + .unwrap(); // Step 2: Set TSLAx rate = 250 usdc per token let set_tsla_rate_ix = Instruction::new_with_bytes( @@ -313,14 +320,18 @@ fn setup_full() -> TestContext { } } -fn initialize_strategy(ctx: &mut TestContext) { - let init_strategy_ix = Instruction::new_with_bytes( +fn build_initialize_strategy_instruction( + ctx: &TestContext, + fee_bps: u16, + swap_router: Pubkey, +) -> Instruction { + Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::InitializeStrategy { weight_bps_a: 4000, weight_bps_b: 6000, - fee_bps: 100, - swap_router: ctx.router_program_id, + fee_bps, + swap_router, price_feed_a: ctx.price_feed_tsla, price_feed_b: ctx.price_feed_nvda, } @@ -340,7 +351,20 @@ fn initialize_strategy(ctx: &mut TestContext) { system_program: system_program::id(), } .to_account_metas(None), - ); + ) +} + +/// Annual management fee used by the happy-path tests: 100 bps = 1%. +const TEST_FEE_BPS: u16 = 100; + +fn initialize_strategy(ctx: &mut TestContext) { + initialize_strategy_with_router(ctx, ctx.router_program_id); +} + +/// Initialize the strategy with an arbitrary stored swap router, so tests can +/// prove that invest/rebalance reject a router program the strategy did not register. +fn initialize_strategy_with_router(ctx: &mut TestContext, swap_router: Pubkey) { + let init_strategy_ix = build_initialize_strategy_instruction(ctx, TEST_FEE_BPS, swap_router); send_transaction_from_instructions( &mut ctx.svm, vec![init_strategy_ix], @@ -395,8 +419,14 @@ fn test_deposit_first() { .unwrap(); let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); - mint_tokens_to_token_account(&mut ctx.svm, &ctx.usdc_mint, &user_usdc, deposit_amount, &ctx.payer) - .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); let deposit_ix = Instruction::new_with_bytes( ctx.vault_program_id, @@ -434,12 +464,15 @@ fn test_deposit_first() { ) .unwrap(); - // First deposit is 1:1 — shares == usdc_amount + // First deposit is 1:1 - shares == usdc_amount let share_balance = get_token_account_balance(&ctx.svm, &user_share).unwrap(); assert_eq!(share_balance, deposit_amount, "First deposit should be 1:1"); let vault_usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); - assert_eq!(vault_usdc_balance, deposit_amount, "Vault USDC should hold deposit"); + assert_eq!( + vault_usdc_balance, deposit_amount, + "Vault USDC should hold deposit" + ); } fn do_deposit(ctx: &mut TestContext, user: &Keypair, usdc_amount: u64) -> Pubkey { @@ -702,7 +735,10 @@ fn test_collect_fees() { let fee_shares = get_token_account_balance(&ctx.svm, &manager_share).unwrap(); assert!(fee_shares > 0, "Manager should receive fee shares"); // 1% of 1_000_000_000 = 10_000_000 - assert_eq!(fee_shares, 10_000_000, "Annual fee should be 1% of total shares"); + assert_eq!( + fee_shares, 10_000_000, + "Annual fee should be 1% of total shares" + ); } #[test] @@ -810,7 +846,7 @@ fn test_withdraw_rejects_slippage() { ctx.vault_program_id, &vault_strategy::instruction::Withdraw { shares_to_burn: shares, - min_usdc_out: deposit_amount + 1, // more than available — should fail + min_usdc_out: deposit_amount + 1, // more than available - should fail min_asset_a_out: 0, min_asset_b_out: 0, } @@ -842,7 +878,10 @@ fn test_withdraw_rejects_slippage() { &[&ctx.payer, &user], &ctx.payer.pubkey(), ); - assert!(result.is_err(), "Withdraw should fail when slippage too high"); + assert!( + result.is_err(), + "Withdraw should fail when slippage too high" + ); } #[test] @@ -937,11 +976,12 @@ fn test_rebalance() { let tsla_before = get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(); let nvda_before = get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(); - // Rebalance: sell 800_000 TSLAx → receive 200_000_000 USDC (800_000 * 250) - // then buy NVDAx with 200_000_000 USDC → 1_111_111 NVDAx (200_000_000 / 180) - let sell_amount: u64 = 800_000; - let usdc_from_sell: u64 = sell_amount * 250; // 200_000_000 - let nvda_bought: u64 = usdc_from_sell / 180; // 1_111_111 + // Rebalance: sell 100_000 TSLAx (vault holds 160_000) → receive + // 25_000_000 USDC (100_000 * 250), then buy NVDAx with that USDC + // → 138_888 NVDAx (25_000_000 / 180, floor) + let sell_amount: u64 = 100_000; + let usdc_from_sell: u64 = sell_amount * 250; // 25_000_000 + let nvda_bought: u64 = usdc_from_sell / 180; // 138_888 let rebalance_ix = Instruction::new_with_bytes( ctx.vault_program_id, @@ -985,6 +1025,347 @@ fn test_rebalance() { let tsla_after = get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(); let nvda_after = get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(); - assert_eq!(tsla_after, tsla_before - sell_amount, "TSLAx balance should decrease by sell_amount"); - assert_eq!(nvda_after, nvda_before + nvda_bought, "NVDAx balance should increase by nvda_bought"); + assert_eq!( + tsla_after, + tsla_before - sell_amount, + "TSLAx balance should decrease by sell_amount" + ); + assert_eq!( + nvda_after, + nvda_before + nvda_bought, + "NVDAx balance should increase by nvda_bought" + ); +} + +fn assert_transaction_fails_with( + result: Result<(), solana_kite::SolanaKiteError>, + expected_error_name: &str, +) { + let error = result.expect_err("transaction should fail"); + let error_text = format!("{error:?}"); + assert!( + error_text.contains(expected_error_name), + "expected failure with {expected_error_name}, got: {error_text}" + ); +} + +#[test] +fn test_initialize_rejects_excessive_fee() { + let mut ctx = setup_full(); + + let excessive_fee_bps = vault_strategy::MAX_FEE_BPS + 1; + let init_strategy_ix = + build_initialize_strategy_instruction(&ctx, excessive_fee_bps, ctx.router_program_id); + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![init_strategy_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ); + assert_transaction_fails_with(result, "FeeTooHigh"); + + assert!( + ctx.svm.get_account(&ctx.strategy_pda).is_none(), + "Strategy PDA must not be created when fee_bps exceeds MAX_FEE_BPS" + ); +} + +#[test] +fn test_deposit_rejects_wrong_usdc_mint() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // A real but unregistered mint: its strategy-owned vault is empty, so + // accepting it would understate NAV and mint inflated shares. + let junk_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let junk_vault = + create_associated_token_account(&mut ctx.svm, &ctx.strategy_pda, &junk_mint, &ctx.payer) + .unwrap(); + + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 1_000_000; + let user_junk = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &junk_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &junk_mint, + &user_junk, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + + let deposit_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Deposit { + usdc_amount: deposit_amount, + minimum_shares: 0, + } + .data(), + vault_strategy::accounts::DepositAccountConstraints { + depositor: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: junk_mint, + asset_mint_a: ctx.tsla_mint, + asset_mint_b: ctx.nvda_mint, + depositor_usdc_account: user_junk, + depositor_share_account: user_share, + vault_usdc: junk_vault, + vault_asset_a: ctx.vault_tsla, + vault_asset_b: ctx.vault_nvda, + price_feed_a: ctx.price_feed_tsla, + price_feed_b: ctx.price_feed_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![deposit_ix], + &[&ctx.payer, &user], + &ctx.payer.pubkey(), + ); + assert_transaction_fails_with(result, "InvalidUsdcMint"); +} + +#[test] +fn test_deposit_rejects_wrong_asset_mint() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // An unregistered mint passed as asset_mint_a: its empty strategy-owned + // vault would hide the real TSLAx holdings from the NAV calculation. + let junk_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let junk_vault = + create_associated_token_account(&mut ctx.svm, &ctx.strategy_pda, &junk_mint, &ctx.payer) + .unwrap(); + + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 1_000_000; + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + + let deposit_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Deposit { + usdc_amount: deposit_amount, + minimum_shares: 0, + } + .data(), + vault_strategy::accounts::DepositAccountConstraints { + depositor: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + asset_mint_a: junk_mint, + asset_mint_b: ctx.nvda_mint, + depositor_usdc_account: user_usdc, + depositor_share_account: user_share, + vault_usdc: ctx.vault_usdc, + vault_asset_a: junk_vault, + vault_asset_b: ctx.vault_nvda, + price_feed_a: ctx.price_feed_tsla, + price_feed_b: ctx.price_feed_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![deposit_ix], + &[&ctx.payer, &user], + &ctx.payer.pubkey(), + ); + assert_transaction_fails_with(result, "InvalidAssetMint"); + + // The deposit must not have moved funds or minted shares + let vault_usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); + assert_eq!(vault_usdc_balance, 0, "Vault USDC must be untouched"); +} + +#[test] +fn test_withdraw_rejects_wrong_asset_mint() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // Deposit normally so the user holds shares + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 10_000_000; + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + let user_share = do_deposit(&mut ctx, &user, deposit_amount); + + // An unregistered mint passed as asset_mint_a on withdraw: the empty junk + // vault would replace the real TSLAx vault in the proportional payout. + let junk_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let junk_vault = + create_associated_token_account(&mut ctx.svm, &ctx.strategy_pda, &junk_mint, &ctx.payer) + .unwrap(); + let user_junk = derive_ata(&user.pubkey(), &junk_mint); + let user_nvda = derive_ata(&user.pubkey(), &ctx.nvda_mint); + + let withdraw_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Withdraw { + shares_to_burn: deposit_amount, + min_usdc_out: 0, + min_asset_a_out: 0, + min_asset_b_out: 0, + } + .data(), + vault_strategy::accounts::WithdrawAccountConstraints { + user: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + asset_mint_a: junk_mint, + asset_mint_b: ctx.nvda_mint, + user_share_account: user_share, + user_usdc_account: user_usdc, + user_asset_a_account: user_junk, + user_asset_b_account: user_nvda, + vault_usdc: ctx.vault_usdc, + vault_asset_a: junk_vault, + vault_asset_b: ctx.vault_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![withdraw_ix], + &[&ctx.payer, &user], + &ctx.payer.pubkey(), + ); + assert_transaction_fails_with(result, "InvalidAssetMint"); + + // Shares must not have been burned and the vault must still hold the USDC + let shares_after = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + assert_eq!(shares_after, deposit_amount, "Shares must be untouched"); + let vault_usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); + assert_eq!( + vault_usdc_balance, deposit_amount, + "Vault USDC must be untouched" + ); +} + +#[test] +fn test_invest_rejects_unregistered_router() { + let mut ctx = setup_full(); + + // Strategy registers a router that is NOT the deployed mock-swap-router + let registered_router = Pubkey::new_unique(); + initialize_strategy_with_router(&mut ctx, registered_router); + + let invest_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Invest { + usdc_amount: 1_000_000, + minimum_asset_out: 0, + } + .data(), + vault_strategy::accounts::InvestAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + asset_mint: ctx.tsla_mint, + vault_usdc: ctx.vault_usdc, + vault_asset: ctx.vault_tsla, + asset_rate: ctx.tsla_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![invest_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ); + assert_transaction_fails_with(result, "InvalidSwapRouter"); +} + +#[test] +fn test_rebalance_rejects_unregistered_router() { + let mut ctx = setup_full(); + + let registered_router = Pubkey::new_unique(); + initialize_strategy_with_router(&mut ctx, registered_router); + + let rebalance_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Rebalance { + sell_amount: 1, + minimum_usdc_from_sell: 0, + usdc_to_invest: 0, + minimum_buy_amount: 0, + } + .data(), + vault_strategy::accounts::RebalanceAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + sell_mint: ctx.tsla_mint, + buy_mint: ctx.nvda_mint, + vault_sell: ctx.vault_tsla, + vault_buy: ctx.vault_nvda, + vault_usdc: ctx.vault_usdc, + sell_rate: ctx.tsla_rate_pda, + buy_rate: ctx.nvda_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![rebalance_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ); + assert_transaction_fails_with(result, "InvalidSwapRouter"); } diff --git a/scripts/generate-quasar-readmes.mjs b/scripts/generate-quasar-readmes.mjs index 6214fae9..acbbaf6c 100644 --- a/scripts/generate-quasar-readmes.mjs +++ b/scripts/generate-quasar-readmes.mjs @@ -156,93 +156,93 @@ const examples = { concepts: ["Token transfer CPI", "Associated token accounts"], }, "tokens/token-extensions/basics/quasar": { - title: "Token Extensions — Basics", + title: "Token Extensions - Basics", purpose: "Mint and transfer with the [Token Extensions Program](https://solana.com/docs/terminology#token-extensions-program).", concepts: ["Extension mints", "Token Extensions CPI"], }, "tokens/token-extensions/cpi-guard/quasar": { - title: "Token Extensions — CPI Guard", + title: "Token Extensions - CPI Guard", purpose: "Block certain token actions inside CPI contexts.", concepts: ["CPI Guard extension"], }, "tokens/token-extensions/default-account-state/quasar": { - title: "Token Extensions — Default Account State", + title: "Token Extensions - Default Account State", purpose: "New token accounts frozen by default until thawed.", concepts: ["Default account state extension"], }, "tokens/token-extensions/group/quasar": { - title: "Token Extensions — Group Pointer", + title: "Token Extensions - Group Pointer", purpose: "Link mints to a group via Group Pointer.", concepts: ["Group pointer extension"], }, "tokens/token-extensions/immutable-owner/quasar": { - title: "Token Extensions — Immutable Owner", + title: "Token Extensions - Immutable Owner", purpose: "Token accounts with an immutable owner field.", concepts: ["Immutable owner extension"], }, "tokens/token-extensions/interest-bearing/quasar": { - title: "Token Extensions — Interest Bearing", + title: "Token Extensions - Interest Bearing", purpose: "Balances that reflect accrued interest over time.", concepts: ["Interest bearing extension"], }, "tokens/token-extensions/memo-transfer/quasar": { - title: "Token Extensions — Memo Transfer", + title: "Token Extensions - Memo Transfer", purpose: "Require a memo on every transfer.", concepts: ["Memo transfer extension"], }, "tokens/token-extensions/mint-close-authority/quasar": { - title: "Token Extensions — Mint Close Authority", + title: "Token Extensions - Mint Close Authority", purpose: "Designated account may close the mint.", concepts: ["Mint close authority extension"], }, "tokens/token-extensions/non-transferable/quasar": { - title: "Token Extensions — Non-Transferable", + title: "Token Extensions - Non-Transferable", purpose: "Tokens that cannot be transferred.", concepts: ["Non-transferable extension"], }, "tokens/token-extensions/permanent-delegate/quasar": { - title: "Token Extensions — Permanent Delegate", + title: "Token Extensions - Permanent Delegate", purpose: "Permanent delegate retains transfer rights.", concepts: ["Permanent delegate extension"], }, "tokens/token-extensions/transfer-fee/quasar": { - title: "Token Extensions — Transfer Fee", + title: "Token Extensions - Transfer Fee", purpose: "Fee charged on each transfer at the mint.", concepts: ["Transfer fee extension"], }, "tokens/token-extensions/transfer-hook/account-data-as-seed/quasar": { - title: "Transfer Hook — Account Data as Seed", + title: "Transfer Hook - Account Data as Seed", purpose: "Derive extra accounts from token account data in a transfer hook.", concepts: ["Transfer hook", "Extra account metas"], }, "tokens/token-extensions/transfer-hook/allow-block-list-token/quasar": { - title: "Transfer Hook — Allow/Block List", + title: "Transfer Hook - Allow/Block List", purpose: "Allow/block list enforced by a transfer hook program.", concepts: ["Transfer hook", "List authority"], }, "tokens/token-extensions/transfer-hook/counter/quasar": { - title: "Transfer Hook — Counter", + title: "Transfer Hook - Counter", purpose: "Count transfers in hook-side state.", concepts: ["Transfer hook", "Counter PDA"], }, "tokens/token-extensions/transfer-hook/hello-world/quasar": { - title: "Transfer Hook — Hello World", + title: "Transfer Hook - Hello World", purpose: "Minimal transfer hook executed on each transfer.", concepts: ["Transfer hook", "Extra account meta list"], }, "tokens/token-extensions/transfer-hook/transfer-cost/quasar": { - title: "Transfer Hook — Transfer Cost", + title: "Transfer Hook - Transfer Cost", purpose: "Additional fee on each transfer via the hook.", concepts: ["Transfer hook", "Fee collection"], }, "tokens/token-extensions/transfer-hook/transfer-switch/quasar": { - title: "Transfer Hook — Transfer Switch", + title: "Transfer Hook - Transfer Switch", purpose: "Globally enable or disable transfers.", concepts: ["Transfer hook", "Admin switch"], }, "tokens/token-extensions/transfer-hook/whitelist/quasar": { - title: "Transfer Hook — Whitelist", + title: "Transfer Hook - Whitelist", purpose: "Only whitelisted accounts may receive tokens.", concepts: ["Transfer hook", "Whitelist PDA"], }, diff --git a/tokens/betting-market/README.md b/tokens/betting-market/README.md new file mode 100644 index 00000000..41ed37e4 --- /dev/null +++ b/tokens/betting-market/README.md @@ -0,0 +1,7 @@ +# Betting Market + +A parimutuel (pooled) betting market. An admin opens an event and its possible outcomes; bettors stake a token on the outcome they expect to win. All stakes share one pool, and when the admin settles the event, losing stakes (minus a protocol fee) are split among winners in proportion to their stake. + +[⚓ Anchor](./anchor) + +See the [Anchor variant's README](./anchor/README.md) for the account model, payout math, and how to run the tests. diff --git a/tokens/betting-market/anchor/README.md b/tokens/betting-market/anchor/README.md index 39dfdd91..9337779b 100644 --- a/tokens/betting-market/anchor/README.md +++ b/tokens/betting-market/anchor/README.md @@ -3,7 +3,7 @@ A parimutuel (pooled) betting market. An admin opens an **event**, adds the possible **outcomes**, and bettors stake a token on the outcome they think will win. Every stake across every outcome goes into one pool. When the admin settles the event to the winning outcome, the -losing stakes — minus a protocol fee — are split among the winners in proportion to their stake. +losing stakes - minus a protocol fee - are split among the winners in proportion to their stake. This is the pooled model used by Solana prediction-market platforms such as Hedgehog Markets, where odds are set by the crowd's stakes rather than by an order book or a fixed-odds bookmaker. @@ -13,32 +13,36 @@ where odds are set by the crowd's stakes rather than by an order book or a fixed It solves the core problem of trustless betting: collecting stakes from many bettors, holding them in one place no single bettor controls, and paying winners by a fixed, public formula. The pool is a token account owned by the event's PDA, so payouts are signed by the program with the event's -seeds — there is no admin key that can move bettors' stakes out of the pool. The admin's only +seeds - there is no admin key that can move bettors' stakes out of the pool. The admin's only powers are creating events/outcomes and choosing the winning outcome (or cancelling). ## Major Concepts ### Accounts -- **Config** (`seeds = [b"config"]`) — one per deployment. Holds the `admin` (the only key that can +- **Config** (`seeds = [b"config"]`) - one per deployment. Holds the `admin` (the only key that can create events/outcomes, settle, and cancel), the `token_mint` every market accepts, the `fee_recipient`, and the `fee_bps`. -- **Event** (`seeds = [b"event", event_id]`) — one betting market. Tracks `total_pool`, `status` - (`Open` / `Settled` / `Cancelled`), and — once settled — the `winning_outcome_index`, +- **Event** (`seeds = [b"event", event_id]`) - one betting market. Tracks `total_pool`, `status` + (`Open` / `Settled` / `Cancelled`), and - once settled - the `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool` that the payout formula reads. The `fee_bps` is snapshotted at creation so later Config changes can't alter a market bettors have already joined. -- **Outcome** (`seeds = [b"outcome", event, index]`) — one possible result. Its `total_amount` is +- **Outcome** (`seeds = [b"outcome", event, index]`) - one possible result. Its `total_amount` is the outcome's share of the pool and the denominator for pro-rata payouts when it wins. -- **Bet** (`seeds = [b"bet", outcome, bettor]`) — a bettor's total stake on one outcome. Re-betting - the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor). -- **User** (`seeds = [b"user", wallet]`) — a per-wallet index listing the bettor's Bet addresses, so - a client can find someone's positions without scanning every Bet on the program. The list is - capped (see `MAX_BETS_PER_USER`) to keep the account a fixed size; the Bet accounts are the - authoritative stake record. +- **Bet** (`seeds = [b"bet", outcome, bettor]`) - a bettor's total stake on one outcome. Re-betting + the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor). The + account exists only while the position is open: it closes (rent back to the bettor) via + `claim_winnings`, `claim_refund`, or `close_losing_bet`, which is also what makes a second claim + impossible. +- **User** (`seeds = [b"user", wallet]`) - a per-wallet index listing the bettor's open Bet + addresses, so a client can find someone's positions without scanning every Bet on the program. + `place_bet` adds an entry and every instruction that closes a Bet removes it, so the cap (see + `MAX_BETS_PER_USER`) limits concurrent open positions, not lifetime bets. The fixed cap keeps the + account a constant size; the Bet accounts are the authoritative stake record. ### The vault -Each event owns a single vault token account — the associated token account of the Event PDA for +Each event owns a single vault token account - the associated token account of the Event PDA for `config.token_mint`. `place_bet` moves the stake from the bettor's token account into this vault. `settle_event`, `claim_winnings`, and `claim_refund` move tokens back out, with the program signing as the Event PDA (`seeds = [b"event", event_id, bump]`). @@ -60,27 +64,31 @@ payout = stake + stake * distributable_losing / winning_pool ``` A winner always gets their own stake back; the fee is only ever taken from losing stakes. Integer -division floors each share, leaving at most a few base units of dust in the vault. +division floors each share, leaving at most a few minor units of dust in the vault. -**Worked example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins. +**Example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins. `losing_pool = 50`, `fee = 1`, `distributable_losing = 49`. A bettor who staked 40 claims `40 + 40 * 49 / 100 = 59`. ### Instruction handlers -| Handler | Who | What it does | -| --- | --- | --- | -| `initialize_config` | anyone (becomes admin) | One-time setup: sets admin, stake token, fee, fee recipient. | -| `create_event` | admin | Opens a market and creates its vault. | -| `add_outcome` | admin | Adds a possible result. Only before any bet is placed. | -| `place_bet` | bettor | Stakes tokens on one outcome; updates the pools and the user's index. | -| `settle_event` | admin | Resolves to a winning outcome, takes the fee, records the payout figures. | -| `claim_winnings` | winning bettor | Withdraws stake plus pro-rata share of the losing pool. | -| `cancel_event` | admin | Voids an unresolved market. | -| `claim_refund` | bettor | After a cancellation, reclaims the exact stake. | +- `initialize_config` - anyone (the signer becomes admin). One-time setup: sets admin, stake + token, fee, fee recipient. +- `create_event` - admin. Opens a market and creates its vault. +- `add_outcome` - admin. Adds a possible result. Only before any bet is placed. +- `place_bet` - bettor. Stakes tokens on one outcome; updates the pools and adds the Bet to the + user's index (rejected with `TooManyBets` if all `MAX_BETS_PER_USER` slots hold open positions). +- `settle_event` - admin. Resolves to a winning outcome, takes the fee, records the payout figures. +- `claim_winnings` - winning bettor. Withdraws stake plus pro-rata share of the losing pool, then + closes the Bet account and removes it from the user's index. +- `close_losing_bet` - losing bettor. After settlement, closes a worthless Bet to reclaim its rent + and free the slot in the user's index. +- `cancel_event` - admin. Voids an unresolved market. +- `claim_refund` - bettor. After a cancellation, reclaims the exact stake; the Bet account closes + and leaves the user's index. `add_outcome` is locked once betting starts, so the field of choices can't change under existing -bettors. `settle_event` rejects a winning outcome with no bets — use `cancel_event` to unwind an +bettors. `settle_event` rejects a winning outcome with no bets - use `cancel_event` to unwind an event that can't be resolved fairly. ## Setup @@ -98,8 +106,9 @@ anchor build Tests are Rust integration tests running against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) with [solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle (bet → settle → claim with exact payout and fee assertions), admin authorization, the -bet-after-settle and double-claim guards, settling an outcome with no bets, and the cancel/refund -path. +bet-after-settle and double-claim guards, settling an outcome with no bets, the cancel/refund +path, the `close_losing_bet` guards, and the User index: claims, refunds, and losing-bet closes +remove the Bet's entry, and a wallet whose index is full can bet again after closing a position. ```sh anchor test diff --git a/tokens/betting-market/anchor/programs/betting-market/Cargo.toml b/tokens/betting-market/anchor/programs/betting-market/Cargo.toml index c5ba4119..d5b8919c 100644 --- a/tokens/betting-market/anchor/programs/betting-market/Cargo.toml +++ b/tokens/betting-market/anchor/programs/betting-market/Cargo.toml @@ -25,6 +25,12 @@ anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } anchor-spl = "1.0.0" [dev-dependencies] +# no-entrypoint: solana-kite pulls these SPL program crates into the host test +# build, and their `entrypoint` symbols collide with this program's own +# entrypoint at link time. Feature unification turns their entrypoints off +# across the test build; the dependencies exist only for that. +spl-token = { version = "9.0.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } litesvm = "0.11.0" solana-signer = "3.0.0" solana-keypair = "3.0.1" diff --git a/tokens/betting-market/anchor/programs/betting-market/src/error.rs b/tokens/betting-market/anchor/programs/betting-market/src/error.rs index fed5a42c..ecf7c14d 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/error.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/error.rs @@ -18,12 +18,16 @@ pub enum BettingError { InvalidWinningOutcome, #[msg("This bet did not win, so there is nothing to claim")] NothingToClaim, - #[msg("This bet has already been claimed")] - AlreadyClaimed, + #[msg("This bet won, so it must be closed via claim_winnings")] + BetWon, #[msg("The bet amount must be greater than zero")] ZeroAmount, - #[msg("This bettor already holds the maximum number of distinct bets")] + #[msg("This bettor already holds the maximum number of open positions")] TooManyBets, + #[msg("This bet is not in the bettor's User index")] + BetNotInUserIndex, + #[msg("Arithmetic overflow")] + MathOverflow, #[msg("Outcomes can only be added before any bets are placed")] BettingAlreadyStarted, #[msg("The event description is too long")] diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs index 1eaeb47a..0095688c 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs @@ -5,7 +5,7 @@ use crate::{error::BettingError, Config, Event, EventStatus, Outcome}; pub const MAX_LABEL_LEN: usize = 64; #[derive(Accounts)] -pub struct AddOutcome<'info> { +pub struct AddOutcomeAccountConstraints<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -35,7 +35,7 @@ pub struct AddOutcome<'info> { pub system_program: Program<'info, System>, } -pub fn handle_add_outcome(context: Context, label: String) -> Result<()> { +pub fn handle_add_outcome(context: Context, label: String) -> Result<()> { require!(label.len() <= MAX_LABEL_LEN, BettingError::LabelTooLong); require!( context.accounts.event.status == EventStatus::Open, diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs index bde8cd8b..2bec2049 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs @@ -5,7 +5,7 @@ use crate::{error::BettingError, Config, Event, EventStatus}; // Abandon an event that can't be resolved (e.g. the real-world result is void). // Bettors then reclaim their exact stakes via `claim_refund`; no fee is taken. #[derive(Accounts)] -pub struct CancelEvent<'info> { +pub struct CancelEventAccountConstraints<'info> { pub admin: Signer<'info>, #[account( @@ -23,7 +23,7 @@ pub struct CancelEvent<'info> { pub event: Account<'info, Event>, } -pub fn handle_cancel_event(context: Context) -> Result<()> { +pub fn handle_cancel_event(context: Context) -> Result<()> { require!( context.accounts.event.status == EventStatus::Open, BettingError::EventNotOpen diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs index 2e3591cb..c922d9cc 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs @@ -1,12 +1,12 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; -use crate::{error::BettingError, Bet, Event, EventStatus}; +use crate::{error::BettingError, Bet, Event, EventStatus, User}; use super::transfer_tokens_from_vault; #[derive(Accounts)] -pub struct ClaimRefund<'info> { +pub struct ClaimRefundAccountConstraints<'info> { #[account(mut)] pub bettor: Signer<'info>, @@ -19,8 +19,11 @@ pub struct ClaimRefund<'info> { )] pub event: Account<'info, Event>, + // Closing the Bet ends the position: the rent goes back to the bettor and + // a second refund fails because the account no longer exists. #[account( mut, + close = bettor, has_one = bettor, has_one = event, seeds = [b"bet", bet.outcome.as_ref(), bettor.key().as_ref()], @@ -28,6 +31,13 @@ pub struct ClaimRefund<'info> { )] pub bet: Account<'info, Bet>, + #[account( + mut, + seeds = [b"user", bettor.key().as_ref()], + bump = user.bump, + )] + pub user: Account<'info, User>, + #[account( mut, associated_token::mint = token_mint, @@ -47,14 +57,20 @@ pub struct ClaimRefund<'info> { pub token_program: Interface<'info, TokenInterface>, } -pub fn handle_claim_refund(context: Context) -> Result<()> { +pub fn handle_claim_refund(context: Context) -> Result<()> { require!( context.accounts.event.status == EventStatus::Cancelled, BettingError::EventNotCancelled ); - require!(!context.accounts.bet.claimed, BettingError::AlreadyClaimed); let stake = context.accounts.bet.amount; + + // The position is over, so drop the Bet from the bettor's index before the + // transfer (effects before interactions); the Bet account itself closes + // when the instruction finishes. + let bet_key = context.accounts.bet.key(); + context.accounts.user.remove_bet(&bet_key)?; + let event_id = context.accounts.event.event_id; let event_bump = context.accounts.event.bump; transfer_tokens_from_vault( @@ -68,6 +84,5 @@ pub fn handle_claim_refund(context: Context) -> Result<()> { event_bump, )?; - context.accounts.bet.claimed = true; Ok(()) } diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs index c4d3c0c3..912945d0 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs @@ -1,12 +1,12 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; -use crate::{error::BettingError, Bet, Event, EventStatus}; +use crate::{error::BettingError, Bet, Event, EventStatus, User}; use super::transfer_tokens_from_vault; #[derive(Accounts)] -pub struct ClaimWinnings<'info> { +pub struct ClaimWinningsAccountConstraints<'info> { #[account(mut)] pub bettor: Signer<'info>, @@ -19,8 +19,11 @@ pub struct ClaimWinnings<'info> { )] pub event: Account<'info, Event>, + // Closing the Bet ends the position: the rent goes back to the bettor and + // a second claim fails because the account no longer exists. #[account( mut, + close = bettor, has_one = bettor, has_one = event, seeds = [b"bet", bet.outcome.as_ref(), bettor.key().as_ref()], @@ -28,6 +31,13 @@ pub struct ClaimWinnings<'info> { )] pub bet: Account<'info, Bet>, + #[account( + mut, + seeds = [b"user", bettor.key().as_ref()], + bump = user.bump, + )] + pub user: Account<'info, User>, + #[account( mut, associated_token::mint = token_mint, @@ -47,12 +57,11 @@ pub struct ClaimWinnings<'info> { pub token_program: Interface<'info, TokenInterface>, } -pub fn handle_claim_winnings(context: Context) -> Result<()> { +pub fn handle_claim_winnings(context: Context) -> Result<()> { require!( context.accounts.event.status == EventStatus::Settled, BettingError::EventNotSettled ); - require!(!context.accounts.bet.claimed, BettingError::AlreadyClaimed); require!( context.accounts.bet.outcome_index == context.accounts.event.winning_outcome_index, BettingError::NothingToClaim @@ -65,13 +74,27 @@ pub fn handle_claim_winnings(context: Context) -> Result<()> { // Parimutuel split: winners share the losing pool in proportion to their // own stake. Work in u128 and divide once, after the multiply, so the - // result is floored a single time — dividing first would throw away - // precision. The floor leaves at most a few base units of dust in the vault. - let losing_pool_share_numerator = stake as u128 * distributable_losing_pool as u128; - let winnings = (losing_pool_share_numerator / winning_pool as u128) as u64; + // result is floored a single time - dividing first would throw away + // precision. The floor leaves at most a few minor units of dust in the vault. + let losing_pool_share_numerator = (stake as u128) + .checked_mul(distributable_losing_pool as u128) + .ok_or(BettingError::MathOverflow)?; + let winnings: u64 = losing_pool_share_numerator + .checked_div(winning_pool as u128) + .ok_or(BettingError::MathOverflow)? + .try_into() + .map_err(|_| BettingError::MathOverflow)?; // Winners always get their own stake back on top of their winnings. - let payout = stake + winnings; + let payout = stake + .checked_add(winnings) + .ok_or(BettingError::MathOverflow)?; + + // The position is over, so drop the Bet from the bettor's index before the + // transfer (effects before interactions); the Bet account itself closes + // when the instruction finishes. + let bet_key = context.accounts.bet.key(); + context.accounts.user.remove_bet(&bet_key)?; let event_id = context.accounts.event.event_id; let event_bump = context.accounts.event.bump; @@ -86,6 +109,5 @@ pub fn handle_claim_winnings(context: Context) -> Result<()> { event_bump, )?; - context.accounts.bet.claimed = true; Ok(()) } diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs new file mode 100644 index 00000000..ed533ddd --- /dev/null +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs @@ -0,0 +1,51 @@ +use anchor_lang::prelude::*; + +use crate::{error::BettingError, Bet, Event, EventStatus, User}; + +// A losing bet pays nothing, but it still occupies a slot in the bettor's +// User index and holds rent. Closing it frees the slot (so the bettor can +// open a new position) and returns the rent. Winning bets must go through +// claim_winnings instead, which also pays out the stake and winnings. +#[derive(Accounts)] +pub struct CloseLosingBetAccountConstraints<'info> { + #[account(mut)] + pub bettor: Signer<'info>, + + #[account( + seeds = [b"event", event.event_id.to_le_bytes().as_ref()], + bump = event.bump, + )] + pub event: Account<'info, Event>, + + #[account( + mut, + close = bettor, + has_one = bettor, + has_one = event, + seeds = [b"bet", bet.outcome.as_ref(), bettor.key().as_ref()], + bump = bet.bump, + )] + pub bet: Account<'info, Bet>, + + #[account( + mut, + seeds = [b"user", bettor.key().as_ref()], + bump = user.bump, + )] + pub user: Account<'info, User>, +} + +pub fn handle_close_losing_bet(context: Context) -> Result<()> { + require!( + context.accounts.event.status == EventStatus::Settled, + BettingError::EventNotSettled + ); + require!( + context.accounts.bet.outcome_index != context.accounts.event.winning_outcome_index, + BettingError::BetWon + ); + + let bet_key = context.accounts.bet.key(); + context.accounts.user.remove_bet(&bet_key)?; + Ok(()) +} diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/create_event.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/create_event.rs index ce7ea4ce..1ea59b71 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/create_event.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/create_event.rs @@ -10,7 +10,7 @@ pub const MAX_DESCRIPTION_LEN: usize = 200; #[derive(Accounts)] #[instruction(event_id: u64)] -pub struct CreateEvent<'info> { +pub struct CreateEventAccountConstraints<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -51,7 +51,7 @@ pub struct CreateEvent<'info> { } pub fn handle_create_event( - context: Context, + context: Context, event_id: u64, description: String, ) -> Result<()> { diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/initialize_config.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/initialize_config.rs index 8a6eec0c..ed66deaf 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/initialize_config.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/initialize_config.rs @@ -6,7 +6,7 @@ use crate::{error::BettingError, Config}; pub const MAX_FEE_BPS: u16 = 10_000; #[derive(Accounts)] -pub struct InitializeConfig<'info> { +pub struct InitializeConfigAccountConstraints<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -27,7 +27,7 @@ pub struct InitializeConfig<'info> { } pub fn handle_initialize_config( - context: Context, + context: Context, fee_bps: u16, fee_recipient: Pubkey, ) -> Result<()> { diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/mod.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/mod.rs index 46739370..47ba4600 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/mod.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/mod.rs @@ -2,6 +2,7 @@ pub mod add_outcome; pub mod cancel_event; pub mod claim_refund; pub mod claim_winnings; +pub mod close_losing_bet; pub mod create_event; pub mod initialize_config; pub mod place_bet; @@ -12,6 +13,7 @@ pub use add_outcome::*; pub use cancel_event::*; pub use claim_refund::*; pub use claim_winnings::*; +pub use close_losing_bet::*; pub use create_event::*; pub use initialize_config::*; pub use place_bet::*; diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs index 03a85ee2..b7043e6f 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs @@ -11,7 +11,7 @@ use crate::{ use super::transfer_tokens_to_vault; #[derive(Accounts)] -pub struct PlaceBet<'info> { +pub struct PlaceBetAccountConstraints<'info> { #[account(mut)] pub bettor: Signer<'info>, @@ -79,7 +79,7 @@ pub struct PlaceBet<'info> { pub system_program: Program<'info, System>, } -pub fn handle_place_bet(context: Context, amount: u64) -> Result<()> { +pub fn handle_place_bet(context: Context, amount: u64) -> Result<()> { require!(amount > 0, BettingError::ZeroAmount); require!( context.accounts.event.status == EventStatus::Open, @@ -112,18 +112,30 @@ pub fn handle_place_bet(context: Context, amount: u64) -> Result<()> { bet.event = event_key; bet.outcome = outcome_key; bet.outcome_index = outcome_index; - bet.claimed = false; bet.bump = bet_bump; } - bet.amount += amount; + bet.amount = bet + .amount + .checked_add(amount) + .ok_or(BettingError::MathOverflow)?; let outcome = &mut context.accounts.outcome; - outcome.total_amount += amount; + outcome.total_amount = outcome + .total_amount + .checked_add(amount) + .ok_or(BettingError::MathOverflow)?; if is_new_bet { - outcome.bet_count += 1; + outcome.bet_count = outcome + .bet_count + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; } - context.accounts.event.total_pool += amount; + let event = &mut context.accounts.event; + event.total_pool = event + .total_pool + .checked_add(amount) + .ok_or(BettingError::MathOverflow)?; let user = &mut context.accounts.user; if user.authority == Pubkey::default() { diff --git a/tokens/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs b/tokens/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs index c8f3a103..debfae8e 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs @@ -12,7 +12,7 @@ const BPS_DENOMINATOR: u128 = 10_000; #[derive(Accounts)] #[instruction(winning_outcome_index: u8)] -pub struct SettleEvent<'info> { +pub struct SettleEventAccountConstraints<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -68,7 +68,7 @@ pub struct SettleEvent<'info> { } pub fn handle_settle_event( - context: Context, + context: Context, winning_outcome_index: u8, ) -> Result<()> { require!( diff --git a/tokens/betting-market/anchor/programs/betting-market/src/lib.rs b/tokens/betting-market/anchor/programs/betting-market/src/lib.rs index 0cf2bec0..c38cdaaf 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/lib.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/lib.rs @@ -16,7 +16,7 @@ pub mod betting_market { // One-time setup: the signer becomes the admin and fixes the stake token and // the settlement fee (basis points) for every market in this deployment. pub fn initialize_config( - context: Context, + context: Context, fee_bps: u16, fee_recipient: Pubkey, ) -> Result<()> { @@ -25,7 +25,7 @@ pub mod betting_market { // Admin opens a new market and creates its pool vault. pub fn create_event( - context: Context, + context: Context, event_id: u64, description: String, ) -> Result<()> { @@ -33,33 +33,41 @@ pub mod betting_market { } // Admin adds a possible result. Only allowed before betting starts. - pub fn add_outcome(context: Context, label: String) -> Result<()> { + pub fn add_outcome(context: Context, label: String) -> Result<()> { instructions::add_outcome::handle_add_outcome(context, label) } // A bettor stakes tokens on one outcome. The stake joins the event's pool. - pub fn place_bet(context: Context, amount: u64) -> Result<()> { + pub fn place_bet(context: Context, amount: u64) -> Result<()> { instructions::place_bet::handle_place_bet(context, amount) } // Admin resolves the market: takes the fee from the losing pool and records // the figures winners need to claim their share. - pub fn settle_event(context: Context, winning_outcome_index: u8) -> Result<()> { + pub fn settle_event(context: Context, winning_outcome_index: u8) -> Result<()> { instructions::settle_event::handle_settle_event(context, winning_outcome_index) } - // A winner withdraws their stake plus their pro-rata share of the losing pool. - pub fn claim_winnings(context: Context) -> Result<()> { + // A winner withdraws their stake plus their pro-rata share of the losing + // pool. The Bet account closes and leaves the bettor's User index. + pub fn claim_winnings(context: Context) -> Result<()> { instructions::claim_winnings::handle_claim_winnings(context) } + // A loser closes their worthless bet after settlement, reclaiming the + // Bet account's rent and freeing the slot in their User index. + pub fn close_losing_bet(context: Context) -> Result<()> { + instructions::close_losing_bet::handle_close_losing_bet(context) + } + // Admin voids an unresolved market so bettors can be made whole. - pub fn cancel_event(context: Context) -> Result<()> { + pub fn cancel_event(context: Context) -> Result<()> { instructions::cancel_event::handle_cancel_event(context) } - // After a cancellation, a bettor reclaims their exact stake. - pub fn claim_refund(context: Context) -> Result<()> { + // After a cancellation, a bettor reclaims their exact stake. The Bet + // account closes and leaves the bettor's User index. + pub fn claim_refund(context: Context) -> Result<()> { instructions::claim_refund::handle_claim_refund(context) } } diff --git a/tokens/betting-market/anchor/programs/betting-market/src/state/bet.rs b/tokens/betting-market/anchor/programs/betting-market/src/state/bet.rs index c1d0f8fb..65330d78 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/state/bet.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/state/bet.rs @@ -2,7 +2,9 @@ use anchor_lang::prelude::*; // A single bettor's total stake on one outcome. Re-betting the same outcome // adds to `amount` rather than creating a second account, so there is exactly -// one Bet per (outcome, bettor). +// one Bet per (outcome, bettor). The account lives only while the position is +// open: it closes (rent back to the bettor) on claim_winnings, claim_refund, +// or close_losing_bet, which is also what prevents double claims. #[account] #[derive(InitSpace)] pub struct Bet { @@ -11,6 +13,5 @@ pub struct Bet { pub outcome: Pubkey, pub outcome_index: u8, pub amount: u64, - pub claimed: bool, pub bump: u8, } diff --git a/tokens/betting-market/anchor/programs/betting-market/src/state/user.rs b/tokens/betting-market/anchor/programs/betting-market/src/state/user.rs index b8deb4f9..44e41b0f 100644 --- a/tokens/betting-market/anchor/programs/betting-market/src/state/user.rs +++ b/tokens/betting-market/anchor/programs/betting-market/src/state/user.rs @@ -1,14 +1,19 @@ use anchor_lang::prelude::*; -// A bettor can hold at most this many distinct bets (one per outcome they back). -// Re-betting an outcome adds to the existing Bet, so this caps the number of -// *different* outcomes a user has staked on, not the number of times they bet. -// A fixed cap keeps the account a constant size — no reallocation on each bet. +use crate::error::BettingError; + +// A bettor can hold at most this many OPEN positions at once (one per outcome +// they currently back). Re-betting an outcome adds to the existing Bet, and +// closing a Bet (claim_winnings, claim_refund, close_losing_bet) removes its +// entry, so this caps concurrent positions, not lifetime bets. A fixed cap +// keeps the account a constant size - no reallocation on each bet. pub const MAX_BETS_PER_USER: usize = 32; -// Per-wallet index of a bettor's bets, so a client can list someone's positions -// without scanning every Bet account on the program. The authoritative stake -// state lives in the Bet accounts; this is a convenience index. +// Per-wallet index of a bettor's open Bet accounts, so a client can list +// someone's positions without scanning every Bet account on the program. The +// authoritative stake state lives in the Bet accounts; this is a convenience +// index. Entries are added by place_bet and removed whenever the Bet account +// closes. #[account] #[derive(InitSpace)] pub struct User { @@ -17,3 +22,17 @@ pub struct User { pub bets: Vec, pub bump: u8, } + +impl User { + // Drop a closed Bet's entry from the index. Order is not meaningful, so a + // swap_remove (move the last entry into the gap) is the cheapest removal. + pub fn remove_bet(&mut self, bet_key: &Pubkey) -> Result<()> { + let index = self + .bets + .iter() + .position(|entry| entry == bet_key) + .ok_or(BettingError::BetNotInUserIndex)?; + self.bets.swap_remove(index); + Ok(()) + } +} diff --git a/tokens/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs b/tokens/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs index 5a6966af..e47d0644 100644 --- a/tokens/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs +++ b/tokens/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs @@ -1,8 +1,9 @@ use { anchor_lang::{ solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, - InstructionData, ToAccountMetas, + AccountDeserialize, InstructionData, ToAccountMetas, }, + betting_market::{User, MAX_BETS_PER_USER}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -110,7 +111,7 @@ fn initialize_config_ix(admin: Pubkey, mint: Pubkey, fee_recipient: Pubkey) -> I fee_recipient, } .data(), - betting_market::accounts::InitializeConfig { + betting_market::accounts::InitializeConfigAccountConstraints { admin, token_mint: mint, config: config_pda(), @@ -130,7 +131,7 @@ fn create_event_ix(admin: Pubkey, mint: Pubkey, event_id: u64, description: &str description: description.to_string(), } .data(), - betting_market::accounts::CreateEvent { + betting_market::accounts::CreateEventAccountConstraints { admin, config: config_pda(), token_mint: mint, @@ -152,7 +153,7 @@ fn add_outcome_ix(admin: Pubkey, event_id: u64, index: u8, label: &str) -> Instr label: label.to_string(), } .data(), - betting_market::accounts::AddOutcome { + betting_market::accounts::AddOutcomeAccountConstraints { admin, config: config_pda(), event, @@ -176,7 +177,7 @@ fn place_bet_ix( Instruction::new_with_bytes( betting_market::id(), &betting_market::instruction::PlaceBet { amount }.data(), - betting_market::accounts::PlaceBet { + betting_market::accounts::PlaceBetAccountConstraints { bettor: *bettor, config: config_pda(), token_mint: mint, @@ -209,7 +210,7 @@ fn settle_event_ix( winning_outcome_index, } .data(), - betting_market::accounts::SettleEvent { + betting_market::accounts::SettleEventAccountConstraints { admin, config: config_pda(), token_mint: mint, @@ -238,11 +239,12 @@ fn claim_winnings_ix( Instruction::new_with_bytes( betting_market::id(), &betting_market::instruction::ClaimWinnings {}.data(), - betting_market::accounts::ClaimWinnings { + betting_market::accounts::ClaimWinningsAccountConstraints { bettor: *bettor, token_mint: mint, event, bet: bet_pda(&outcome, bettor), + user: user_pda(bettor), bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), token_program: token_program_id(), @@ -255,7 +257,7 @@ fn cancel_event_ix(admin: Pubkey, event_id: u64) -> Instruction { Instruction::new_with_bytes( betting_market::id(), &betting_market::instruction::CancelEvent {}.data(), - betting_market::accounts::CancelEvent { + betting_market::accounts::CancelEventAccountConstraints { admin, config: config_pda(), event: event_pda(event_id), @@ -276,11 +278,12 @@ fn claim_refund_ix( Instruction::new_with_bytes( betting_market::id(), &betting_market::instruction::ClaimRefund {}.data(), - betting_market::accounts::ClaimRefund { + betting_market::accounts::ClaimRefundAccountConstraints { bettor: *bettor, token_mint: mint, event, bet: bet_pda(&outcome, bettor), + user: user_pda(bettor), bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), token_program: token_program_id(), @@ -289,12 +292,29 @@ fn claim_refund_ix( ) } -// Decode a User account's `bets` Vec length from raw account data. -// Layout after the 8-byte discriminator: authority (32) + vec_len (4) + entries. -fn read_user_bet_count(market: &Market, bettor: &Pubkey) -> u32 { +fn close_losing_bet_ix(bettor: &Pubkey, event_id: u64, outcome_index: u8) -> Instruction { + let event = event_pda(event_id); + let outcome = outcome_pda(&event, outcome_index); + Instruction::new_with_bytes( + betting_market::id(), + &betting_market::instruction::CloseLosingBet {}.data(), + betting_market::accounts::CloseLosingBetAccountConstraints { + bettor: *bettor, + event, + bet: bet_pda(&outcome, bettor), + user: user_pda(bettor), + } + .to_account_metas(None), + ) +} + +// Decode a User account so tests can assert exactly which Bet addresses the +// per-wallet index currently holds. +fn read_user_bets(market: &Market, bettor: &Pubkey) -> Vec { let account = market.svm.get_account(&user_pda(bettor)).unwrap(); - let data = &account.data[8..]; - u32::from_le_bytes(data[32..36].try_into().unwrap()) + User::try_deserialize(&mut account.data.as_slice()) + .unwrap() + .bets } fn init_config(market: &mut Market) { @@ -362,7 +382,10 @@ fn test_full_lifecycle() { // Vault holds the entire pool. let vault = derive_ata(&event_pda(event_id), &mint); assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 600); - assert_eq!(read_user_bet_count(&market, &alice.pubkey()), 1); + assert_eq!( + read_user_bets(&market, &alice.pubkey()), + vec![bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey())] + ); // Settle to "Yes" (index 0). Losing pool 200, fee = 2% = 4, distributable = 196. let fee_recipient = market.fee_recipient.pubkey(); @@ -406,6 +429,10 @@ fn test_full_lifecycle() { // Pool fully distributed: 400 stakes + 196 winnings + 4 fee = 600. assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 0); + // Claiming closed the winners' Bet accounts and emptied their indexes. + assert!(read_user_bets(&market, &alice.pubkey()).is_empty()); + assert!(read_user_bets(&market, &bob.pubkey()).is_empty()); + // Carol bet the losing outcome, so she has nothing to claim. let carol_claim = send_transaction_from_instructions( &mut market.svm, @@ -414,6 +441,18 @@ fn test_full_lifecycle() { &carol.pubkey(), ); assert!(carol_claim.is_err(), "loser must not be able to claim winnings"); + + // Her losing position stays in the index until she closes it. + let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); + assert_eq!(read_user_bets(&market, &carol.pubkey()), vec![carol_bet]); + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + assert!(read_user_bets(&market, &carol.pubkey()).is_empty()); } #[test] @@ -626,6 +665,9 @@ fn test_cancel_and_refund() { ) .unwrap(); + let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); + assert_eq!(read_user_bets(&market, &alice.pubkey()), vec![alice_bet]); + send_transaction_from_instructions( &mut market.svm, vec![claim_refund_ix(mint, &alice.pubkey(), &alice_ata, event_id, 0)], @@ -633,6 +675,10 @@ fn test_cancel_and_refund() { &alice.pubkey(), ) .unwrap(); + + // The refund closed Alice's Bet account and removed it from her index. + assert!(read_user_bets(&market, &alice.pubkey()).is_empty()); + send_transaction_from_instructions( &mut market.svm, vec![claim_refund_ix(mint, &carol.pubkey(), &carol_ata, event_id, 1)], @@ -647,3 +693,202 @@ fn test_cancel_and_refund() { let vault = derive_ata(&event_pda(event_id), &mint); assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 0); } + +#[test] +fn test_close_losing_bet_only_after_settle_and_only_for_losers() { + let mut market = setup(); + let event_id: u64 = 6; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + send_transaction_from_instructions( + &mut market.svm, + vec![ + create_event_ix(admin, mint, event_id, "Derby winner"), + add_outcome_ix(admin, event_id, 0, "Red"), + add_outcome_ix(admin, event_id, 1, "Blue"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix(mint, &alice.pubkey(), &alice_ata, event_id, 0, 100)], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix(mint, &carol.pubkey(), &carol_ata, event_id, 1, 100)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + + // The event is still open, so no position is a losing one yet. + let premature_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ); + assert!(premature_close.is_err(), "closing before settlement must fail"); + + send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix(admin, mint, fee_recipient, fee_recipient_ata, event_id, 0)], + &[&market.admin], + &admin, + ) + .unwrap(); + + // Alice won; her bet must be closed via claim_winnings, not discarded. + let winner_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&alice.pubkey(), event_id, 0)], + &[&alice], + &alice.pubkey(), + ); + assert!(winner_close.is_err(), "a winning bet must not be closed as losing"); + let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); + assert_eq!(read_user_bets(&market, &alice.pubkey()), vec![alice_bet]); + + // Carol lost; closing frees her index slot. A fresh blockhash so this is + // a distinct transaction from her premature attempt above. + market.svm.expire_blockhash(); + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + assert!(read_user_bets(&market, &carol.pubkey()).is_empty()); +} + +// Regression test: closing a Bet must free its User index slot, so a wallet +// that fills all MAX_BETS_PER_USER slots can bet again after unwinding a +// position. Without the removal, a full index rejects every future bet on +// every market, permanently. +#[test] +fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { + const STAKE: u64 = 10; + let mut market = setup(); + let full_event_id: u64 = 7; + let second_event_id: u64 = 8; + // Enough outcomes to fill the index and attempt one more bet. + let outcome_count = (MAX_BETS_PER_USER + 1) as u8; + let (alice, alice_ata) = create_bettor(&mut market, outcome_count as u64 * STAKE); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + + send_transaction_from_instructions( + &mut market.svm, + vec![create_event_ix(admin, mint, full_event_id, "Wide field")], + &[&market.admin], + &admin, + ) + .unwrap(); + for index in 0..outcome_count { + send_transaction_from_instructions( + &mut market.svm, + vec![add_outcome_ix(admin, full_event_id, index, &format!("Runner {index}"))], + &[&market.admin], + &admin, + ) + .unwrap(); + } + send_transaction_from_instructions( + &mut market.svm, + vec![ + create_event_ix(admin, mint, second_event_id, "Second market"), + add_outcome_ix(admin, second_event_id, 0, "Yes"), + add_outcome_ix(admin, second_event_id, 1, "No"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + // Fill every slot in Alice's index. + for index in 0..MAX_BETS_PER_USER as u8 { + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix(mint, &alice.pubkey(), &alice_ata, full_event_id, index, STAKE)], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + } + assert_eq!(read_user_bets(&market, &alice.pubkey()).len(), MAX_BETS_PER_USER); + + // With the index full, any new position is rejected - on this event or another. + let one_too_many = send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix( + mint, + &alice.pubkey(), + &alice_ata, + full_event_id, + MAX_BETS_PER_USER as u8, + STAKE, + )], + &[&alice], + &alice.pubkey(), + ); + assert!(one_too_many.is_err(), "a full index must reject a new position"); + let other_market_bet = send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix(mint, &alice.pubkey(), &alice_ata, second_event_id, 0, STAKE)], + &[&alice], + &alice.pubkey(), + ); + assert!(other_market_bet.is_err(), "a full index must reject bets on any market"); + + // Unwind one position: cancel the event and refund the first bet. + send_transaction_from_instructions( + &mut market.svm, + vec![cancel_event_ix(admin, full_event_id)], + &[&market.admin], + &admin, + ) + .unwrap(); + send_transaction_from_instructions( + &mut market.svm, + vec![claim_refund_ix(mint, &alice.pubkey(), &alice_ata, full_event_id, 0)], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + let bets_after_refund = read_user_bets(&market, &alice.pubkey()); + assert_eq!(bets_after_refund.len(), MAX_BETS_PER_USER - 1); + let refunded_bet = bet_pda(&outcome_pda(&event_pda(full_event_id), 0), &alice.pubkey()); + assert!( + !bets_after_refund.contains(&refunded_bet), + "the refunded bet must leave the index" + ); + + // The freed slot lets the wallet bet again. A fresh blockhash so this is + // a distinct transaction from the rejected attempt above. + market.svm.expire_blockhash(); + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix(mint, &alice.pubkey(), &alice_ata, second_event_id, 0, STAKE)], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + let final_bets = read_user_bets(&market, &alice.pubkey()); + assert_eq!(final_bets.len(), MAX_BETS_PER_USER); + let new_bet = bet_pda(&outcome_pda(&event_pda(second_event_id), 0), &alice.pubkey()); + assert!(final_bets.contains(&new_bet), "the new position must appear in the index"); +} diff --git a/tokens/create-token/README.md b/tokens/create-token/README.md index 4b1ed65b..dc4e544b 100644 --- a/tokens/create-token/README.md +++ b/tokens/create-token/README.md @@ -31,7 +31,7 @@ A token is represented [onchain](https://solana.com/docs/terminology#onchain) by } ``` -Metadata about a mint — name, symbol, image URI — lives in a separate **Metadata [Account](https://solana.com/docs/terminology#account)**: +Metadata about a mint - name, symbol, image URI - lives in a separate **Metadata [Account](https://solana.com/docs/terminology#account)**: ```typescript { diff --git a/tokens/create-token/anchor/Anchor.toml b/tokens/create-token/anchor/Anchor.toml index c13c0f16..1b6d1e6c 100644 --- a/tokens/create-token/anchor/Anchor.toml +++ b/tokens/create-token/anchor/Anchor.toml @@ -8,14 +8,12 @@ skip-lint = false [programs.localnet] create_token = "GwvQ53QTu1xz3XXYfG5m5jEqwhMBvVBudPS8TUuFYnhT" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# Only run bankrun tests — the validator tests (test.ts) need Metaplex Token +# Only run bankrun tests - the validator tests (test.ts) need Metaplex Token # Metadata cloned from mainnet which is too slow/unreliable in CI. # bankrun.test.ts uses a local fixture (tests/fixtures/token_metadata.so). test = "cargo test" diff --git a/tokens/create-token/anchor/programs/create-token/src/lib.rs b/tokens/create-token/anchor/programs/create-token/src/lib.rs index 1596b45a..59173b77 100644 --- a/tokens/create-token/anchor/programs/create-token/src/lib.rs +++ b/tokens/create-token/anchor/programs/create-token/src/lib.rs @@ -16,7 +16,7 @@ pub mod create_token { use super::*; pub fn create_token_mint( - context: Context, + context: Context, _token_decimals: u8, token_name: String, token_symbol: String, @@ -65,7 +65,7 @@ pub mod create_token { #[derive(Accounts)] #[instruction(_token_decimals: u8)] -pub struct CreateTokenMint<'info> { +pub struct CreateTokenMintAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, diff --git a/tokens/create-token/anchor/programs/create-token/tests/test_create_token.rs b/tokens/create-token/anchor/programs/create-token/tests/test_create_token.rs index 8acc0195..11e7bf7a 100644 --- a/tokens/create-token/anchor/programs/create-token/tests/test_create_token.rs +++ b/tokens/create-token/anchor/programs/create-token/tests/test_create_token.rs @@ -66,7 +66,7 @@ fn test_create_spl_token() { token_uri: "https://example.com/token.json".to_string(), } .data(), - create_token::accounts::CreateTokenMint { + create_token::accounts::CreateTokenMintAccountConstraints { payer: payer.pubkey(), metadata_account, mint_account: mint_keypair.pubkey(), @@ -117,7 +117,7 @@ fn test_create_nft() { token_uri: "https://example.com/nft.json".to_string(), } .data(), - create_token::accounts::CreateTokenMint { + create_token::accounts::CreateTokenMintAccountConstraints { payer: payer.pubkey(), metadata_account, mint_account: mint_keypair.pubkey(), diff --git a/tokens/create-token/quasar/Cargo.toml b/tokens/create-token/quasar/Cargo.toml index 329ef92e..42c073e5 100644 --- a/tokens/create-token/quasar/Cargo.toml +++ b/tokens/create-token/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-create-token" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/tokens/create-token/quasar/README.md b/tokens/create-token/quasar/README.md index 80960c39..7d4a9282 100644 --- a/tokens/create-token/quasar/README.md +++ b/tokens/create-token/quasar/README.md @@ -1,13 +1,15 @@ # Create Token (Quasar) -Create a mint with metadata using Token and Metaplex programs. +Create a token mint and mint tokens to a token account. + +The Anchor variant also creates Metaplex metadata; this Quasar variant focuses on the core SPL Token operations. Quasar's metadata crate is demonstrated in the [nft-operations](../../nft-operations/quasar/) example. See also: [Create Token overview](../README.md) and the [repository catalog](../../../README.md). ## Major concepts -- Mint + metadata CPI -- See [tokens/create-token/README.md](../create-token/README.md) +- `create_token` takes a `decimals` instruction argument and initializes the mint with it (create_account + initialize_mint2 CPIs) +- `mint_tokens` takes `amount` in minor units, the raw integer the token program operates on ## Setup diff --git a/tokens/create-token/quasar/src/lib.rs b/tokens/create-token/quasar/src/lib.rs index 223aa4d6..bbbb486f 100644 --- a/tokens/create-token/quasar/src/lib.rs +++ b/tokens/create-token/quasar/src/lib.rs @@ -1,57 +1,64 @@ #![cfg_attr(not(test), no_std)] -use quasar_lang::prelude::*; -use quasar_spl::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar}; +use quasar_spl::{initialize_mint2, prelude::*}; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("GwvQ53QTu1xz3XXYfG5m5jEqwhMBvVBudPS8TUuFYnhT"); + +/// SPL Mint account size in bytes. +const MINT_SPACE: usize = 82; /// Creates a token mint and mints initial tokens to the creator's token account. /// /// The Anchor version uses Metaplex for onchain metadata. Quasar's metadata -/// crate is demonstrated in the `nft-minter` and `token-minter` examples; -/// this example focuses on the core SPL Token operations: creating a mint and -/// minting tokens. +/// crate is demonstrated in the `nft-operations` example; this example focuses +/// on the core SPL Token operations: creating a mint and minting tokens. #[program] mod quasar_create_token { use super::*; - /// Create a new token mint (account init handled by Quasar's `#[account(init)]`). + /// Create a new token mint with the caller-supplied number of decimals. #[instruction(discriminator = 0)] - pub fn create_token(ctx: Ctx, _decimals: u8) -> Result<(), ProgramError> { - handle_create_token(&mut ctx.accounts) + pub fn create_token( + ctx: Ctx, + decimals: u8, + ) -> Result<(), ProgramError> { + handle_create_token(&mut ctx.accounts, decimals) } - /// Mint tokens to the creator's token account. + /// Mint `amount` minor units to the creator's token account. #[instruction(discriminator = 1)] - pub fn mint_tokens(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn mint_tokens( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { handle_mint_tokens(&mut ctx.accounts, amount) } } /// Accounts for creating a new token mint. -/// Quasar's `#[account(init)]` handles the create_account + initialize_mint CPI. +/// +/// The mint is created and initialized in the handler (create_account + +/// initialize_mint2 CPIs) rather than through Quasar's `mint(...)` init +/// constraint, because constraint arguments must be account fields or +/// literals and cannot reference the `decimals` instruction argument. #[derive(Accounts)] -pub struct CreateToken { +pub struct CreateTokenAccountConstraints { #[account(mut)] pub payer: Signer, - #[account( - mut, - init, - payer = payer, - mint(decimals = 9, authority = payer, freeze_authority = None, token_program = token_program), - )] - pub mint: Account, - pub rent: Sysvar, + /// The new mint. Must sign (it is a fresh keypair account). + #[account(mut)] + pub mint: UncheckedAccount, pub token_program: Program, pub system_program: Program, } /// Accounts for minting tokens to an existing token account. #[derive(Accounts)] -pub struct MintTokens { +pub struct MintTokensAccountConstraints { #[account(mut)] pub authority: Signer, #[account(mut)] @@ -62,14 +69,48 @@ pub struct MintTokens { } #[inline(always)] -fn handle_create_token(_accounts: &mut CreateToken) -> Result<(), ProgramError> { - // Mint account is created and initialised by Quasar's account init. - Ok(()) +fn handle_create_token( + accounts: &mut CreateTokenAccountConstraints, + decimals: u8, +) -> Result<(), ProgramError> { + let payer_address = *accounts.payer.address(); + + let rent = Rent::get()?; + let lamports = rent.minimum_balance_unchecked(MINT_SPACE); + + accounts + .system_program + .create_account( + &accounts.payer, + &accounts.mint, + lamports, + MINT_SPACE as u64, + accounts.token_program.address(), + ) + .invoke()?; + + initialize_mint2( + accounts.token_program.to_account_view(), + accounts.mint.to_account_view(), + decimals, + &payer_address, + None, + ) + .invoke() } #[inline(always)] -fn handle_mint_tokens(accounts: &mut MintTokens, amount: u64) -> Result<(), ProgramError> { - accounts.token_program - .mint_to(&accounts.mint, &accounts.token_account, &accounts.authority, amount) +fn handle_mint_tokens( + accounts: &mut MintTokensAccountConstraints, + amount: u64, +) -> Result<(), ProgramError> { + accounts + .token_program + .mint_to( + &accounts.mint, + &accounts.token_account, + &accounts.authority, + amount, + ) .invoke() } diff --git a/tokens/create-token/quasar/src/tests.rs b/tokens/create-token/quasar/src/tests.rs index 97bc420e..8e99ad1a 100644 --- a/tokens/create-token/quasar/src/tests.rs +++ b/tokens/create-token/quasar/src/tests.rs @@ -53,14 +53,6 @@ fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> A ) } -/// Mark specific account indices as signers. -fn with_signers(mut ix: Instruction, indices: &[usize]) -> Instruction { - for &i in indices { - ix.accounts[i].is_signer = true; - } - ix -} - /// Build create_token instruction data. /// Wire format: [discriminator: u8 = 0] [decimals: u8] fn build_create_token_data(decimals: u8) -> Vec { @@ -83,16 +75,17 @@ fn test_create_token() { let mint_address = Pubkey::new_unique(); let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; let system_program = quasar_svm::system_program::ID; - let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - let data = build_create_token_data(9); + // Deliberately not 9: proves the decimals instruction argument reaches + // the initialize_mint2 CPI instead of being hardcoded. + let requested_decimals = 6u8; + let data = build_create_token_data(requested_decimals); let instruction = Instruction { program_id: crate::ID, accounts: vec![ solana_instruction::AccountMeta::new(payer.into(), true), solana_instruction::AccountMeta::new(mint_address.into(), true), - solana_instruction::AccountMeta::new_readonly(rent.into(), false), solana_instruction::AccountMeta::new_readonly(token_program.into(), false), solana_instruction::AccountMeta::new_readonly(system_program.into(), false), ], @@ -105,6 +98,14 @@ fn test_create_token() { ); assert!(result.is_ok(), "create_token failed: {:?}", result.raw_result); + + // The created mint must carry the requested decimals. + let mint_account = result.account(&mint_address).expect("mint should exist"); + let mint_state = + ::unpack(&mint_account.data).expect("valid mint"); + assert_eq!(mint_state.decimals, requested_decimals); + assert_eq!(mint_state.mint_authority, Some(payer).into()); + println!(" CREATE TOKEN CU: {}", result.compute_units_consumed); } @@ -141,5 +142,17 @@ fn test_mint_tokens() { ); assert!(result.is_ok(), "mint_tokens failed: {:?}", result.raw_result); + + // The handler mints exactly the minor-unit amount passed: no decimal scaling. + let token_after = result.account(&token_addr).expect("token account exists"); + let token_state = ::unpack(&token_after.data) + .expect("valid token account"); + assert_eq!(token_state.amount, amount); + + let mint_after = result.account(&mint_address).expect("mint exists"); + let mint_state = + ::unpack(&mint_after.data).expect("valid mint"); + assert_eq!(mint_state.supply, amount); + println!(" MINT TOKENS CU: {}", result.compute_units_consumed); } diff --git a/tokens/external-delegate-token-master/README.md b/tokens/external-delegate-token-master/README.md new file mode 100644 index 00000000..d24ba6d8 --- /dev/null +++ b/tokens/external-delegate-token-master/README.md @@ -0,0 +1,45 @@ +# External Delegate Token Master + +A program that lets an **external delegate**, identified by an Ethereum address, authorize token transfers out of a program-controlled vault using a secp256k1 signature, without that delegate ever holding a Solana keypair. + +Two builds of the same program live here: [anchor/](anchor/) and [quasar/](quasar/). They share the same state layout semantics, the same signed-message format, and the same checks, so a client written against one works against the other. + +## How it works + +Each user creates a **user account** (`initialize` instruction handler) storing three fields: + +- `authority`: the Solana wallet that owns the user account. Every instruction requires this wallet as a signer. +- `ethereum_address`: a 20-byte Ethereum address set later via `set_ethereum_address`. The delegate's secp256k1 key hashes to this address. +- `nonce`: a strictly increasing counter, starting at zero, consumed by each signature-authorized transfer. + +Tokens sit in a token account owned by a **user PDA** derived from the user account's address. The program signs transfer CPIs with this PDA. There are two ways to move tokens, both using `transfer_checked` so the mint and decimals are verified in the CPI: + +- `authority_transfer`: the Solana authority signs the transaction directly. +- `transfer_tokens`: the Solana authority signs the transaction AND presents a 65-byte recoverable secp256k1 signature from the delegate. The signature supplements the authority check, it does not replace it. + +## Signed message format + +The program reconstructs the signed message onchain. The delegate signs the keccak256 hash of this 112-byte preimage: + +- program id (32 bytes) +- user account address (32 bytes) +- amount in minor units (8 bytes, little-endian u64) +- recipient token account address (32 bytes) +- nonce (8 bytes, little-endian u64) + +The signature is `r || s || recovery id` (65 bytes), over the 32-byte keccak hash directly. + +## Nonce semantics + +The hash commits to the user account's current `nonce`. On every successful `transfer_tokens` the program increments the stored nonce before invoking the transfer CPI, so: + +- each signature authorizes exactly one execution; replaying it fails because the reconstructed message changes, +- a signature over a different amount or recipient fails verification, +- signatures cannot be transplanted between user accounts or programs, because the user account address and program id are part of the hash. + +## Testing + +Each variant has in-process SVM tests that initialize a user account with a fixed secp256k1 test key, sign real transfer authorizations, send transactions, and assert token balances and nonce state, including the replay, wrong-amount, wrong-recipient, and wrong-authority failure paths. + +- Anchor variant: from [anchor/](anchor/), run `cargo build-sbf` then `cargo test` (LiteSVM). +- Quasar variant: from [quasar/](quasar/), run `quasar build` then `quasar test` (QuasarSVM). diff --git a/tokens/external-delegate-token-master/anchor/Anchor.toml b/tokens/external-delegate-token-master/anchor/Anchor.toml index b0562260..daef46fa 100644 --- a/tokens/external-delegate-token-master/anchor/Anchor.toml +++ b/tokens/external-delegate-token-master/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] external_delegate_token_master = "FYPkt5VWMvtyWZDMGCwoKFkE3wXTzphicTpnNGuHWVbD" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/external-delegate-token-master/anchor/README.md b/tokens/external-delegate-token-master/anchor/README.md index 326b2ae5..2fb02923 100644 --- a/tokens/external-delegate-token-master/anchor/README.md +++ b/tokens/external-delegate-token-master/anchor/README.md @@ -2,34 +2,36 @@ Authorize token transfers using an external secp256k1 delegate signature. -See also: the [repository catalog](../../../README.md). +See the [example overview](../README.md) for the signed-message format and nonce semantics shared with the [Quasar variant](../quasar/), and the [repository catalog](../../../README.md). ## Major concepts -- Delegate approval flow -- Signature verification onchain +- `UserAccount` state: the Solana `authority`, the delegate's 20-byte `ethereum_address`, and a `nonce` consumed by each signature-authorized transfer. +- `transfer_tokens` rebuilds the authorized message onchain as keccak256(program id || user account || amount LE || recipient token account || nonce LE), recovers the signer with the secp256k1 syscall, compares the recovered Ethereum address to the stored one, and increments the nonce before the transfer CPI. The `authority` must also sign the transaction; the Ethereum signature supplements that check. +- `authority_transfer` moves tokens with only the Solana authority's signature. +- Both transfer handlers use `transfer_checked` through `anchor_spl::token_interface`, so the program works against the Classic Token Program and the Token Extensions Program. +- Tokens are held by a token account owned by a PDA derived from the user account's address; the program signs the CPI with that PDA. ## Setup From this directory (`tokens/external-delegate-token-master/anchor/`): ```bash -pnpm install -anchor build +cargo build-sbf ``` -Prerequisites: [Agave](https://docs.anza.xyz/) CLI (version in `Anchor.toml` `[toolchain]`), [Anchor](https://www.anchor-lang.com/docs), and `pnpm`. +Prerequisites: [Agave](https://docs.anza.xyz/) CLI (version in `Anchor.toml` `[toolchain]`) and [Anchor](https://www.anchor-lang.com/docs). ## Testing -Tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm). No local validator. +Tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm). No local validator. Build first so `target/deploy/external_delegate_token_master.so` exists, then: ```bash -pnpm test +cargo test ``` -This runs `cargo test` as configured in `Anchor.toml`. Tests call instruction handlers and check onchain state. +The tests sign real transfer authorizations with a fixed secp256k1 key, send transactions, and assert token balances and nonce state, including the replay, wrong-amount, wrong-recipient, and wrong-authority failure paths. ## Usage -Read the program `programs/` source and `Anchor.toml` for deployed program IDs. For deployment, use `anchor build && anchor deploy` against your target cluster. +Read the program source under `programs/` and `Anchor.toml` for the program ID. For deployment, use `anchor build && anchor deploy` against your target cluster. diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/Cargo.toml b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/Cargo.toml index d9d1f08f..ac8b99ac 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/Cargo.toml +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/Cargo.toml @@ -26,6 +26,9 @@ sha3 = "0.10.8" solana-secp256k1-recover = "2.0.0" [dev-dependencies] +# Signs test transfer authorizations with a fixed secp256k1 key so tests can +# exercise the Ethereum-signature path end to end. +libsecp256k1 = "0.7.2" litesvm = "0.11.0" solana-signer = "3.0.0" solana-keypair = "3.0.1" diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/authority_transfer.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/authority_transfer.rs index 060399ba..9fc507ee 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/authority_transfer.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/authority_transfer.rs @@ -1,44 +1,53 @@ use anchor_lang::prelude::*; -use anchor_spl::token; -use anchor_spl::token::{Token, TokenAccount, Transfer}; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; use crate::UserAccount; #[derive(Accounts)] -pub struct AuthorityTransfer<'info> { +pub struct AuthorityTransferAccountConstraints<'info> { #[account(has_one = authority)] pub user_account: Account<'info, UserAccount>, + pub authority: Signer<'info>, + + pub mint: InterfaceAccount<'info, Mint>, + #[account(mut)] - pub user_token_account: Account<'info, TokenAccount>, + pub user_token_account: InterfaceAccount<'info, TokenAccount>, + #[account(mut)] - pub recipient_token_account: Account<'info, TokenAccount>, + pub recipient_token_account: InterfaceAccount<'info, TokenAccount>, + #[account( seeds = [user_account.key().as_ref()], bump, )] pub user_pda: SystemAccount<'info>, - pub token_program: Program<'info, Token>, + + pub token_program: Interface<'info, TokenInterface>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { - // Transfer tokens - let transfer_instruction = Transfer { +pub fn handler(context: Context, amount: u64) -> Result<()> { + let transfer_accounts = TransferChecked { from: context.accounts.user_token_account.to_account_info(), + mint: context.accounts.mint.to_account_info(), to: context.accounts.recipient_token_account.to_account_info(), authority: context.accounts.user_pda.to_account_info(), }; - token::transfer( + transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), - transfer_instruction, + transfer_accounts, &[&[ context.accounts.user_account.key().as_ref(), &[context.bumps.user_pda], ]], ), amount, + context.accounts.mint.decimals, )?; Ok(()) diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/initialize.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/initialize.rs index 708a0335..12890e6e 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/initialize.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/initialize.rs @@ -3,22 +3,24 @@ use anchor_lang::prelude::*; use crate::UserAccount; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account( init, payer = authority, space = UserAccount::DISCRIMINATOR.len() + UserAccount::INIT_SPACE, )] - // Ensure this is only for user_account pub user_account: Account<'info, UserAccount>, + #[account(mut)] - pub authority: Signer<'info>, // This should remain as a signer - pub system_program: Program<'info, System>, // Required for initialization + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(context: Context) -> Result<()> { let user_account = &mut context.accounts.user_account; user_account.authority = context.accounts.authority.key(); user_account.ethereum_address = [0; 20]; + user_account.nonce = 0; Ok(()) } diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/set_ethereum_address.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/set_ethereum_address.rs index 3d14f42a..1967fc1b 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/set_ethereum_address.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/set_ethereum_address.rs @@ -3,14 +3,15 @@ use anchor_lang::prelude::*; use crate::UserAccount; #[derive(Accounts)] -pub struct SetEthereumAddress<'info> { +pub struct SetEthereumAddressAccountConstraints<'info> { #[account(mut, has_one = authority)] pub user_account: Account<'info, UserAccount>, + pub authority: Signer<'info>, } pub fn handler( - mut context: Context, + context: Context, ethereum_address: [u8; 20], ) -> Result<()> { let user_account = &mut context.accounts.user_account; diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/transfer_tokens.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/transfer_tokens.rs index 7fe2cc69..4ab2c863 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/transfer_tokens.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/instructions/transfer_tokens.rs @@ -1,52 +1,79 @@ use anchor_lang::prelude::*; -use anchor_spl::token; -use anchor_spl::token::{Token, TokenAccount, Transfer}; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; -use crate::{verify_ethereum_signature, ErrorCode, UserAccount}; +use crate::{build_transfer_authorization_message, verify_ethereum_signature, ErrorCode, UserAccount}; #[derive(Accounts)] -pub struct TransferTokens<'info> { - #[account(has_one = authority)] +pub struct TransferTokensAccountConstraints<'info> { + #[account(mut, has_one = authority)] pub user_account: Account<'info, UserAccount>, + pub authority: Signer<'info>, + + pub mint: InterfaceAccount<'info, Mint>, + #[account(mut)] - pub user_token_account: Account<'info, TokenAccount>, + pub user_token_account: InterfaceAccount<'info, TokenAccount>, + #[account(mut)] - pub recipient_token_account: Account<'info, TokenAccount>, + pub recipient_token_account: InterfaceAccount<'info, TokenAccount>, + #[account( seeds = [user_account.key().as_ref()], bump, )] pub user_pda: SystemAccount<'info>, - pub token_program: Program<'info, Token>, + + pub token_program: Interface<'info, TokenInterface>, } pub fn handler( - context: Context, + context: Context, amount: u64, signature: [u8; 65], - message: [u8; 32], ) -> Result<()> { let user_account = &context.accounts.user_account; + let user_account_key = user_account.key(); + + // Rebuild the authorized message onchain so the signature commits to + // this exact transfer (amount, recipient, and the current nonce). + let message = build_transfer_authorization_message( + &user_account_key, + amount, + &context.accounts.recipient_token_account.key(), + user_account.nonce, + ); + + require!( + verify_ethereum_signature(&user_account.ethereum_address, &message, &signature), + ErrorCode::InvalidSignature + ); - if !verify_ethereum_signature(&user_account.ethereum_address, &message, &signature) { - return Err(ErrorCode::InvalidSignature.into()); - } + // Consume the nonce before the transfer CPI (checks-effects-interactions), + // so this signature can never authorize a second execution. + let user_account = &mut context.accounts.user_account; + user_account.nonce = user_account + .nonce + .checked_add(1) + .ok_or(ErrorCode::NonceOverflow)?; - // Transfer tokens - let transfer_instruction = Transfer { + let transfer_accounts = TransferChecked { from: context.accounts.user_token_account.to_account_info(), + mint: context.accounts.mint.to_account_info(), to: context.accounts.recipient_token_account.to_account_info(), authority: context.accounts.user_pda.to_account_info(), }; - token::transfer( + transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), - transfer_instruction, - &[&[user_account.key().as_ref(), &[context.bumps.user_pda]]], + transfer_accounts, + &[&[user_account_key.as_ref(), &[context.bumps.user_pda]]], ), amount, + context.accounts.mint.decimals, )?; Ok(()) diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs index 8159d7c6..27aed7a1 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs @@ -11,27 +11,29 @@ declare_id!("FYPkt5VWMvtyWZDMGCwoKFkE3wXTzphicTpnNGuHWVbD"); pub mod external_delegate_token_master { use super::*; - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { instructions::initialize::handler(context) } pub fn set_ethereum_address( - context: Context, + context: Context, ethereum_address: [u8; 20], ) -> Result<()> { instructions::set_ethereum_address::handler(context, ethereum_address) } pub fn transfer_tokens( - context: Context, + context: Context, amount: u64, signature: [u8; 65], - message: [u8; 32], ) -> Result<()> { - instructions::transfer_tokens::handler(context, amount, signature, message) + instructions::transfer_tokens::handler(context, amount, signature) } - pub fn authority_transfer(context: Context, amount: u64) -> Result<()> { + pub fn authority_transfer( + context: Context, + amount: u64, + ) -> Result<()> { instructions::authority_transfer::handler(context, amount) } } @@ -41,12 +43,38 @@ pub mod external_delegate_token_master { pub struct UserAccount { pub authority: Pubkey, pub ethereum_address: [u8; 20], + /// Strictly increasing counter committed into every signed transfer + /// authorization, so each Ethereum signature executes exactly once. + pub nonce: u64, } #[error_code] pub enum ErrorCode { #[msg("Invalid Ethereum signature")] InvalidSignature, + #[msg("Nonce overflow")] + NonceOverflow, +} + +/// Reconstructs the message a delegate must sign to authorize one transfer: +/// keccak256(program id || user account || amount LE || recipient token account || nonce LE). +/// +/// Because the hash commits to every transfer parameter plus the user +/// account's stored nonce, a signature is valid for exactly one +/// (amount, recipient, nonce) execution and cannot be replayed. +pub fn build_transfer_authorization_message( + user_account: &Pubkey, + amount: u64, + recipient_token_account: &Pubkey, + nonce: u64, +) -> [u8; 32] { + let mut hasher = Keccak256::new(); + hasher.update(ID.as_ref()); + hasher.update(user_account.as_ref()); + hasher.update(amount.to_le_bytes()); + hasher.update(recipient_token_account.as_ref()); + hasher.update(nonce.to_le_bytes()); + hasher.finalize().into() } pub fn verify_ethereum_signature( @@ -59,9 +87,12 @@ pub fn verify_ethereum_signature( sig.copy_from_slice(&signature[..64]); if let Ok(pubkey) = secp256k1_recover(message, recovery_id, &sig) { + // An Ethereum address is the last 20 bytes of the keccak256 hash of + // the 64-byte uncompressed public key (x || y, no 0x04 prefix byte). + // `secp256k1_recover` already returns exactly those 64 bytes. let pubkey_bytes = pubkey.to_bytes(); let mut recovered_address = [0u8; 20]; - recovered_address.copy_from_slice(&keccak256(&pubkey_bytes[1..])[12..]); + recovered_address.copy_from_slice(&keccak256(&pubkey_bytes)[12..]); recovered_address == *ethereum_address } else { false diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/tests/test_external_delegate.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/tests/test_external_delegate.rs index edc4ae81..4b6d73bd 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/tests/test_external_delegate.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/tests/test_external_delegate.rs @@ -3,7 +3,9 @@ use { solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, InstructionData, ToAccountMetas, }, + borsh::BorshDeserialize, litesvm::LiteSVM, + sha3::{Digest, Keccak256}, solana_keypair::Keypair, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, @@ -12,21 +14,80 @@ use { solana_signer::Signer, }; +const WALLET_LAMPORTS: u64 = 10_000_000_000; +const MINT_DECIMALS: u8 = 6; +const MINT_AMOUNT: u64 = 1_000_000_000; +const TRANSFER_AMOUNT: u64 = 500_000_000; + +/// Fixed delegate key so tests are deterministic. Any nonzero scalar below +/// the secp256k1 curve order works. +const DELEGATE_SECP256K1_PRIVATE_KEY: [u8; 32] = [0x42; 32]; + fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() .unwrap() } -fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { - let ata_program: Pubkey = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" - .parse() - .unwrap(); - let (ata, _bump) = Pubkey::find_program_address( - &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], - &ata_program, - ); - ata +/// Mirror of the program's `UserAccount` for reading state in tests +/// (after the 8-byte Anchor discriminator). +#[derive(BorshDeserialize)] +struct UserAccountState { + authority: [u8; 32], + ethereum_address: [u8; 20], + nonce: u64, +} + +fn read_user_account(svm: &LiteSVM, address: &Pubkey) -> UserAccountState { + let account = svm.get_account(address).expect("user account should exist"); + let anchor_discriminator_len = 8; + UserAccountState::try_from_slice(&account.data[anchor_discriminator_len..]).unwrap() +} + +fn delegate_secret_key() -> libsecp256k1::SecretKey { + libsecp256k1::SecretKey::parse(&DELEGATE_SECP256K1_PRIVATE_KEY).unwrap() +} + +/// Ethereum address = last 20 bytes of keccak256 of the 64-byte uncompressed +/// public key (0x04 prefix dropped). +fn ethereum_address_of(secret_key: &libsecp256k1::SecretKey) -> [u8; 20] { + let public_key = libsecp256k1::PublicKey::from_secret_key(secret_key); + let uncompressed = public_key.serialize(); + let hash = Keccak256::digest(&uncompressed[1..]); + let mut address = [0u8; 20]; + address.copy_from_slice(&hash[12..]); + address +} + +/// Builds the exact preimage the program reconstructs onchain: +/// keccak256(program id || user account || amount LE || recipient token account || nonce LE). +fn build_transfer_authorization_message( + program_id: &Pubkey, + user_account: &Pubkey, + amount: u64, + recipient_token_account: &Pubkey, + nonce: u64, +) -> [u8; 32] { + let mut hasher = Keccak256::new(); + hasher.update(program_id.as_ref()); + hasher.update(user_account.as_ref()); + hasher.update(amount.to_le_bytes()); + hasher.update(recipient_token_account.as_ref()); + hasher.update(nonce.to_le_bytes()); + hasher.finalize().into() +} + +/// 65-byte recoverable signature: r || s || recovery id. +fn sign_transfer_authorization( + secret_key: &libsecp256k1::SecretKey, + message: &[u8; 32], +) -> [u8; 65] { + let (signature, recovery_id) = + libsecp256k1::sign(&libsecp256k1::Message::parse(message), secret_key); + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(&signature.serialize()); + bytes[64] = recovery_id.serialize(); + bytes } fn setup() -> (LiteSVM, Pubkey, Keypair) { @@ -36,189 +97,419 @@ fn setup() -> (LiteSVM, Pubkey, Keypair) { let program_bytes = include_bytes!("../../../target/deploy/external_delegate_token_master.so"); svm.add_program(program_id, program_bytes).unwrap(); - let payer = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let payer = create_wallet(&mut svm, WALLET_LAMPORTS).unwrap(); (svm, program_id, payer) } -#[test] -fn test_initialize_user_account() { - let (mut svm, program_id, authority) = setup(); - let user_account = Keypair::new(); - - let init_ix = Instruction::new_with_bytes( - program_id, +fn initialize_user_account( + svm: &mut LiteSVM, + program_id: &Pubkey, + authority: &Keypair, + user_account: &Keypair, +) { + let init_instruction = Instruction::new_with_bytes( + *program_id, &external_delegate_token_master::instruction::Initialize {}.data(), - external_delegate_token_master::accounts::Initialize { + external_delegate_token_master::accounts::InitializeAccountConstraints { user_account: user_account.pubkey(), authority: authority.pubkey(), system_program: system_program::id(), } .to_account_metas(None), ); + send_transaction_from_instructions( + svm, + vec![init_instruction], + &[authority, user_account], + &authority.pubkey(), + ) + .unwrap(); +} +fn set_ethereum_address( + svm: &mut LiteSVM, + program_id: &Pubkey, + authority: &Keypair, + user_account: &Pubkey, + ethereum_address: [u8; 20], +) { + let set_address_instruction = Instruction::new_with_bytes( + *program_id, + &external_delegate_token_master::instruction::SetEthereumAddress { ethereum_address } + .data(), + external_delegate_token_master::accounts::SetEthereumAddressAccountConstraints { + user_account: *user_account, + authority: authority.pubkey(), + } + .to_account_metas(None), + ); send_transaction_from_instructions( - &mut svm, - vec![init_ix], - &[&authority, &user_account], + svm, + vec![set_address_instruction], + &[authority], &authority.pubkey(), ) .unwrap(); +} + +/// Everything a transfer_tokens test needs: a user account linked to the +/// fixed delegate Ethereum key, a funded PDA-owned token account, and a +/// recipient token account. +struct TransferFixture { + svm: LiteSVM, + program_id: Pubkey, + authority: Keypair, + user_account: Pubkey, + user_pda: Pubkey, + mint: Pubkey, + user_pda_token_account: Pubkey, + recipient_token_account: Pubkey, +} + +fn setup_transfer_fixture() -> TransferFixture { + let (mut svm, program_id, authority) = setup(); + let user_account_keypair = Keypair::new(); + initialize_user_account(&mut svm, &program_id, &authority, &user_account_keypair); - // Verify the account was created - let account_data = svm - .get_account(&user_account.pubkey()) - .expect("User account should exist"); + let user_account = user_account_keypair.pubkey(); + set_ethereum_address( + &mut svm, + &program_id, + &authority, + &user_account, + ethereum_address_of(&delegate_secret_key()), + ); + + let (user_pda, _bump) = Pubkey::find_program_address(&[user_account.as_ref()], &program_id); + + let mint = create_token_mint(&mut svm, &authority, MINT_DECIMALS, None).unwrap(); + let user_pda_token_account = + create_associated_token_account(&mut svm, &user_pda, &mint, &authority).unwrap(); + mint_tokens_to_token_account(&mut svm, &mint, &user_pda_token_account, MINT_AMOUNT, &authority) + .unwrap(); + + let recipient = Keypair::new(); + let recipient_token_account = + create_associated_token_account(&mut svm, &recipient.pubkey(), &mint, &authority).unwrap(); + + TransferFixture { + svm, + program_id, + authority, + user_account, + user_pda, + mint, + user_pda_token_account, + recipient_token_account, + } +} - // Skip 8-byte discriminator - let data = &account_data.data[8..]; - let stored_authority = Pubkey::try_from(&data[0..32]).unwrap(); - assert_eq!(stored_authority, authority.pubkey()); +fn build_transfer_tokens_instruction( + fixture: &TransferFixture, + authority: &Pubkey, + recipient_token_account: &Pubkey, + amount: u64, + signature: [u8; 65], +) -> Instruction { + Instruction::new_with_bytes( + fixture.program_id, + &external_delegate_token_master::instruction::TransferTokens { amount, signature }.data(), + external_delegate_token_master::accounts::TransferTokensAccountConstraints { + user_account: fixture.user_account, + authority: *authority, + mint: fixture.mint, + user_token_account: fixture.user_pda_token_account, + recipient_token_account: *recipient_token_account, + user_pda: fixture.user_pda, + token_program: token_program_id(), + } + .to_account_metas(None), + ) +} + +#[test] +fn test_initialize_user_account() { + let (mut svm, program_id, authority) = setup(); + let user_account = Keypair::new(); + initialize_user_account(&mut svm, &program_id, &authority, &user_account); - // ethereum_address: [u8; 20] — should be all zeros - let eth_addr = &data[32..52]; - assert_eq!(eth_addr, &[0u8; 20]); + let state = read_user_account(&svm, &user_account.pubkey()); + assert_eq!(state.authority, authority.pubkey().to_bytes()); + assert_eq!(state.ethereum_address, [0u8; 20]); + assert_eq!(state.nonce, 0); } #[test] fn test_set_ethereum_address() { let (mut svm, program_id, authority) = setup(); let user_account = Keypair::new(); + initialize_user_account(&mut svm, &program_id, &authority, &user_account); - // Initialize - let init_ix = Instruction::new_with_bytes( - program_id, - &external_delegate_token_master::instruction::Initialize {}.data(), - external_delegate_token_master::accounts::Initialize { - user_account: user_account.pubkey(), - authority: authority.pubkey(), - system_program: system_program::id(), - } - .to_account_metas(None), + let ethereum_address = ethereum_address_of(&delegate_secret_key()); + set_ethereum_address( + &mut svm, + &program_id, + &authority, + &user_account.pubkey(), + ethereum_address, + ); + + let state = read_user_account(&svm, &user_account.pubkey()); + assert_eq!(state.ethereum_address, ethereum_address); +} + +#[test] +fn test_transfer_tokens_with_valid_signature_moves_tokens_and_increments_nonce() { + let mut fixture = setup_transfer_fixture(); + + let message = build_transfer_authorization_message( + &fixture.program_id, + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let authority_pubkey = fixture.authority.pubkey(); + let transfer_instruction = build_transfer_tokens_instruction( + &fixture, + &authority_pubkey, + &fixture.recipient_token_account.clone(), + TRANSFER_AMOUNT, + signature, ); send_transaction_from_instructions( - &mut svm, - vec![init_ix], - &[&authority, &user_account], - &authority.pubkey(), + &mut fixture.svm, + vec![transfer_instruction], + &[&fixture.authority], + &authority_pubkey, ) .unwrap(); - // Set ethereum address - let ethereum_address: [u8; 20] = [ - 0x1C, 0x8c, 0xd0, 0xc3, 0x8F, 0x8D, 0xE3, 0x5d, 0x60, 0x56, 0xc7, 0xC7, 0xaB, 0xFa, - 0x7e, 0x65, 0xD2, 0x60, 0xE8, 0x16, - ]; + assert_eq!( + get_token_account_balance(&fixture.svm, &fixture.recipient_token_account).unwrap(), + TRANSFER_AMOUNT + ); + assert_eq!( + get_token_account_balance(&fixture.svm, &fixture.user_pda_token_account).unwrap(), + MINT_AMOUNT - TRANSFER_AMOUNT + ); + assert_eq!(read_user_account(&fixture.svm, &fixture.user_account).nonce, 1); +} - let set_eth_ix = Instruction::new_with_bytes( - program_id, - &external_delegate_token_master::instruction::SetEthereumAddress { - ethereum_address, - } - .data(), - external_delegate_token_master::accounts::SetEthereumAddress { - user_account: user_account.pubkey(), - authority: authority.pubkey(), - } - .to_account_metas(None), +#[test] +fn test_transfer_tokens_replayed_signature_fails() { + let mut fixture = setup_transfer_fixture(); + + let message = build_transfer_authorization_message( + &fixture.program_id, + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let authority_pubkey = fixture.authority.pubkey(); + let transfer_instruction = build_transfer_tokens_instruction( + &fixture, + &authority_pubkey, + &fixture.recipient_token_account.clone(), + TRANSFER_AMOUNT, + signature, ); send_transaction_from_instructions( - &mut svm, - vec![set_eth_ix], - &[&authority], - &authority.pubkey(), + &mut fixture.svm, + vec![transfer_instruction.clone()], + &[&fixture.authority], + &authority_pubkey, ) .unwrap(); - // Verify - let account_data = svm - .get_account(&user_account.pubkey()) - .expect("User account should exist"); - let data = &account_data.data[8..]; - let stored_eth_addr = &data[32..52]; - assert_eq!(stored_eth_addr, ðereum_address); + // Replay the identical instruction. The stored nonce is now 1, so the + // onchain reconstruction differs from the signed message. + fixture.svm.expire_blockhash(); + let replay_result = send_transaction_from_instructions( + &mut fixture.svm, + vec![transfer_instruction], + &[&fixture.authority], + &authority_pubkey, + ); + assert!(replay_result.is_err(), "replayed signature must be rejected"); + + // Exactly one transfer happened. + assert_eq!( + get_token_account_balance(&fixture.svm, &fixture.recipient_token_account).unwrap(), + TRANSFER_AMOUNT + ); + assert_eq!(read_user_account(&fixture.svm, &fixture.user_account).nonce, 1); } #[test] -fn test_authority_transfer() { - let (mut svm, program_id, authority) = setup(); - let user_account = Keypair::new(); +fn test_transfer_tokens_signature_over_different_amount_fails() { + let mut fixture = setup_transfer_fixture(); - // Initialize user account - let init_ix = Instruction::new_with_bytes( - program_id, - &external_delegate_token_master::instruction::Initialize {}.data(), - external_delegate_token_master::accounts::Initialize { - user_account: user_account.pubkey(), - authority: authority.pubkey(), - system_program: system_program::id(), - } - .to_account_metas(None), + let authorized_amount = TRANSFER_AMOUNT; + let attempted_amount = MINT_AMOUNT; + let message = build_transfer_authorization_message( + &fixture.program_id, + &fixture.user_account, + authorized_amount, + &fixture.recipient_token_account, + 0, ); - send_transaction_from_instructions( - &mut svm, - vec![init_ix], - &[&authority, &user_account], - &authority.pubkey(), + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let authority_pubkey = fixture.authority.pubkey(); + let transfer_instruction = build_transfer_tokens_instruction( + &fixture, + &authority_pubkey, + &fixture.recipient_token_account.clone(), + attempted_amount, + signature, + ); + let result = send_transaction_from_instructions( + &mut fixture.svm, + vec![transfer_instruction], + &[&fixture.authority], + &authority_pubkey, + ); + assert!( + result.is_err(), + "signature over a different amount must be rejected" + ); + assert_eq!( + get_token_account_balance(&fixture.svm, &fixture.recipient_token_account).unwrap(), + 0 + ); + assert_eq!(read_user_account(&fixture.svm, &fixture.user_account).nonce, 0); +} + +#[test] +fn test_transfer_tokens_signature_over_different_recipient_fails() { + let mut fixture = setup_transfer_fixture(); + + // Sign for the legitimate recipient, then try to redirect the transfer + // to an attacker-controlled token account. + let attacker = Keypair::new(); + let attacker_token_account = create_associated_token_account( + &mut fixture.svm, + &attacker.pubkey(), + &fixture.mint, + &fixture.authority, ) .unwrap(); - // user_pda is derived from user_account key - let (user_pda, _bump) = - Pubkey::find_program_address(&[user_account.pubkey().as_ref()], &program_id); + let message = build_transfer_authorization_message( + &fixture.program_id, + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let authority_pubkey = fixture.authority.pubkey(); + let transfer_instruction = build_transfer_tokens_instruction( + &fixture, + &authority_pubkey, + &attacker_token_account, + TRANSFER_AMOUNT, + signature, + ); + let result = send_transaction_from_instructions( + &mut fixture.svm, + vec![transfer_instruction], + &[&fixture.authority], + &authority_pubkey, + ); + assert!( + result.is_err(), + "signature over a different recipient must be rejected" + ); + assert_eq!( + get_token_account_balance(&fixture.svm, &attacker_token_account).unwrap(), + 0 + ); +} - // Create mint and token accounts using Kite - let mint_pubkey = create_token_mint(&mut svm, &authority, 6, None).unwrap(); +#[test] +fn test_transfer_tokens_wrong_solana_authority_fails() { + let mut fixture = setup_transfer_fixture(); - // Create ATA for the user_pda - let user_pda_ata = - create_associated_token_account(&mut svm, &user_pda, &mint_pubkey, &authority).unwrap(); + // A correctly signed Ethereum authorization must not bypass the + // Solana-side authority check. + let message = build_transfer_authorization_message( + &fixture.program_id, + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); - // Mint tokens to user_pda's ATA - let mint_amount: u64 = 1_000_000_000; - mint_tokens_to_token_account(&mut svm, &mint_pubkey, &user_pda_ata, mint_amount, &authority) - .unwrap(); + let mallory = create_wallet(&mut fixture.svm, WALLET_LAMPORTS).unwrap(); + let mallory_pubkey = mallory.pubkey(); + let transfer_instruction = build_transfer_tokens_instruction( + &fixture, + &mallory_pubkey, + &fixture.recipient_token_account.clone(), + TRANSFER_AMOUNT, + signature, + ); + let result = send_transaction_from_instructions( + &mut fixture.svm, + vec![transfer_instruction], + &[&mallory], + &mallory_pubkey, + ); + assert!( + result.is_err(), + "a signer other than user_account.authority must be rejected" + ); + assert_eq!( + get_token_account_balance(&fixture.svm, &fixture.recipient_token_account).unwrap(), + 0 + ); +} - // Create recipient ATA - let recipient = Keypair::new(); - let recipient_ata = - create_associated_token_account(&mut svm, &recipient.pubkey(), &mint_pubkey, &authority) - .unwrap(); +#[test] +fn test_authority_transfer() { + let fixture = setup_transfer_fixture(); + let mut svm = fixture.svm; - // Perform authority transfer - let transfer_amount: u64 = 500_000_000; - let authority_transfer_ix = Instruction::new_with_bytes( - program_id, + let authority_transfer_instruction = Instruction::new_with_bytes( + fixture.program_id, &external_delegate_token_master::instruction::AuthorityTransfer { - amount: transfer_amount, + amount: TRANSFER_AMOUNT, } .data(), - external_delegate_token_master::accounts::AuthorityTransfer { - user_account: user_account.pubkey(), - authority: authority.pubkey(), - user_token_account: user_pda_ata, - recipient_token_account: recipient_ata, - user_pda, + external_delegate_token_master::accounts::AuthorityTransferAccountConstraints { + user_account: fixture.user_account, + authority: fixture.authority.pubkey(), + mint: fixture.mint, + user_token_account: fixture.user_pda_token_account, + recipient_token_account: fixture.recipient_token_account, + user_pda: fixture.user_pda, token_program: token_program_id(), } .to_account_metas(None), ); send_transaction_from_instructions( &mut svm, - vec![authority_transfer_ix], - &[&authority], - &authority.pubkey(), + vec![authority_transfer_instruction], + &[&fixture.authority], + &fixture.authority.pubkey(), ) .unwrap(); - // Verify recipient received tokens assert_eq!( - get_token_account_balance(&svm, &recipient_ata).unwrap(), - transfer_amount + get_token_account_balance(&svm, &fixture.recipient_token_account).unwrap(), + TRANSFER_AMOUNT ); - - // Verify user_pda's balance decreased assert_eq!( - get_token_account_balance(&svm, &user_pda_ata).unwrap(), - mint_amount - transfer_amount + get_token_account_balance(&svm, &fixture.user_pda_token_account).unwrap(), + MINT_AMOUNT - TRANSFER_AMOUNT ); } diff --git a/tokens/external-delegate-token-master/quasar/Cargo.toml b/tokens/external-delegate-token-master/quasar/Cargo.toml index 09d9c638..9b548d45 100644 --- a/tokens/external-delegate-token-master/quasar/Cargo.toml +++ b/tokens/external-delegate-token-master/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-external-delegate-token-master" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace, not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] @@ -37,6 +37,9 @@ solana-define-syscall = "4.0" solana-keccak-hasher = "3.1" [dev-dependencies] +# Signs test transfer authorizations with a fixed secp256k1 key so tests can +# exercise the Ethereum-signature path end to end. +libsecp256k1 = "0.7.2" quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } spl-token-interface = { version = "2.0.0" } solana-program-pack = { version = "3.1.0" } diff --git a/tokens/external-delegate-token-master/quasar/README.md b/tokens/external-delegate-token-master/quasar/README.md index 706dacb6..4408f4f7 100644 --- a/tokens/external-delegate-token-master/quasar/README.md +++ b/tokens/external-delegate-token-master/quasar/README.md @@ -1,13 +1,16 @@ # External Delegate Token Master (Quasar) -Token transfers authorized by an external secp256k1 signature. +Authorize token transfers using an external secp256k1 delegate signature. -See also: the [repository catalog](../../../README.md). +See the [example overview](../README.md) for the signed-message format and nonce semantics shared with the [Anchor variant](../anchor/), and the [repository catalog](../../../README.md). ## Major concepts -- Delegate approval -- Signature verification +- `UserAccount` state: the Solana `authority`, the delegate's 20-byte `ethereum_address`, and a `nonce` consumed by each signature-authorized transfer. +- `transfer_tokens` rebuilds the authorized message onchain as keccak256(program id || user account || amount LE || recipient token account || nonce LE), recovers the signer with the raw `sol_secp256k1_recover` syscall, compares the recovered Ethereum address to the stored one, and increments the nonce before the transfer CPI. The `authority` must also sign the transaction; the Ethereum signature supplements that check. +- `authority_transfer` moves tokens with only the Solana authority's signature. +- Both transfer handlers use the token program's `transfer_checked` CPI, which verifies the mint and decimals. +- Tokens are held by a token account owned by a PDA derived from the user account's address; the program signs the CPI with that PDA. ## Setup @@ -21,14 +24,14 @@ Prerequisites: [Quasar](https://quasar-lang.com/docs) CLI and [Agave](https://do ## Testing -In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): +In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`). Build first so `target/deploy/quasar_external_delegate_token_master.so` exists, then: ```bash -cargo test +quasar test ``` -Tests invoke instruction handlers and assert onchain state. No local validator. +The tests sign real transfer authorizations with a fixed secp256k1 key, send instructions through the SVM, and assert token balances and nonce state, including the replay, wrong-amount, wrong-recipient, and wrong-authority failure paths. ## Usage -Read `src/` and `Quasar.toml`. Compare with the [Anchor](../anchor/) variant in the same example where present. +Read `src/` and `Quasar.toml`. The [Anchor variant](../anchor/) in the same example shares the message format and state layout semantics. diff --git a/tokens/external-delegate-token-master/quasar/src/lib.rs b/tokens/external-delegate-token-master/quasar/src/lib.rs index f49e1225..73953211 100644 --- a/tokens/external-delegate-token-master/quasar/src/lib.rs +++ b/tokens/external-delegate-token-master/quasar/src/lib.rs @@ -6,23 +6,32 @@ use quasar_spl::prelude::*; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("FYPkt5VWMvtyWZDMGCwoKFkE3wXTzphicTpnNGuHWVbD"); /// User account storing the Solana authority and linked Ethereum address. #[account(discriminator = 1, set_inner)] pub struct UserAccount { pub authority: Address, pub ethereum_address: [u8; 20], + /// Strictly increasing counter committed into every signed transfer + /// authorization, so each Ethereum signature executes exactly once. + pub nonce: u64, } /// Marker carrying the seeds for the per-user PDA: just the user account -/// address (no string prefix). Required since PR #195 because inline -/// `seeds = [...]` is gone — derivation now happens through a -/// `#[derive(Seeds)]` type referenced by `address = T::seeds(...)`. +/// address (no string prefix). Referenced through +/// `address = UserPda::seeds(...)` in the account constraints. #[derive(Seeds)] #[seeds(b"", user_account: Address)] pub struct UserPda; +#[error_code] +pub enum ExternalDelegateError { + /// Matches the Anchor variant's error codes, which start at 6000. + InvalidSignature = 6000, + NonceOverflow, +} + /// External delegate token master: allows transfers authorised either by /// the Solana authority or by an Ethereum signature (secp256k1). #[program] @@ -31,14 +40,14 @@ mod quasar_external_delegate_token_master { /// Initialize a user account with zero Ethereum address. #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } /// Set the Ethereum address for signature verification. #[instruction(discriminator = 1)] pub fn set_ethereum_address( - ctx: Ctx, + ctx: Ctx, ethereum_address: [u8; 20], ) -> Result<(), ProgramError> { handle_set_ethereum_address(&mut ctx.accounts, ethereum_address) @@ -47,18 +56,17 @@ mod quasar_external_delegate_token_master { /// Transfer tokens using an Ethereum signature for authorisation. #[instruction(discriminator = 2)] pub fn transfer_tokens( - ctx: Ctx, + ctx: Ctx, amount: u64, signature: [u8; 65], - message: [u8; 32], ) -> Result<(), ProgramError> { - handle_transfer_tokens(&mut ctx.accounts, amount, &signature, &message, &ctx.bumps) + handle_transfer_tokens(&mut ctx.accounts, amount, &signature, &ctx.bumps) } /// Transfer tokens using the Solana authority directly. #[instruction(discriminator = 3)] pub fn authority_transfer( - ctx: Ctx, + ctx: Ctx, amount: u64, ) -> Result<(), ProgramError> { handle_authority_transfer(&mut ctx.accounts, amount, &ctx.bumps) @@ -70,7 +78,7 @@ mod quasar_external_delegate_token_master { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut, init, payer = authority)] pub user_account: Account, #[account(mut)] @@ -79,24 +87,27 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { - accounts.user_account - .set_inner(UserAccountInner { - authority: *accounts.authority.address(), - ethereum_address: [0u8; 20], - }); +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { + accounts.user_account.set_inner(UserAccountInner { + authority: *accounts.authority.address(), + ethereum_address: [0u8; 20], + nonce: 0, + }); Ok(()) } #[derive(Accounts)] -pub struct SetEthereumAddress { +pub struct SetEthereumAddressAccountConstraints { #[account(mut)] pub user_account: Account, pub authority: Signer, } #[inline(always)] -fn handle_set_ethereum_address(accounts: &mut SetEthereumAddress, ethereum_address: [u8; 20]) -> Result<(), ProgramError> { +fn handle_set_ethereum_address( + accounts: &mut SetEthereumAddressAccountConstraints, + ethereum_address: [u8; 20], +) -> Result<(), ProgramError> { require_keys_eq!( accounts.user_account.authority, *accounts.authority.address(), @@ -107,9 +118,11 @@ fn handle_set_ethereum_address(accounts: &mut SetEthereumAddress, ethereum_addre } #[derive(Accounts)] -pub struct TransferTokens { +pub struct TransferTokensAccountConstraints { + #[account(mut)] pub user_account: Account, pub authority: Signer, + pub mint: Account, #[account(mut)] pub user_token_account: Account, #[account(mut)] @@ -122,40 +135,64 @@ pub struct TransferTokens { #[inline(always)] fn handle_transfer_tokens( - accounts: &mut TransferTokens, + accounts: &mut TransferTokensAccountConstraints, amount: u64, signature: &[u8; 65], - message: &[u8; 32], - bumps: &TransferTokensBumps, + bumps: &TransferTokensAccountConstraintsBumps, ) -> Result<(), ProgramError> { - if !verify_ethereum_signature( - &accounts.user_account.ethereum_address, - message, - signature, - ) { - return Err(ProgramError::Custom(1)); // InvalidSignature + // The Ethereum signature supplements the Solana-side authority check; + // it does not replace it. + require_keys_eq!( + accounts.user_account.authority, + *accounts.authority.address(), + ProgramError::MissingRequiredSignature + ); + + // Rebuild the authorized message onchain so the signature commits to + // this exact transfer (amount, recipient, and the current nonce). + let nonce: u64 = accounts.user_account.nonce.into(); + let message = build_transfer_authorization_message( + accounts.user_account.address(), + amount, + accounts.recipient_token_account.address(), + nonce, + ); + + if !verify_ethereum_signature(&accounts.user_account.ethereum_address, &message, signature) { + return Err(ExternalDelegateError::InvalidSignature.into()); } + // Consume the nonce before the transfer CPI (checks-effects-interactions), + // so this signature can never authorize a second execution. + let next_nonce = nonce + .checked_add(1) + .ok_or(ExternalDelegateError::NonceOverflow)?; + accounts.user_account.nonce = PodU64::from(next_nonce); + let bump = [bumps.user_pda]; let seeds: &[Seed] = &[ Seed::from(accounts.user_account.address().as_ref()), Seed::from(&bump as &[u8]), ]; - accounts.token_program - .transfer( + accounts + .token_program + .transfer_checked( &accounts.user_token_account, + &accounts.mint, &accounts.recipient_token_account, &accounts.user_pda, amount, + accounts.mint.decimals, ) .invoke_signed(seeds) } #[derive(Accounts)] -pub struct AuthorityTransfer { +pub struct AuthorityTransferAccountConstraints { pub user_account: Account, pub authority: Signer, + pub mint: Account, #[account(mut)] pub user_token_account: Account, #[account(mut)] @@ -167,7 +204,11 @@ pub struct AuthorityTransfer { } #[inline(always)] -fn handle_authority_transfer(accounts: &mut AuthorityTransfer, amount: u64, bumps: &AuthorityTransferBumps) -> Result<(), ProgramError> { +fn handle_authority_transfer( + accounts: &mut AuthorityTransferAccountConstraints, + amount: u64, + bumps: &AuthorityTransferAccountConstraintsBumps, +) -> Result<(), ProgramError> { require_keys_eq!( accounts.user_account.authority, *accounts.authority.address(), @@ -180,16 +221,59 @@ fn handle_authority_transfer(accounts: &mut AuthorityTransfer, amount: u64, bump Seed::from(&bump as &[u8]), ]; - accounts.token_program - .transfer( + accounts + .token_program + .transfer_checked( &accounts.user_token_account, + &accounts.mint, &accounts.recipient_token_account, &accounts.user_pda, amount, + accounts.mint.decimals, ) .invoke_signed(seeds) } +// --------------------------------------------------------------------------- +// Transfer authorization message +// --------------------------------------------------------------------------- + +/// Byte length of the transfer authorization preimage: program id, user +/// account, amount, recipient token account, nonce. +const TRANSFER_AUTHORIZATION_PREIMAGE_LEN: usize = + core::mem::size_of::
() * 3 + core::mem::size_of::() * 2; + +/// Reconstructs the message a delegate must sign to authorize one transfer: +/// keccak256(program id || user account || amount LE || recipient token account || nonce LE). +/// +/// Because the hash commits to every transfer parameter plus the user +/// account's stored nonce, a signature is valid for exactly one +/// (amount, recipient, nonce) execution and cannot be replayed. +fn build_transfer_authorization_message( + user_account: &Address, + amount: u64, + recipient_token_account: &Address, + nonce: u64, +) -> [u8; 32] { + let amount_bytes = amount.to_le_bytes(); + let nonce_bytes = nonce.to_le_bytes(); + let parts: [&[u8]; 5] = [ + ID.as_ref(), + user_account.as_ref(), + &amount_bytes, + recipient_token_account.as_ref(), + &nonce_bytes, + ]; + + let mut preimage = [0u8; TRANSFER_AUTHORIZATION_PREIMAGE_LEN]; + let mut offset = 0usize; + for part in parts { + preimage[offset..offset + part.len()].copy_from_slice(part); + offset += part.len(); + } + keccak256(&preimage) +} + // --------------------------------------------------------------------------- // Ethereum signature verification using raw syscalls // --------------------------------------------------------------------------- diff --git a/tokens/external-delegate-token-master/quasar/src/tests.rs b/tokens/external-delegate-token-master/quasar/src/tests.rs index 91b48d16..a44f1a45 100644 --- a/tokens/external-delegate-token-master/quasar/src/tests.rs +++ b/tokens/external-delegate-token-master/quasar/src/tests.rs @@ -1,10 +1,21 @@ extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::ExternalDelegateError, + quasar_svm::{Account, Instruction, ProgramError, Pubkey, QuasarSvm}, + solana_program_pack::Pack, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::{println, vec, vec::Vec}, }; +const SIGNER_LAMPORTS: u64 = 5_000_000_000; +const MINT_DECIMALS: u8 = 6; +const MINT_AMOUNT: u64 = 1_000_000_000; +const TRANSFER_AMOUNT: u64 = 500_000_000; + +/// Fixed delegate key so tests are deterministic. Any nonzero scalar below +/// the secp256k1 curve order works. +const DELEGATE_SECP256K1_PRIVATE_KEY: [u8; 32] = [0x42; 32]; + fn setup() -> QuasarSvm { let elf = std::fs::read("target/deploy/quasar_external_delegate_token_master.so").unwrap(); QuasarSvm::new() @@ -13,7 +24,7 @@ fn setup() -> QuasarSvm { } fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 5_000_000_000) + quasar_svm::token::create_keyed_system_account(&address, SIGNER_LAMPORTS) } fn empty(address: Pubkey) -> Account { @@ -26,41 +37,572 @@ fn empty(address: Pubkey) -> Account { } } +fn mint(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: MINT_AMOUNT, + decimals: MINT_DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} + +fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} + +fn token_balance(svm: &QuasarSvm, address: &Pubkey) -> u64 { + let account = svm.get_account(address).unwrap(); + TokenAccount::unpack(&account.data).unwrap().amount +} + +/// Deserialized UserAccount state, parsed from the zero-copy layout: +/// [disc:1] [authority:32] [ethereum_address:20] [nonce:8 LE] +struct UserAccountState { + authority: Pubkey, + ethereum_address: [u8; 20], + nonce: u64, +} + +fn parse_user_account(data: &[u8]) -> UserAccountState { + assert_eq!(data[0], 1, "UserAccount discriminator"); + let mut offset = 1usize; + let mut take = |len: usize| { + let bytes = &data[offset..offset + len]; + offset += len; + bytes + }; + UserAccountState { + authority: Pubkey::new_from_array(take(32).try_into().unwrap()), + ethereum_address: take(20).try_into().unwrap(), + nonce: u64::from_le_bytes(take(8).try_into().unwrap()), + } +} + +fn read_user_account(svm: &QuasarSvm, address: &Pubkey) -> UserAccountState { + parse_user_account(&svm.get_account(address).unwrap().data) +} + +fn delegate_secret_key() -> libsecp256k1::SecretKey { + libsecp256k1::SecretKey::parse(&DELEGATE_SECP256K1_PRIVATE_KEY).unwrap() +} + +/// Ethereum address = last 20 bytes of keccak256 of the 64-byte uncompressed +/// public key (0x04 prefix dropped). +fn ethereum_address_of(secret_key: &libsecp256k1::SecretKey) -> [u8; 20] { + let public_key = libsecp256k1::PublicKey::from_secret_key(secret_key); + let uncompressed = public_key.serialize(); + let hash = solana_keccak_hasher::hash(&uncompressed[1..]); + let mut address = [0u8; 20]; + address.copy_from_slice(&hash.as_ref()[12..]); + address +} + +/// Builds the exact preimage the program reconstructs onchain: +/// keccak256(program id || user account || amount LE || recipient token account || nonce LE). +fn build_transfer_authorization_message( + user_account: &Pubkey, + amount: u64, + recipient_token_account: &Pubkey, + nonce: u64, +) -> [u8; 32] { + let mut preimage = Vec::new(); + preimage.extend_from_slice(crate::ID.as_ref()); + preimage.extend_from_slice(user_account.as_ref()); + preimage.extend_from_slice(&amount.to_le_bytes()); + preimage.extend_from_slice(recipient_token_account.as_ref()); + preimage.extend_from_slice(&nonce.to_le_bytes()); + let hash = solana_keccak_hasher::hash(&preimage); + let mut message = [0u8; 32]; + message.copy_from_slice(hash.as_ref()); + message +} + +/// 65-byte recoverable signature: r || s || recovery id. +fn sign_transfer_authorization( + secret_key: &libsecp256k1::SecretKey, + message: &[u8; 32], +) -> [u8; 65] { + let (signature, recovery_id) = + libsecp256k1::sign(&libsecp256k1::Message::parse(message), secret_key); + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(&signature.serialize()); + bytes[64] = recovery_id.serialize(); + bytes +} + /// Build initialize instruction data. /// Wire format: [disc=0] fn build_initialize_data() -> Vec { vec![0u8] } +/// Build set_ethereum_address instruction data. +/// Wire format: [disc=1] [ethereum_address: 20 bytes] +fn build_set_ethereum_address_data(ethereum_address: [u8; 20]) -> Vec { + let mut data = vec![1u8]; + data.extend_from_slice(ðereum_address); + data +} + +/// Build transfer_tokens instruction data. +/// Wire format: [disc=2] [amount: u64 LE] [signature: 65 bytes] +fn build_transfer_tokens_data(amount: u64, signature: [u8; 65]) -> Vec { + let mut data = vec![2u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&signature); + data +} + +/// Build authority_transfer instruction data. +/// Wire format: [disc=3] [amount: u64 LE] +fn build_authority_transfer_data(amount: u64) -> Vec { + let mut data = vec![3u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data +} + +fn initialize_instruction(user_account: Pubkey, authority: Pubkey) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(user_account.into(), true), + solana_instruction::AccountMeta::new(authority.into(), true), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::system_program::ID.into(), + false, + ), + ], + data: build_initialize_data(), + } +} + +fn set_ethereum_address_instruction( + user_account: Pubkey, + authority: Pubkey, + ethereum_address: [u8; 20], +) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(user_account.into(), false), + solana_instruction::AccountMeta::new_readonly(authority.into(), true), + ], + data: build_set_ethereum_address_data(ethereum_address), + } +} + +/// Addresses shared by every transfer test. +struct Fixture { + authority: Pubkey, + user_account: Pubkey, + user_pda: Pubkey, + mint: Pubkey, + user_token_account: Pubkey, + recipient_token_account: Pubkey, +} + +fn fixture() -> Fixture { + let user_account = Pubkey::new_unique(); + let (user_pda, _bump) = + Pubkey::find_program_address(&[user_account.as_ref()], &crate::ID); + Fixture { + authority: Pubkey::new_unique(), + user_account, + user_pda, + mint: Pubkey::new_unique(), + user_token_account: Pubkey::new_unique(), + recipient_token_account: Pubkey::new_unique(), + } +} + +fn transfer_tokens_instruction( + fixture: &Fixture, + authority: Pubkey, + recipient_token_account: Pubkey, + amount: u64, + signature: [u8; 65], +) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(fixture.user_account.into(), false), + solana_instruction::AccountMeta::new_readonly(authority.into(), true), + solana_instruction::AccountMeta::new_readonly(fixture.mint.into(), false), + solana_instruction::AccountMeta::new(fixture.user_token_account.into(), false), + solana_instruction::AccountMeta::new(recipient_token_account.into(), false), + solana_instruction::AccountMeta::new_readonly(fixture.user_pda.into(), false), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::SPL_TOKEN_PROGRAM_ID.into(), + false, + ), + ], + data: build_transfer_tokens_data(amount, signature), + } +} + +/// Initializes the user account, links the fixed delegate Ethereum key, and +/// loads the PDA-owned token account plus an empty recipient token account. +fn setup_transfer_fixture() -> (QuasarSvm, Fixture) { + let mut svm = setup(); + let fixture = fixture(); + + svm.process_instruction( + &initialize_instruction(fixture.user_account, fixture.authority), + &[empty(fixture.user_account), signer(fixture.authority)], + ) + .assert_success(); + + svm.process_instruction( + &set_ethereum_address_instruction( + fixture.user_account, + fixture.authority, + ethereum_address_of(&delegate_secret_key()), + ), + &[], + ) + .assert_success(); + + // Load token state directly: a mint, the PDA-owned funded token account, + // the empty recipient token account, and the (data-less) PDA itself. + svm.set_account(mint(fixture.mint, fixture.authority)); + svm.set_account(token( + fixture.user_token_account, + fixture.mint, + fixture.user_pda, + MINT_AMOUNT, + )); + svm.set_account(token( + fixture.recipient_token_account, + fixture.mint, + fixture.authority, + 0, + )); + svm.set_account(empty(fixture.user_pda)); + + (svm, fixture) +} + +fn external_delegate_error(error: ExternalDelegateError) -> ProgramError { + ProgramError::Custom(error as u32) +} + #[test] fn test_initialize() { let mut svm = setup(); let authority = Pubkey::new_unique(); let user_account = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let data = build_initialize_data(); + let result = svm.process_instruction( + &initialize_instruction(user_account, authority), + &[empty(user_account), signer(authority)], + ); + result.assert_success(); + println!(" INITIALIZE CU: {}", result.compute_units_consumed); + + let state = read_user_account(&svm, &user_account); + assert_eq!(state.authority, authority); + assert_eq!(state.ethereum_address, [0u8; 20]); + assert_eq!(state.nonce, 0); +} + +#[test] +fn test_set_ethereum_address() { + let mut svm = setup(); + + let authority = Pubkey::new_unique(); + let user_account = Pubkey::new_unique(); + + svm.process_instruction( + &initialize_instruction(user_account, authority), + &[empty(user_account), signer(authority)], + ) + .assert_success(); + + let ethereum_address = ethereum_address_of(&delegate_secret_key()); + let result = svm.process_instruction( + &set_ethereum_address_instruction(user_account, authority, ethereum_address), + &[], + ); + result.assert_success(); + println!(" SET_ETHEREUM_ADDRESS CU: {}", result.compute_units_consumed); + + assert_eq!( + read_user_account(&svm, &user_account).ethereum_address, + ethereum_address + ); +} + +#[test] +fn test_set_ethereum_address_wrong_authority_fails() { + let mut svm = setup(); + + let authority = Pubkey::new_unique(); + let mallory = Pubkey::new_unique(); + let user_account = Pubkey::new_unique(); + + svm.process_instruction( + &initialize_instruction(user_account, authority), + &[empty(user_account), signer(authority)], + ) + .assert_success(); + + let result = svm.process_instruction( + &set_ethereum_address_instruction( + user_account, + mallory, + ethereum_address_of(&delegate_secret_key()), + ), + &[signer(mallory)], + ); + assert!( + !result.is_ok(), + "a signer other than user_account.authority must be rejected" + ); +} + +#[test] +fn test_transfer_tokens_with_valid_signature_moves_tokens_and_increments_nonce() { + let (mut svm, fixture) = setup_transfer_fixture(); + + let message = build_transfer_authorization_message( + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let result = svm.process_instruction( + &transfer_tokens_instruction( + &fixture, + fixture.authority, + fixture.recipient_token_account, + TRANSFER_AMOUNT, + signature, + ), + &[], + ); + result.assert_success(); + println!(" TRANSFER_TOKENS CU: {}", result.compute_units_consumed); + + assert_eq!( + token_balance(&svm, &fixture.recipient_token_account), + TRANSFER_AMOUNT + ); + assert_eq!( + token_balance(&svm, &fixture.user_token_account), + MINT_AMOUNT - TRANSFER_AMOUNT + ); + assert_eq!(read_user_account(&svm, &fixture.user_account).nonce, 1); +} + +#[test] +fn test_transfer_tokens_replayed_signature_fails() { + let (mut svm, fixture) = setup_transfer_fixture(); + + let message = build_transfer_authorization_message( + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let instruction = transfer_tokens_instruction( + &fixture, + fixture.authority, + fixture.recipient_token_account, + TRANSFER_AMOUNT, + signature, + ); + svm.process_instruction(&instruction, &[]).assert_success(); + + // Replay the identical instruction. The stored nonce is now 1, so the + // onchain reconstruction differs from the signed message. + let replay_result = svm.process_instruction(&instruction, &[]); + replay_result.assert_error(external_delegate_error( + ExternalDelegateError::InvalidSignature, + )); + + // Exactly one transfer happened. + assert_eq!( + token_balance(&svm, &fixture.recipient_token_account), + TRANSFER_AMOUNT + ); + assert_eq!(read_user_account(&svm, &fixture.user_account).nonce, 1); +} + +#[test] +fn test_transfer_tokens_signature_over_different_amount_fails() { + let (mut svm, fixture) = setup_transfer_fixture(); + + let authorized_amount = TRANSFER_AMOUNT; + let attempted_amount = MINT_AMOUNT; + let message = build_transfer_authorization_message( + &fixture.user_account, + authorized_amount, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let result = svm.process_instruction( + &transfer_tokens_instruction( + &fixture, + fixture.authority, + fixture.recipient_token_account, + attempted_amount, + signature, + ), + &[], + ); + result.assert_error(external_delegate_error( + ExternalDelegateError::InvalidSignature, + )); + assert_eq!(token_balance(&svm, &fixture.recipient_token_account), 0); + assert_eq!(read_user_account(&svm, &fixture.user_account).nonce, 0); +} + +#[test] +fn test_transfer_tokens_signature_over_different_recipient_fails() { + let (mut svm, fixture) = setup_transfer_fixture(); + + // Sign for the legitimate recipient, then try to redirect the transfer + // to an attacker-controlled token account. + let attacker = Pubkey::new_unique(); + let attacker_token_account = Pubkey::new_unique(); + svm.set_account(token(attacker_token_account, fixture.mint, attacker, 0)); + + let message = build_transfer_authorization_message( + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let result = svm.process_instruction( + &transfer_tokens_instruction( + &fixture, + fixture.authority, + attacker_token_account, + TRANSFER_AMOUNT, + signature, + ), + &[], + ); + result.assert_error(external_delegate_error( + ExternalDelegateError::InvalidSignature, + )); + assert_eq!(token_balance(&svm, &attacker_token_account), 0); +} + +#[test] +fn test_transfer_tokens_wrong_solana_authority_fails() { + let (mut svm, fixture) = setup_transfer_fixture(); + + // A correctly signed Ethereum authorization must not bypass the + // Solana-side authority check. + let mallory = Pubkey::new_unique(); + let message = build_transfer_authorization_message( + &fixture.user_account, + TRANSFER_AMOUNT, + &fixture.recipient_token_account, + 0, + ); + let signature = sign_transfer_authorization(&delegate_secret_key(), &message); + + let result = svm.process_instruction( + &transfer_tokens_instruction( + &fixture, + mallory, + fixture.recipient_token_account, + TRANSFER_AMOUNT, + signature, + ), + &[signer(mallory)], + ); + assert!( + !result.is_ok(), + "a signer other than user_account.authority must be rejected" + ); + assert_eq!(token_balance(&svm, &fixture.recipient_token_account), 0); + assert_eq!(read_user_account(&svm, &fixture.user_account).nonce, 0); +} + +#[test] +fn test_authority_transfer() { + let (mut svm, fixture) = setup_transfer_fixture(); let instruction = Instruction { program_id: crate::ID, accounts: vec![ - solana_instruction::AccountMeta::new(user_account.into(), true), - solana_instruction::AccountMeta::new(authority.into(), true), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), + solana_instruction::AccountMeta::new_readonly(fixture.user_account.into(), false), + solana_instruction::AccountMeta::new_readonly(fixture.authority.into(), true), + solana_instruction::AccountMeta::new_readonly(fixture.mint.into(), false), + solana_instruction::AccountMeta::new(fixture.user_token_account.into(), false), + solana_instruction::AccountMeta::new(fixture.recipient_token_account.into(), false), + solana_instruction::AccountMeta::new_readonly(fixture.user_pda.into(), false), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::SPL_TOKEN_PROGRAM_ID.into(), + false, + ), ], - data, + data: build_authority_transfer_data(TRANSFER_AMOUNT), }; + let result = svm.process_instruction(&instruction, &[]); + result.assert_success(); + println!(" AUTHORITY_TRANSFER CU: {}", result.compute_units_consumed); - let result = svm.process_instruction( - &instruction, - &[empty(user_account), signer(authority)], + assert_eq!( + token_balance(&svm, &fixture.recipient_token_account), + TRANSFER_AMOUNT ); + assert_eq!( + token_balance(&svm, &fixture.user_token_account), + MINT_AMOUNT - TRANSFER_AMOUNT + ); +} +#[test] +fn test_authority_transfer_wrong_authority_fails() { + let (mut svm, fixture) = setup_transfer_fixture(); + + let mallory = Pubkey::new_unique(); + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new_readonly(fixture.user_account.into(), false), + solana_instruction::AccountMeta::new_readonly(mallory.into(), true), + solana_instruction::AccountMeta::new_readonly(fixture.mint.into(), false), + solana_instruction::AccountMeta::new(fixture.user_token_account.into(), false), + solana_instruction::AccountMeta::new(fixture.recipient_token_account.into(), false), + solana_instruction::AccountMeta::new_readonly(fixture.user_pda.into(), false), + solana_instruction::AccountMeta::new_readonly( + quasar_svm::SPL_TOKEN_PROGRAM_ID.into(), + false, + ), + ], + data: build_authority_transfer_data(TRANSFER_AMOUNT), + }; + let result = svm.process_instruction(&instruction, &[signer(mallory)]); assert!( - result.is_ok(), - "initialize failed: {:?}", - result.raw_result + !result.is_ok(), + "a signer other than user_account.authority must be rejected" ); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); + assert_eq!(token_balance(&svm, &fixture.recipient_token_account), 0); } diff --git a/tokens/nft-minter/README.md b/tokens/nft-minter/README.md index 8a17c967..c7090214 100644 --- a/tokens/nft-minter/README.md +++ b/tokens/nft-minter/README.md @@ -10,4 +10,4 @@ The way to do that is to remove the mint authority from the mint: Setting the mint authority to `null` permanently disables minting. **This is irreversible.** -You can do this manually, or use Metaplex to mark the NFT as a Limited Edition. When you use an Edition — such as a Master Edition — for your NFT, you get extra Metaplex metadata, and the mint authority is delegated to the Master Edition account. That delegation effectively disables future minting. Be sure you understand the trade-offs of letting the Master Edition account hold the mint authority instead of setting it permanently to `null`. +You can do this manually, or use Metaplex to mark the NFT as a Limited Edition. When you use an Edition - such as a Master Edition - for your NFT, you get extra Metaplex metadata, and the mint authority is delegated to the Master Edition account. That delegation effectively disables future minting. Be sure you understand the trade-offs of letting the Master Edition account hold the mint authority instead of setting it permanently to `null`. diff --git a/tokens/nft-minter/anchor/Anchor.toml b/tokens/nft-minter/anchor/Anchor.toml index e156133c..4a27cd42 100644 --- a/tokens/nft-minter/anchor/Anchor.toml +++ b/tokens/nft-minter/anchor/Anchor.toml @@ -9,14 +9,12 @@ seeds = true [programs.localnet] nft_minter = "52quezNUzc1Ej6Jh6L4bvtxPW8j6TEFHuLVAWiFvdnsc" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# Only run bankrun tests — the validator tests (test.ts) need Metaplex Token +# Only run bankrun tests - the validator tests (test.ts) need Metaplex Token # Metadata cloned from mainnet which is too slow/unreliable in CI. # bankrun.test.ts uses a local fixture (tests/fixtures/token_metadata.so). test = "cargo test" diff --git a/tokens/nft-minter/anchor/programs/nft-minter/src/lib.rs b/tokens/nft-minter/anchor/programs/nft-minter/src/lib.rs index d64e6470..134a07cb 100644 --- a/tokens/nft-minter/anchor/programs/nft-minter/src/lib.rs +++ b/tokens/nft-minter/anchor/programs/nft-minter/src/lib.rs @@ -18,7 +18,7 @@ pub mod nft_minter { use super::*; pub fn mint_nft( - context: Context, + context: Context, nft_name: String, nft_symbol: String, nft_uri: String, @@ -96,7 +96,7 @@ pub mod nft_minter { } #[derive(Accounts)] -pub struct CreateToken<'info> { +pub struct MintNftAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, diff --git a/tokens/nft-minter/anchor/programs/nft-minter/tests/test_nft_minter.rs b/tokens/nft-minter/anchor/programs/nft-minter/tests/test_nft_minter.rs index a619ac93..733c91af 100644 --- a/tokens/nft-minter/anchor/programs/nft-minter/tests/test_nft_minter.rs +++ b/tokens/nft-minter/anchor/programs/nft-minter/tests/test_nft_minter.rs @@ -98,7 +98,7 @@ fn test_mint_nft() { nft_uri: "https://example.com/nft.json".to_string(), } .data(), - nft_minter::accounts::CreateToken { + nft_minter::accounts::MintNftAccountConstraints { payer: payer.pubkey(), metadata_account, edition_account, diff --git a/tokens/nft-minter/quasar/Cargo.toml b/tokens/nft-minter/quasar/Cargo.toml index c40707f7..367dbe2f 100644 --- a/tokens/nft-minter/quasar/Cargo.toml +++ b/tokens/nft-minter/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-nft-minter" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/tokens/nft-minter/quasar/src/lib.rs b/tokens/nft-minter/quasar/src/lib.rs index fde20297..c288d576 100644 --- a/tokens/nft-minter/quasar/src/lib.rs +++ b/tokens/nft-minter/quasar/src/lib.rs @@ -7,7 +7,7 @@ use quasar_spl::prelude::*; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("52quezNUzc1Ej6Jh6L4bvtxPW8j6TEFHuLVAWiFvdnsc"); /// NFT minter: creates a mint (decimals = 0), mints 1 token, creates Metaplex /// metadata and master edition in a single instruction. @@ -19,7 +19,7 @@ mod quasar_nft_minter { // PR #195 made the capacity bound on `String` mandatory. #[instruction(discriminator = 0)] pub fn mint_nft( - ctx: Ctx, + ctx: Ctx, nft_name: String<32>, nft_symbol: String<10>, nft_uri: String<200>, @@ -30,18 +30,18 @@ mod quasar_nft_minter { /// All accounts needed to mint an NFT in one transaction. #[derive(Accounts)] -pub struct MintNft { +pub struct MintNftAccountConstraints { #[account(mut)] pub payer: Signer, - /// Metadata PDA — initialised via the Metaplex program by an explicit + /// Metadata PDA - initialised via the Metaplex program by an explicit /// CPI below; stays an UncheckedAccount because the new /// `metadata(...)` behaviour only accepts compile-time literals for /// name / symbol / uri. #[account(mut)] pub metadata_account: UncheckedAccount, - /// Master edition PDA — initialised via the Metaplex program below. + /// Master edition PDA - initialised via the Metaplex program below. #[account(mut)] pub edition_account: UncheckedAccount, @@ -76,7 +76,7 @@ pub struct MintNft { #[inline(always)] fn handle_mint_nft( - accounts: &mut MintNft, + accounts: &mut MintNftAccountConstraints, nft_name: &str, nft_symbol: &str, nft_uri: &str, diff --git a/tokens/nft-operations/README.md b/tokens/nft-operations/README.md new file mode 100644 index 00000000..352411d0 --- /dev/null +++ b/tokens/nft-operations/README.md @@ -0,0 +1,7 @@ +# NFT Operations + +Create an NFT collection, mint NFTs into it, and verify NFTs as collection members using the Metaplex Token Metadata program. + +[⚓ Anchor](./anchor) [💫 Quasar](./quasar) + +Each variant's README covers its setup and how to run the tests. diff --git a/tokens/nft-operations/anchor/Anchor.toml b/tokens/nft-operations/anchor/Anchor.toml index 45d87e06..a5d70849 100644 --- a/tokens/nft-operations/anchor/Anchor.toml +++ b/tokens/nft-operations/anchor/Anchor.toml @@ -11,14 +11,12 @@ mint_nft = "3EMcczaGi9ivdLxvvFwRbGYeEUEHpGwabXegARw4jLxa" [programs.devnet] mint_nft = "3EMcczaGi9ivdLxvvFwRbGYeEUEHpGwabXegARw4jLxa" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# Only run bankrun tests — the validator tests (mint-nft.ts) need Metaplex Token -# Metadata cloned from mainnet which is too slow/unreliable in CI. -# bankrun.test.ts uses a local fixture (tests/fixtures/token_metadata.so). +# Rust + LiteSVM tests; they load Metaplex Token Metadata from the local +# fixture (tests/fixtures/mpl_token_metadata.so) instead of cloning it from +# mainnet, which is too slow/unreliable in CI. test = "cargo test" diff --git a/tokens/nft-operations/anchor/README.md b/tokens/nft-operations/anchor/README.md index f05d2697..f60df0ab 100644 --- a/tokens/nft-operations/anchor/README.md +++ b/tokens/nft-operations/anchor/README.md @@ -1,20 +1,10 @@ # NFT Operations -Create an NFT collection, mint an NFT, and verify an NFT as part of a collection — all using Metaplex Token Metadata. +Create an NFT collection, mint an NFT, and verify an NFT as part of a collection - all using Metaplex Token Metadata. ## Program setup -This example clones the Metaplex Token Metadata [program](https://solana.com/docs/terminology#program) from mainnet. See `Anchor.toml`: - -```toml -[test.validator] -url = "https://api.mainnet-beta.solana.com" - -[[test.validator.clone]] -address = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" -``` - -The program is needed for [CPIs](https://solana.com/docs/terminology#cross-program-invocation-cpi) that create metadata [accounts](https://solana.com/docs/terminology#account) and master edition accounts, and to verify NFTs as part of a collection. +The [CPIs](https://solana.com/docs/terminology#cross-program-invocation-cpi) that create metadata [accounts](https://solana.com/docs/terminology#account) and master edition accounts, and that verify NFTs as part of a collection, all target the Metaplex Token Metadata program. The Rust test suite loads a dump of that program from `tests/fixtures/mpl_token_metadata.so` into LiteSVM. To refresh the dump from mainnet, run `prepare.mjs` (requires [zx](https://github.com/google/zx)). ## Create an NFT collection @@ -22,9 +12,10 @@ The accounts needed to create an NFT collection are: ```rust #[derive(Accounts)] -pub struct CreateCollection<'info> { +pub struct CreateCollectionAccountConstraints<'info> { #[account(mut)] user: Signer<'info>, + #[account( init, payer = user, @@ -33,18 +24,22 @@ pub struct CreateCollection<'info> { mint::freeze_authority = mint_authority, )] mint: Account<'info, Mint>, + #[account( seeds = [b"authority"], bump, )] /// CHECK: This account is not initialized and is being used for signing purposes only pub mint_authority: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program metadata: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program master_edition: UncheckedAccount<'info>, + #[account( init, payer = user, @@ -52,6 +47,7 @@ pub struct CreateCollection<'info> { associated_token::authority = user )] destination: Account<'info, TokenAccount>, + system_program: Program<'info, System>, token_program: Program<'info, Token>, associated_token_program: Program<'info, AssociatedToken>, @@ -71,30 +67,32 @@ pub struct CreateCollection<'info> { - `token_program` / `associated_token_program`: create new [ATAs](https://solana.com/docs/terminology#associated-token-account-ata) and mint tokens. - `token_metadata_program`: the MPL Token Metadata program, used to create the metadata and master edition accounts. -Both `metadata` and `master_edition` are `UncheckedAccount` because they are uninitialized at the start of the [instruction](https://solana.com/docs/terminology#instruction) — the Token Metadata program initializes them via CPI. - -Had we written: +The `metadata` and `master_edition` accounts are `UncheckedAccount` because the Metaplex program initializes them during the CPI. If instead we wrote: ```rust -#[derive(Accounts)] -pub struct CreateCollection<'info> { - #[account(mut)] - metadata: Account<'info, MetadataAccount>, - #[account(mut)] - master_edition: Account<'info, MasterEditionAccount>, -} +#[account(mut)] +metadata: Account<'info, MetadataAccount>, +#[account(mut)] +master_edition: Account<'info, MasterEditionAccount>, ``` the instruction would fail because [Anchor](https://solana.com/docs/terminology#anchor) would expect the accounts to already be initialized. When an account *is* already initialized (as in the verify-collection flow below), use the specific account types. -### Implementation for `CreateCollection` +### Implementation for `create_collection` -Each [instruction handler](https://solana.com/docs/terminology#instruction-handler) is a free function (`pub fn handler(accounts: &mut X, bumps: &XBumps)`) called from the `#[program]` module in `lib.rs`. The account-validation struct lives in the same file as the handler. +Each [instruction handler](https://solana.com/docs/terminology#instruction-handler) is a free function called from the `#[program]` module in `lib.rs`. The account constraints struct lives in the same file as the handler. The metadata `name`, `symbol`, and `uri` are instruction arguments, validated against the Metaplex limits (32, 10, and 200 bytes) by `validate_metadata_strings`, which returns the named errors `NameTooLong` / `SymbolTooLong` / `UriTooLong` instead of an opaque CPI failure. ```rust -pub fn handler(accounts: &mut CreateCollection, bumps: &CreateCollectionBumps) -> Result<()> { +pub fn handle_create_collection( + accounts: &mut CreateCollectionAccountConstraints, + bumps: &CreateCollectionAccountConstraintsBumps, + name: String, + symbol: String, + uri: String, +) -> Result<()> { + validate_metadata_strings(&name, &symbol, &uri)?; let metadata = &accounts.metadata.to_account_info(); let master_edition = &accounts.master_edition.to_account_info(); @@ -113,12 +111,13 @@ pub fn handler(accounts: &mut CreateCollection, bumps: &CreateCollectionBumps) - to: accounts.destination.to_account_info(), authority: accounts.mint_authority.to_account_info(), }; - let cpi_ctx = CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); + let cpi_ctx = + CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); mint_to(cpi_ctx, 1)?; msg!("Collection NFT minted!"); let creator = vec![Creator { - address: accounts.mint_authority.key().clone(), + address: accounts.mint_authority.key(), verified: true, share: 100, }]; @@ -126,16 +125,19 @@ pub fn handler(accounts: &mut CreateCollection, bumps: &CreateCollectionBumps) - let metadata_account = CreateMetadataAccountV3Cpi::new( spl_metadata_program, CreateMetadataAccountV3CpiAccounts { - metadata, mint, mint_authority: authority, payer, + metadata, + mint, + mint_authority: authority, + payer, update_authority: (authority, true), system_program, rent: None, }, CreateMetadataAccountV3InstructionArgs { data: DataV2 { - name: "DummyCollection".to_owned(), - symbol: "DC".to_owned(), - uri: "".to_owned(), + name, + symbol, + uri, seller_fee_basis_points: 0, creators: Some(creator), collection: None, @@ -154,12 +156,16 @@ pub fn handler(accounts: &mut CreateCollection, bumps: &CreateCollectionBumps) - edition: master_edition, update_authority: authority, mint_authority: authority, - mint, payer, metadata, + mint, + payer, + metadata, token_program: spl_token_program, system_program, rent: None, }, - CreateMasterEditionV3InstructionArgs { max_supply: Some(0) }, + CreateMasterEditionV3InstructionArgs { + max_supply: Some(0), + }, ); master_edition_account.invoke_signed(signer_seeds)?; msg!("Master Edition Account created"); @@ -182,9 +188,10 @@ The accounts needed to mint an NFT: ```rust #[derive(Accounts)] -pub struct MintNFT<'info> { +pub struct MintNftAccountConstraints<'info> { #[account(mut)] pub owner: Signer<'info>, + #[account( init, payer = owner, @@ -193,6 +200,7 @@ pub struct MintNFT<'info> { mint::freeze_authority = mint_authority, )] pub mint: Account<'info, Mint>, + #[account( init, payer = owner, @@ -200,20 +208,25 @@ pub struct MintNFT<'info> { associated_token::authority = owner )] pub destination: Account<'info, TokenAccount>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program pub metadata: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program pub master_edition: UncheckedAccount<'info>, + #[account( seeds = [b"authority"], bump, )] /// CHECK: This is account is not initialized and is being used for signing purposes only pub mint_authority: UncheckedAccount<'info>, + #[account(mut)] pub collection_mint: Account<'info, Mint>, + pub system_program: Program<'info, System>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, @@ -234,135 +247,53 @@ pub struct MintNFT<'info> { Apart from `collection_mint`, the accounts are the same as the collection creation flow. A collection is just a regular NFT with the `collection_details` field set and the `collection` field on `data` set to `None`. An NFT belonging to a collection has `collection_details` set to `None` and the `collection` field on `data` set to a `Collection` struct with the collection's key and a `verified` boolean. `verified` starts false and flips to true once the NFT is verified as part of the collection. -That's where the `collection` account comes from — it provides the address that goes into the `Collection` struct on the NFT's metadata. - -### Implementation for `MintNFT` - -```rust -pub fn handler(accounts: &mut MintNFT, bumps: &MintNFTBumps) -> Result<()> { - - let metadata = &accounts.metadata.to_account_info(); - let master_edition = &accounts.master_edition.to_account_info(); - let mint = &accounts.mint.to_account_info(); - let authority = &accounts.mint_authority.to_account_info(); - let payer = &accounts.owner.to_account_info(); - let system_program = &accounts.system_program.to_account_info(); - let spl_token_program = &accounts.token_program.to_account_info(); - let spl_metadata_program = &accounts.token_metadata_program.to_account_info(); - - let seeds = &[&b"authority"[..], &[bumps.mint_authority]]; - let signer_seeds = &[&seeds[..]]; - - let cpi_accounts = MintTo { - mint: accounts.mint.to_account_info(), - to: accounts.destination.to_account_info(), - authority: accounts.mint_authority.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); - mint_to(cpi_ctx, 1)?; - msg!("Collection NFT minted!"); - - let creator = vec![Creator { - address: accounts.mint_authority.key(), - verified: true, - share: 100, - }]; - - let metadata_account = CreateMetadataAccountV3Cpi::new( - spl_metadata_program, - CreateMetadataAccountV3CpiAccounts { - metadata, mint, mint_authority: authority, payer, - update_authority: (authority, true), - system_program, - rent: None, - }, - CreateMetadataAccountV3InstructionArgs { - data: DataV2 { - name: "Mint Test".to_string(), - symbol: "YAY".to_string(), - uri: "".to_string(), - seller_fee_basis_points: 0, - creators: Some(creator), - collection: Some(Collection { - verified: false, - key: accounts.collection_mint.key(), - }), - uses: None, - }, - is_mutable: true, - collection_details: None, - }, - ); - metadata_account.invoke_signed(signer_seeds)?; - - let master_edition_account = CreateMasterEditionV3Cpi::new( - spl_metadata_program, - CreateMasterEditionV3CpiAccounts { - edition: master_edition, - update_authority: authority, - mint_authority: authority, - mint, payer, metadata, - token_program: spl_token_program, - system_program, - rent: None, - }, - CreateMasterEditionV3InstructionArgs { max_supply: Some(0) }, - ); - master_edition_account.invoke_signed(signer_seeds)?; +That's where the `collection_mint` account comes from - it provides the address that goes into the `Collection` struct on the NFT's metadata. - Ok(()) -} -``` - -Because a collection NFT is just a regular NFT with special metadata, the implementation mirrors `CreateCollection`. The same three steps: - -1. Mint one token to the destination via a Classic Token Program CPI. -2. Create a metadata account via a Token Metadata CPI (signed with the PDA seeds). -3. Create a master edition account via a Token Metadata CPI (signed with the PDA seeds). +### Implementation for `mint_nft` -The difference is in the data on the metadata account. +`handle_mint_nft` (in `mint_nft.rs`) mirrors `handle_create_collection`: the same caller-supplied `name` / `symbol` / `uri` arguments, the same validation, and the same three CPIs (mint one token, create metadata, create master edition). The difference is in the data on the metadata account. For the collection NFT: + ```rust CreateMetadataAccountV3InstructionArgs { data: DataV2 { - name: "DummyCollection".to_owned(), - symbol: "DC".to_owned(), - uri: "".to_owned(), + name, + symbol, + uri, seller_fee_basis_points: 0, creators: Some(creator), collection: None, uses: None, }, is_mutable: true, - collection_details: Some( - CollectionDetails::V1 { - size: 0 - } - ) + collection_details: Some(CollectionDetails::V1 { size: 0 }), } ``` + We set `collection_details`. For a regular NFT: + ```rust CreateMetadataAccountV3InstructionArgs { data: DataV2 { - name: "Mint Test".to_string(), - symbol: "YAY".to_string(), - uri: "".to_string(), + name, + symbol, + uri, seller_fee_basis_points: 0, creators: Some(creator), collection: Some(Collection { verified: false, - key: self.collection_mint.key(), + key: accounts.collection_mint.key(), }), - uses: None + uses: None, }, is_mutable: true, collection_details: None, } ``` + We set the `collection` field with the key of the collection. `verified` starts false until the NFT is verified. ## Verify an NFT as part of a collection @@ -371,7 +302,7 @@ The accounts needed to verify an NFT as part of a collection: ```rust #[derive(Accounts)] -pub struct VerifyCollectionMint<'info> { +pub struct VerifyCollectionMintAccountConstraints<'info> { pub authority: Signer<'info>, #[account(mut)] pub metadata: Account<'info, MetadataAccount>, @@ -407,12 +338,15 @@ pub struct VerifyCollectionMint<'info> { - `sysvar_instruction`: provides access to the serialized instruction data for the running transaction. - `token_metadata_program`: MPL Token Metadata, used to perform the verification CPI. -Only the NFT and collection NFT metadata accounts need to be mutable — both are updated. The NFT metadata gets its `verified` boolean flipped to true, and the collection NFT metadata has its collection size incremented. +Only the NFT and collection NFT metadata accounts need to be mutable - both are updated. The NFT metadata gets its `verified` boolean flipped to true, and the collection NFT metadata has its collection size incremented. -### Implementation for `VerifyCollectionMint` +### Implementation for `verify_collection` ```rust -pub fn handler(accounts: &mut VerifyCollectionMint, bumps: &VerifyCollectionMintBumps) -> Result<()> { +pub fn handle_verify_collection( + accounts: &mut VerifyCollectionMintAccountConstraints, + bumps: &VerifyCollectionMintAccountConstraintsBumps, +) -> Result<()> { let metadata = &accounts.metadata.to_account_info(); let authority = &accounts.mint_authority.to_account_info(); let collection_mint = &accounts.collection_mint.to_account_info(); @@ -449,4 +383,13 @@ pub fn handler(accounts: &mut VerifyCollectionMint, bumps: &VerifyCollectionMint `verify_collection` performs a CPI to the Token Metadata program with the right accounts. The collection NFT's mint authority signs the CPI, and the NFT is verified as part of the collection. +## Testing + +Rust + LiteSVM tests live in `programs/mint-nft/tests/test_nft_operations.rs`. They load the program binary and the Metaplex fixture, then run the full lifecycle - create a collection, mint an NFT into it, verify membership - asserting token balances and that the caller-supplied metadata strings land in the metadata accounts. + +```bash +cargo build-sbf +cargo test +``` + Use this as a starting point for your own collections, NFTs, and verification flows. diff --git a/tokens/nft-operations/anchor/prepare.mjs b/tokens/nft-operations/anchor/prepare.mjs index fb6b2622..7b58d3ea 100644 --- a/tokens/nft-operations/anchor/prepare.mjs +++ b/tokens/nft-operations/anchor/prepare.mjs @@ -7,7 +7,8 @@ import { $ } from "zx"; const programs = [ { id: "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", - name: "token_metadata.so", + // Must match the fixture filename the tests load via include_bytes!. + name: "mpl_token_metadata.so", }, ]; diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/error.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/error.rs new file mode 100644 index 00000000..4f538434 --- /dev/null +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/error.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum MintNftError { + #[msg("Metadata name exceeds the Metaplex maximum of 32 bytes")] + NameTooLong, + #[msg("Metadata symbol exceeds the Metaplex maximum of 10 bytes")] + SymbolTooLong, + #[msg("Metadata URI exceeds the Metaplex maximum of 200 bytes")] + UriTooLong, +} diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/create_collection.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/create_collection.rs index b1411b0b..6fd2722c 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/create_collection.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/create_collection.rs @@ -1,35 +1,26 @@ use anchor_lang::prelude::*; use anchor_spl::{ - associated_token::AssociatedToken, - metadata::Metadata, - token::{ - mint_to, - Mint, - MintTo, - Token, - TokenAccount, - } + associated_token::AssociatedToken, + metadata::Metadata, + token::{mint_to, Mint, MintTo, Token, TokenAccount}, }; + use anchor_spl::metadata::mpl_token_metadata::{ instructions::{ - CreateMasterEditionV3Cpi, - CreateMasterEditionV3CpiAccounts, - CreateMasterEditionV3InstructionArgs, - CreateMetadataAccountV3Cpi, - CreateMetadataAccountV3CpiAccounts, - CreateMetadataAccountV3InstructionArgs - }, - types::{ - CollectionDetails, - Creator, - DataV2 - } + CreateMasterEditionV3Cpi, CreateMasterEditionV3CpiAccounts, + CreateMasterEditionV3InstructionArgs, CreateMetadataAccountV3Cpi, + CreateMetadataAccountV3CpiAccounts, CreateMetadataAccountV3InstructionArgs, + }, + types::{CollectionDetails, Creator, DataV2}, }; +use super::validate_metadata_strings; + #[derive(Accounts)] -pub struct CreateCollection<'info> { +pub struct CreateCollectionAccountConstraints<'info> { #[account(mut)] user: Signer<'info>, + #[account( init, payer = user, @@ -38,18 +29,22 @@ pub struct CreateCollection<'info> { mint::freeze_authority = mint_authority, )] mint: Account<'info, Mint>, + #[account( seeds = [b"authority"], bump, )] /// CHECK: This account is not initialized and is being used for signing purposes only pub mint_authority: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program metadata: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program master_edition: UncheckedAccount<'info>, + #[account( init, payer = user, @@ -57,97 +52,101 @@ pub struct CreateCollection<'info> { associated_token::authority = user )] destination: Account<'info, TokenAccount>, + system_program: Program<'info, System>, token_program: Program<'info, Token>, associated_token_program: Program<'info, AssociatedToken>, token_metadata_program: Program<'info, Metadata>, } -pub fn handler(accounts: &mut CreateCollection, bumps: &CreateCollectionBumps) -> Result<()> { - - let metadata = &accounts.metadata.to_account_info(); - let master_edition = &accounts.master_edition.to_account_info(); - let mint = &accounts.mint.to_account_info(); - let authority = &accounts.mint_authority.to_account_info(); - let payer = &accounts.user.to_account_info(); - let system_program = &accounts.system_program.to_account_info(); - let spl_token_program = &accounts.token_program.to_account_info(); - let spl_metadata_program = &accounts.token_metadata_program.to_account_info(); - - let seeds = &[ - &b"authority"[..], - &[bumps.mint_authority] - ]; - let signer_seeds = &[&seeds[..]]; - - let cpi_accounts = MintTo { - mint: accounts.mint.to_account_info(), - to: accounts.destination.to_account_info(), - authority: accounts.mint_authority.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); - mint_to(cpi_ctx, 1)?; - msg!("Collection NFT minted!"); - - let creator = vec![ - Creator { - address: accounts.mint_authority.key().clone(), - verified: true, - share: 100, - }, - ]; - - let metadata_account = CreateMetadataAccountV3Cpi::new( - spl_metadata_program, - CreateMetadataAccountV3CpiAccounts { - metadata, - mint, - mint_authority: authority, - payer, - update_authority: (authority, true), - system_program, - rent: None, - }, - CreateMetadataAccountV3InstructionArgs { - data: DataV2 { - name: "DummyCollection".to_owned(), - symbol: "DC".to_owned(), - uri: "".to_owned(), - seller_fee_basis_points: 0, - creators: Some(creator), - collection: None, - uses: None, - }, - is_mutable: true, - collection_details: Some( - CollectionDetails::V1 { - size: 0 - } - ) - } - ); - metadata_account.invoke_signed(signer_seeds)?; - msg!("Metadata Account created!"); - - let master_edition_account = CreateMasterEditionV3Cpi::new( - spl_metadata_program, - CreateMasterEditionV3CpiAccounts { - edition: master_edition, - update_authority: authority, - mint_authority: authority, - mint, - payer, - metadata, - token_program: spl_token_program, - system_program, - rent: None, +/// Creates a collection NFT with caller-supplied metadata. +/// +/// `name`, `symbol`, and `uri` are validated against the Metaplex Token +/// Metadata limits (32, 10, and 200 bytes respectively). +pub fn handle_create_collection( + accounts: &mut CreateCollectionAccountConstraints, + bumps: &CreateCollectionAccountConstraintsBumps, + name: String, + symbol: String, + uri: String, +) -> Result<()> { + validate_metadata_strings(&name, &symbol, &uri)?; + + let metadata = &accounts.metadata.to_account_info(); + let master_edition = &accounts.master_edition.to_account_info(); + let mint = &accounts.mint.to_account_info(); + let authority = &accounts.mint_authority.to_account_info(); + let payer = &accounts.user.to_account_info(); + let system_program = &accounts.system_program.to_account_info(); + let spl_token_program = &accounts.token_program.to_account_info(); + let spl_metadata_program = &accounts.token_metadata_program.to_account_info(); + + let seeds = &[&b"authority"[..], &[bumps.mint_authority]]; + let signer_seeds = &[&seeds[..]]; + + let cpi_accounts = MintTo { + mint: accounts.mint.to_account_info(), + to: accounts.destination.to_account_info(), + authority: accounts.mint_authority.to_account_info(), + }; + let cpi_ctx = + CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); + mint_to(cpi_ctx, 1)?; + msg!("Collection NFT minted!"); + + let creator = vec![Creator { + address: accounts.mint_authority.key(), + verified: true, + share: 100, + }]; + + let metadata_account = CreateMetadataAccountV3Cpi::new( + spl_metadata_program, + CreateMetadataAccountV3CpiAccounts { + metadata, + mint, + mint_authority: authority, + payer, + update_authority: (authority, true), + system_program, + rent: None, + }, + CreateMetadataAccountV3InstructionArgs { + data: DataV2 { + name, + symbol, + uri, + seller_fee_basis_points: 0, + creators: Some(creator), + collection: None, + uses: None, }, - CreateMasterEditionV3InstructionArgs { - max_supply: Some(0), - } - ); - master_edition_account.invoke_signed(signer_seeds)?; - msg!("Master Edition Account created"); - - Ok(()) - } + is_mutable: true, + collection_details: Some(CollectionDetails::V1 { size: 0 }), + }, + ); + metadata_account.invoke_signed(signer_seeds)?; + msg!("Metadata Account created!"); + + let master_edition_account = CreateMasterEditionV3Cpi::new( + spl_metadata_program, + CreateMasterEditionV3CpiAccounts { + edition: master_edition, + update_authority: authority, + mint_authority: authority, + mint, + payer, + metadata, + token_program: spl_token_program, + system_program, + rent: None, + }, + CreateMasterEditionV3InstructionArgs { + max_supply: Some(0), + }, + ); + master_edition_account.invoke_signed(signer_seeds)?; + msg!("Master Edition Account created"); + + Ok(()) +} diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mint_nft.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mint_nft.rs index 0ad58138..04023aad 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mint_nft.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mint_nft.rs @@ -1,35 +1,26 @@ use anchor_lang::prelude::*; use anchor_spl::{ - associated_token::AssociatedToken, - metadata::Metadata, - token::{ - mint_to, - Mint, - MintTo, - Token, - TokenAccount - } + associated_token::AssociatedToken, + metadata::Metadata, + token::{mint_to, Mint, MintTo, Token, TokenAccount}, }; + use anchor_spl::metadata::mpl_token_metadata::{ instructions::{ - CreateMasterEditionV3Cpi, - CreateMasterEditionV3CpiAccounts, - CreateMasterEditionV3InstructionArgs, - CreateMetadataAccountV3Cpi, - CreateMetadataAccountV3CpiAccounts, - CreateMetadataAccountV3InstructionArgs, - }, - types::{ - Collection, - Creator, - DataV2, - } + CreateMasterEditionV3Cpi, CreateMasterEditionV3CpiAccounts, + CreateMasterEditionV3InstructionArgs, CreateMetadataAccountV3Cpi, + CreateMetadataAccountV3CpiAccounts, CreateMetadataAccountV3InstructionArgs, + }, + types::{Collection, Creator, DataV2}, }; +use super::validate_metadata_strings; + #[derive(Accounts)] -pub struct MintNFT<'info> { +pub struct MintNftAccountConstraints<'info> { #[account(mut)] pub owner: Signer<'info>, + #[account( init, payer = owner, @@ -38,6 +29,7 @@ pub struct MintNFT<'info> { mint::freeze_authority = mint_authority, )] pub mint: Account<'info, Mint>, + #[account( init, payer = owner, @@ -45,109 +37,121 @@ pub struct MintNFT<'info> { associated_token::authority = owner )] pub destination: Account<'info, TokenAccount>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program pub metadata: UncheckedAccount<'info>, + #[account(mut)] /// CHECK: This account will be initialized by the metaplex program pub master_edition: UncheckedAccount<'info>, + #[account( seeds = [b"authority"], bump, )] /// CHECK: This is account is not initialized and is being used for signing purposes only pub mint_authority: UncheckedAccount<'info>, + #[account(mut)] pub collection_mint: Account<'info, Mint>, + pub system_program: Program<'info, System>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, pub token_metadata_program: Program<'info, Metadata>, } -pub fn handler(accounts: &mut MintNFT, bumps: &MintNFTBumps) -> Result<()> { - - let metadata = &accounts.metadata.to_account_info(); - let master_edition = &accounts.master_edition.to_account_info(); - let mint = &accounts.mint.to_account_info(); - let authority = &accounts.mint_authority.to_account_info(); - let payer = &accounts.owner.to_account_info(); - let system_program = &accounts.system_program.to_account_info(); - let spl_token_program = &accounts.token_program.to_account_info(); - let spl_metadata_program = &accounts.token_metadata_program.to_account_info(); - - let seeds = &[ - &b"authority"[..], - &[bumps.mint_authority] - ]; - let signer_seeds = &[&seeds[..]]; - - let cpi_accounts = MintTo { - mint: accounts.mint.to_account_info(), - to: accounts.destination.to_account_info(), - authority: accounts.mint_authority.to_account_info(), - }; - let cpi_ctx = CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); - mint_to(cpi_ctx, 1)?; - msg!("Collection NFT minted!"); - - let creator = vec![ - Creator { - address: accounts.mint_authority.key(), - verified: true, - share: 100, - }, - ]; - - let metadata_account = CreateMetadataAccountV3Cpi::new( - spl_metadata_program, - CreateMetadataAccountV3CpiAccounts { - metadata, - mint, - mint_authority: authority, - payer, - update_authority: (authority, true), - system_program, - rent: None, - }, - CreateMetadataAccountV3InstructionArgs { - data: DataV2 { - name: "Mint Test".to_string(), - symbol: "YAY".to_string(), - uri: "".to_string(), - seller_fee_basis_points: 0, - creators: Some(creator), - collection: Some(Collection { - verified: false, - key: accounts.collection_mint.key(), - }), - uses: None - }, - is_mutable: true, - collection_details: None, - } - ); - metadata_account.invoke_signed(signer_seeds)?; - - let master_edition_account = CreateMasterEditionV3Cpi::new( - spl_metadata_program, - CreateMasterEditionV3CpiAccounts { - edition: master_edition, - update_authority: authority, - mint_authority: authority, - mint, - payer, - metadata, - token_program: spl_token_program, - system_program, - rent: None, +/// Mints an NFT into the collection with caller-supplied metadata. +/// +/// `name`, `symbol`, and `uri` are validated against the Metaplex Token +/// Metadata limits (32, 10, and 200 bytes respectively). The collection +/// reference starts unverified; call `verify_collection` to verify it. +pub fn handle_mint_nft( + accounts: &mut MintNftAccountConstraints, + bumps: &MintNftAccountConstraintsBumps, + name: String, + symbol: String, + uri: String, +) -> Result<()> { + validate_metadata_strings(&name, &symbol, &uri)?; + + let metadata = &accounts.metadata.to_account_info(); + let master_edition = &accounts.master_edition.to_account_info(); + let mint = &accounts.mint.to_account_info(); + let authority = &accounts.mint_authority.to_account_info(); + let payer = &accounts.owner.to_account_info(); + let system_program = &accounts.system_program.to_account_info(); + let spl_token_program = &accounts.token_program.to_account_info(); + let spl_metadata_program = &accounts.token_metadata_program.to_account_info(); + + let seeds = &[&b"authority"[..], &[bumps.mint_authority]]; + let signer_seeds = &[&seeds[..]]; + + let cpi_accounts = MintTo { + mint: accounts.mint.to_account_info(), + to: accounts.destination.to_account_info(), + authority: accounts.mint_authority.to_account_info(), + }; + let cpi_ctx = + CpiContext::new_with_signer(accounts.token_program.key(), cpi_accounts, signer_seeds); + mint_to(cpi_ctx, 1)?; + msg!("NFT minted!"); + + let creator = vec![Creator { + address: accounts.mint_authority.key(), + verified: true, + share: 100, + }]; + + let metadata_account = CreateMetadataAccountV3Cpi::new( + spl_metadata_program, + CreateMetadataAccountV3CpiAccounts { + metadata, + mint, + mint_authority: authority, + payer, + update_authority: (authority, true), + system_program, + rent: None, + }, + CreateMetadataAccountV3InstructionArgs { + data: DataV2 { + name, + symbol, + uri, + seller_fee_basis_points: 0, + creators: Some(creator), + collection: Some(Collection { + verified: false, + key: accounts.collection_mint.key(), + }), + uses: None, }, - CreateMasterEditionV3InstructionArgs { - max_supply: Some(0), - } - ); - master_edition_account.invoke_signed(signer_seeds)?; - - Ok(()) - - } + is_mutable: true, + collection_details: None, + }, + ); + metadata_account.invoke_signed(signer_seeds)?; + + let master_edition_account = CreateMasterEditionV3Cpi::new( + spl_metadata_program, + CreateMasterEditionV3CpiAccounts { + edition: master_edition, + update_authority: authority, + mint_authority: authority, + mint, + payer, + metadata, + token_program: spl_token_program, + system_program, + rent: None, + }, + CreateMasterEditionV3InstructionArgs { + max_supply: Some(0), + }, + ); + master_edition_account.invoke_signed(signer_seeds)?; + + Ok(()) +} diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mod.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mod.rs index d4557134..6321509c 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mod.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/mod.rs @@ -1,7 +1,27 @@ -pub mod mint_nft; -pub mod create_collection; -pub mod verify_collection; - -pub use mint_nft::*; -pub use create_collection::*; -pub use verify_collection::*; +pub mod create_collection; +pub mod mint_nft; +pub mod verify_collection; + +pub use create_collection::*; +pub use mint_nft::*; +pub use verify_collection::*; + +use { + crate::error::MintNftError, + anchor_lang::prelude::*, + anchor_spl::metadata::mpl_token_metadata::{ + MAX_NAME_LENGTH, MAX_SYMBOL_LENGTH, MAX_URI_LENGTH, + }, +}; + +/// Rejects metadata strings that exceed the Metaplex Token Metadata limits, +/// so callers get a named error instead of an opaque CPI failure. +pub fn validate_metadata_strings(name: &str, symbol: &str, uri: &str) -> Result<()> { + require!(name.len() <= MAX_NAME_LENGTH, MintNftError::NameTooLong); + require!( + symbol.len() <= MAX_SYMBOL_LENGTH, + MintNftError::SymbolTooLong + ); + require!(uri.len() <= MAX_URI_LENGTH, MintNftError::UriTooLong); + Ok(()) +} diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/verify_collection.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/verify_collection.rs index 880dcb2b..4748ec25 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/verify_collection.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/instructions/verify_collection.rs @@ -12,11 +12,11 @@ use anchor_spl::{ token::Mint, metadata::Metadata, }; -// In Anchor 1.0, sysvar::instructions::ID moved — use the well-known address directly +// In Anchor 1.0, sysvar::instructions::ID moved - use the well-known address directly const INSTRUCTIONS_SYSVAR_ID: Pubkey = anchor_lang::solana_program::pubkey::pubkey!("Sysvar1nstructions1111111111111111111111111"); #[derive(Accounts)] -pub struct VerifyCollectionMint<'info> { +pub struct VerifyCollectionMintAccountConstraints<'info> { pub authority: Signer<'info>, #[account(mut)] pub metadata: Account<'info, MetadataAccount>, @@ -38,7 +38,10 @@ pub struct VerifyCollectionMint<'info> { pub token_metadata_program: Program<'info, Metadata>, } -pub fn handler(accounts: &mut VerifyCollectionMint, bumps: &VerifyCollectionMintBumps) -> Result<()> { +pub fn handle_verify_collection( + accounts: &mut VerifyCollectionMintAccountConstraints, + bumps: &VerifyCollectionMintAccountConstraintsBumps, +) -> Result<()> { let metadata = &accounts.metadata.to_account_info(); let authority = &accounts.mint_authority.to_account_info(); let collection_mint = &accounts.collection_mint.to_account_info(); diff --git a/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs b/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs index b36f0ff9..2e1c3eac 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/src/lib.rs @@ -2,6 +2,7 @@ use anchor_lang::prelude::*; declare_id!("3EMcczaGi9ivdLxvvFwRbGYeEUEHpGwabXegARw4jLxa"); +pub mod error; pub mod instructions; pub use instructions::*; @@ -10,15 +11,40 @@ pub use instructions::*; pub mod mint_nft { use super::*; - pub fn create_collection(mut context: Context) -> Result<()> { - instructions::create_collection::handler(&mut context.accounts, &context.bumps) + + /// Create a collection NFT with the given metadata. + pub fn create_collection( + mut context: Context, + name: String, + symbol: String, + uri: String, + ) -> Result<()> { + instructions::create_collection::handle_create_collection( + &mut context.accounts, + &context.bumps, + name, + symbol, + uri, + ) } - pub fn mint_nft(mut context: Context) -> Result<()> { - instructions::mint_nft::handler(&mut context.accounts, &context.bumps) + /// Mint an NFT into the collection with the given metadata. + pub fn mint_nft( + mut context: Context, + name: String, + symbol: String, + uri: String, + ) -> Result<()> { + instructions::mint_nft::handle_mint_nft(&mut context.accounts, &context.bumps, name, symbol, uri) } - pub fn verify_collection(mut context: Context) -> Result<()> { - instructions::verify_collection::handler(&mut context.accounts, &context.bumps) + /// Verify an NFT as a member of the collection. + pub fn verify_collection( + mut context: Context, + ) -> Result<()> { + instructions::verify_collection::handle_verify_collection( + &mut context.accounts, + &context.bumps, + ) } } diff --git a/tokens/nft-operations/anchor/programs/mint-nft/tests/test_nft_operations.rs b/tokens/nft-operations/anchor/programs/mint-nft/tests/test_nft_operations.rs index d22fdcf3..93bb7e51 100644 --- a/tokens/nft-operations/anchor/programs/mint-nft/tests/test_nft_operations.rs +++ b/tokens/nft-operations/anchor/programs/mint-nft/tests/test_nft_operations.rs @@ -66,6 +66,13 @@ fn derive_edition_pda(mint: &Pubkey) -> Pubkey { pda } +/// Returns true if `haystack` contains `needle` anywhere. Used to check that +/// caller-supplied metadata strings landed in the Metaplex metadata account +/// without fully deserializing the Metaplex layout. +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|window| window == needle) +} + fn setup() -> (LiteSVM, Pubkey, Keypair) { let program_id = mint_nft::id(); let mut svm = LiteSVM::new(); @@ -94,8 +101,13 @@ fn test_create_collection() { let instruction = Instruction::new_with_bytes( program_id, - &mint_nft::instruction::CreateCollection {}.data(), - mint_nft::accounts::CreateCollection { + &mint_nft::instruction::CreateCollection { + name: "Example Collection".to_string(), + symbol: "EXCO".to_string(), + uri: "https://example.com/collection.json".to_string(), + } + .data(), + mint_nft::accounts::CreateCollectionAccountConstraints { user: payer.pubkey(), mint: collection_keypair.pubkey(), mint_authority, @@ -124,11 +136,15 @@ fn test_create_collection() { .expect("Collection mint should exist"); assert!(!mint_account.data.is_empty()); - // Verify metadata exists + // Verify metadata exists and carries the caller-supplied name let meta_account = svm .get_account(&metadata) .expect("Metadata should exist"); assert!(!meta_account.data.is_empty()); + assert!( + contains_bytes(&meta_account.data, b"Example Collection"), + "Metadata should contain the caller-supplied collection name" + ); // Verify master edition exists let edition_account = svm @@ -155,8 +171,13 @@ fn test_mint_nft_to_collection() { let create_collection_ix = Instruction::new_with_bytes( program_id, - &mint_nft::instruction::CreateCollection {}.data(), - mint_nft::accounts::CreateCollection { + &mint_nft::instruction::CreateCollection { + name: "Example Collection".to_string(), + symbol: "EXCO".to_string(), + uri: "https://example.com/collection.json".to_string(), + } + .data(), + mint_nft::accounts::CreateCollectionAccountConstraints { user: payer.pubkey(), mint: collection_keypair.pubkey(), mint_authority, @@ -188,8 +209,13 @@ fn test_mint_nft_to_collection() { let mint_nft_ix = Instruction::new_with_bytes( program_id, - &mint_nft::instruction::MintNft {}.data(), - mint_nft::accounts::MintNFT { + &mint_nft::instruction::MintNft { + name: "Example NFT #1".to_string(), + symbol: "EXNFT".to_string(), + uri: "https://example.com/nft-1.json".to_string(), + } + .data(), + mint_nft::accounts::MintNftAccountConstraints { owner: payer.pubkey(), mint: nft_keypair.pubkey(), destination: nft_destination, @@ -217,11 +243,15 @@ fn test_mint_nft_to_collection() { let balance = get_token_account_balance(&svm, &nft_destination).unwrap(); assert_eq!(balance, 1, "Should have 1 NFT"); - // Verify NFT metadata exists + // Verify NFT metadata exists and carries the caller-supplied name let nft_meta = svm .get_account(&nft_metadata) .expect("NFT metadata should exist"); assert!(!nft_meta.data.is_empty()); + assert!( + contains_bytes(&nft_meta.data, b"Example NFT #1"), + "Metadata should contain the caller-supplied NFT name" + ); } #[test] @@ -238,8 +268,13 @@ fn test_verify_collection() { let create_collection_ix = Instruction::new_with_bytes( program_id, - &mint_nft::instruction::CreateCollection {}.data(), - mint_nft::accounts::CreateCollection { + &mint_nft::instruction::CreateCollection { + name: "Example Collection".to_string(), + symbol: "EXCO".to_string(), + uri: "https://example.com/collection.json".to_string(), + } + .data(), + mint_nft::accounts::CreateCollectionAccountConstraints { user: payer.pubkey(), mint: collection_keypair.pubkey(), mint_authority, @@ -271,8 +306,13 @@ fn test_verify_collection() { let mint_nft_ix = Instruction::new_with_bytes( program_id, - &mint_nft::instruction::MintNft {}.data(), - mint_nft::accounts::MintNFT { + &mint_nft::instruction::MintNft { + name: "Example NFT #1".to_string(), + symbol: "EXNFT".to_string(), + uri: "https://example.com/nft-1.json".to_string(), + } + .data(), + mint_nft::accounts::MintNftAccountConstraints { owner: payer.pubkey(), mint: nft_keypair.pubkey(), destination: nft_destination, @@ -301,7 +341,7 @@ fn test_verify_collection() { let verify_ix = Instruction::new_with_bytes( program_id, &mint_nft::instruction::VerifyCollection {}.data(), - mint_nft::accounts::VerifyCollectionMint { + mint_nft::accounts::VerifyCollectionMintAccountConstraints { authority: payer.pubkey(), metadata: nft_metadata, mint: nft_keypair.pubkey(), diff --git a/tokens/nft-operations/quasar/Cargo.toml b/tokens/nft-operations/quasar/Cargo.toml index 5d2cc6b8..7f6cf364 100644 --- a/tokens/nft-operations/quasar/Cargo.toml +++ b/tokens/nft-operations/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-nft-operations" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/tokens/nft-operations/quasar/README.md b/tokens/nft-operations/quasar/README.md index bc17d65f..ee014bdf 100644 --- a/tokens/nft-operations/quasar/README.md +++ b/tokens/nft-operations/quasar/README.md @@ -1,13 +1,16 @@ # NFT Operations (Quasar) -Collection mint, NFT mint, and collection verification via Metaplex. +Collection mint, NFT mint, and collection verification via Metaplex. The Quasar twin of the [Anchor](../anchor/) variant, sharing its program ID and instruction surface. See also: the [repository catalog](../../../README.md). ## Major concepts -- Collection NFTs -- Verification CPI +- A PDA at seeds `["authority"]` is the mint authority and update authority for the collection and every NFT. +- `create_collection` mints a **collection NFT**: it mints one token, creates the Metaplex metadata account (marked as a sized collection via `CollectionDetails`), and creates the master edition. Metadata `name`, `symbol`, and `uri` are instruction arguments, bounded to the Metaplex limits by their types (`String<32>`, `String<10>`, `String<200>`), so oversized values are rejected at instruction decoding. +- `mint_nft` mints an individual NFT the same way, with an unverified reference to the collection in its metadata. +- `verify_collection` verifies the NFT's collection membership through a `VerifySizedCollectionItem` CPI signed by the PDA authority. +- The metadata-creation and verification CPIs are built in the program (`src/instructions/mod.rs` and `verify_collection.rs`) rather than with `quasar_metadata`'s helpers, because the helpers cannot encode creators, collection references, or sized-collection details, and mark the collection metadata readonly during verification. ## Setup @@ -27,8 +30,8 @@ In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): cargo test ``` -Tests invoke instruction handlers and assert onchain state. No local validator. +The suite loads the Metaplex Token Metadata program from the fixture shared with the Anchor twin (`../anchor/tests/fixtures/mpl_token_metadata.so`) and exercises the full lifecycle: create the collection, mint an NFT into it, and verify membership, asserting token balances and metadata contents. No local validator. ## Usage -Read `src/` and `Quasar.toml`. Compare with the [Anchor](../anchor/) variant in the same example where present. +Read `src/` and `Quasar.toml`. Compare with the [Anchor](../anchor/) variant of the same example. diff --git a/tokens/nft-operations/quasar/src/instructions/create_collection.rs b/tokens/nft-operations/quasar/src/instructions/create_collection.rs index 8ac792cf..ac7c4b1c 100644 --- a/tokens/nft-operations/quasar/src/instructions/create_collection.rs +++ b/tokens/nft-operations/quasar/src/instructions/create_collection.rs @@ -9,7 +9,7 @@ use { /// /// The PDA `["authority"]` acts as mint authority and update authority. #[derive(Accounts)] -pub struct CreateCollection { +pub struct CreateCollectionAccountConstraints { #[account(mut)] pub user: Signer, #[account( @@ -27,10 +27,10 @@ pub struct CreateCollection { /// PDA used as mint authority and update authority. #[account(address = MintAuthorityPda::seeds())] pub mint_authority: UncheckedAccount, - /// Metadata PDA — initialised by the Metaplex program. + /// Metadata PDA - initialised by the Metaplex program. #[account(mut)] pub metadata: UncheckedAccount, - /// Master edition PDA — initialised by the Metaplex program. + /// Master edition PDA - initialised by the Metaplex program. #[account(mut)] pub master_edition: UncheckedAccount, /// Token account to hold the collection NFT. @@ -47,42 +47,54 @@ pub struct CreateCollection { pub rent: Sysvar, } +/// Creates a collection NFT with caller-supplied metadata: mints one token, +/// then creates the metadata account (with sized collection details) and the +/// master edition, all signed by the PDA authority. #[inline(always)] -pub fn handle_create_collection(accounts: &mut CreateCollection, bumps: &CreateCollectionBumps) -> Result<(), ProgramError> { +pub fn handle_create_collection( + accounts: &mut CreateCollectionAccountConstraints, + bumps: &CreateCollectionAccountConstraintsBumps, + name: &str, + symbol: &str, + uri: &str, +) -> Result<(), ProgramError> { let bump = [bumps.mint_authority]; let seeds: &[Seed] = &[ Seed::from(b"authority" as &[u8]), Seed::from(&bump as &[u8]), ]; - // Mint 1 token to the destination. - accounts.token_program + // Mint 1 token (the collection NFT) to the destination. + accounts + .token_program .mint_to(&accounts.mint, &accounts.destination, &accounts.mint_authority, 1u64) .invoke_signed(seeds)?; log("Collection NFT minted!"); - // Create metadata account. - accounts.token_metadata_program - .create_metadata_accounts_v3( - &accounts.metadata, - &accounts.mint, - &accounts.mint_authority, - &accounts.user, - &accounts.mint_authority, - &accounts.system_program, - &accounts.rent, - "DummyCollection", - "DC", - "", - 0, // seller_fee_basis_points - true, // is_mutable - true, // update_authority_is_signer - )? - .invoke_signed(seeds)?; + // Create the metadata account, marked as a sized collection + // (CollectionDetails::V1) so NFTs can be verified into it. + super::create_metadata_account_v3( + &accounts.token_metadata_program, + &accounts.metadata, + &accounts.mint, + &accounts.mint_authority, + &accounts.user, + &accounts.mint_authority, + &accounts.system_program, + &accounts.rent, + name, + symbol, + uri, + accounts.mint_authority.address(), + None, + true, + )? + .invoke_signed(seeds)?; log("Metadata Account created!"); // Create master edition. - accounts.token_metadata_program + accounts + .token_metadata_program .create_master_edition_v3( &accounts.master_edition, &accounts.mint, diff --git a/tokens/nft-operations/quasar/src/instructions/mint_nft.rs b/tokens/nft-operations/quasar/src/instructions/mint_nft.rs index 53be3dc3..71c392c6 100644 --- a/tokens/nft-operations/quasar/src/instructions/mint_nft.rs +++ b/tokens/nft-operations/quasar/src/instructions/mint_nft.rs @@ -7,7 +7,7 @@ use { /// Accounts for minting an individual NFT with a collection reference. #[derive(Accounts)] -pub struct MintNft { +pub struct MintNftAccountConstraints { #[account(mut)] pub owner: Signer, #[account( @@ -30,10 +30,10 @@ pub struct MintNft { token(mint = mint, authority = owner, token_program = token_program), )] pub destination: Account, - /// Metadata PDA — initialised by the Metaplex program. + /// Metadata PDA - initialised by the Metaplex program. #[account(mut)] pub metadata: UncheckedAccount, - /// Master edition PDA — initialised by the Metaplex program. + /// Master edition PDA - initialised by the Metaplex program. #[account(mut)] pub master_edition: UncheckedAccount, /// PDA used as mint authority and update authority. @@ -48,43 +48,54 @@ pub struct MintNft { pub rent: Sysvar, } +/// Mints an NFT into the collection with caller-supplied metadata. The +/// collection reference starts unverified; call `verify_collection` to +/// verify it. #[inline(always)] -pub fn handle_mint_nft(accounts: &mut MintNft, bumps: &MintNftBumps) -> Result<(), ProgramError> { +pub fn handle_mint_nft( + accounts: &mut MintNftAccountConstraints, + bumps: &MintNftAccountConstraintsBumps, + name: &str, + symbol: &str, + uri: &str, +) -> Result<(), ProgramError> { let bump = [bumps.mint_authority]; let seeds: &[Seed] = &[ Seed::from(b"authority" as &[u8]), Seed::from(&bump as &[u8]), ]; - // Mint 1 token to the destination. - accounts.token_program + // Mint 1 token (the NFT) to the destination. + accounts + .token_program .mint_to(&accounts.mint, &accounts.destination, &accounts.mint_authority, 1u64) .invoke_signed(seeds)?; log("NFT minted!"); - // Create metadata with collection reference. - // Note: The collection is set as unverified here; call verify_collection - // separately to verify it. - accounts.token_metadata_program - .create_metadata_accounts_v3( - &accounts.metadata, - &accounts.mint, - &accounts.mint_authority, - &accounts.owner, - &accounts.mint_authority, - &accounts.system_program, - &accounts.rent, - "Mint Test", - "YAY", - "", - 0, // seller_fee_basis_points - true, // is_mutable - true, // update_authority_is_signer - )? - .invoke_signed(seeds)?; + // Create the metadata account with an unverified collection reference. + let collection_mint_address = *accounts.collection_mint.to_account_view().address(); + super::create_metadata_account_v3( + &accounts.token_metadata_program, + &accounts.metadata, + &accounts.mint, + &accounts.mint_authority, + &accounts.owner, + &accounts.mint_authority, + &accounts.system_program, + &accounts.rent, + name, + symbol, + uri, + accounts.mint_authority.address(), + Some(&collection_mint_address), + false, + )? + .invoke_signed(seeds)?; + log("Metadata Account created!"); // Create master edition. - accounts.token_metadata_program + accounts + .token_metadata_program .create_master_edition_v3( &accounts.master_edition, &accounts.mint, @@ -98,6 +109,7 @@ pub fn handle_mint_nft(accounts: &mut MintNft, bumps: &MintNftBumps) -> Result<( Some(0), // max_supply = 0 means unique 1/1 ) .invoke_signed(seeds)?; + log("Master Edition Account created"); Ok(()) } diff --git a/tokens/nft-operations/quasar/src/instructions/mod.rs b/tokens/nft-operations/quasar/src/instructions/mod.rs index a2f6758e..63ea854c 100644 --- a/tokens/nft-operations/quasar/src/instructions/mod.rs +++ b/tokens/nft-operations/quasar/src/instructions/mod.rs @@ -5,3 +5,185 @@ mod verify_collection; pub use create_collection::*; pub use mint_nft::*; pub use verify_collection::*; + +use quasar_lang::{cpi::CpiDynamic, prelude::*}; + +// Byte sizes of the Borsh encoding used by the Metaplex +// CreateMetadataAccountV3 instruction, used to size the CPI data buffer. +const BORSH_STRING_PREFIX: usize = core::mem::size_of::(); +const BORSH_OPTION_TAG: usize = 1; +const BORSH_VEC_PREFIX: usize = core::mem::size_of::(); +const BORSH_ENUM_TAG: usize = 1; +const BORSH_BOOL: usize = 1; +/// Creator = address (32) + verified (bool) + share (u8). +const CREATOR_SIZE: usize = core::mem::size_of::
() + BORSH_BOOL + 1; +/// Collection = verified (bool) + key (32). +const COLLECTION_SIZE: usize = BORSH_BOOL + core::mem::size_of::
(); +/// CollectionDetails::V1 = enum tag + size (u64). +const COLLECTION_DETAILS_SIZE: usize = BORSH_ENUM_TAG + core::mem::size_of::(); + +/// Metaplex Token Metadata field limits, in bytes. These match the +/// `String` capacities on the instruction arguments, so oversized +/// values are rejected at instruction decoding. +pub const MAX_NAME_LENGTH: usize = 32; +pub const MAX_SYMBOL_LENGTH: usize = 10; +pub const MAX_URI_LENGTH: usize = 200; + +/// Instruction discriminator of CreateMetadataAccountV3 within the Metaplex +/// Token Metadata program. +const CREATE_METADATA_ACCOUNTS_V3_DISCRIMINATOR: u8 = 33; + +/// Accounts taken by CreateMetadataAccountV3: metadata, mint, mint +/// authority, payer, update authority, system program, rent. +const CREATE_METADATA_ACCOUNT_COUNT: usize = 7; + +/// Worst-case CreateMetadataAccountV3 instruction data length: +/// discriminator + DataV2 (name, symbol, uri, seller fee, one creator, +/// collection, uses) + is_mutable + collection_details. +const CREATE_METADATA_MAX_DATA: usize = 1 + + BORSH_STRING_PREFIX + + MAX_NAME_LENGTH + + BORSH_STRING_PREFIX + + MAX_SYMBOL_LENGTH + + BORSH_STRING_PREFIX + + MAX_URI_LENGTH + + core::mem::size_of::() + + BORSH_OPTION_TAG + + BORSH_VEC_PREFIX + + CREATOR_SIZE + + BORSH_OPTION_TAG + + COLLECTION_SIZE + + BORSH_OPTION_TAG + + BORSH_BOOL + + BORSH_OPTION_TAG + + COLLECTION_DETAILS_SIZE; + +const BORSH_OPTION_NONE: u8 = 0; +const BORSH_OPTION_SOME: u8 = 1; +/// CollectionDetails::V1 is the first enum variant. +const COLLECTION_DETAILS_V1_VARIANT: u8 = 0; +/// The PDA authority is the sole creator and receives the full royalty share. +const FULL_CREATOR_SHARE_PERCENT: u8 = 100; + +/// Sequential writer over the fixed CPI data buffer. All writes are bounded +/// by `CREATE_METADATA_MAX_DATA` because the string arguments are capped by +/// their `String` capacities and every other field is fixed size. +struct BorshWriter { + buffer: [u8; CREATE_METADATA_MAX_DATA], + offset: usize, +} + +impl BorshWriter { + fn new() -> Self { + Self { + buffer: [0; CREATE_METADATA_MAX_DATA], + offset: 0, + } + } + + fn write_byte(&mut self, value: u8) { + self.buffer[self.offset] = value; + self.offset += 1; + } + + fn write_slice(&mut self, bytes: &[u8]) { + let end = self.offset + bytes.len(); + self.buffer[self.offset..end].copy_from_slice(bytes); + self.offset = end; + } + + fn write_string(&mut self, value: &str) { + self.write_slice(&(value.len() as u32).to_le_bytes()); + self.write_slice(value.as_bytes()); + } + + fn data(&self) -> &[u8] { + &self.buffer[..self.offset] + } +} + +/// Builds a Metaplex CreateMetadataAccountV3 CPI. +/// +/// `quasar_metadata`'s `create_metadata_accounts_v3` helper always encodes +/// `creators`, `collection`, and `collection_details` as `None`. This program +/// needs all three (the PDA authority as verified creator, a collection +/// reference on minted NFTs, and sized collection details on the collection +/// NFT), so the instruction data is built here instead. +#[allow(clippy::too_many_arguments)] +#[inline(always)] +pub fn create_metadata_account_v3<'a>( + token_metadata_program: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + mint_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + system_program: &'a impl AsAccountView, + rent: &'a impl AsAccountView, + name: &str, + symbol: &str, + uri: &str, + creator: &Address, + collection_mint: Option<&Address>, + is_sized_collection: bool, +) -> Result, ProgramError> { + let mut cpi = CpiDynamic::::new( + token_metadata_program.to_account_view().address(), + ); + + cpi.push_account(metadata.to_account_view(), false, true)?; + cpi.push_account(mint.to_account_view(), false, false)?; + cpi.push_account(mint_authority.to_account_view(), true, false)?; + cpi.push_account(payer.to_account_view(), true, true)?; + cpi.push_account(update_authority.to_account_view(), true, false)?; + cpi.push_account(system_program.to_account_view(), false, false)?; + cpi.push_account(rent.to_account_view(), false, false)?; + + let mut writer = BorshWriter::new(); + writer.write_byte(CREATE_METADATA_ACCOUNTS_V3_DISCRIMINATOR); + + // DataV2.name / symbol / uri + writer.write_string(name); + writer.write_string(symbol); + writer.write_string(uri); + + // DataV2.seller_fee_basis_points + writer.write_slice(&0u16.to_le_bytes()); + + // DataV2.creators: Some([creator]) - verified, full share. Verified is + // allowed because the creator (the PDA authority) signs the CPI. + writer.write_byte(BORSH_OPTION_SOME); + writer.write_slice(&1u32.to_le_bytes()); + writer.write_slice(creator.as_ref()); + writer.write_byte(true as u8); + writer.write_byte(FULL_CREATOR_SHARE_PERCENT); + + // DataV2.collection: the (unverified) collection reference, if any. + // Verification happens later via verify_collection. + match collection_mint { + Some(collection_key) => { + writer.write_byte(BORSH_OPTION_SOME); + writer.write_byte(false as u8); + writer.write_slice(collection_key.as_ref()); + } + None => writer.write_byte(BORSH_OPTION_NONE), + } + + // DataV2.uses: None + writer.write_byte(BORSH_OPTION_NONE); + + // is_mutable + writer.write_byte(true as u8); + + // collection_details: Some(V1 { size: 0 }) marks a sized collection NFT. + if is_sized_collection { + writer.write_byte(BORSH_OPTION_SOME); + writer.write_byte(COLLECTION_DETAILS_V1_VARIANT); + writer.write_slice(&0u64.to_le_bytes()); + } else { + writer.write_byte(BORSH_OPTION_NONE); + } + + cpi.set_data(writer.data())?; + Ok(cpi) +} diff --git a/tokens/nft-operations/quasar/src/instructions/verify_collection.rs b/tokens/nft-operations/quasar/src/instructions/verify_collection.rs index 031b902a..3d8683e7 100644 --- a/tokens/nft-operations/quasar/src/instructions/verify_collection.rs +++ b/tokens/nft-operations/quasar/src/instructions/verify_collection.rs @@ -1,20 +1,29 @@ use { crate::MintAuthorityPda, - quasar_lang::prelude::*, + quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, + }, quasar_metadata::prelude::*, }; +/// Instruction discriminator of VerifySizedCollectionItem within the +/// Metaplex Token Metadata program - the verify instruction for sized +/// collections (the collection NFT carries `CollectionDetails::V1`). +const VERIFY_SIZED_COLLECTION_ITEM_DISCRIMINATOR: u8 = 30; + +/// Accounts taken by VerifySizedCollectionItem. +const VERIFY_ACCOUNT_COUNT: usize = 6; + /// Accounts for verifying an NFT as part of a collection. /// -/// Uses `verify_sized_collection_item` which is the Metaplex Token Metadata -/// instruction for verifying collection membership on sized collections. -/// /// The Anchor version uses typed `MetadataAccount` / `MasterEditionAccount` /// wrappers for owner and discriminant validation. In Quasar we use /// `UncheckedAccount` and rely on the Metaplex program itself to validate -/// the accounts during CPI — the onchain program enforces correctness. +/// the accounts during CPI - the onchain program enforces correctness. #[derive(Accounts)] -pub struct VerifyCollectionMint { +pub struct VerifyCollectionMintAccountConstraints { + #[account(mut)] pub authority: Signer, /// The NFT's metadata account (will be updated with verified=true). #[account(mut)] @@ -24,33 +33,61 @@ pub struct VerifyCollectionMint { pub mint_authority: UncheckedAccount, /// The collection mint. pub collection_mint: UncheckedAccount, - /// The collection's metadata account. + /// The collection's metadata account. Writable: verifying a sized + /// collection item increments the stored collection size. #[account(mut)] pub collection_metadata: UncheckedAccount, /// The collection's master edition account. pub collection_master_edition: UncheckedAccount, - pub system_program: Program, pub token_metadata_program: Program, } +/// Verifies the NFT's collection membership via a VerifySizedCollectionItem +/// CPI signed by the PDA collection authority. +/// +/// The CPI is built here rather than with `quasar_metadata`'s +/// `verify_sized_collection_item` helper because the helper marks +/// `collection_metadata` readonly, while the Metaplex program writes the +/// incremented collection size to it. #[inline(always)] -pub fn handle_verify_collection(accounts: &mut VerifyCollectionMint, bumps: &VerifyCollectionMintBumps) -> Result<(), ProgramError> { +pub fn handle_verify_collection( + accounts: &mut VerifyCollectionMintAccountConstraints, + bumps: &VerifyCollectionMintAccountConstraintsBumps, +) -> Result<(), ProgramError> { let bump = [bumps.mint_authority]; let seeds: &[Seed] = &[ Seed::from(b"authority" as &[u8]), Seed::from(&bump as &[u8]), ]; - accounts.token_metadata_program - .verify_sized_collection_item( - &accounts.metadata, - &accounts.mint_authority, - &accounts.authority, // payer - &accounts.collection_mint, - &accounts.collection_metadata, - &accounts.collection_master_edition, - ) - .invoke_signed(seeds)?; + let metadata = accounts.metadata.to_account_view(); + let collection_authority = accounts.mint_authority.to_account_view(); + let payer = accounts.authority.to_account_view(); + let collection_mint = accounts.collection_mint.to_account_view(); + let collection_metadata = accounts.collection_metadata.to_account_view(); + let collection_master_edition = accounts.collection_master_edition.to_account_view(); + + CpiCall::::new( + accounts.token_metadata_program.to_account_view().address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::writable(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + payer, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [VERIFY_SIZED_COLLECTION_ITEM_DISCRIMINATOR], + ) + .invoke_signed(seeds)?; log("Collection Verified!"); Ok(()) diff --git a/tokens/nft-operations/quasar/src/lib.rs b/tokens/nft-operations/quasar/src/lib.rs index 58185ce9..f68d4ad3 100644 --- a/tokens/nft-operations/quasar/src/lib.rs +++ b/tokens/nft-operations/quasar/src/lib.rs @@ -7,12 +7,11 @@ use instructions::*; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("3EMcczaGi9ivdLxvvFwRbGYeEUEHpGwabXegARw4jLxa"); /// Marker carrying the seeds for the shared PDA mint authority used as -/// both mint and update authority. PR #195 removed inline -/// `seeds = [...]`; derivation now happens through a `#[derive(Seeds)]` -/// type referenced by `address = T::seeds()`. +/// both mint and update authority. Quasar derives PDA addresses through a +/// `#[derive(Seeds)]` type referenced by `address = T::seeds()`. #[derive(Seeds)] #[seeds(b"authority")] pub struct MintAuthorityPda; @@ -26,21 +25,37 @@ pub struct MintAuthorityPda; mod quasar_nft_operations { use super::*; + // String capacities follow the Metaplex Token Metadata limits: + // name <= 32, symbol <= 10, uri <= 200 bytes. The bounded types reject + // oversized values at instruction decoding. + /// Create a collection NFT: mint, metadata, and master edition. #[instruction(discriminator = 0)] - pub fn create_collection(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_create_collection(&mut ctx.accounts, &ctx.bumps) + pub fn create_collection( + ctx: Ctx, + name: String<32>, + symbol: String<10>, + uri: String<200>, + ) -> Result<(), ProgramError> { + instructions::handle_create_collection(&mut ctx.accounts, &ctx.bumps, &name, &symbol, &uri) } - /// Mint an individual NFT with a reference to the collection. + /// Mint an individual NFT with an unverified reference to the collection. #[instruction(discriminator = 1)] - pub fn mint_nft(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_mint_nft(&mut ctx.accounts, &ctx.bumps) + pub fn mint_nft( + ctx: Ctx, + name: String<32>, + symbol: String<10>, + uri: String<200>, + ) -> Result<(), ProgramError> { + instructions::handle_mint_nft(&mut ctx.accounts, &ctx.bumps, &name, &symbol, &uri) } /// Verify the NFT as a member of the collection. #[instruction(discriminator = 2)] - pub fn verify_collection(ctx: Ctx) -> Result<(), ProgramError> { + pub fn verify_collection( + ctx: Ctx, + ) -> Result<(), ProgramError> { instructions::handle_verify_collection(&mut ctx.accounts, &ctx.bumps) } } diff --git a/tokens/nft-operations/quasar/src/tests.rs b/tokens/nft-operations/quasar/src/tests.rs index 5d57bab2..21e1d252 100644 --- a/tokens/nft-operations/quasar/src/tests.rs +++ b/tokens/nft-operations/quasar/src/tests.rs @@ -1,24 +1,333 @@ +//! QuasarSVM integration tests, ported from the Anchor twin's LiteSVM suite. +//! +//! The SVM loads this program, the SPL Token program, and the Metaplex Token +//! Metadata fixture shared with the Anchor twin +//! (`../anchor/tests/fixtures/mpl_token_metadata.so`), then exercises the +//! full collection lifecycle: create_collection, mint_nft, verify_collection. + extern crate std; use { - quasar_svm::QuasarSvm, - std::println, + quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, + spl_token_interface::state::Account as TokenAccount, + std::{vec, vec::Vec}, }; +const METADATA_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"); + +/// Comfortably above rent exemption for every account size used here. +const FUNDING_LAMPORTS: u64 = 10_000_000_000; + +const CREATE_COLLECTION_DISCRIMINATOR: u8 = 0; +const MINT_NFT_DISCRIMINATOR: u8 = 1; +const VERIFY_COLLECTION_DISCRIMINATOR: u8 = 2; + +fn program_id() -> Pubkey { + Pubkey::from(crate::ID) +} + fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_nft_operations.so").unwrap(); + let program_elf = std::fs::read("target/deploy/quasar_nft_operations.so").unwrap(); + // The fixture binary is shared with the Anchor twin's LiteSVM suite. + let metadata_elf = std::fs::read("../anchor/tests/fixtures/mpl_token_metadata.so").unwrap(); QuasarSvm::new() - .with_program(&crate::ID, &elf) + .with_program(&program_id(), &program_elf) + .with_program(&METADATA_PROGRAM_ID, &metadata_elf) .with_token_program() } -// Note: All three instructions (create_collection, mint_nft, verify_collection) -// require the Metaplex Token Metadata program deployed in the SVM. The -// quasar-svm harness does not currently include it, so we verify the program -// builds and loads. Full integration testing requires a localnet deploy with -// the Metaplex program. +fn signer(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, FUNDING_LAMPORTS) +} + +/// A not-yet-created account: empty and system-owned. +fn empty(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: quasar_svm::system_program::ID, + executable: false, + } +} + +fn derive_mint_authority() -> Pubkey { + let (mint_authority, _) = Pubkey::find_program_address(&[b"authority"], &program_id()); + mint_authority +} + +fn derive_metadata_pda(mint: &Pubkey) -> Pubkey { + let (pda, _) = Pubkey::find_program_address( + &[b"metadata", METADATA_PROGRAM_ID.as_ref(), mint.as_ref()], + &METADATA_PROGRAM_ID, + ); + pda +} + +fn derive_edition_pda(mint: &Pubkey) -> Pubkey { + let (pda, _) = Pubkey::find_program_address( + &[ + b"metadata", + METADATA_PROGRAM_ID.as_ref(), + mint.as_ref(), + b"edition", + ], + &METADATA_PROGRAM_ID, + ); + pda +} + +/// Instruction data for create_collection / mint_nft. Quasar's compact +/// argument encoding packs the dynamic `String` arguments as a header of +/// per-field length prefixes (u8 each) followed by the packed string bytes. +fn metadata_instruction_data(discriminator: u8, name: &str, symbol: &str, uri: &str) -> Vec { + let mut data = vec![ + discriminator, + name.len() as u8, + symbol.len() as u8, + uri.len() as u8, + ]; + data.extend_from_slice(name.as_bytes()); + data.extend_from_slice(symbol.as_bytes()); + data.extend_from_slice(uri.as_bytes()); + data +} + +/// Returns true if `haystack` contains `needle` anywhere. Used to check that +/// caller-supplied metadata strings landed in the Metaplex metadata account +/// without fully deserializing the Metaplex layout. +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn token_amount(account: &Account) -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount +} + +/// Addresses for one NFT (or collection NFT): mint, its Metaplex PDAs, and +/// the holding token account. +struct NftAccounts { + mint: Pubkey, + metadata: Pubkey, + master_edition: Pubkey, + destination: Pubkey, +} + +impl NftAccounts { + fn new() -> Self { + let mint = Pubkey::new_unique(); + Self { + mint, + metadata: derive_metadata_pda(&mint), + master_edition: derive_edition_pda(&mint), + destination: Pubkey::new_unique(), + } + } +} + +const COLLECTION_NAME: &str = "Quasar Collection"; +const COLLECTION_SYMBOL: &str = "QCOL"; +const COLLECTION_URI: &str = "https://example.com/collection.json"; +const NFT_NAME: &str = "Quasar NFT #1"; +const NFT_SYMBOL: &str = "QNFT"; +const NFT_URI: &str = "https://example.com/nft-1.json"; + +fn build_create_collection_instruction(payer: Pubkey, collection: &NftAccounts) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(payer, true), + // The mint and destination accounts are created by the + // instruction, so they sign (fresh keypair accounts). + AccountMeta::new(collection.mint, true), + AccountMeta::new_readonly(derive_mint_authority(), false), + AccountMeta::new(collection.metadata, false), + AccountMeta::new(collection.master_edition, false), + AccountMeta::new(collection.destination, true), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(METADATA_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), + ], + data: metadata_instruction_data( + CREATE_COLLECTION_DISCRIMINATOR, + COLLECTION_NAME, + COLLECTION_SYMBOL, + COLLECTION_URI, + ), + } +} + +fn build_mint_nft_instruction( + payer: Pubkey, + nft: &NftAccounts, + collection_mint: Pubkey, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(payer, true), + AccountMeta::new(nft.mint, true), + AccountMeta::new(nft.destination, true), + AccountMeta::new(nft.metadata, false), + AccountMeta::new(nft.master_edition, false), + AccountMeta::new_readonly(derive_mint_authority(), false), + AccountMeta::new(collection_mint, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(METADATA_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), + ], + data: metadata_instruction_data(MINT_NFT_DISCRIMINATOR, NFT_NAME, NFT_SYMBOL, NFT_URI), + } +} + +fn build_verify_collection_instruction( + payer: Pubkey, + nft: &NftAccounts, + collection: &NftAccounts, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + // The Metaplex verify CPI takes the payer as writable signer. + AccountMeta::new(payer, true), + AccountMeta::new(nft.metadata, false), + AccountMeta::new_readonly(derive_mint_authority(), false), + AccountMeta::new_readonly(collection.mint, false), + AccountMeta::new(collection.metadata, false), + AccountMeta::new_readonly(collection.master_edition, false), + AccountMeta::new_readonly(METADATA_PROGRAM_ID, false), + ], + data: vec![VERIFY_COLLECTION_DISCRIMINATOR], + } +} + +/// New (not-yet-created) accounts an NFT mint touches. +fn new_nft_accounts(nft: &NftAccounts) -> [Account; 4] { + [ + empty(nft.mint), + empty(nft.metadata), + empty(nft.master_edition), + empty(nft.destination), + ] +} + +#[test] +fn test_create_collection() { + let mut svm = setup(); + let payer = Pubkey::new_unique(); + let collection = NftAccounts::new(); + + let mut accounts = vec![signer(payer), empty(derive_mint_authority())]; + accounts.extend(new_nft_accounts(&collection)); + + let result = svm.process_instruction( + &build_create_collection_instruction(payer, &collection), + &accounts, + ); + result.assert_success(); + + // The collection mint exists and 1 token was minted to the destination. + let mint_account = result.account(&collection.mint).unwrap(); + assert!(!mint_account.data.is_empty()); + assert_eq!( + token_amount(&result.account(&collection.destination).unwrap()), + 1, + "Should hold 1 collection token" + ); + + // The metadata account carries the caller-supplied name, and the master + // edition exists. + let metadata_account = result.account(&collection.metadata).unwrap(); + assert!( + contains_bytes(&metadata_account.data, COLLECTION_NAME.as_bytes()), + "Metadata should contain the caller-supplied collection name" + ); + assert!(!result.account(&collection.master_edition).unwrap().data.is_empty()); +} #[test] -fn test_program_builds() { - let _svm = setup(); - println!(" NFT operations program loaded successfully"); +fn test_mint_nft_to_collection() { + let mut svm = setup(); + let payer = Pubkey::new_unique(); + let collection = NftAccounts::new(); + + let mut create_accounts = vec![signer(payer), empty(derive_mint_authority())]; + create_accounts.extend(new_nft_accounts(&collection)); + svm.process_instruction( + &build_create_collection_instruction(payer, &collection), + &create_accounts, + ) + .assert_success(); + + // Mint an NFT into the collection. Only the NFT's own accounts are new; + // the payer, authority PDA, and collection mint persist in the SVM. + let nft = NftAccounts::new(); + let result = svm.process_instruction( + &build_mint_nft_instruction(payer, &nft, collection.mint), + &new_nft_accounts(&nft), + ); + result.assert_success(); + + assert_eq!( + token_amount(&result.account(&nft.destination).unwrap()), + 1, + "Should hold 1 NFT" + ); + let nft_metadata_account = result.account(&nft.metadata).unwrap(); + assert!( + contains_bytes(&nft_metadata_account.data, NFT_NAME.as_bytes()), + "Metadata should contain the caller-supplied NFT name" + ); + // The metadata carries the (unverified) collection reference. + assert!( + contains_bytes(&nft_metadata_account.data, collection.mint.as_ref()), + "Metadata should reference the collection mint" + ); +} + +#[test] +fn test_verify_collection() { + let mut svm = setup(); + let payer = Pubkey::new_unique(); + let collection = NftAccounts::new(); + + let mut create_accounts = vec![signer(payer), empty(derive_mint_authority())]; + create_accounts.extend(new_nft_accounts(&collection)); + svm.process_instruction( + &build_create_collection_instruction(payer, &collection), + &create_accounts, + ) + .assert_success(); + + let nft = NftAccounts::new(); + svm.process_instruction( + &build_mint_nft_instruction(payer, &nft, collection.mint), + &new_nft_accounts(&nft), + ) + .assert_success(); + + let unverified_metadata = svm.get_account(&nft.metadata).unwrap().data; + + let result = svm.process_instruction( + &build_verify_collection_instruction(payer, &nft, &collection), + &[], + ); + result.assert_success(); + + // Verification flips the collection's `verified` flag in the NFT's + // metadata, so the account data must have changed. + let verified_metadata = result.account(&nft.metadata).unwrap().data.clone(); + assert!( + contains_bytes(&verified_metadata, collection.mint.as_ref()), + "Metadata should still reference the collection mint" + ); + assert_ne!( + unverified_metadata, verified_metadata, + "verify_collection should update the NFT metadata" + ); } diff --git a/tokens/pda-mint-authority/anchor/Anchor.toml b/tokens/pda-mint-authority/anchor/Anchor.toml index e46e35c7..1104b2b3 100644 --- a/tokens/pda-mint-authority/anchor/Anchor.toml +++ b/tokens/pda-mint-authority/anchor/Anchor.toml @@ -8,14 +8,11 @@ skip-lint = false [programs.localnet] token_minter = "3LFrPHqwk5jMrmiz48BFj6NV2k4NjobgTe1jChzx3JGD" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# Only run bankrun tests — the validator tests (test.ts) need Metaplex Token -# Metadata cloned from mainnet which is too slow/unreliable in CI. -# bankrun.test.ts uses a local fixture (tests/fixtures/token_metadata.so). +# Rust + LiteSVM tests; Metaplex Token Metadata is loaded from a local +# fixture (tests/fixtures/mpl_token_metadata.so), no validator needed. test = "cargo test" diff --git a/tokens/pda-mint-authority/anchor/README.md b/tokens/pda-mint-authority/anchor/README.md index cbd1864c..a088d909 100644 --- a/tokens/pda-mint-authority/anchor/README.md +++ b/tokens/pda-mint-authority/anchor/README.md @@ -8,6 +8,7 @@ See also: [Pda Mint Authority overview](../README.md) and the [repository catalo - PDA mint authority - CPI mint_to +- Amounts: `mint_token` takes `amount` in **minor units**, the raw integer the token program operates on. Clients convert from major units offchain: 1 token with 9 decimals is `1 * 10^9` minor units. The program never scales amounts onchain. ## Setup diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs index baeaedbe..75891fe9 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/create.rs @@ -12,7 +12,7 @@ use { }; #[derive(Accounts)] -pub struct CreateToken<'info> { +pub struct CreateTokenAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -46,7 +46,7 @@ pub struct CreateToken<'info> { } pub fn handle_create_token( - context: Context, + context: Context, token_name: String, token_symbol: String, token_uri: String, diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs index bfd52cb1..eedae32c 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/instructions/mint.rs @@ -7,7 +7,7 @@ use { }; #[derive(Accounts)] -pub struct MintToken<'info> { +pub struct MintTokenAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -34,7 +34,16 @@ pub struct MintToken<'info> { pub system_program: Program<'info, System>, } -pub fn handle_mint_token(context: Context, amount: u64) -> Result<()> { +/// Mints `amount` tokens to the payer's associated token account, signed by +/// the PDA mint authority. +/// +/// `amount` is in minor units (the raw integer the token program operates +/// on). Clients convert from major units, e.g. 1 token with 9 decimals is +/// `1 * 10u64.pow(9)` minor units. +pub fn handle_mint_token( + context: Context, + amount: u64, +) -> Result<()> { msg!("Minting token to associated token account..."); msg!("Mint: {}", &context.accounts.mint_account.key()); msg!( @@ -56,7 +65,7 @@ pub fn handle_mint_token(context: Context, amount: u64) -> Result<()> }, ) .with_signer(signer_seeds), // using PDA to sign - amount * 10u64.pow(context.accounts.mint_account.decimals as u32), // Mint tokens, adjust for decimals + amount, )?; msg!("Token minted successfully."); diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs index 71a6c08f..fb5ba679 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/src/lib.rs @@ -9,7 +9,7 @@ pub mod token_minter { use super::*; pub fn create_token( - context: Context, + context: Context, token_name: String, token_symbol: String, token_uri: String, @@ -17,7 +17,11 @@ pub mod token_minter { create::handle_create_token(context, token_name, token_symbol, token_uri) } - pub fn mint_token(context: Context, amount: u64) -> Result<()> { + /// Mint `amount` minor units of the token to the payer. + pub fn mint_token( + context: Context, + amount: u64, + ) -> Result<()> { mint::handle_mint_token(context, amount) } } diff --git a/tokens/pda-mint-authority/anchor/programs/token-minter/tests/test_pda_mint.rs b/tokens/pda-mint-authority/anchor/programs/token-minter/tests/test_pda_mint.rs index 35f7ff18..f451e5a0 100644 --- a/tokens/pda-mint-authority/anchor/programs/token-minter/tests/test_pda_mint.rs +++ b/tokens/pda-mint-authority/anchor/programs/token-minter/tests/test_pda_mint.rs @@ -11,6 +11,16 @@ use { solana_signer::Signer, }; +/// Decimals configured by the program's `mint::decimals` constraint in +/// `CreateTokenAccountConstraints`. +const MINT_DECIMALS: u32 = 9; + +/// Converts a whole-token (major unit) count to minor units, the form the +/// program's `mint_token` handler takes amounts in. +fn to_minor_units(major_units: u64) -> u64 { + major_units.checked_mul(10u64.pow(MINT_DECIMALS)).unwrap() +} + fn metadata_program_id() -> Pubkey { "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" .parse() @@ -88,7 +98,7 @@ fn test_create_token_and_mint() { token_uri: "https://example.com/token.json".to_string(), } .data(), - token_minter::accounts::CreateToken { + token_minter::accounts::CreateTokenAccountConstraints { payer: payer.pubkey(), mint_account: mint_pda, metadata_account, @@ -117,14 +127,17 @@ fn test_create_token_and_mint() { .expect("Metadata should exist"); assert!(!meta.data.is_empty()); - // 2. Mint tokens (100 tokens to payer's ATA) + // 2. Mint 100 tokens to the payer's ATA. The handler takes minor units. svm.expire_blockhash(); let ata = derive_ata(&payer.pubkey(), &mint_pda); let mint_ix = Instruction::new_with_bytes( program_id, - &token_minter::instruction::MintToken { amount: 100 }.data(), - token_minter::accounts::MintToken { + &token_minter::instruction::MintToken { + amount: to_minor_units(100), + } + .data(), + token_minter::accounts::MintTokenAccountConstraints { payer: payer.pubkey(), mint_account: mint_pda, associated_token_account: ata, @@ -142,7 +155,7 @@ fn test_create_token_and_mint() { ) .unwrap(); - // Verify: 100 * 10^9 = 100_000_000_000 tokens (9 decimals) + // Verify 100 tokens minted (in minor units) let balance = get_token_account_balance(&svm, &ata).unwrap(); - assert_eq!(balance, 100_000_000_000, "Should have 100 tokens"); + assert_eq!(balance, to_minor_units(100), "Should have 100 tokens"); } diff --git a/tokens/pda-mint-authority/quasar/Cargo.toml b/tokens/pda-mint-authority/quasar/Cargo.toml index 83828582..638cb98f 100644 --- a/tokens/pda-mint-authority/quasar/Cargo.toml +++ b/tokens/pda-mint-authority/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-pda-mint-authority" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. [workspace] [lints.rust.unexpected_cfgs] diff --git a/tokens/pda-mint-authority/quasar/README.md b/tokens/pda-mint-authority/quasar/README.md index 092416af..1e3a43e7 100644 --- a/tokens/pda-mint-authority/quasar/README.md +++ b/tokens/pda-mint-authority/quasar/README.md @@ -8,6 +8,8 @@ See also: [Pda Mint Authority overview](../README.md) and the [repository catalo - PDA mint authority - mint_to CPI +- `create_mint` takes a `decimals` instruction argument and initializes the mint with it +- `mint_tokens` takes `amount` in minor units, the raw integer the token program operates on ## Setup diff --git a/tokens/pda-mint-authority/quasar/src/lib.rs b/tokens/pda-mint-authority/quasar/src/lib.rs index 650a9ccb..dc218121 100644 --- a/tokens/pda-mint-authority/quasar/src/lib.rs +++ b/tokens/pda-mint-authority/quasar/src/lib.rs @@ -6,7 +6,7 @@ use quasar_spl::{initialize_mint2, prelude::*}; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("3LFrPHqwk5jMrmiz48BFj6NV2k4NjobgTe1jChzx3JGD"); /// SPL Mint account size in bytes. const MINT_SPACE: usize = 82; @@ -26,15 +26,22 @@ pub struct MintPda; mod quasar_pda_mint_authority { use super::*; - /// Create a token mint at a PDA. The PDA is its own mint authority. + /// Create a token mint at a PDA with the caller-supplied number of + /// decimals. The PDA is its own mint authority. #[instruction(discriminator = 0)] - pub fn create_mint(ctx: Ctx, _decimals: u8) -> Result<(), ProgramError> { - handle_create_mint(&mut ctx.accounts, ctx.bumps.mint) + pub fn create_mint( + ctx: Ctx, + decimals: u8, + ) -> Result<(), ProgramError> { + handle_create_mint(&mut ctx.accounts, decimals, ctx.bumps.mint) } - /// Mint tokens using the PDA mint authority. + /// Mint `amount` minor units using the PDA mint authority. #[instruction(discriminator = 1)] - pub fn mint_tokens(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn mint_tokens( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { handle_mint_tokens(&mut ctx.accounts, amount, ctx.bumps.mint) } } @@ -42,7 +49,7 @@ mod quasar_pda_mint_authority { /// Create the mint at a PDA. Manually created and initialized to avoid /// a borrow conflict from `mint(authority = mint)` in the init constraint. #[derive(Accounts)] -pub struct CreateMint { +pub struct CreateMintAccountConstraints { #[account(mut)] pub payer: Signer, /// The PDA that will become the mint (and its own authority). @@ -53,7 +60,11 @@ pub struct CreateMint { } #[inline(always)] -fn handle_create_mint(accounts: &mut CreateMint, bump: u8) -> Result<(), ProgramError> { +fn handle_create_mint( + accounts: &mut CreateMintAccountConstraints, + decimals: u8, + bump: u8, +) -> Result<(), ProgramError> { let mint_address = *accounts.mint.address(); let bump_bytes = [bump]; let seeds: &[Seed] = &[ @@ -77,7 +88,7 @@ fn handle_create_mint(accounts: &mut CreateMint, bump: u8) -> Result<(), Program initialize_mint2( accounts.token_program.to_account_view(), accounts.mint.to_account_view(), - 9, + decimals, &mint_address, None, ) @@ -86,7 +97,7 @@ fn handle_create_mint(accounts: &mut CreateMint, bump: u8) -> Result<(), Program /// Mint tokens to a token account, signing with the PDA mint authority. #[derive(Accounts)] -pub struct MintTokens { +pub struct MintTokensAccountConstraints { #[account(mut)] pub payer: Signer, /// The PDA mint whose authority is itself. @@ -105,7 +116,11 @@ pub struct MintTokens { } #[inline(always)] -fn handle_mint_tokens(accounts: &mut MintTokens, amount: u64, mint_bump: u8) -> Result<(), ProgramError> { +fn handle_mint_tokens( + accounts: &mut MintTokensAccountConstraints, + amount: u64, + mint_bump: u8, +) -> Result<(), ProgramError> { let bump = [mint_bump]; let seeds: &[Seed] = &[ Seed::from(b"mint" as &[u8]), diff --git a/tokens/pda-mint-authority/quasar/src/tests.rs b/tokens/pda-mint-authority/quasar/src/tests.rs index 1fa3244b..2d51ded0 100644 --- a/tokens/pda-mint-authority/quasar/src/tests.rs +++ b/tokens/pda-mint-authority/quasar/src/tests.rs @@ -76,9 +76,12 @@ fn test_create_mint() { let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; let system_program = quasar_svm::system_program::ID; - let data = build_create_mint_data(9); + // Deliberately not 9: proves the decimals instruction argument reaches + // the initialize_mint2 CPI instead of being hardcoded. + let requested_decimals = 6u8; + let data = build_create_mint_data(requested_decimals); - // Account order matches the `CreateMint` Accounts struct: + // Account order matches the `CreateMintAccountConstraints` struct: // payer, mint, token_program, system_program. let instruction = Instruction { program_id: crate::ID, @@ -93,6 +96,15 @@ fn test_create_mint() { let result = svm.process_instruction(&instruction, &[signer(payer), empty(mint_pda)]); assert!(result.is_ok(), "create_mint failed: {:?}", result.raw_result); + + // The created mint must carry the requested decimals, and be its own + // mint authority. + let created_mint = result.account(&mint_pda).expect("mint should exist"); + let mint_state = + ::unpack(&created_mint.data).expect("valid mint"); + assert_eq!(mint_state.decimals, requested_decimals); + assert_eq!(mint_state.mint_authority, Some(mint_pda).into()); + println!(" CREATE MINT CU: {}", result.compute_units_consumed); } @@ -130,5 +142,17 @@ fn test_mint_with_pda_authority() { ); assert!(result.is_ok(), "mint_tokens failed: {:?}", result.raw_result); + + // The handler mints exactly the minor-unit amount passed: no decimal scaling. + let token_after = result.account(&token_addr).expect("token account exists"); + let token_state = ::unpack(&token_after.data) + .expect("valid token account"); + assert_eq!(token_state.amount, amount); + + let mint_after = result.account(&mint_pda).expect("mint exists"); + let mint_state = + ::unpack(&mint_after.data).expect("valid mint"); + assert_eq!(mint_state.supply, amount); + println!(" MINT WITH PDA CU: {}", result.compute_units_consumed); } diff --git a/tokens/token-extensions/basics/anchor/Anchor.toml b/tokens/token-extensions/basics/anchor/Anchor.toml index d50924a7..49b424c0 100644 --- a/tokens/token-extensions/basics/anchor/Anchor.toml +++ b/tokens/token-extensions/basics/anchor/Anchor.toml @@ -7,8 +7,6 @@ skip-lint = false [programs.localnet] anchor = "6qNqxkRF791FXFeQwqYQLEzAbGiqDULC5SSHVsfRoG89" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/basics/anchor/README.md b/tokens/token-extensions/basics/anchor/README.md index 42a04649..f1997633 100644 --- a/tokens/token-extensions/basics/anchor/README.md +++ b/tokens/token-extensions/basics/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Basics (Anchor) +# Token Extensions - Basics (Anchor) Create mints, mint tokens, and transfer using the [Token Extensions Program](https://solana.com/docs/terminology#token-extensions-program). diff --git a/tokens/token-extensions/basics/anchor/migrations/deploy.ts b/tokens/token-extensions/basics/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/basics/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_associated_token_account.rs b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_associated_token_account.rs index f007b187..9c00f4be 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_associated_token_account.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_associated_token_account.rs @@ -3,7 +3,7 @@ use anchor_spl::associated_token::AssociatedToken; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[derive(Accounts)] -pub struct CreateAssociatedTokenAccount<'info> { +pub struct CreateAssociatedTokenAccountAccountConstraints<'info> { #[account(mut)] pub signer: Signer<'info>, pub mint: InterfaceAccount<'info, Mint>, @@ -19,7 +19,7 @@ pub struct CreateAssociatedTokenAccount<'info> { pub associated_token_program: Program<'info, AssociatedToken>, } -pub fn handler(_context: Context) -> Result<()> { +pub fn handler(_context: Context) -> Result<()> { msg!("Create Associated Token Account"); Ok(()) } diff --git a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token.rs b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token.rs index c68ad0ec..bc08105a 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token.rs @@ -3,7 +3,7 @@ use anchor_spl::token_interface::{Mint, TokenInterface}; #[derive(Accounts)] #[instruction(token_name: String)] -pub struct CreateToken<'info> { +pub struct CreateTokenAccountConstraints<'info> { #[account(mut)] pub signer: Signer<'info>, #[account( @@ -19,7 +19,7 @@ pub struct CreateToken<'info> { pub token_program: Interface<'info, TokenInterface>, } -pub fn handler(_context: Context, _token_name: String) -> Result<()> { +pub fn handler(_context: Context, _token_name: String) -> Result<()> { msg!("Create Token"); Ok(()) } diff --git a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token_account.rs b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token_account.rs index eda53708..d5e87541 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token_account.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/create_token_account.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; #[derive(Accounts)] -pub struct CreateTokenAccount<'info> { +pub struct CreateTokenAccountAccountConstraints<'info> { #[account(mut)] pub signer: Signer<'info>, pub mint: InterfaceAccount<'info, Mint>, @@ -19,7 +19,7 @@ pub struct CreateTokenAccount<'info> { pub token_program: Interface<'info, TokenInterface>, } -pub fn handler(_context: Context) -> Result<()> { +pub fn handler(_context: Context) -> Result<()> { msg!("Create Token Account"); Ok(()) } diff --git a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/mint_token.rs b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/mint_token.rs index 2027bb21..26798f21 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/mint_token.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/mint_token.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{self, Mint, MintTo, TokenAccount, TokenInterface}; #[derive(Accounts)] -pub struct MintToken<'info> { +pub struct MintTokenAccountConstraints<'info> { #[account(mut)] pub signer: Signer<'info>, #[account(mut)] @@ -12,7 +12,7 @@ pub struct MintToken<'info> { pub token_program: Interface<'info, TokenInterface>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { +pub fn handler(context: Context, amount: u64) -> Result<()> { let cpi_accounts = MintTo { mint: context.accounts.mint.to_account_info().clone(), to: context.accounts.receiver.to_account_info().clone(), diff --git a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/transfer_token.rs b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/transfer_token.rs index aeecb560..57ce4168 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/transfer_token.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/src/instructions/transfer_token.rs @@ -3,7 +3,7 @@ use anchor_spl::associated_token::AssociatedToken; use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; #[derive(Accounts)] -pub struct TransferToken<'info> { +pub struct TransferTokenAccountConstraints<'info> { #[account(mut)] pub signer: Signer<'info>, #[account(mut)] @@ -23,7 +23,7 @@ pub struct TransferToken<'info> { pub associated_token_program: Program<'info, AssociatedToken>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { +pub fn handler(context: Context, amount: u64) -> Result<()> { let cpi_accounts = TransferChecked { from: context.accounts.from.to_account_info().clone(), mint: context.accounts.mint.to_account_info().clone(), diff --git a/tokens/token-extensions/basics/anchor/programs/basics/src/lib.rs b/tokens/token-extensions/basics/anchor/programs/basics/src/lib.rs index 03c4a8bf..0eb3bbf0 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/src/lib.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/src/lib.rs @@ -10,25 +10,25 @@ pub mod anchor { use super::*; - pub fn create_token(context: Context, token_name: String) -> Result<()> { + pub fn create_token(context: Context, token_name: String) -> Result<()> { instructions::create_token::handler(context, token_name) } - pub fn create_token_account(context: Context) -> Result<()> { + pub fn create_token_account(context: Context) -> Result<()> { instructions::create_token_account::handler(context) } pub fn create_associated_token_account( - context: Context, + context: Context, ) -> Result<()> { instructions::create_associated_token_account::handler(context) } - pub fn transfer_token(context: Context, amount: u64) -> Result<()> { + pub fn transfer_token(context: Context, amount: u64) -> Result<()> { instructions::transfer_token::handler(context, amount) } - pub fn mint_token(context: Context, amount: u64) -> Result<()> { + pub fn mint_token(context: Context, amount: u64) -> Result<()> { instructions::mint_token::handler(context, amount) } } diff --git a/tokens/token-extensions/basics/anchor/programs/basics/tests/test_basics.rs b/tokens/token-extensions/basics/anchor/programs/basics/tests/test_basics.rs index e1a244b5..21b5218c 100644 --- a/tokens/token-extensions/basics/anchor/programs/basics/tests/test_basics.rs +++ b/tokens/token-extensions/basics/anchor/programs/basics/tests/test_basics.rs @@ -54,7 +54,7 @@ fn test_create_token_and_mint_and_transfer() { token_name: token_name.clone(), } .data(), - anchor::accounts::CreateToken { + anchor::accounts::CreateTokenAccountConstraints { signer: payer.pubkey(), mint, system_program: system_program::id(), @@ -77,7 +77,7 @@ fn test_create_token_and_mint_and_transfer() { let create_ata_ix = Instruction::new_with_bytes( program_id, &anchor::instruction::CreateAssociatedTokenAccount {}.data(), - anchor::accounts::CreateAssociatedTokenAccount { + anchor::accounts::CreateAssociatedTokenAccountAccountConstraints { signer: payer.pubkey(), mint, token_account: payer_ata, @@ -107,7 +107,7 @@ fn test_create_token_and_mint_and_transfer() { amount: mint_amount, } .data(), - anchor::accounts::MintToken { + anchor::accounts::MintTokenAccountConstraints { signer: payer.pubkey(), mint, receiver: payer_ata, @@ -139,7 +139,7 @@ fn test_create_token_and_mint_and_transfer() { amount: transfer_amount, } .data(), - anchor::accounts::TransferToken { + anchor::accounts::TransferTokenAccountConstraints { signer: payer.pubkey(), from: payer_ata, to: receiver.pubkey(), diff --git a/tokens/token-extensions/basics/quasar/Cargo.toml b/tokens/token-extensions/basics/quasar/Cargo.toml index f1b48734..cac6228f 100644 --- a/tokens/token-extensions/basics/quasar/Cargo.toml +++ b/tokens/token-extensions/basics/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-token-2022-basics" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. [workspace] [lints.rust.unexpected_cfgs] diff --git a/tokens/token-extensions/basics/quasar/README.md b/tokens/token-extensions/basics/quasar/README.md index fec9c267..e6c23813 100644 --- a/tokens/token-extensions/basics/quasar/README.md +++ b/tokens/token-extensions/basics/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Basics (Quasar) +# Token Extensions - Basics (Quasar) Mint and transfer with the [Token Extensions Program](https://solana.com/docs/terminology#token-extensions-program). diff --git a/tokens/token-extensions/basics/quasar/src/lib.rs b/tokens/token-extensions/basics/quasar/src/lib.rs index 5ab5ace0..2611ae08 100644 --- a/tokens/token-extensions/basics/quasar/src/lib.rs +++ b/tokens/token-extensions/basics/quasar/src/lib.rs @@ -8,11 +8,11 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("6qNqxkRF791FXFeQwqYQLEzAbGiqDULC5SSHVsfRoG89"); -/// Correct Token-2022 program ID. +/// Correct Token Extensions program ID. /// -/// quasar-spl 0.0.0 ships incorrect bytes for the Token-2022 address +/// quasar-spl 0.0.0 ships incorrect bytes for the Token Extensions address /// (`TokenzSRvw8aVrEuYKv3gLJaYV39h1EWGpCCGYBJPZQ` instead of the real /// `TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb`). We define a local /// marker with the correct mainnet address until that's fixed upstream. @@ -25,28 +25,28 @@ impl Id for Token2022Program { ]); } -/// Demonstrates Token-2022 basics: minting tokens and transferring (checked) -/// via raw CPI to the Token-2022 program. +/// Demonstrates Token Extensions basics: minting tokens and transferring (checked) +/// via raw CPI to the Token Extensions program. #[program] mod quasar_token_2022_basics { use super::*; /// Mint tokens to a recipient's token account. #[instruction(discriminator = 0)] - pub fn mint_token(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn mint_token(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { handle_mint_token(&mut ctx.accounts, amount) } - /// Transfer tokens using transfer_checked (required for Token-2022). + /// Transfer tokens using transfer_checked (required for Token Extensions). #[instruction(discriminator = 1)] - pub fn transfer_token(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn transfer_token(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { handle_transfer_token(&mut ctx.accounts, amount) } } -/// Accounts for minting tokens via Token-2022. +/// Accounts for minting tokens via Token Extensions. #[derive(Accounts)] -pub struct MintToken { +pub struct MintTokenAccountConstraints { #[account(mut)] pub authority: Signer, #[account(mut)] @@ -57,7 +57,7 @@ pub struct MintToken { } #[inline(always)] -fn handle_mint_token(accounts: &mut MintToken, amount: u64) -> Result<(), ProgramError> { +fn handle_mint_token(accounts: &mut MintTokenAccountConstraints, amount: u64) -> Result<(), ProgramError> { // SPL Token MintTo instruction: opcode 7, amount as u64 LE. let data = build_u64_data(7, amount); CpiCall::new( @@ -77,9 +77,9 @@ fn handle_mint_token(accounts: &mut MintToken, amount: u64) -> Result<(), Progra .invoke() } -/// Accounts for transferring tokens via Token-2022 transfer_checked. +/// Accounts for transferring tokens via Token Extensions transfer_checked. #[derive(Accounts)] -pub struct TransferToken { +pub struct TransferTokenAccountConstraints { #[account(mut)] pub sender: Signer, #[account(mut)] @@ -91,7 +91,7 @@ pub struct TransferToken { } #[inline(always)] -fn handle_transfer_token(accounts: &mut TransferToken, amount: u64) -> Result<(), ProgramError> { +fn handle_transfer_token(accounts: &mut TransferTokenAccountConstraints, amount: u64) -> Result<(), ProgramError> { // SPL Token TransferChecked instruction: opcode 12, amount as u64 LE, decimals as u8. let data = build_transfer_checked_data(amount, 6); CpiCall::new( diff --git a/tokens/token-extensions/cpi-guard/anchor/Anchor.toml b/tokens/token-extensions/cpi-guard/anchor/Anchor.toml index dd7636b1..22ea7193 100644 --- a/tokens/token-extensions/cpi-guard/anchor/Anchor.toml +++ b/tokens/token-extensions/cpi-guard/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] cpi_guard = "6tU3MEowU6oxxeDZLSxEwzcEZsZrhBJsfUR6xECvShid" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/cpi-guard/anchor/README.md b/tokens/token-extensions/cpi-guard/anchor/README.md index da380d5c..e0220463 100644 --- a/tokens/token-extensions/cpi-guard/anchor/README.md +++ b/tokens/token-extensions/cpi-guard/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — CPI Guard (Anchor) +# Token Extensions - CPI Guard (Anchor) Enable CPI Guard so certain token actions cannot run inside a CPI context. diff --git a/tokens/token-extensions/cpi-guard/anchor/migrations/deploy.ts b/tokens/token-extensions/cpi-guard/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/cpi-guard/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/src/lib.rs b/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/src/lib.rs index 715ddc22..23f51a37 100644 --- a/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/src/lib.rs +++ b/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/src/lib.rs @@ -12,7 +12,7 @@ declare_id!("6tU3MEowU6oxxeDZLSxEwzcEZsZrhBJsfUR6xECvShid"); pub mod cpi_guard { use super::*; - pub fn cpi_transfer(context: Context) -> Result<()> { + pub fn cpi_transfer(context: Context) -> Result<()> { transfer_checked( CpiContext::new( context.accounts.token_program.key(), @@ -31,7 +31,7 @@ pub mod cpi_guard { } #[derive(Accounts)] -pub struct CpiTransfer<'info> { +pub struct CpiTransferAccountConstraints<'info> { #[account(mut)] pub sender: Signer<'info>, diff --git a/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/tests/test_cpi_guard.rs b/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/tests/test_cpi_guard.rs index 378b3550..6f2145cc 100644 --- a/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/tests/test_cpi_guard.rs +++ b/tokens/token-extensions/cpi-guard/anchor/programs/cpi-guard/tests/test_cpi_guard.rs @@ -31,7 +31,7 @@ fn setup() -> (LiteSVM, Pubkey, Keypair) { } /// Create a basic Token Extensions token account (165 bytes, no extensions). -/// Uses explicit keypair — kite's ATA creation won't work here because +/// Uses explicit keypair - kite's ATA creation won't work here because /// we need to reallocate and add the CPI Guard extension later. fn create_basic_token_account_instructions( payer: &Pubkey, @@ -154,14 +154,14 @@ fn test_cpi_guard_prevents_transfer_then_allows_after_disable() { ).unwrap(); svm.expire_blockhash(); - // Step 6: Try CPI transfer — should fail because CPI Guard is enabled + // Step 6: Try CPI transfer - should fail because CPI Guard is enabled let (recipient_token_account, _bump) = Pubkey::find_program_address(&[b"pda"], &program_id); let transfer_ix = Instruction::new_with_bytes( program_id, &cpi_guard::instruction::CpiTransfer {}.data(), - cpi_guard::accounts::CpiTransfer { + cpi_guard::accounts::CpiTransferAccountConstraints { sender: payer.pubkey(), sender_token_account: token_keypair.pubkey(), recipient_token_account, @@ -188,7 +188,7 @@ fn test_cpi_guard_prevents_transfer_then_allows_after_disable() { let transfer_ix2 = Instruction::new_with_bytes( program_id, &cpi_guard::instruction::CpiTransfer {}.data(), - cpi_guard::accounts::CpiTransfer { + cpi_guard::accounts::CpiTransferAccountConstraints { sender: payer.pubkey(), sender_token_account: token_keypair.pubkey(), recipient_token_account, diff --git a/tokens/token-extensions/cpi-guard/quasar/README.md b/tokens/token-extensions/cpi-guard/quasar/README.md index 1715b8cd..19f8ce21 100644 --- a/tokens/token-extensions/cpi-guard/quasar/README.md +++ b/tokens/token-extensions/cpi-guard/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — CPI Guard (Quasar) +# Token Extensions - CPI Guard (Quasar) Block certain token actions inside CPI contexts. diff --git a/tokens/token-extensions/cpi-guard/quasar/src/lib.rs b/tokens/token-extensions/cpi-guard/quasar/src/lib.rs index 40f3b75b..29e935dc 100644 --- a/tokens/token-extensions/cpi-guard/quasar/src/lib.rs +++ b/tokens/token-extensions/cpi-guard/quasar/src/lib.rs @@ -8,9 +8,9 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("6tU3MEowU6oxxeDZLSxEwzcEZsZrhBJsfUR6xECvShid"); -/// Correct Token-2022 program ID (quasar-spl 0.0.0 has wrong bytes). +/// Correct Token Extensions program ID (quasar-spl 0.0.0 has wrong bytes). pub struct Token2022Program; impl Id for Token2022Program { const ID: Address = Address::new_from_array([ @@ -29,13 +29,13 @@ mod quasar_cpi_guard { /// Attempt a CPI transfer_checked. Will fail if CPI Guard is enabled /// on the sender's token account. #[instruction(discriminator = 0)] - pub fn cpi_transfer(ctx: Ctx) -> Result<(), ProgramError> { + pub fn cpi_transfer(ctx: Ctx) -> Result<(), ProgramError> { handle_cpi_transfer(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct CpiTransfer { +pub struct CpiTransferAccountConstraints { #[account(mut)] pub sender: Signer, #[account(mut)] @@ -47,7 +47,7 @@ pub struct CpiTransfer { } #[inline(always)] -fn handle_cpi_transfer(accounts: &mut CpiTransfer) -> Result<(), ProgramError> { +fn handle_cpi_transfer(accounts: &mut CpiTransferAccountConstraints) -> Result<(), ProgramError> { // TransferChecked: opcode 12, amount=1, decimals=9 let mut data = [0u8; 10]; data[0] = 12; diff --git a/tokens/token-extensions/cpi-guard/quasar/src/tests.rs b/tokens/token-extensions/cpi-guard/quasar/src/tests.rs index ef2a93b4..a2f0b383 100644 --- a/tokens/token-extensions/cpi-guard/quasar/src/tests.rs +++ b/tokens/token-extensions/cpi-guard/quasar/src/tests.rs @@ -43,7 +43,7 @@ fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> A ) } -/// Test CPI transfer_checked (without CPI guard — should succeed). +/// Test CPI transfer_checked (without CPI guard - should succeed). #[test] fn test_cpi_transfer() { let mut svm = setup(); diff --git a/tokens/token-extensions/default-account-state/anchor/Anchor.toml b/tokens/token-extensions/default-account-state/anchor/Anchor.toml index 4ec0a46f..38444c91 100644 --- a/tokens/token-extensions/default-account-state/anchor/Anchor.toml +++ b/tokens/token-extensions/default-account-state/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] default_account_state = "5LdYbHiUsFxVG8bfqoeBkhBYMRmWZb3BoLuABgYW7coB" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/default-account-state/anchor/README.md b/tokens/token-extensions/default-account-state/anchor/README.md index c9529c20..1b6702ac 100644 --- a/tokens/token-extensions/default-account-state/anchor/README.md +++ b/tokens/token-extensions/default-account-state/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Default Account State (Anchor) +# Token Extensions - Default Account State (Anchor) New token accounts are frozen by default until thawed. diff --git a/tokens/token-extensions/default-account-state/anchor/migrations/deploy.ts b/tokens/token-extensions/default-account-state/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/default-account-state/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/initialize.rs b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/initialize.rs index ecb4703a..939241ab 100644 --- a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/initialize.rs +++ b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/initialize.rs @@ -12,7 +12,7 @@ use anchor_spl::{ }; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut)] @@ -24,7 +24,7 @@ pub struct Initialize<'info> { // There is currently not an anchor constraint to automatically initialize the DefaultAccountState extension // We can manually create and initialize the mint account via CPIs in the instruction handler -pub fn handler(context: Context) -> Result<()> { +pub fn handler(context: Context) -> Result<()> { // Calculate space required for mint and extension data let mint_size = ExtensionType::try_calculate_account_len::(&[ ExtensionType::DefaultAccountState, diff --git a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/update_default_state.rs b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/update_default_state.rs index 2e20cd62..7ed0ec0d 100644 --- a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/update_default_state.rs +++ b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/instructions/update_default_state.rs @@ -6,7 +6,7 @@ use anchor_spl::token_interface::{ use crate::AnchorAccountState; #[derive(Accounts)] -pub struct UpdateDefaultState<'info> { +pub struct UpdateDefaultStateAccountConstraints<'info> { #[account(mut)] pub freeze_authority: Signer<'info>, #[account( @@ -20,7 +20,7 @@ pub struct UpdateDefaultState<'info> { } pub fn handler( - context: Context, + context: Context, account_state: AnchorAccountState, ) -> Result<()> { // Convert AnchorAccountState to spl_token_2022::state::AccountState diff --git a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/lib.rs b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/lib.rs index 0efe20c3..8d6ef76c 100644 --- a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/lib.rs +++ b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/src/lib.rs @@ -10,12 +10,12 @@ declare_id!("5LdYbHiUsFxVG8bfqoeBkhBYMRmWZb3BoLuABgYW7coB"); pub mod default_account_state { use super::*; - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { instructions::initialize::handler(context) } pub fn update_default_state( - context: Context, + context: Context, account_state: AnchorAccountState, ) -> Result<()> { instructions::update_default_state::handler(context, account_state) diff --git a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/tests/test_default_account_state.rs b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/tests/test_default_account_state.rs index c0666812..c23a86ef 100644 --- a/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/tests/test_default_account_state.rs +++ b/tokens/token-extensions/default-account-state/anchor/programs/default-account-state/tests/test_default_account_state.rs @@ -19,7 +19,7 @@ use { }; /// Create a Token Extensions token account (165 bytes, no extra extensions). -/// Uses explicit keypair — not an ATA — so we can inspect account state bytes. +/// Uses explicit keypair - not an ATA - so we can inspect account state bytes. fn create_token_account_instruction( payer: &Pubkey, token_account: &Pubkey, @@ -69,7 +69,7 @@ fn test_default_account_state() { let initialize_ix = Instruction::new_with_bytes( program_id, &default_account_state::instruction::Initialize {}.data(), - default_account_state::accounts::Initialize { + default_account_state::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -102,7 +102,7 @@ fn test_default_account_state() { "Token account should be frozen (state=2)" ); - // Step 3: Attempt to mint to the frozen account — should fail + // Step 3: Attempt to mint to the frozen account - should fail let result = mint_tokens_to_token_extensions_account( &mut svm, &mint_keypair.pubkey(), @@ -123,7 +123,7 @@ fn test_default_account_state() { account_state: default_account_state::AnchorAccountState::Initialized, } .data(), - default_account_state::accounts::UpdateDefaultState { + default_account_state::accounts::UpdateDefaultStateAccountConstraints { freeze_authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -134,7 +134,7 @@ fn test_default_account_state() { send_transaction_from_instructions(&mut svm, vec![update_ix], &[&payer], &payer.pubkey()).unwrap(); svm.expire_blockhash(); - // Step 5: Create a new token account — should be initialized (not frozen) now + // Step 5: Create a new token account - should be initialized (not frozen) now let token2 = Keypair::new(); let create_token2_ixs = create_token_account_instruction( &payer.pubkey(), @@ -152,7 +152,7 @@ fn test_default_account_state() { "Token account should be initialized (state=1)" ); - // Step 6: Mint to the new account — should succeed + // Step 6: Mint to the new account - should succeed mint_tokens_to_token_extensions_account( &mut svm, &mint_keypair.pubkey(), diff --git a/tokens/token-extensions/default-account-state/native/README.md b/tokens/token-extensions/default-account-state/native/README.md index fffb3634..a2f30b8b 100644 --- a/tokens/token-extensions/default-account-state/native/README.md +++ b/tokens/token-extensions/default-account-state/native/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Default Account State +# Token Extensions - Default Account State This extension sets a default state for all [token accounts](https://solana.com/docs/terminology#token-account) of a given [mint](https://solana.com/docs/terminology#token-mint). diff --git a/tokens/token-extensions/default-account-state/native/program/tests/test.rs b/tokens/token-extensions/default-account-state/native/program/tests/test.rs index 5e2567e1..8e99a783 100644 --- a/tokens/token-extensions/default-account-state/native/program/tests/test.rs +++ b/tokens/token-extensions/default-account-state/native/program/tests/test.rs @@ -26,7 +26,7 @@ fn test_create_token_with_default_account_state() { include_bytes!("../../tests/fixtures/token_2022_default_account_state_program.so"); svm.add_program(program_id, program_bytes).unwrap(); - // litesvm bundles the SPL Token-2022 program by default. + // litesvm bundles the Token Extensions program by default. let token_program_id = spl_token_2022_interface::id(); let payer = Keypair::new(); @@ -62,7 +62,7 @@ fn test_create_token_with_default_account_state() { svm.send_transaction(tx).unwrap(); - // The mint should be owned by Token-2022, carry the DefaultAccountState + // The mint should be owned by Token Extensions, carry the DefaultAccountState // extension, and that default state should have been flipped to // Initialized by the program (it starts as Frozen). let mint_account = svm.get_account(&mint.pubkey()).unwrap(); diff --git a/tokens/token-extensions/default-account-state/quasar/README.md b/tokens/token-extensions/default-account-state/quasar/README.md index 88d3bd04..e655c664 100644 --- a/tokens/token-extensions/default-account-state/quasar/README.md +++ b/tokens/token-extensions/default-account-state/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Default Account State (Quasar) +# Token Extensions - Default Account State (Quasar) New token accounts frozen by default until thawed. diff --git a/tokens/token-extensions/default-account-state/quasar/src/lib.rs b/tokens/token-extensions/default-account-state/quasar/src/lib.rs index 947b71ed..866e8a4e 100644 --- a/tokens/token-extensions/default-account-state/quasar/src/lib.rs +++ b/tokens/token-extensions/default-account-state/quasar/src/lib.rs @@ -9,9 +9,9 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("5LdYbHiUsFxVG8bfqoeBkhBYMRmWZb3BoLuABgYW7coB"); -/// Correct Token-2022 program ID (quasar-spl 0.0.0 has wrong bytes). +/// Correct Token Extensions program ID (quasar-spl 0.0.0 has wrong bytes). pub struct Token2022Program; impl Id for Token2022Program { const ID: Address = Address::new_from_array([ @@ -29,7 +29,7 @@ mod quasar_default_account_state { /// Create a new mint with DefaultAccountState extension set to frozen. /// The mint account must be a signer (keypair created client-side). #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } @@ -37,7 +37,7 @@ mod quasar_default_account_state { /// 0 = Uninitialized, 1 = Initialized, 2 = Frozen #[instruction(discriminator = 1)] pub fn update_default_state( - ctx: Ctx, + ctx: Ctx, account_state: u8, ) -> Result<(), ProgramError> { handle_update_default_state(&mut ctx.accounts, account_state) @@ -45,7 +45,7 @@ mod quasar_default_account_state { } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -55,12 +55,12 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { // 165 (base account) + 1 (account type) + 4 (TLV header) + 1 (DefaultAccountState data) = 171 bytes let mint_size: u64 = 171; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; - // 1. Create account owned by Token-2022 + // 1. Create account owned by Token Extensions accounts .system_program .create_account( @@ -107,7 +107,7 @@ fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { } #[derive(Accounts)] -pub struct UpdateDefaultState { +pub struct UpdateDefaultStateAccountConstraints { #[account(mut)] pub freeze_authority: Signer, #[account(mut)] @@ -117,7 +117,7 @@ pub struct UpdateDefaultState { #[inline(always)] fn handle_update_default_state( - accounts: &mut UpdateDefaultState, + accounts: &mut UpdateDefaultStateAccountConstraints, account_state: u8, ) -> Result<(), ProgramError> { // DefaultAccountState Update: opcode 28, sub-opcode 1, new state diff --git a/tokens/token-extensions/group/anchor/Anchor.toml b/tokens/token-extensions/group/anchor/Anchor.toml index 7f3f1d97..b7029fcb 100644 --- a/tokens/token-extensions/group/anchor/Anchor.toml +++ b/tokens/token-extensions/group/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] group = "4XCDGMD8fsdjUzmYj6d9if8twFt1f23Ym52iDmWK8fFs" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/group/anchor/README.md b/tokens/token-extensions/group/anchor/README.md index 69b0028a..169b7372 100644 --- a/tokens/token-extensions/group/anchor/README.md +++ b/tokens/token-extensions/group/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Group Pointer (Anchor) +# Token Extensions - Group Pointer (Anchor) Link tokens to a group using the Group Pointer extension. diff --git a/tokens/token-extensions/group/anchor/migrations/deploy.ts b/tokens/token-extensions/group/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/group/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/group/anchor/programs/group/src/instructions/test_initialize_group.rs b/tokens/token-extensions/group/anchor/programs/group/src/instructions/test_initialize_group.rs index d707e12d..d7b992a0 100644 --- a/tokens/token-extensions/group/anchor/programs/group/src/instructions/test_initialize_group.rs +++ b/tokens/token-extensions/group/anchor/programs/group/src/instructions/test_initialize_group.rs @@ -9,7 +9,7 @@ use anchor_spl::token_interface::{ }; #[derive(Accounts)] -pub struct InitializeGroup<'info> { +pub struct InitializeGroupAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -29,7 +29,7 @@ pub struct InitializeGroup<'info> { pub system_program: Program<'info, System>, } -fn check_mint_data(accounts: &mut InitializeGroup) -> Result<()> { +fn check_mint_data(accounts: &mut InitializeGroupAccountConstraints) -> Result<()> { let mint = &accounts.mint_account.to_account_info(); let mint_data = mint.data.borrow(); let mint_with_extension = StateWithExtensions::::unpack(&mint_data)?; @@ -40,7 +40,7 @@ fn check_mint_data(accounts: &mut InitializeGroup) -> Result<()> { Ok(()) } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { check_mint_data(&mut context.accounts)?; // // Token Group and Token Member extensions features not enabled yet on the Token2022 program diff --git a/tokens/token-extensions/group/anchor/programs/group/src/lib.rs b/tokens/token-extensions/group/anchor/programs/group/src/lib.rs index ef5fa47a..4c221923 100644 --- a/tokens/token-extensions/group/anchor/programs/group/src/lib.rs +++ b/tokens/token-extensions/group/anchor/programs/group/src/lib.rs @@ -10,7 +10,7 @@ pub mod group { use super::*; - pub fn test_initialize_group(context: Context) -> Result<()> { + pub fn test_initialize_group(context: Context) -> Result<()> { instructions::test_initialize_group::handler(context) } } diff --git a/tokens/token-extensions/group/anchor/programs/group/tests/test_group.rs b/tokens/token-extensions/group/anchor/programs/group/tests/test_group.rs index 4d6135a0..331ca1fb 100644 --- a/tokens/token-extensions/group/anchor/programs/group/tests/test_group.rs +++ b/tokens/token-extensions/group/anchor/programs/group/tests/test_group.rs @@ -27,7 +27,7 @@ fn test_initialize_group() { let instruction = Instruction::new_with_bytes( program_id, &group::instruction::TestInitializeGroup {}.data(), - group::accounts::InitializeGroup { + group::accounts::InitializeGroupAccountConstraints { payer: payer.pubkey(), mint_account, token_program: TOKEN_EXTENSIONS_PROGRAM_ID, diff --git a/tokens/token-extensions/group/quasar/README.md b/tokens/token-extensions/group/quasar/README.md index 91ca9a79..62606abc 100644 --- a/tokens/token-extensions/group/quasar/README.md +++ b/tokens/token-extensions/group/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Group Pointer (Quasar) +# Token Extensions - Group Pointer (Quasar) Link mints to a group via Group Pointer. diff --git a/tokens/token-extensions/group/quasar/src/lib.rs b/tokens/token-extensions/group/quasar/src/lib.rs index d9c4fad4..3529fde3 100644 --- a/tokens/token-extensions/group/quasar/src/lib.rs +++ b/tokens/token-extensions/group/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("4XCDGMD8fsdjUzmYj6d9if8twFt1f23Ym52iDmWK8fFs"); pub struct Token2022Program; impl Id for Token2022Program { @@ -22,7 +22,7 @@ impl Id for Token2022Program { /// Creates a mint with the GroupPointer extension. /// /// The Token Group and Token Member extensions are not yet fully enabled on -/// the Token-2022 program. This example demonstrates initializing the +/// the Token Extensions program. This example demonstrates initializing the /// GroupPointer extension on a mint. Actual group/member initialization /// is commented out in the Anchor version as well. #[program] @@ -30,13 +30,13 @@ mod quasar_group { use super::*; #[instruction(discriminator = 0)] - pub fn initialize_group(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize_group(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize_group(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct InitializeGroup { +pub struct InitializeGroupAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -46,7 +46,7 @@ pub struct InitializeGroup { } #[inline(always)] -fn handle_initialize_group(accounts: &mut InitializeGroup) -> Result<(), ProgramError> { +fn handle_initialize_group(accounts: &mut InitializeGroupAccountConstraints) -> Result<(), ProgramError> { // Mint + GroupPointer extension = 234 bytes // (base mint padded to 165 + account_type byte + GroupPointer TLV [2 type + 2 len + 64 data]) let mint_size: u64 = 234; diff --git a/tokens/token-extensions/immutable-owner/anchor/Anchor.toml b/tokens/token-extensions/immutable-owner/anchor/Anchor.toml index 82cf05bc..2c283e0d 100644 --- a/tokens/token-extensions/immutable-owner/anchor/Anchor.toml +++ b/tokens/token-extensions/immutable-owner/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] immutable_owner = "6g5URpqqurW8RbKjuGeRCVZBKky3J4kYcLeotQ6vj6UT" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/immutable-owner/anchor/README.md b/tokens/token-extensions/immutable-owner/anchor/README.md index 295ea8d8..83ee103c 100644 --- a/tokens/token-extensions/immutable-owner/anchor/README.md +++ b/tokens/token-extensions/immutable-owner/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Immutable Owner (Anchor) +# Token Extensions - Immutable Owner (Anchor) Create token accounts whose owner field cannot be changed after creation. diff --git a/tokens/token-extensions/immutable-owner/anchor/migrations/deploy.ts b/tokens/token-extensions/immutable-owner/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/immutable-owner/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/src/lib.rs b/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/src/lib.rs index c24c5860..e21a5e71 100644 --- a/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/src/lib.rs +++ b/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/src/lib.rs @@ -17,7 +17,7 @@ pub mod immutable_owner { // There is currently not an anchor constraint to automatically initialize the ImmutableOwner extension // We can manually create and initialize the token account via CPIs in the instruction handler - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { // Calculate space required for token and extension data let token_account_size = ExtensionType::try_calculate_account_len::(&[ ExtensionType::ImmutableOwner, @@ -63,7 +63,7 @@ pub mod immutable_owner { } #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, diff --git a/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/tests/test_immutable_owner.rs b/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/tests/test_immutable_owner.rs index dad6478b..90710e4c 100644 --- a/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/tests/test_immutable_owner.rs +++ b/tokens/token-extensions/immutable-owner/anchor/programs/immutable-owner/tests/test_immutable_owner.rs @@ -68,7 +68,7 @@ fn test_create_token_account_with_immutable_owner() { let initialize_ix = Instruction::new_with_bytes( program_id, &immutable_owner::instruction::Initialize {}.data(), - immutable_owner::accounts::Initialize { + immutable_owner::accounts::InitializeAccountConstraints { payer: payer.pubkey(), token_account: token_keypair.pubkey(), mint_account: mint, @@ -90,7 +90,7 @@ fn test_create_token_account_with_immutable_owner() { token_data.data.len() ); - // Step 3: Attempt to change the account owner — should fail due to immutable owner + // Step 3: Attempt to change the account owner - should fail due to immutable owner let new_owner = Keypair::new(); let set_authority_ix = set_authority_instruction( &token_keypair.pubkey(), diff --git a/tokens/token-extensions/immutable-owner/quasar/README.md b/tokens/token-extensions/immutable-owner/quasar/README.md index b951799c..3486ee51 100644 --- a/tokens/token-extensions/immutable-owner/quasar/README.md +++ b/tokens/token-extensions/immutable-owner/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Immutable Owner (Quasar) +# Token Extensions - Immutable Owner (Quasar) Token accounts with an immutable owner field. diff --git a/tokens/token-extensions/immutable-owner/quasar/src/lib.rs b/tokens/token-extensions/immutable-owner/quasar/src/lib.rs index 83606f22..6ec0b52b 100644 --- a/tokens/token-extensions/immutable-owner/quasar/src/lib.rs +++ b/tokens/token-extensions/immutable-owner/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("6g5URpqqurW8RbKjuGeRCVZBKky3J4kYcLeotQ6vj6UT"); pub struct Token2022Program; impl Id for Token2022Program { @@ -26,13 +26,13 @@ mod quasar_immutable_owner { use super::*; #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -43,7 +43,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { // 165 (base) + 1 (account type) + 4 (TLV header, ImmutableOwner is zero-size) = 170 bytes let account_size: u64 = 170; let lamports = Rent::get()?.try_minimum_balance(account_size as usize)?; diff --git a/tokens/token-extensions/interest-bearing/anchor/Anchor.toml b/tokens/token-extensions/interest-bearing/anchor/Anchor.toml index 44a07fa9..303e73ef 100644 --- a/tokens/token-extensions/interest-bearing/anchor/Anchor.toml +++ b/tokens/token-extensions/interest-bearing/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] interest_bearing = "DMQdkzRJz8uQSN8Kx2QYmQJn6xLKhsu3LcPYxs314MgC" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/interest-bearing/anchor/README.md b/tokens/token-extensions/interest-bearing/anchor/README.md index 3b42b894..74b6ad19 100644 --- a/tokens/token-extensions/interest-bearing/anchor/README.md +++ b/tokens/token-extensions/interest-bearing/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Interest Bearing (Anchor) +# Token Extensions - Interest Bearing (Anchor) Display balances that accrue interest over time using the interest-bearing extension. diff --git a/tokens/token-extensions/interest-bearing/anchor/migrations/deploy.ts b/tokens/token-extensions/interest-bearing/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/interest-bearing/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/initialize.rs b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/initialize.rs index 00be5c73..cb34219a 100644 --- a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/initialize.rs +++ b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/initialize.rs @@ -12,7 +12,7 @@ use anchor_spl::{ use crate::check_mint_data; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut)] @@ -22,7 +22,7 @@ pub struct Initialize<'info> { pub system_program: Program<'info, System>, } -pub fn handler(context: Context, rate: i16) -> Result<()> { +pub fn handler(context: Context, rate: i16) -> Result<()> { // Calculate space required for mint and extension data let mint_size = ExtensionType::try_calculate_account_len::(&[ ExtensionType::InterestBearingConfig, diff --git a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/update_rate.rs b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/update_rate.rs index 357082b6..87da6338 100644 --- a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/update_rate.rs +++ b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/instructions/update_rate.rs @@ -6,7 +6,7 @@ use anchor_spl::token_interface::{ use crate::check_mint_data; #[derive(Accounts)] -pub struct UpdateRate<'info> { +pub struct UpdateRateAccountConstraints<'info> { #[account(mut)] pub authority: Signer<'info>, #[account(mut)] @@ -16,7 +16,7 @@ pub struct UpdateRate<'info> { pub system_program: Program<'info, System>, } -pub fn handler(context: Context, rate: i16) -> Result<()> { +pub fn handler(context: Context, rate: i16) -> Result<()> { interest_bearing_mint_update_rate( CpiContext::new( context.accounts.token_program.key(), diff --git a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/lib.rs b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/lib.rs index a8da9cc5..b0978218 100644 --- a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/lib.rs +++ b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/src/lib.rs @@ -18,11 +18,11 @@ pub mod interest_bearing { use super::*; - pub fn initialize(context: Context, rate: i16) -> Result<()> { + pub fn initialize(context: Context, rate: i16) -> Result<()> { instructions::initialize::handler(context, rate) } - pub fn update_rate(context: Context, rate: i16) -> Result<()> { + pub fn update_rate(context: Context, rate: i16) -> Result<()> { instructions::update_rate::handler(context, rate) } } diff --git a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/tests/test_interest_bearing.rs b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/tests/test_interest_bearing.rs index c354d3ba..c9954bcc 100644 --- a/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/tests/test_interest_bearing.rs +++ b/tokens/token-extensions/interest-bearing/anchor/programs/interest-bearing/tests/test_interest_bearing.rs @@ -32,7 +32,7 @@ fn test_initialize_and_update_rate() { let initialize_ix = Instruction::new_with_bytes( program_id, &interest_bearing::instruction::Initialize { rate: 0 }.data(), - interest_bearing::accounts::Initialize { + interest_bearing::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -54,7 +54,7 @@ fn test_initialize_and_update_rate() { let update_rate_ix = Instruction::new_with_bytes( program_id, &interest_bearing::instruction::UpdateRate { rate: 100 }.data(), - interest_bearing::accounts::UpdateRate { + interest_bearing::accounts::UpdateRateAccountConstraints { authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, diff --git a/tokens/token-extensions/interest-bearing/quasar/README.md b/tokens/token-extensions/interest-bearing/quasar/README.md index a1e340ce..b225a3bb 100644 --- a/tokens/token-extensions/interest-bearing/quasar/README.md +++ b/tokens/token-extensions/interest-bearing/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Interest Bearing (Quasar) +# Token Extensions - Interest Bearing (Quasar) Balances that reflect accrued interest over time. diff --git a/tokens/token-extensions/interest-bearing/quasar/src/lib.rs b/tokens/token-extensions/interest-bearing/quasar/src/lib.rs index b649b59e..356dbcaa 100644 --- a/tokens/token-extensions/interest-bearing/quasar/src/lib.rs +++ b/tokens/token-extensions/interest-bearing/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("DMQdkzRJz8uQSN8Kx2QYmQJn6xLKhsu3LcPYxs314MgC"); pub struct Token2022Program; impl Id for Token2022Program { @@ -26,18 +26,18 @@ mod quasar_interest_bearing { use super::*; #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx, rate: i16) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx, rate: i16) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts, rate) } #[instruction(discriminator = 1)] - pub fn update_rate(ctx: Ctx, rate: i16) -> Result<(), ProgramError> { + pub fn update_rate(ctx: Ctx, rate: i16) -> Result<(), ProgramError> { handle_update_rate(&mut ctx.accounts, rate) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -47,7 +47,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize, rate: i16) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints, rate: i16) -> Result<(), ProgramError> { // 165 (base) + 1 (account type) + 4 (TLV header) + 52 (InterestBearingConfig data) = 222 bytes let mint_size: u64 = 222; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; @@ -101,7 +101,7 @@ fn handle_initialize(accounts: &mut Initialize, rate: i16) -> Result<(), Program } #[derive(Accounts)] -pub struct UpdateRate { +pub struct UpdateRateAccountConstraints { #[account(mut)] pub authority: Signer, #[account(mut)] @@ -110,7 +110,7 @@ pub struct UpdateRate { } #[inline(always)] -fn handle_update_rate(accounts: &mut UpdateRate, rate: i16) -> Result<(), ProgramError> { +fn handle_update_rate(accounts: &mut UpdateRateAccountConstraints, rate: i16) -> Result<(), ProgramError> { // InterestBearingMintUpdateRate: opcode 33, sub-opcode 1, rate (i16 LE) let mut data = [0u8; 4]; data[0] = 33; diff --git a/tokens/token-extensions/memo-transfer/anchor/Anchor.toml b/tokens/token-extensions/memo-transfer/anchor/Anchor.toml index 88e101df..86cc116c 100644 --- a/tokens/token-extensions/memo-transfer/anchor/Anchor.toml +++ b/tokens/token-extensions/memo-transfer/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] memo_transfer = "5BQyC7y2Pc283woThq11uZRqsgcRbBRLKz4yQ8BJadi2" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/memo-transfer/anchor/README.md b/tokens/token-extensions/memo-transfer/anchor/README.md index eb359e3e..613bc8dd 100644 --- a/tokens/token-extensions/memo-transfer/anchor/README.md +++ b/tokens/token-extensions/memo-transfer/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Memo Transfer (Anchor) +# Token Extensions - Memo Transfer (Anchor) Require a memo on every transfer via the memo-transfer extension. diff --git a/tokens/token-extensions/memo-transfer/anchor/migrations/deploy.ts b/tokens/token-extensions/memo-transfer/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/memo-transfer/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/disable.rs b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/disable.rs index b1d35d98..f38442c0 100644 --- a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/disable.rs +++ b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/disable.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{memo_transfer_disable, MemoTransfer, Token2022, TokenAccount}; #[derive(Accounts)] -pub struct Disable<'info> { +pub struct DisableAccountConstraints<'info> { #[account(mut)] pub owner: Signer<'info>, @@ -14,7 +14,7 @@ pub struct Disable<'info> { pub token_program: Program<'info, Token2022>, } -pub fn handler(context: Context) -> Result<()> { +pub fn handler(context: Context) -> Result<()> { memo_transfer_disable(CpiContext::new( context.accounts.token_program.key(), MemoTransfer { diff --git a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/initialize.rs b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/initialize.rs index da389ad6..b465e542 100644 --- a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/initialize.rs +++ b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/instructions/initialize.rs @@ -10,7 +10,7 @@ use anchor_spl::{ }; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -21,7 +21,7 @@ pub struct Initialize<'info> { pub system_program: Program<'info, System>, } -pub fn handler(context: Context) -> Result<()> { +pub fn handler(context: Context) -> Result<()> { // Calculate space required for token and extension data let token_account_size = ExtensionType::try_calculate_account_len::(&[ExtensionType::MemoTransfer])?; diff --git a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/lib.rs b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/lib.rs index b85b27fe..d9e71f05 100644 --- a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/lib.rs +++ b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/src/lib.rs @@ -9,11 +9,11 @@ declare_id!("5BQyC7y2Pc283woThq11uZRqsgcRbBRLKz4yQ8BJadi2"); pub mod memo_transfer { use super::*; - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { instructions::initialize::handler(context) } - pub fn disable(context: Context) -> Result<()> { + pub fn disable(context: Context) -> Result<()> { instructions::disable::handler(context) } } diff --git a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/tests/test_memo_transfer.rs b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/tests/test_memo_transfer.rs index 85c85e8c..e410f234 100644 --- a/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/tests/test_memo_transfer.rs +++ b/tokens/token-extensions/memo-transfer/anchor/programs/memo-transfer/tests/test_memo_transfer.rs @@ -26,7 +26,7 @@ fn memo_program_id() -> Pubkey { } /// Create a Token Extensions token account (165 bytes, no extra extensions). -/// Uses explicit keypair — not an ATA — because the test needs multiple +/// Uses explicit keypair - not an ATA - because the test needs multiple /// source accounts for the same owner+mint. fn create_token_account_instructions( payer: &Pubkey, @@ -119,7 +119,7 @@ fn test_memo_transfer() { let initialize_ix = Instruction::new_with_bytes( program_id, &memo_transfer::instruction::Initialize {}.data(), - memo_transfer::accounts::Initialize { + memo_transfer::accounts::InitializeAccountConstraints { payer: payer.pubkey(), token_account: token_keypair.pubkey(), mint_account: mint, @@ -161,7 +161,7 @@ fn test_memo_transfer() { ).unwrap(); svm.expire_blockhash(); - // Step 4: Transfer without memo — should fail + // Step 4: Transfer without memo - should fail let transfer_ix = transfer_instruction( &source_keypair.pubkey(), &token_keypair.pubkey(), @@ -175,7 +175,7 @@ fn test_memo_transfer() { ); svm.expire_blockhash(); - // Step 5: Transfer with memo — should succeed + // Step 5: Transfer with memo - should succeed let memo_ix = memo_instruction("hello, world", &[&payer.pubkey()]); let transfer_ix = transfer_instruction( &source_keypair.pubkey(), @@ -192,7 +192,7 @@ fn test_memo_transfer() { let disable_ix = Instruction::new_with_bytes( program_id, &memo_transfer::instruction::Disable {}.data(), - memo_transfer::accounts::Disable { + memo_transfer::accounts::DisableAccountConstraints { owner: payer.pubkey(), token_account: token_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, diff --git a/tokens/token-extensions/memo-transfer/quasar/README.md b/tokens/token-extensions/memo-transfer/quasar/README.md index f21b2106..a50449ad 100644 --- a/tokens/token-extensions/memo-transfer/quasar/README.md +++ b/tokens/token-extensions/memo-transfer/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Memo Transfer (Quasar) +# Token Extensions - Memo Transfer (Quasar) Require a memo on every transfer. diff --git a/tokens/token-extensions/memo-transfer/quasar/src/lib.rs b/tokens/token-extensions/memo-transfer/quasar/src/lib.rs index bead24bb..9b214cd0 100644 --- a/tokens/token-extensions/memo-transfer/quasar/src/lib.rs +++ b/tokens/token-extensions/memo-transfer/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("5BQyC7y2Pc283woThq11uZRqsgcRbBRLKz4yQ8BJadi2"); pub struct Token2022Program; impl Id for Token2022Program { @@ -26,18 +26,18 @@ mod quasar_memo_transfer { use super::*; #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } #[instruction(discriminator = 1)] - pub fn disable(ctx: Ctx) -> Result<(), ProgramError> { + pub fn disable(ctx: Ctx) -> Result<(), ProgramError> { handle_disable(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -48,7 +48,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { // Token account + MemoTransfer extension = 300 bytes let account_size: u64 = 300; let lamports = Rent::get()?.try_minimum_balance(account_size as usize)?; @@ -100,7 +100,7 @@ fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { } #[derive(Accounts)] -pub struct Disable { +pub struct DisableAccountConstraints { #[account(mut)] pub owner: Signer, #[account(mut)] @@ -109,7 +109,7 @@ pub struct Disable { } #[inline(always)] -fn handle_disable(accounts: &mut Disable) -> Result<(), ProgramError> { +fn handle_disable(accounts: &mut DisableAccountConstraints) -> Result<(), ProgramError> { // MemoTransfer disable: opcode 30, sub-opcode 1 CpiCall::new( accounts.token_program.to_account_view().address(), diff --git a/tokens/token-extensions/metadata/anchor/Anchor.toml b/tokens/token-extensions/metadata/anchor/Anchor.toml index 14b1e98d..e036a3f5 100644 --- a/tokens/token-extensions/metadata/anchor/Anchor.toml +++ b/tokens/token-extensions/metadata/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] metadata = "BJHEDXSQfD9kBFvhw8ZCGmPFRihzvbMoxoHUKpXdpn4D" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/metadata/anchor/README.md b/tokens/token-extensions/metadata/anchor/README.md index 371ff677..eb758e65 100644 --- a/tokens/token-extensions/metadata/anchor/README.md +++ b/tokens/token-extensions/metadata/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Onchain Metadata (Anchor) +# Token Extensions - Onchain Metadata (Anchor) Store token metadata inside the mint account using Token Extensions metadata. diff --git a/tokens/token-extensions/metadata/anchor/migrations/deploy.ts b/tokens/token-extensions/metadata/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/metadata/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/emit.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/emit.rs index 72749e2f..b849ab82 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/emit.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/emit.rs @@ -4,14 +4,14 @@ use anchor_spl::token_interface::{Mint, Token2022}; use spl_token_metadata_interface::instruction::emit; #[derive(Accounts)] -pub struct Emit<'info> { +pub struct EmitAccountConstraints<'info> { pub mint_account: InterfaceAccount<'info, Mint>, pub token_program: Program<'info, Token2022>, } // Invoke the emit instruction from spl_token_metadata_interface directly // There is not an anchor CpiContext for this instruction -pub fn process_emit(context: Context) -> Result<()> { +pub fn process_emit(context: Context) -> Result<()> { invoke( &emit( &context.accounts.token_program.key(), // token program id diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/initialize.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/initialize.rs index 9f57330f..740b61ba 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/initialize.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/initialize.rs @@ -7,7 +7,7 @@ use spl_token_metadata_interface::state::TokenMetadata; use spl_type_length_value::variable_len_pack::VariableLenPack; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -24,7 +24,7 @@ pub struct Initialize<'info> { pub system_program: Program<'info, System>, } -pub fn process_initialize(context: Context, args: TokenMetadataArgs) -> Result<()> { +pub fn process_initialize(context: Context, args: TokenMetadataArgs) -> Result<()> { let TokenMetadataArgs { name, symbol, uri } = args; // Define token metadata diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/remove_key.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/remove_key.rs index d171e18d..0114a4b2 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/remove_key.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/remove_key.rs @@ -4,7 +4,7 @@ use anchor_spl::token_interface::{Mint, Token2022}; use spl_token_metadata_interface::instruction::remove_key; #[derive(Accounts)] -pub struct RemoveKey<'info> { +pub struct RemoveKeyAccountConstraints<'info> { #[account(mut)] pub update_authority: Signer<'info>, @@ -19,7 +19,7 @@ pub struct RemoveKey<'info> { // Invoke the remove_key instruction from spl_token_metadata_interface directly // There is not an anchor CpiContext for this instruction -pub fn process_remove_key(context: Context, key: String) -> Result<()> { +pub fn process_remove_key(context: Context, key: String) -> Result<()> { invoke( &remove_key( &context.accounts.token_program.key(), // token program id diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_authority.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_authority.rs index 92a8ac1e..ded7b65f 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_authority.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_authority.rs @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{ }; #[derive(Accounts)] -pub struct UpdateAuthority<'info> { +pub struct UpdateAuthorityAccountConstraints<'info> { pub current_authority: Signer<'info>, pub new_authority: Option>, @@ -18,7 +18,7 @@ pub struct UpdateAuthority<'info> { pub system_program: Program<'info, System>, } -pub fn process_update_authority(context: Context) -> Result<()> { +pub fn process_update_authority(context: Context) -> Result<()> { let new_authority_key = match &context.accounts.new_authority { Some(account) => OptionalNonZeroPubkey::try_from(Some(account.key()))?, None => OptionalNonZeroPubkey::try_from(None)?, diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_field.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_field.rs index cdd5504a..754fb809 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_field.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/src/instructions/update_field.rs @@ -10,7 +10,7 @@ use anchor_spl::{ use spl_token_metadata_interface::state::{Field, TokenMetadata}; #[derive(Accounts)] -pub struct UpdateField<'info> { +pub struct UpdateFieldAccountConstraints<'info> { #[account(mut)] pub authority: Signer<'info>, @@ -23,7 +23,7 @@ pub struct UpdateField<'info> { pub system_program: Program<'info, System>, } -pub fn process_update_field(context: Context, args: UpdateFieldArgs) -> Result<()> { +pub fn process_update_field(context: Context, args: UpdateFieldArgs) -> Result<()> { let UpdateFieldArgs { field, value } = args; // Convert to Field type from spl_token_metadata_interface diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/src/lib.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/src/lib.rs index 1496bd5e..e086f704 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/src/lib.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/src/lib.rs @@ -11,23 +11,23 @@ declare_id!("BJHEDXSQfD9kBFvhw8ZCGmPFRihzvbMoxoHUKpXdpn4D"); pub mod metadata { use super::*; - pub fn initialize(context: Context, args: TokenMetadataArgs) -> Result<()> { + pub fn initialize(context: Context, args: TokenMetadataArgs) -> Result<()> { process_initialize(context, args) } - pub fn update_field(context: Context, args: UpdateFieldArgs) -> Result<()> { + pub fn update_field(context: Context, args: UpdateFieldArgs) -> Result<()> { process_update_field(context, args) } - pub fn remove_key(context: Context, key: String) -> Result<()> { + pub fn remove_key(context: Context, key: String) -> Result<()> { process_remove_key(context, key) } - pub fn emit(context: Context) -> Result<()> { + pub fn emit(context: Context) -> Result<()> { process_emit(context) } - pub fn update_authority(context: Context) -> Result<()> { + pub fn update_authority(context: Context) -> Result<()> { process_update_authority(context) } } diff --git a/tokens/token-extensions/metadata/anchor/programs/metadata/tests/test_metadata.rs b/tokens/token-extensions/metadata/anchor/programs/metadata/tests/test_metadata.rs index 3b34a126..8439e9d3 100644 --- a/tokens/token-extensions/metadata/anchor/programs/metadata/tests/test_metadata.rs +++ b/tokens/token-extensions/metadata/anchor/programs/metadata/tests/test_metadata.rs @@ -39,7 +39,7 @@ fn test_metadata_full_flow() { }, } .data(), - metadata::accounts::Initialize { + metadata::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -73,7 +73,7 @@ fn test_metadata_full_flow() { }, } .data(), - metadata::accounts::UpdateField { + metadata::accounts::UpdateFieldAccountConstraints { authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -95,7 +95,7 @@ fn test_metadata_full_flow() { }, } .data(), - metadata::accounts::UpdateField { + metadata::accounts::UpdateFieldAccountConstraints { authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -119,7 +119,7 @@ fn test_metadata_full_flow() { key: "color".to_string(), } .data(), - metadata::accounts::RemoveKey { + metadata::accounts::RemoveKeyAccountConstraints { update_authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -135,7 +135,7 @@ fn test_metadata_full_flow() { let update_authority_ix = Instruction::new_with_bytes( program_id, &metadata::instruction::UpdateAuthority {}.data(), - metadata::accounts::UpdateAuthority { + metadata::accounts::UpdateAuthorityAccountConstraints { current_authority: payer.pubkey(), new_authority: None, mint_account: mint_keypair.pubkey(), @@ -157,7 +157,7 @@ fn test_metadata_full_flow() { let emit_ix = Instruction::new_with_bytes( program_id, &metadata::instruction::Emit {}.data(), - metadata::accounts::Emit { + metadata::accounts::EmitAccountConstraints { mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, } diff --git a/tokens/token-extensions/mint-close-authority/anchor/Anchor.toml b/tokens/token-extensions/mint-close-authority/anchor/Anchor.toml index 630ccf80..782fb5a3 100644 --- a/tokens/token-extensions/mint-close-authority/anchor/Anchor.toml +++ b/tokens/token-extensions/mint-close-authority/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] mint_close_authority = "AcfQLsYKuzprcCNH1n96pKKgAbAnZchwpbr3gbVN742n" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/mint-close-authority/anchor/README.md b/tokens/token-extensions/mint-close-authority/anchor/README.md index 69e99d8e..22e53fe5 100644 --- a/tokens/token-extensions/mint-close-authority/anchor/README.md +++ b/tokens/token-extensions/mint-close-authority/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Mint Close Authority (Anchor) +# Token Extensions - Mint Close Authority (Anchor) Designate an account allowed to close the mint and reclaim lamports. diff --git a/tokens/token-extensions/mint-close-authority/anchor/migrations/deploy.ts b/tokens/token-extensions/mint-close-authority/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/mint-close-authority/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/close.rs b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/close.rs index 1d591763..f87c3ccb 100644 --- a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/close.rs +++ b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/close.rs @@ -5,7 +5,7 @@ use anchor_spl::{ }; #[derive(Accounts)] -pub struct Close<'info> { +pub struct CloseAccountConstraints<'info> { #[account(mut)] pub authority: Signer<'info>, @@ -17,7 +17,7 @@ pub struct Close<'info> { pub token_program: Program<'info, Token2022>, } -pub fn handler(context: Context) -> Result<()> { +pub fn handler(context: Context) -> Result<()> { // cpi to token extensions programs to close mint account // alternatively, this can also be done in the client close_account(CpiContext::new( diff --git a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/initialize.rs b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/initialize.rs index ea19148f..ba2f2a47 100644 --- a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/initialize.rs +++ b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/instructions/initialize.rs @@ -12,7 +12,7 @@ use anchor_spl::token_interface::{ }; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -28,13 +28,13 @@ pub struct Initialize<'info> { pub system_program: Program<'info, System>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { handle_check_mint_data(&mut context.accounts)?; Ok(()) } // helper to check mint data, and demonstrate how to read mint extension data within a program -fn handle_check_mint_data(accounts: &mut Initialize) -> Result<()> { +fn handle_check_mint_data(accounts: &mut InitializeAccountConstraints) -> Result<()> { let mint = &accounts.mint_account.to_account_info(); let mint_data = mint.data.borrow(); let mint_with_extension = StateWithExtensions::::unpack(&mint_data)?; diff --git a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/lib.rs b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/lib.rs index f4d21dc7..c5ac6ba4 100644 --- a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/lib.rs +++ b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/src/lib.rs @@ -9,11 +9,11 @@ declare_id!("AcfQLsYKuzprcCNH1n96pKKgAbAnZchwpbr3gbVN742n"); pub mod mint_close_authority { use super::*; - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { instructions::initialize::handler(context) } - pub fn close(context: Context) -> Result<()> { + pub fn close(context: Context) -> Result<()> { instructions::close::handler(context) } } diff --git a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/tests/test_mint_close_authority.rs b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/tests/test_mint_close_authority.rs index 2530914f..156c9204 100644 --- a/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/tests/test_mint_close_authority.rs +++ b/tokens/token-extensions/mint-close-authority/anchor/programs/mint-close-authority/tests/test_mint_close_authority.rs @@ -32,7 +32,7 @@ fn test_create_and_close_mint() { let initialize_ix = Instruction::new_with_bytes( program_id, &mint_close_authority::instruction::Initialize {}.data(), - mint_close_authority::accounts::Initialize { + mint_close_authority::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -54,7 +54,7 @@ fn test_create_and_close_mint() { let close_ix = Instruction::new_with_bytes( program_id, &mint_close_authority::instruction::Close {}.data(), - mint_close_authority::accounts::Close { + mint_close_authority::accounts::CloseAccountConstraints { authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -76,7 +76,7 @@ fn test_create_and_close_mint() { let initialize_ix2 = Instruction::new_with_bytes( program_id, &mint_close_authority::instruction::Initialize {}.data(), - mint_close_authority::accounts::Initialize { + mint_close_authority::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, diff --git a/tokens/token-extensions/mint-close-authority/native/program/tests/test.rs b/tokens/token-extensions/mint-close-authority/native/program/tests/test.rs index 69796994..553f096e 100644 --- a/tokens/token-extensions/mint-close-authority/native/program/tests/test.rs +++ b/tokens/token-extensions/mint-close-authority/native/program/tests/test.rs @@ -25,7 +25,7 @@ fn test_create_token_with_mint_close_authority() { include_bytes!("../../tests/fixtures/token_2022_mint_close_authority_program.so"); svm.add_program(program_id, program_bytes).unwrap(); - // litesvm bundles the SPL Token-2022 program by default. + // litesvm bundles the Token Extensions program by default. let token_program_id = spl_token_2022_interface::id(); let payer = Keypair::new(); @@ -58,7 +58,7 @@ fn test_create_token_with_mint_close_authority() { svm.send_transaction(tx).unwrap(); - // The mint should be owned by Token-2022 and carry the MintCloseAuthority + // The mint should be owned by Token Extensions and carry the MintCloseAuthority // extension pointing at the payer. let mint_account = svm.get_account(&mint.pubkey()).unwrap(); assert_eq!(mint_account.owner, token_program_id); diff --git a/tokens/token-extensions/mint-close-authority/quasar/README.md b/tokens/token-extensions/mint-close-authority/quasar/README.md index beab3875..8c11a408 100644 --- a/tokens/token-extensions/mint-close-authority/quasar/README.md +++ b/tokens/token-extensions/mint-close-authority/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Mint Close Authority (Quasar) +# Token Extensions - Mint Close Authority (Quasar) Designated account may close the mint. diff --git a/tokens/token-extensions/mint-close-authority/quasar/src/lib.rs b/tokens/token-extensions/mint-close-authority/quasar/src/lib.rs index 3c9eae46..b02a8b38 100644 --- a/tokens/token-extensions/mint-close-authority/quasar/src/lib.rs +++ b/tokens/token-extensions/mint-close-authority/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("AcfQLsYKuzprcCNH1n96pKKgAbAnZchwpbr3gbVN742n"); pub struct Token2022Program; impl Id for Token2022Program { @@ -27,19 +27,19 @@ mod quasar_mint_close_authority { /// Create a mint with the MintCloseAuthority extension. #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } /// Close the mint account, reclaiming lamports to the authority. #[instruction(discriminator = 1)] - pub fn close(ctx: Ctx) -> Result<(), ProgramError> { + pub fn close(ctx: Ctx) -> Result<(), ProgramError> { handle_close(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -49,7 +49,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { // 165 (base) + 1 (account type) + 4 (TLV header) + 32 (MintCloseAuthority data) = 202 bytes let mint_size: u64 = 202; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; @@ -100,7 +100,7 @@ fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { } #[derive(Accounts)] -pub struct Close { +pub struct CloseAccountConstraints { #[account(mut)] pub authority: Signer, #[account(mut)] @@ -109,7 +109,7 @@ pub struct Close { } #[inline(always)] -fn handle_close(accounts: &mut Close) -> Result<(), ProgramError> { +fn handle_close(accounts: &mut CloseAccountConstraints) -> Result<(), ProgramError> { // CloseAccount: opcode 9 CpiCall::new( accounts.token_program.to_account_view().address(), diff --git a/tokens/token-extensions/multiple-extensions/native/program/tests/test.rs b/tokens/token-extensions/multiple-extensions/native/program/tests/test.rs index f6f414a1..2962284c 100644 --- a/tokens/token-extensions/multiple-extensions/native/program/tests/test.rs +++ b/tokens/token-extensions/multiple-extensions/native/program/tests/test.rs @@ -26,7 +26,7 @@ fn test_create_token_with_multiple_extensions() { include_bytes!("../../tests/fixtures/token_2022_multiple_extensions_program.so"); svm.add_program(program_id, program_bytes).unwrap(); - // litesvm bundles the SPL Token-2022 program by default. + // litesvm bundles the Token Extensions program by default. let token_program_id = spl_token_2022_interface::id(); let payer = Keypair::new(); @@ -59,7 +59,7 @@ fn test_create_token_with_multiple_extensions() { svm.send_transaction(tx).unwrap(); - // The mint should now exist, be owned by Token-2022, and carry both the + // The mint should now exist, be owned by Token Extensions, and carry both the // MintCloseAuthority and NonTransferable extensions. let mint_account = svm.get_account(&mint.pubkey()).unwrap(); assert_eq!(mint_account.owner, token_program_id); diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/README.md b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/README.md index 36be1cd0..43d72a1f 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/README.md +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/README.md @@ -2,7 +2,7 @@ An Anchor [program](https://solana.com/docs/terminology#program) that mints an NFT using the [Token Extensions](https://solana.com/docs/terminology#token-extensions-program) metadata-pointer extension. The mint itself stores its own metadata via the metadata extension, so no separate Metaplex metadata [account](https://solana.com/docs/terminology#account) is needed. -This is particularly useful for games — you get arbitrary key/value metadata stored [onchain](https://solana.com/docs/terminology#onchain) that you can use to record character state. In this example, the player's level and collected wood are stored on the NFT. +This is particularly useful for games - you get arbitrary key/value metadata stored [onchain](https://solana.com/docs/terminology#onchain) that you can use to record character state. In this example, the player's level and collected wood are stored on the NFT. When marketplaces support additional metadata, NFTs can be filtered or ranked by those fields, e.g. by character level. @@ -37,7 +37,7 @@ Creating an NFT this way: 5. Add any custom fields (e.g. `level`). 6. Create the player's [Associated Token Account](https://solana.com/docs/terminology#associated-token-account-ata). 7. Mint one token to the ATA. -8. Remove the mint authority — irreversible, makes it an NFT. +8. Remove the mint authority - irreversible, makes it an NFT. See `programs/extension_nft/src/instructions/mint_nft.rs` for the Rust implementation. diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml index 379bee00..90c302c3 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/Anchor.toml @@ -6,8 +6,6 @@ seeds = false [programs.localnet] extension_nft = "9aZZ7TJ2fQZxY8hMtWXywp5y6BgqC4N2BPcr9FDT47sW" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/migrations/deploy.ts b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/migrations/deploy.ts deleted file mode 100644 index 20e6e1c1..00000000 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/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. - -const anchor = require("@project-serum/anchor"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/constants.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/constants.rs index b7d445d2..a2854482 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/constants.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/constants.rs @@ -5,6 +5,6 @@ pub const MAX_WOOD_PER_TREE: u64 = 100000; /// Rough over-allocation for the inline SPL Token Metadata extension TLV /// appended to the Mint account. The TLV is dynamic (name / symbol / uri / /// key-value additional fields), so we cannot derive it via `InitSpace`. -/// 250 bytes is enough headroom for our fixture NFTs — raise if you add +/// 250 bytes is enough headroom for our fixture NFTs - raise if you add /// longer strings or many extra fields. pub const TOKEN_METADATA_EXTENSION_SPACE: usize = 250; diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs index e32ab30a..597f9f0c 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/chop_tree.rs @@ -7,11 +7,11 @@ use anchor_spl::token_2022_extensions::spl_token_metadata_interface; use anchor_spl::token_interface::{spl_token_2022, Token2022}; use session_keys::{Session, SessionToken}; -pub fn chop_tree(context: Context, counter: u16, amount: u64) -> Result<()> { +pub fn chop_tree(context: Context, counter: u16, amount: u64) -> Result<()> { // Save game_data bump on first creation (init_if_needed). See init_player.rs // for the same pattern. let game_data_bump = context.bumps.game_data; - let account: &mut ChopTree<'_> = context.accounts; + let account: &mut ChopTreeAccountConstraints<'_> = context.accounts; account.player.update_energy()?; account.player.print()?; @@ -59,7 +59,7 @@ pub fn chop_tree(context: Context, counter: u16, amount: u64) -> Resul #[derive(Accounts, Session)] #[instruction(level_seed: String)] -pub struct ChopTree<'info> { +pub struct ChopTreeAccountConstraints<'info> { #[session( // The ephemeral key pair signing the transaction signer = signer, diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/init_player.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/init_player.rs index 797bed02..c38c3ba6 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/init_player.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/init_player.rs @@ -3,12 +3,12 @@ use crate::state::player_data::PlayerData; use crate::{constants::MAX_ENERGY, GameData}; use anchor_lang::prelude::*; -pub fn handle_init_player(context: Context) -> Result<()> { +pub fn handle_init_player(context: Context) -> Result<()> { context.accounts.player.energy = MAX_ENERGY; context.accounts.player.last_login = Clock::get()?.unix_timestamp; context.accounts.player.authority = context.accounts.signer.key(); context.accounts.player.bump = context.bumps.player; - // init_if_needed — only save bump if this is the first init. Subsequent + // init_if_needed - only save bump if this is the first init. Subsequent // calls reuse the existing account and must not overwrite the stored bump // (they'd be equal anyway because PDA derivation is deterministic, but // guarding keeps the intent crystal-clear). @@ -20,7 +20,7 @@ pub fn handle_init_player(context: Context) -> Result<()> { #[derive(Accounts)] #[instruction(level_seed: String)] -pub struct InitPlayer<'info> { +pub struct InitPlayerAccountConstraints<'info> { #[account( init, payer = signer, diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs index 980e6ad7..22fa0c98 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/instructions/mint_nft.rs @@ -13,7 +13,7 @@ use anchor_spl::{ }, }; -pub fn handle_mint_nft(context: Context) -> Result<()> { +pub fn handle_mint_nft(context: Context) -> Result<()> { msg!("Mint nft with meta data extension and additional meta data"); let space = @@ -187,7 +187,7 @@ pub fn handle_mint_nft(context: Context) -> Result<()> { } #[derive(Accounts)] -pub struct MintNft<'info> { +pub struct MintNftAccountConstraints<'info> { #[account(mut)] pub signer: Signer<'info>, pub system_program: Program<'info, System>, diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs index 02ea5493..6d513f1b 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/lib.rs @@ -24,7 +24,7 @@ declare_id!("9aZZ7TJ2fQZxY8hMtWXywp5y6BgqC4N2BPcr9FDT47sW"); pub mod extension_nft { use super::*; - pub fn init_player(context: Context, _level_seed: String) -> Result<()> { + pub fn init_player(context: Context, _level_seed: String) -> Result<()> { init_player::handle_init_player(context) } @@ -39,11 +39,11 @@ pub mod extension_nft { ctx.accounts.player.authority.key() == ctx.accounts.signer.key(), GameErrorCode::WrongAuthority )] - pub fn chop_tree(ctx: Context, _level_seed: String, counter: u16) -> Result<()> { + pub fn chop_tree(ctx: Context, _level_seed: String, counter: u16) -> Result<()> { chop_tree::chop_tree(ctx, counter, 1) } - pub fn mint_nft(context: Context) -> Result<()> { + pub fn mint_nft(context: Context) -> Result<()> { mint_nft::handle_mint_nft(context) } } diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/state/player_data.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/state/player_data.rs index eed69d1f..90c6fa64 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/state/player_data.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/src/state/player_data.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; #[derive(InitSpace)] pub struct PlayerData { pub authority: Pubkey, - /// Player name. Capped at 32 bytes — a conservative upper bound for + /// Player name. Capped at 32 bytes - a conservative upper bound for /// display names; bump `#[max_len]` if you need room for emoji-heavy /// or international names (each non-ASCII codepoint costs up to 4 bytes). #[max_len(32)] diff --git a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs index 89291327..74a58e90 100644 --- a/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs +++ b/tokens/token-extensions/nft-meta-data-pointer/anchor-example/anchor/programs/extension_nft/tests/test_extension_nft.rs @@ -1,17 +1,17 @@ //! LiteSVM integration test for the `extension_nft` "chop tree" game program. //! //! It drives the full happy path against an in-memory validator: -//! 1. `init_player` — create the player + game-data PDAs. -//! 2. `mint_nft` — mint a Token-2022 NFT that carries its metadata inline via +//! 1. `init_player` - create the player + game-data PDAs. +//! 2. `mint_nft` - mint a Token Extensions NFT that carries its metadata inline via //! the metadata-pointer + token-metadata extensions. -//! 3. `chop_tree` — gain wood/lose energy and push the new wood total into the +//! 3. `chop_tree` - gain wood/lose energy and push the new wood total into the //! NFT metadata as an additional field. //! //! The session-keys lesson (`#[session_auth_or]`) is exercised through its //! *fallback* branch: `chop_tree` is signed directly by the player's main //! wallet with `session_token = None`, so the macro checks -//! `player.authority == signer`. This keeps the test self-contained — it does -//! not need the on-chain session-keys program as a fixture, because the program +//! `player.authority == signer`. This keeps the test self-contained - it does +//! not need the onchain session-keys program as a fixture, because the program //! never CPIs into it (the session token is only ever read as an account). //! //! IMPORTANT: CI runs `anchor keys sync` before building, which rewrites the @@ -30,8 +30,8 @@ use { solana_signer::Signer, }; -// Token-2022 and Associated-Token-Account program ids (the modern, fixed -// on-chain addresses bundled by LiteSVM). +// Token Extensions and Associated-Token-Account program ids (the modern, fixed +// onchain addresses bundled by LiteSVM). const TOKEN_2022_ID: Pubkey = Pubkey::from_str_const("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); const ASSOCIATED_TOKEN_ID: Pubkey = Pubkey::from_str_const("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); @@ -67,7 +67,7 @@ fn nft_authority_pda(program_id: &Pubkey) -> Pubkey { get_pda_and_bump(&[Seed::from(b"nft_authority".as_ref())], program_id).0 } -/// Derive the associated token account for (wallet, mint) under Token-2022. +/// Derive the associated token account for (wallet, mint) under Token Extensions. fn associated_token_address(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { Pubkey::find_program_address( &[wallet.as_ref(), TOKEN_2022_ID.as_ref(), mint.as_ref()], @@ -79,7 +79,7 @@ fn associated_token_address(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { fn init_player_ix(program_id: &Pubkey, signer: &Pubkey) -> Instruction { Instruction { program_id: *program_id, - accounts: extension_nft::accounts::InitPlayer { + accounts: extension_nft::accounts::InitPlayerAccountConstraints { player: player_pda(program_id, signer), game_data: game_data_pda(program_id, LEVEL_SEED), signer: *signer, @@ -96,7 +96,7 @@ fn init_player_ix(program_id: &Pubkey, signer: &Pubkey) -> Instruction { fn mint_nft_ix(program_id: &Pubkey, signer: &Pubkey, mint: &Pubkey) -> Instruction { Instruction { program_id: *program_id, - accounts: extension_nft::accounts::MintNft { + accounts: extension_nft::accounts::MintNftAccountConstraints { signer: *signer, system_program: system_program::id(), token_program: TOKEN_2022_ID, @@ -114,7 +114,7 @@ fn mint_nft_ix(program_id: &Pubkey, signer: &Pubkey, mint: &Pubkey) -> Instructi fn chop_tree_ix(program_id: &Pubkey, signer: &Pubkey, mint: &Pubkey, counter: u16) -> Instruction { Instruction { program_id: *program_id, - accounts: extension_nft::accounts::ChopTree { + accounts: extension_nft::accounts::ChopTreeAccountConstraints { // session_token is optional; pass None -> the macro falls back to // the main-wallet authority check. session_token: None, @@ -180,7 +180,7 @@ fn test_init_player_mint_and_chop() { "fresh player starts at max energy (100)" ); - // 2. mint_nft — the mint account is a fresh keypair (it's a Signer in the + // 2. mint_nft - the mint account is a fresh keypair (it's a Signer in the // instruction because the program creates it via a system CPI). let mint = Keypair::new(); send_transaction_from_instructions( @@ -191,12 +191,12 @@ fn test_init_player_mint_and_chop() { ) .expect("mint_nft should succeed"); - // The mint account is now owned by the Token-2022 program and holds the + // The mint account is now owned by the Token Extensions program and holds the // inline metadata extension, so it is comfortably larger than a bare mint. let mint_account = svm.get_account(&mint.pubkey()).expect("mint exists"); assert_eq!( mint_account.owner, TOKEN_2022_ID, - "mint owned by Token-2022" + "mint owned by Token Extensions" ); assert!( mint_account.data.len() > 82, @@ -207,9 +207,9 @@ fn test_init_player_mint_and_chop() { // The associated token account should exist and hold the single NFT. let ata = associated_token_address(&signer, &mint.pubkey()); let ata_account = svm.get_account(&ata).expect("ATA created"); - assert_eq!(ata_account.owner, TOKEN_2022_ID, "ATA owned by Token-2022"); + assert_eq!(ata_account.owner, TOKEN_2022_ID, "ATA owned by Token Extensions"); - // 3. chop_tree — needs the existing mint so it can push the new wood total + // 3. chop_tree - needs the existing mint so it can push the new wood total // into the NFT metadata. Signed by the player's main wallet (no session). send_transaction_from_instructions( &mut svm, diff --git a/tokens/token-extensions/non-transferable/anchor/Anchor.toml b/tokens/token-extensions/non-transferable/anchor/Anchor.toml index 2fbd1bb8..7e3e072f 100644 --- a/tokens/token-extensions/non-transferable/anchor/Anchor.toml +++ b/tokens/token-extensions/non-transferable/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] non_transferable = "8Bz4wpHaUckiC169Rg5ZfaBHFemp5S8RwTSDTKzhJ9W" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/non-transferable/anchor/README.md b/tokens/token-extensions/non-transferable/anchor/README.md index b8f620df..ff52fbbb 100644 --- a/tokens/token-extensions/non-transferable/anchor/README.md +++ b/tokens/token-extensions/non-transferable/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Non-Transferable (Anchor) +# Token Extensions - Non-Transferable (Anchor) Create tokens that cannot be transferred between accounts. diff --git a/tokens/token-extensions/non-transferable/anchor/migrations/deploy.ts b/tokens/token-extensions/non-transferable/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/non-transferable/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/src/lib.rs b/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/src/lib.rs index 3094931f..6e3447b0 100644 --- a/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/src/lib.rs +++ b/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/src/lib.rs @@ -17,7 +17,7 @@ pub mod non_transferable { // There is currently not an anchor constraint to automatically initialize the NonTransferable extension // We can manually create and initialize the mint account via CPIs in the instruction handler - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { // Calculate space required for mint and extension data let mint_size = ExtensionType::try_calculate_account_len::(&[ExtensionType::NonTransferable])?; @@ -66,7 +66,7 @@ pub mod non_transferable { } #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut)] diff --git a/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/tests/test_non_transferable.rs b/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/tests/test_non_transferable.rs index 3ec60c6b..65f05d6a 100644 --- a/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/tests/test_non_transferable.rs +++ b/tokens/token-extensions/non-transferable/anchor/programs/non-transferable/tests/test_non_transferable.rs @@ -40,7 +40,7 @@ fn test_create_non_transferable_mint_and_attempt_transfer() { let initialize_ix = Instruction::new_with_bytes( program_id, &non_transferable::instruction::Initialize {}.data(), - non_transferable::accounts::Initialize { + non_transferable::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -89,7 +89,7 @@ fn test_create_non_transferable_mint_and_attempt_transfer() { ).unwrap(); svm.expire_blockhash(); - // Step 4: Attempt transfer — should fail because mint is NonTransferable + // Step 4: Attempt transfer - should fail because mint is NonTransferable let result = transfer_checked_token_extensions( &mut svm, &source_ata, diff --git a/tokens/token-extensions/non-transferable/native/program/tests/test.rs b/tokens/token-extensions/non-transferable/native/program/tests/test.rs index 27458358..4927b8f4 100644 --- a/tokens/token-extensions/non-transferable/native/program/tests/test.rs +++ b/tokens/token-extensions/non-transferable/native/program/tests/test.rs @@ -25,7 +25,7 @@ fn test_create_non_transferable_token() { include_bytes!("../../tests/fixtures/token_2022_non_transferable_program.so"); svm.add_program(program_id, program_bytes).unwrap(); - // litesvm bundles the SPL Token-2022 program by default. + // litesvm bundles the Token Extensions program by default. let token_program_id = spl_token_2022_interface::id(); let payer = Keypair::new(); @@ -57,7 +57,7 @@ fn test_create_non_transferable_token() { svm.send_transaction(tx).unwrap(); - // The mint should be owned by Token-2022 and carry the NonTransferable + // The mint should be owned by Token Extensions and carry the NonTransferable // extension (it has no fields; presence is what we assert). let mint_account = svm.get_account(&mint.pubkey()).unwrap(); assert_eq!(mint_account.owner, token_program_id); diff --git a/tokens/token-extensions/non-transferable/quasar/README.md b/tokens/token-extensions/non-transferable/quasar/README.md index 87d9c370..87bd8496 100644 --- a/tokens/token-extensions/non-transferable/quasar/README.md +++ b/tokens/token-extensions/non-transferable/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Non-Transferable (Quasar) +# Token Extensions - Non-Transferable (Quasar) Tokens that cannot be transferred. diff --git a/tokens/token-extensions/non-transferable/quasar/src/lib.rs b/tokens/token-extensions/non-transferable/quasar/src/lib.rs index 1e66dba5..4e1e6221 100644 --- a/tokens/token-extensions/non-transferable/quasar/src/lib.rs +++ b/tokens/token-extensions/non-transferable/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("8Bz4wpHaUckiC169Rg5ZfaBHFemp5S8RwTSDTKzhJ9W"); pub struct Token2022Program; impl Id for Token2022Program { @@ -26,13 +26,13 @@ mod quasar_non_transferable { use super::*; #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -42,7 +42,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { // Mint + NonTransferable extension = 170 bytes let mint_size: u64 = 170; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; diff --git a/tokens/token-extensions/permanent-delegate/anchor/Anchor.toml b/tokens/token-extensions/permanent-delegate/anchor/Anchor.toml index 04095b38..3403b668 100644 --- a/tokens/token-extensions/permanent-delegate/anchor/Anchor.toml +++ b/tokens/token-extensions/permanent-delegate/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] permanent_delegate = "A9rxKS84ZoJVyeTfQbCEfxME2vvAM4uwSMjkmhR5XWb1" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/permanent-delegate/anchor/README.md b/tokens/token-extensions/permanent-delegate/anchor/README.md index 750f8175..7f80c076 100644 --- a/tokens/token-extensions/permanent-delegate/anchor/README.md +++ b/tokens/token-extensions/permanent-delegate/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Permanent Delegate (Anchor) +# Token Extensions - Permanent Delegate (Anchor) Keep a permanent delegate with transfer rights over all token accounts for the mint. diff --git a/tokens/token-extensions/permanent-delegate/anchor/migrations/deploy.ts b/tokens/token-extensions/permanent-delegate/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/permanent-delegate/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/instructions/initialize.rs b/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/instructions/initialize.rs index 9f87ad3c..0879e5e5 100644 --- a/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/instructions/initialize.rs +++ b/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/instructions/initialize.rs @@ -12,7 +12,7 @@ use anchor_spl::{ }; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -29,7 +29,7 @@ pub struct Initialize<'info> { } // helper to check mint data, and demonstrate how to read mint extension data within a program -fn check_mint_data(accounts: &mut Initialize) -> Result<()> { +fn check_mint_data(accounts: &mut InitializeAccountConstraints) -> Result<()> { let mint = &accounts.mint_account.to_account_info(); let mint_data = mint.data.borrow(); let mint_with_extension = StateWithExtensions::::unpack(&mint_data)?; @@ -44,7 +44,7 @@ fn check_mint_data(accounts: &mut Initialize) -> Result<()> { Ok(()) } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { check_mint_data(&mut context.accounts)?; Ok(()) } diff --git a/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/lib.rs b/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/lib.rs index 6124a33d..5c2005fa 100644 --- a/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/lib.rs +++ b/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/src/lib.rs @@ -9,7 +9,7 @@ declare_id!("A9rxKS84ZoJVyeTfQbCEfxME2vvAM4uwSMjkmhR5XWb1"); pub mod permanent_delegate { use super::*; - pub fn initialize(context: Context) -> Result<()> { + pub fn initialize(context: Context) -> Result<()> { instructions::initialize::handler(context) } } diff --git a/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/tests/test_permanent_delegate.rs b/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/tests/test_permanent_delegate.rs index 894f3f17..034ac53f 100644 --- a/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/tests/test_permanent_delegate.rs +++ b/tokens/token-extensions/permanent-delegate/anchor/programs/permanent-delegate/tests/test_permanent_delegate.rs @@ -87,7 +87,7 @@ fn test_create_mint_with_permanent_delegate_and_burn() { let initialize_ix = Instruction::new_with_bytes( program_id, &permanent_delegate::instruction::Initialize {}.data(), - permanent_delegate::accounts::Initialize { + permanent_delegate::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, diff --git a/tokens/token-extensions/permanent-delegate/quasar/README.md b/tokens/token-extensions/permanent-delegate/quasar/README.md index 6c518f6b..40774b47 100644 --- a/tokens/token-extensions/permanent-delegate/quasar/README.md +++ b/tokens/token-extensions/permanent-delegate/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Permanent Delegate (Quasar) +# Token Extensions - Permanent Delegate (Quasar) Permanent delegate retains transfer rights. diff --git a/tokens/token-extensions/permanent-delegate/quasar/src/lib.rs b/tokens/token-extensions/permanent-delegate/quasar/src/lib.rs index a57b3b1d..b60702f2 100644 --- a/tokens/token-extensions/permanent-delegate/quasar/src/lib.rs +++ b/tokens/token-extensions/permanent-delegate/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("A9rxKS84ZoJVyeTfQbCEfxME2vvAM4uwSMjkmhR5XWb1"); pub struct Token2022Program; impl Id for Token2022Program { @@ -19,20 +19,20 @@ impl Id for Token2022Program { ]); } -/// Creates a mint with the PermanentDelegate extension — a delegate that +/// Creates a mint with the PermanentDelegate extension - a delegate that /// can transfer or burn any tokens from any account of this mint. #[program] mod quasar_permanent_delegate { use super::*; #[instruction(discriminator = 0)] - pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -42,7 +42,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints) -> Result<(), ProgramError> { // 165 (base) + 1 (account type) + 4 (TLV header) + 32 (PermanentDelegate data) = 202 bytes let mint_size: u64 = 202; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; diff --git a/tokens/token-extensions/transfer-fee/anchor/Anchor.toml b/tokens/token-extensions/transfer-fee/anchor/Anchor.toml index f7e31c87..71da0c08 100644 --- a/tokens/token-extensions/transfer-fee/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-fee/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] transfer_fee = "4evptdGtALCNT8uTxJhbWBRZpBE8w5oNtmgfSyfQu7td" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/transfer-fee/anchor/README.md b/tokens/token-extensions/transfer-fee/anchor/README.md index 7252f581..1e4b0b78 100644 --- a/tokens/token-extensions/transfer-fee/anchor/README.md +++ b/tokens/token-extensions/transfer-fee/anchor/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Transfer Fee (Anchor) +# Token Extensions - Transfer Fee (Anchor) Charge a fee on each transfer configured at the mint level. diff --git a/tokens/token-extensions/transfer-fee/anchor/migrations/deploy.ts b/tokens/token-extensions/transfer-fee/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/transfer-fee/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/harvest.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/harvest.rs index c6cf62e2..870e2947 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/harvest.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/harvest.rs @@ -4,7 +4,7 @@ use anchor_spl::token_interface::{ }; #[derive(Accounts)] -pub struct Harvest<'info> { +pub struct HarvestAccountConstraints<'info> { #[account(mut)] pub mint_account: InterfaceAccount<'info, Mint>, pub token_program: Program<'info, Token2022>, @@ -12,7 +12,7 @@ pub struct Harvest<'info> { // transfer fees are stored directly on the recipient token account and must be "harvested" // "harvesting" transfers fees accumulated on token accounts to the mint account -pub fn process_harvest<'info>(context: Context<'info, Harvest<'info>>) -> Result<()> { +pub fn process_harvest<'info>(context: Context<'info, HarvestAccountConstraints<'info>>) -> Result<()> { // Using remaining accounts to allow for passing in an unknown number of token accounts to harvest from // Check that remaining accounts are token accounts for the mint to harvest to let sources = context diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/initialize.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/initialize.rs index 4dd0b522..1ce45637 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/initialize.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/initialize.rs @@ -20,7 +20,7 @@ use anchor_spl::{ }; #[derive(Accounts)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut)] @@ -33,7 +33,7 @@ pub struct Initialize<'info> { // There is currently not an anchor constraint to automatically initialize the TransferFeeConfig extension // We can manually create and initialize the mint account via CPIs in the instruction handler pub fn handle_process_initialize( - context: Context, + context: Context, transfer_fee_basis_points: u16, maximum_fee: u64, ) -> Result<()> { @@ -92,7 +92,7 @@ pub fn handle_process_initialize( } // helper to demonstrate how to read mint extension data within a program -pub fn handle_check_mint_data(accounts: &Initialize) -> Result<()> { +pub fn handle_check_mint_data(accounts: &InitializeAccountConstraints) -> Result<()> { let mint = &accounts.mint_account.to_account_info(); let mint_data = mint.data.borrow(); let mint_with_extension = StateWithExtensions::::unpack(&mint_data)?; diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/transfer.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/transfer.rs index 4bff1ce6..20846e2e 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/transfer.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/transfer.rs @@ -13,7 +13,7 @@ use anchor_spl::{ }; #[derive(Accounts)] -pub struct Transfer<'info> { +pub struct TransferAccountConstraints<'info> { #[account(mut)] pub sender: Signer<'info>, pub recipient: SystemAccount<'info>, @@ -43,7 +43,7 @@ pub struct Transfer<'info> { // transfer fees are automatically deducted from the transfer amount // recipients receives (transfer amount - fees) // transfer fees are stored directly on the recipient token account and must be "harvested" -pub fn handle_process_transfer(context: Context, amount: u64) -> Result<()> { +pub fn handle_process_transfer(context: Context, amount: u64) -> Result<()> { // read mint account extension data let mint = &context.accounts.mint_account.to_account_info(); let mint_data = mint.data.borrow(); diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/update_fee.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/update_fee.rs index a0f8b81d..da0646bb 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/update_fee.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/update_fee.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{transfer_fee_set, Mint, Token2022, TransferFeeSetTransferFee}; #[derive(Accounts)] -pub struct UpdateFee<'info> { +pub struct UpdateFeeAccountConstraints<'info> { pub authority: Signer<'info>, #[account(mut)] @@ -14,7 +14,7 @@ pub struct UpdateFee<'info> { // This is a safely feature built into the extension // https://github.com/solana-program/token-2022/blob/2d18d97f083627d3f13ce43b16fa4305cbfac4de/program/src/extension/transfer_fee/processor.rs#L92-L109 pub fn handle_process_update_fee( - context: Context, + context: Context, transfer_fee_basis_points: u16, maximum_fee: u64, ) -> Result<()> { diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/withdraw.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/withdraw.rs index f3cbd1b5..ba9fb665 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/withdraw.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/instructions/withdraw.rs @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{ }; #[derive(Accounts)] -pub struct Withdraw<'info> { +pub struct WithdrawAccountConstraints<'info> { pub authority: Signer<'info>, #[account(mut)] @@ -17,7 +17,7 @@ pub struct Withdraw<'info> { // transfer fees "harvested" to the mint account can then be withdraw by the withdraw authority // this transfers fees on the mint account to the specified token account -pub fn handle_process_withdraw(context: Context) -> Result<()> { +pub fn handle_process_withdraw(context: Context) -> Result<()> { withdraw_withheld_tokens_from_mint(CpiContext::new( context.accounts.token_program.key(), WithdrawWithheldTokensFromMint { diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/lib.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/lib.rs index cd27f2ce..81138535 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/lib.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/src/lib.rs @@ -10,27 +10,27 @@ pub mod transfer_fee { use super::*; pub fn initialize( - context: Context, + context: Context, transfer_fee_basis_points: u16, maximum_fee: u64, ) -> Result<()> { handle_process_initialize(context, transfer_fee_basis_points, maximum_fee) } - pub fn transfer(context: Context, amount: u64) -> Result<()> { + pub fn transfer(context: Context, amount: u64) -> Result<()> { handle_process_transfer(context, amount) } - pub fn harvest<'info>(context: Context<'info, Harvest<'info>>) -> Result<()> { + pub fn harvest<'info>(context: Context<'info, HarvestAccountConstraints<'info>>) -> Result<()> { process_harvest(context) } - pub fn withdraw(context: Context) -> Result<()> { + pub fn withdraw(context: Context) -> Result<()> { handle_process_withdraw(context) } pub fn update_fee( - context: Context, + context: Context, transfer_fee_basis_points: u16, maximum_fee: u64, ) -> Result<()> { diff --git a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/tests/test_transfer_fee.rs b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/tests/test_transfer_fee.rs index 5a734aa5..087f1ae6 100644 --- a/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/tests/test_transfer_fee.rs +++ b/tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee/tests/test_transfer_fee.rs @@ -54,7 +54,7 @@ fn test_transfer_fee_full_flow() { maximum_fee: 1, } .data(), - transfer_fee::accounts::Initialize { + transfer_fee::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -87,7 +87,7 @@ fn test_transfer_fee_full_flow() { let transfer_ix = Instruction::new_with_bytes( program_id, &transfer_fee::instruction::Transfer { amount: 100 }.data(), - transfer_fee::accounts::Transfer { + transfer_fee::accounts::TransferAccountConstraints { sender: payer.pubkey(), recipient: recipient.pubkey(), mint_account: mint_keypair.pubkey(), @@ -106,7 +106,7 @@ fn test_transfer_fee_full_flow() { let transfer_ix2 = Instruction::new_with_bytes( program_id, &transfer_fee::instruction::Transfer { amount: 200 }.data(), - transfer_fee::accounts::Transfer { + transfer_fee::accounts::TransferAccountConstraints { sender: payer.pubkey(), recipient: recipient.pubkey(), mint_account: mint_keypair.pubkey(), @@ -126,7 +126,7 @@ fn test_transfer_fee_full_flow() { program_id, &transfer_fee::instruction::Harvest {}.data(), { - let mut metas = transfer_fee::accounts::Harvest { + let mut metas = transfer_fee::accounts::HarvestAccountConstraints { mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, } @@ -142,7 +142,7 @@ fn test_transfer_fee_full_flow() { let withdraw_ix = Instruction::new_with_bytes( program_id, &transfer_fee::instruction::Withdraw {}.data(), - transfer_fee::accounts::Withdraw { + transfer_fee::accounts::WithdrawAccountConstraints { authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_account: sender_ata, @@ -161,7 +161,7 @@ fn test_transfer_fee_full_flow() { maximum_fee: 0, } .data(), - transfer_fee::accounts::UpdateFee { + transfer_fee::accounts::UpdateFeeAccountConstraints { authority: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, diff --git a/tokens/token-extensions/transfer-fee/native/program/tests/test.rs b/tokens/token-extensions/transfer-fee/native/program/tests/test.rs index ff75cf1d..3c5a9262 100644 --- a/tokens/token-extensions/transfer-fee/native/program/tests/test.rs +++ b/tokens/token-extensions/transfer-fee/native/program/tests/test.rs @@ -24,7 +24,7 @@ fn test_create_token_with_transfer_fee() { let program_bytes = include_bytes!("../../tests/fixtures/token_2022_transfer_fees_program.so"); svm.add_program(program_id, program_bytes).unwrap(); - // litesvm bundles the SPL Token-2022 program by default. + // litesvm bundles the Token Extensions program by default. let token_program_id = spl_token_2022_interface::id(); let payer = Keypair::new(); @@ -60,7 +60,7 @@ fn test_create_token_with_transfer_fee() { svm.send_transaction(tx).unwrap(); - // The mint should be owned by Token-2022 and carry the TransferFeeConfig + // The mint should be owned by Token Extensions and carry the TransferFeeConfig // extension. The program initializes a 1% fee, then sets the newer fee to // 10% (1000 bps) with a max fee of 5 tokens. let mint_account = svm.get_account(&mint.pubkey()).unwrap(); diff --git a/tokens/token-extensions/transfer-fee/quasar/README.md b/tokens/token-extensions/transfer-fee/quasar/README.md index 7c51c8c2..5c552f3b 100644 --- a/tokens/token-extensions/transfer-fee/quasar/README.md +++ b/tokens/token-extensions/transfer-fee/quasar/README.md @@ -1,4 +1,4 @@ -# Token Extensions — Transfer Fee (Quasar) +# Token Extensions - Transfer Fee (Quasar) Fee charged on each transfer at the mint. diff --git a/tokens/token-extensions/transfer-fee/quasar/src/lib.rs b/tokens/token-extensions/transfer-fee/quasar/src/lib.rs index 4307fd38..f74923a4 100644 --- a/tokens/token-extensions/transfer-fee/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-fee/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("4evptdGtALCNT8uTxJhbWBRZpBE8w5oNtmgfSyfQu7td"); pub struct Token2022Program; impl Id for Token2022Program { @@ -28,7 +28,7 @@ mod quasar_transfer_fee { /// Create a mint with the TransferFeeConfig extension. #[instruction(discriminator = 0)] pub fn initialize( - ctx: Ctx, + ctx: Ctx, transfer_fee_basis_points: u16, maximum_fee: u64, ) -> Result<(), ProgramError> { @@ -37,14 +37,14 @@ mod quasar_transfer_fee { /// Transfer tokens with fee. #[instruction(discriminator = 1)] - pub fn transfer(ctx: Ctx, amount: u64, fee: u64) -> Result<(), ProgramError> { + pub fn transfer(ctx: Ctx, amount: u64, fee: u64) -> Result<(), ProgramError> { handle_transfer(&mut ctx.accounts, amount, fee) } /// Update the transfer fee (takes effect after 2 epochs). #[instruction(discriminator = 2)] pub fn update_fee( - ctx: Ctx, + ctx: Ctx, transfer_fee_basis_points: u16, maximum_fee: u64, ) -> Result<(), ProgramError> { @@ -53,13 +53,13 @@ mod quasar_transfer_fee { /// Withdraw withheld fees from the mint account. #[instruction(discriminator = 3)] - pub fn withdraw(ctx: Ctx) -> Result<(), ProgramError> { + pub fn withdraw(ctx: Ctx) -> Result<(), ProgramError> { handle_withdraw(&mut ctx.accounts) } } #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -69,7 +69,7 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize, basis_points: u16, max_fee: u64) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints, basis_points: u16, max_fee: u64) -> Result<(), ProgramError> { // 165 (base) + 1 (AccountType) + 4 (TLV header) + 108 (TransferFeeConfig data) = 278 bytes let mint_size: u64 = 278; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; @@ -127,7 +127,7 @@ fn handle_initialize(accounts: &mut Initialize, basis_points: u16, max_fee: u64) } #[derive(Accounts)] -pub struct Transfer { +pub struct TransferAccountConstraints { #[account(mut)] pub sender: Signer, #[account(mut)] @@ -139,7 +139,7 @@ pub struct Transfer { } #[inline(always)] -fn handle_transfer(accounts: &mut Transfer, amount: u64, fee: u64) -> Result<(), ProgramError> { +fn handle_transfer(accounts: &mut TransferAccountConstraints, amount: u64, fee: u64) -> Result<(), ProgramError> { // TransferCheckedWithFee: opcode 37 // Data: [37, amount (u64 LE), decimals (u8), fee (u64 LE)] let mut data = [0u8; 18]; @@ -168,7 +168,7 @@ fn handle_transfer(accounts: &mut Transfer, amount: u64, fee: u64) -> Result<(), } #[derive(Accounts)] -pub struct UpdateFee { +pub struct UpdateFeeAccountConstraints { pub authority: Signer, #[account(mut)] pub mint_account: UncheckedAccount, @@ -176,7 +176,7 @@ pub struct UpdateFee { } #[inline(always)] -fn handle_update_fee(accounts: &mut UpdateFee, basis_points: u16, max_fee: u64) -> Result<(), ProgramError> { +fn handle_update_fee(accounts: &mut UpdateFeeAccountConstraints, basis_points: u16, max_fee: u64) -> Result<(), ProgramError> { // SetTransferFee: opcode 26, sub-opcode 4 // Actually: extension instruction layout is different. // TransferFeeInstruction::SetTransferFee = 4 within type 26 @@ -202,7 +202,7 @@ fn handle_update_fee(accounts: &mut UpdateFee, basis_points: u16, max_fee: u64) } #[derive(Accounts)] -pub struct Withdraw { +pub struct WithdrawAccountConstraints { pub authority: Signer, #[account(mut)] pub mint_account: UncheckedAccount, @@ -212,7 +212,7 @@ pub struct Withdraw { } #[inline(always)] -fn handle_withdraw(accounts: &mut Withdraw) -> Result<(), ProgramError> { +fn handle_withdraw(accounts: &mut WithdrawAccountConstraints) -> Result<(), ProgramError> { // WithdrawWithheldTokensFromMint: opcode 26, sub-opcode 3 let data: [u8; 2] = [26, 3]; diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/Anchor.toml b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/Anchor.toml index a91ae7b8..7823a01c 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] transfer_hook = "1qahDxKHeCLZhbBU2NyMU6vQCQmEUmdeSEBrG5drffK" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/README.md b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/README.md index e6af5c16..4a19404b 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/README.md +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/README.md @@ -80,7 +80,7 @@ pub struct InitializeExtraAccountMetaList<'info> { } ``` -The counter account also has to appear on the `TransferHook` struct — the [program](https://solana.com/docs/terminology#program) needs to know about every account passed in by the runtime: +The counter account also has to appear on the `TransferHook` struct - the [program](https://solana.com/docs/terminology#program) needs to know about every account passed in by the runtime: ```rust #[derive(Accounts)] @@ -126,4 +126,4 @@ const [counterPDA] = PublicKey.findProgramAddressSync( ); ``` -Note: the counter account must exist before a transfer, since the hook reads/writes it. In this example we initialize it alongside the extra-account-metas, so there's only ever one counter — the one for the wallet that initialized the metas. If you want a counter per holder, you'd need to expose an opt-in handler to create it (a "sign up for counter" button in your dapp, for example). +Note: the counter account must exist before a transfer, since the hook reads/writes it. In this example we initialize it alongside the extra-account-metas, so there's only ever one counter - the one for the wallet that initialized the metas. If you want a counter per holder, you'd need to expose an opt-in handler to create it (a "sign up for counter" button in your dapp, for example). diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/migrations/deploy.ts b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs index 23dd3148..34c854ce 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs @@ -10,7 +10,7 @@ use spl_transfer_hook_interface::instruction::ExecuteInstruction; use crate::{handle_extra_account_metas, handle_extra_account_metas_count, CounterAccount}; #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList<'info> { +pub struct InitializeExtraAccountMetaListAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -19,7 +19,7 @@ pub struct InitializeExtraAccountMetaList<'info> { init, seeds = [b"extra-account-metas", mint.key().as_ref()], bump, - // size_of returns Result with spl's ProgramError — unwrap is safe for known-good input + // size_of returns Result with spl's ProgramError - unwrap is safe for known-good input space = ExtraAccountMetaList::size_of( handle_extra_account_metas_count() ).unwrap(), @@ -34,12 +34,12 @@ pub struct InitializeExtraAccountMetaList<'info> { pub system_program: Program<'info, System>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { let extra_account_metas = handle_extra_account_metas()?; // initialize ExtraAccountMetaList account with extra accounts // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types ExtraAccountMetaList::init::( &mut context.accounts.extra_account_meta_list.try_borrow_mut_data()?, &extra_account_metas, diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs index 4feabf57..62aafc31 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs @@ -8,7 +8,7 @@ use crate::{check_is_transferring, CounterAccount, TransferError}; // Remaining accounts are the extra accounts required from the ExtraAccountMetaList account // These accounts are provided via CPI to this program from the token2022 program #[derive(Accounts)] -pub struct TransferHook<'info> { +pub struct TransferHookAccountConstraints<'info> { #[account(token::mint = mint, token::authority = owner)] pub source_token: InterfaceAccount<'info, TokenAccount>, pub mint: InterfaceAccount<'info, Mint>, @@ -23,7 +23,7 @@ pub struct TransferHook<'info> { pub counter_account: Account<'info, CounterAccount>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { +pub fn handler(context: Context, amount: u64) -> Result<()> { // Fail this instruction if it is not called from within a transfer hook check_is_transferring(&context)?; diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs index 0b905b54..c8352560 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs @@ -33,22 +33,22 @@ pub mod transfer_hook { #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)] pub fn initialize_extra_account_meta_list( - context: Context, + context: Context, ) -> Result<()> { instructions::initialize_extra_account_meta_list::handler(context) } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { + pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { instructions::transfer_hook::handler(context, amount) } } -pub fn check_is_transferring(context: &Context) -> Result<()> { +pub fn check_is_transferring(context: &Context) -> Result<()> { let source_token_info = context.accounts.source_token.to_account_info(); let mut account_data_ref: RefMut<&mut [u8]> = source_token_info.try_borrow_mut_data()?; // .map_err() needed because spl-token-2022 uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types let mut account = PodStateWithExtensionsMut::::unpack(*account_data_ref) .map_err(|_| ProgramError::InvalidAccountData)?; let account_extension = account.get_extension_mut::() @@ -64,7 +64,7 @@ pub fn check_is_transferring(context: &Context) -> Result<()> { // Define extra account metas to store on extra_account_meta_list account pub fn handle_extra_account_metas() -> Result> { // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types Ok(vec![ExtraAccountMeta::new_with_seeds( &[ Seed::Literal { diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/tests/test_transfer_hook.rs b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/tests/test_transfer_hook.rs index 4bcf15b1..4b083e01 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/tests/test_transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/tests/test_transfer_hook.rs @@ -95,7 +95,7 @@ fn test_transfer_hook_account_data_as_seed() { let init_extra_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::InitializeExtraAccountMetaList {}.data(), - transfer_hook::accounts::InitializeExtraAccountMetaList { + transfer_hook::accounts::InitializeExtraAccountMetaListAccountConstraints { payer: payer.pubkey(), extra_account_meta_list, mint, @@ -132,11 +132,11 @@ fn test_transfer_hook_account_data_as_seed() { ).unwrap(); svm.expire_blockhash(); - // Step 5: Try calling transfer_hook directly (should fail — not transferring) + // Step 5: Try calling transfer_hook directly (should fail - not transferring) let direct_hook_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::TransferHook { amount: 1 }.data(), - transfer_hook::accounts::TransferHook { + transfer_hook::accounts::TransferHookAccountConstraints { source_token: source_ata, mint, destination_token: dest_ata, diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/README.md b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/README.md index 8e4fdb29..870c0c45 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Account Data as Seed (Quasar) +# Transfer Hook - Account Data as Seed (Quasar) Derive extra accounts from token account data in a transfer hook. diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/lib.rs index ade35702..d9f22485 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("1qahDxKHeCLZhbBU2NyMU6vQCQmEUmdeSEBrG5drffK"); /// SPL Transfer Hook Interface discriminators (SHA-256 prefix). #[allow(dead_code)] @@ -17,7 +17,7 @@ const EXECUTE_DISCRIMINATOR: [u8; 8] = [105, 37, 101, 197, 75, 251, 102, 26]; /// Transfer hook that uses account data as a PDA seed. The counter PDA is /// seeded by ["counter", owner_pubkey] where the owner pubkey is read from -/// the source token account data at runtime by the Token-2022 program. +/// the source token account data at runtime by the Token Extensions program. #[program] mod quasar_transfer_hook_account_data_as_seed { use super::*; @@ -28,15 +28,15 @@ mod quasar_transfer_hook_account_data_as_seed { /// Discriminator = sha256("spl-transfer-hook-interface:initialize-extra-account-metas")[:8] #[instruction(discriminator = [43, 34, 13, 49, 167, 88, 235, 235])] pub fn initialize_extra_account_meta_list( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { handle_initialize_extra_account_meta_list(&mut ctx.accounts) } - /// Transfer hook handler — increments a per-owner counter on each transfer. + /// Transfer hook handler - increments a per-owner counter on each transfer. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { + pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { handle_transfer_hook(&mut ctx.accounts) } } @@ -46,7 +46,7 @@ mod quasar_transfer_hook_account_data_as_seed { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList { +pub struct InitializeExtraAccountMetaListAccountConstraints { #[account(mut)] pub payer: Signer, /// ExtraAccountMetaList PDA: ["extra-account-metas", mint] @@ -60,7 +60,7 @@ pub struct InitializeExtraAccountMetaList { } #[inline(always)] -pub fn handle_initialize_extra_account_meta_list(accounts: &mut InitializeExtraAccountMetaList) -> Result<(), ProgramError> { +pub fn handle_initialize_extra_account_meta_list(accounts: &mut InitializeExtraAccountMetaListAccountConstraints) -> Result<(), ProgramError> { // ExtraAccountMetaList with 1 extra account. // ExtraAccountMeta for a PDA with seeds [Literal("counter"), AccountData(0, 32, 32)]: // The AccountData seed resolves the owner pubkey from account_index=0 @@ -168,19 +168,19 @@ pub fn handle_initialize_extra_account_meta_list(accounts: &mut InitializeExtraA // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct TransferHook { +pub struct TransferHookAccountConstraints { pub source_token: UncheckedAccount, pub mint: UncheckedAccount, pub destination_token: UncheckedAccount, pub owner: UncheckedAccount, pub extra_account_meta_list: UncheckedAccount, - /// Counter PDA resolved by Token-2022 using account data seeds + /// Counter PDA resolved by Token Extensions using account data seeds #[account(mut)] pub counter_account: UncheckedAccount, } #[inline(always)] -pub fn handle_transfer_hook(accounts: &mut TransferHook) -> Result<(), ProgramError> { +pub fn handle_transfer_hook(accounts: &mut TransferHookAccountConstraints) -> Result<(), ProgramError> { let view = unsafe { &mut *(&mut accounts.counter_account as *mut UncheckedAccount as *mut AccountView) diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/README.md b/tokens/token-extensions/transfer-hook/allow-block-list-token/README.md index d7888ab1..ac83f8aa 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/README.md +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/README.md @@ -2,7 +2,7 @@ A [Token Extensions](https://solana.com/docs/terminology#token-extensions-program) example that gates transfers through an allow/block list managed by a separate authority. The list is consumed by a transfer hook. -One list authority can manage lists for many [mints](https://solana.com/docs/terminology#token-mint) — useful when an issuer wants a third-party-managed list or wants to share a single list across a set of assets. +One list authority can manage lists for many [mints](https://solana.com/docs/terminology#token-mint) - useful when an issuer wants a third-party-managed list or wants to share a single list across a set of assets. ## Features diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/Anchor.toml b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/Anchor.toml index ee3b3cfa..daa8bea4 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] abl-token = "EYBRvArz4kb5YLtzjD4TW6DbWhS8qjcMYqBU4wHLW3qj" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/README.md b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/README.md index 46df51ee..9504a5d1 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/README.md +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Allow/Block List (Anchor) +# Transfer Hook - Allow/Block List (Anchor) Restrict transfers using an onchain allow/block list enforced by a transfer hook program. diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs index 36c3598c..750c21c5 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs @@ -10,7 +10,7 @@ use spl_transfer_hook_interface::instruction::ExecuteInstruction; use crate::{get_extra_account_metas, get_meta_list_size, META_LIST_ACCOUNT_SEED}; #[derive(Accounts)] -pub struct AttachToMint<'info> { +pub struct AttachToMintAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -35,7 +35,7 @@ pub struct AttachToMint<'info> { pub token_program: Program<'info, Token2022>, } -impl AttachToMint<'_> { +impl AttachToMintAccountConstraints<'_> { pub fn attach_to_mint(&mut self) -> Result<()> { let tx_hook_accs = TransferHookUpdate { token_program_id: self.token_program.to_account_info(), diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs index f0071a45..81fde182 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs @@ -16,7 +16,7 @@ use anchor_spl::{ use crate::Mode; #[derive(Accounts)] -pub struct ChangeMode<'info> { +pub struct ChangeModeAccountConstraints<'info> { #[account(mut)] pub authority: Signer<'info>, @@ -37,7 +37,7 @@ pub struct ChangeModeArgs { pub threshold: u64, } -impl ChangeMode<'_> { +impl ChangeModeAccountConstraints<'_> { pub fn change_mode(&mut self, args: ChangeModeArgs) -> Result<()> { let cpi_accounts = TokenMetadataUpdateField { metadata: self.mint.to_account_info(), diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs index 00ce77b9..67142441 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs @@ -2,7 +2,7 @@ use crate::{Config, CONFIG_SEED}; use anchor_lang::prelude::*; #[derive(Accounts)] -pub struct InitConfig<'info> { +pub struct InitConfigAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -18,7 +18,7 @@ pub struct InitConfig<'info> { pub system_program: Program<'info, System>, } -impl InitConfig<'_> { +impl InitConfigAccountConstraints<'_> { pub fn init_config(&mut self, config_bump: u8) -> Result<()> { self.config.set_inner(Config { authority: self.payer.key(), diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs index 7c0e0c81..cfbfb90c 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs @@ -16,7 +16,7 @@ use crate::{get_extra_account_metas, get_meta_list_size, Mode, META_LIST_ACCOUNT #[derive(Accounts)] #[instruction(args: InitMintArgs)] -pub struct InitMint<'info> { +pub struct InitMintAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -50,7 +50,7 @@ pub struct InitMint<'info> { pub token_program: Program<'info, Token2022>, } -impl InitMint<'_> { +impl InitMintAccountConstraints<'_> { pub fn init_mint(&mut self, args: InitMintArgs) -> Result<()> { let cpi_accounts = TokenMetadataInitialize { program_id: self.token_program.to_account_info(), diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs index d5809a57..f1eb1b95 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; use crate::{ABWallet, Config, AB_WALLET_SEED, CONFIG_SEED}; #[derive(Accounts)] -pub struct InitWallet<'info> { +pub struct InitWalletAccountConstraints<'info> { #[account(mut)] pub authority: Signer<'info>, @@ -28,7 +28,7 @@ pub struct InitWallet<'info> { pub system_program: Program<'info, System>, } -impl InitWallet<'_> { +impl InitWalletAccountConstraints<'_> { pub fn init_wallet(&mut self, args: InitWalletArgs, bump: u8) -> Result<()> { let ab_wallet = &mut self.ab_wallet; ab_wallet.wallet = self.wallet.key(); diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs index a7c23a94..a505f085 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; use crate::{ABWallet, Config}; #[derive(Accounts)] -pub struct RemoveWallet<'info> { +pub struct RemoveWalletAccountConstraints<'info> { #[account(mut)] pub authority: Signer<'info>, @@ -23,7 +23,7 @@ pub struct RemoveWallet<'info> { pub system_program: Program<'info, System>, } -impl RemoveWallet<'_> { +impl RemoveWalletAccountConstraints<'_> { pub fn remove_wallet(&mut self) -> Result<()> { Ok(()) } diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs index 01e807c4..0c3f6291 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs @@ -12,7 +12,7 @@ use anchor_spl::{ use crate::{ABListError, ABWallet, Mode}; #[derive(Accounts)] -pub struct TxHook<'info> { +pub struct TxHookAccountConstraints<'info> { /// CHECK: pub source_token_account: UncheckedAccount<'info>, /// CHECK: @@ -27,7 +27,7 @@ pub struct TxHook<'info> { pub ab_wallet: UncheckedAccount<'info>, } -impl TxHook<'_> { +impl TxHookAccountConstraints<'_> { pub fn tx_hook(&self, amount: u64) -> Result<()> { let mint_info = self.mint.to_account_info(); let mint_data = mint_info.data.borrow(); diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs index 81987e14..517ef28d 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs @@ -20,33 +20,33 @@ pub mod abl_token { use super::*; - pub fn init_mint(context: Context, args: InitMintArgs) -> Result<()> { + pub fn init_mint(context: Context, args: InitMintArgs) -> Result<()> { context.accounts.init_mint(args) } - pub fn init_config(context: Context) -> Result<()> { + pub fn init_config(context: Context) -> Result<()> { context.accounts.init_config(context.bumps.config) } - pub fn attach_to_mint(context: Context) -> Result<()> { + pub fn attach_to_mint(context: Context) -> Result<()> { context.accounts.attach_to_mint() } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn tx_hook(context: Context, amount: u64) -> Result<()> { + pub fn tx_hook(context: Context, amount: u64) -> Result<()> { context.accounts.tx_hook(amount) } - pub fn init_wallet(context: Context, args: InitWalletArgs) -> Result<()> { + pub fn init_wallet(context: Context, args: InitWalletArgs) -> Result<()> { let bump = context.bumps.ab_wallet; context.accounts.init_wallet(args, bump) } - pub fn remove_wallet(context: Context) -> Result<()> { + pub fn remove_wallet(context: Context) -> Result<()> { context.accounts.remove_wallet() } - pub fn change_mode(context: Context, args: ChangeModeArgs) -> Result<()> { + pub fn change_mode(context: Context, args: ChangeModeArgs) -> Result<()> { context.accounts.change_mode(args) } } diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test_abl_token.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test_abl_token.rs index 286d3060..ccc5712c 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test_abl_token.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test_abl_token.rs @@ -44,7 +44,7 @@ fn test_init_config_and_init_mint() { let init_config_ix = Instruction::new_with_bytes( program_id, &abl_token::instruction::InitConfig {}.data(), - abl_token::accounts::InitConfig { + abl_token::accounts::InitConfigAccountConstraints { payer: payer.pubkey(), config: config_pda, system_program: system_program::id(), @@ -73,7 +73,7 @@ fn test_init_config_and_init_mint() { args: init_mint_args, } .data(), - abl_token::accounts::InitMint { + abl_token::accounts::InitMintAccountConstraints { payer: payer.pubkey(), mint: mint_keypair.pubkey(), extra_metas_account: extra_account_meta_list, diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/tests-rs/test.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/tests-rs/test.rs index 17194a6f..0f9ecc47 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/tests-rs/test.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/tests-rs/test.rs @@ -5,7 +5,7 @@ // This will resolve when anchor-lang upgrades to solana 3.x (likely anchor 0.33+). use { - abl_token::{accounts::InitConfig, accounts::InitMint, instructions::InitMintArgs, Mode}, + abl_token::{accounts::InitConfigAccountConstraints, accounts::InitMintAccountConstraints, instructions::InitMintArgs, Mode}, anchor_lang::InstructionData, anchor_lang::ToAccountMetas, litesvm::LiteSVM, @@ -54,7 +54,7 @@ fn test() { let init_cfg_ix = abl_token::instruction::InitConfig {}; - let init_cfg_accounts = InitConfig { + let init_cfg_accounts = InitConfigAccountConstraints { payer: admin_pk, config: config, system_program: SYSTEM_PROGRAM_ID, @@ -88,7 +88,7 @@ fn test() { let data = init_mint_ix.data(); - let init_mint_accounts = InitMint { + let init_mint_accounts = InitMintAccountConstraints { payer: admin_pk, mint: mint_pk, extra_metas_account: meta_list, diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Cargo.toml index 545ce549..7b9b29b9 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Cargo.toml @@ -13,7 +13,7 @@ check-cfg = ['cfg(target_os, values("solana"))'] crate-type = ["cdylib", "lib"] [features] -# Removed `alloc` feature — the upstream `quasar-lang` master no longer +# Removed `alloc` feature - the upstream `quasar-lang` master no longer # exposes it, and nothing in this crate depends on alloc. client = [] debug = [] diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/README.md b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/README.md index 3c829adb..6d4e0d5d 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Allow/Block List (Quasar) +# Transfer Hook - Allow/Block List (Quasar) Allow/block list enforced by a transfer hook program. diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/constants.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/constants.rs index 4874f49c..a8eacdf0 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/constants.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/constants.rs @@ -10,5 +10,5 @@ pub const MAX_NAME: usize = 32; pub const MAX_SYMBOL: usize = 10; pub const MAX_URI: usize = 128; -/// Maximum buffer size for Token-2022 metadata CPI instructions. +/// Maximum buffer size for Token Extensions metadata CPI instructions. pub const MAX_META_IX: usize = 512; diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/attach_to_mint.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/attach_to_mint.rs index b65b139a..b23cd9a3 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/attach_to_mint.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/attach_to_mint.rs @@ -6,7 +6,7 @@ use crate::constants::*; use crate::instructions::init_mint::Token2022; #[derive(Accounts)] -pub struct AttachToMint { +pub struct AttachToMintAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -18,7 +18,7 @@ pub struct AttachToMint { } #[inline(always)] -pub fn handle_attach_to_mint(accounts: &mut AttachToMint) -> Result<(), ProgramError> { +pub fn handle_attach_to_mint(accounts: &mut AttachToMintAccountConstraints) -> Result<(), ProgramError> { let mint_key = accounts.mint.to_account_view().address(); let payer_key = accounts.payer.to_account_view().address(); let token_prog = accounts.token_program.to_account_view().address(); diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/change_mode.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/change_mode.rs index 01b980a3..09b5dae5 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/change_mode.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/change_mode.rs @@ -7,7 +7,7 @@ use crate::instructions::init_mint::Token2022; use crate::state::mode_to_metadata_value; #[derive(Accounts)] -pub struct ChangeMode { +pub struct ChangeModeAccountConstraints { #[account(mut)] pub authority: Signer, #[account(mut)] @@ -17,7 +17,7 @@ pub struct ChangeMode { } #[inline(always)] -pub fn handle_change_mode(accounts: &mut ChangeMode, mode: u8, threshold: u64) -> Result<(), ProgramError> { +pub fn handle_change_mode(accounts: &mut ChangeModeAccountConstraints, mode: u8, threshold: u64) -> Result<(), ProgramError> { let mode_value = mode_to_metadata_value(mode); let token_prog = accounts.token_program.to_account_view().address(); let mint_key = accounts.mint.to_account_view().address(); @@ -65,7 +65,7 @@ fn emit_update_field( token_prog: &Address, mint_key: &Address, auth_key: &Address, - ctx: &ChangeMode, + ctx: &ChangeModeAccountConstraints, key: &[u8], value: &[u8], ) -> Result<(), ProgramError> { @@ -97,7 +97,7 @@ fn emit_update_field( } /// Check if the mint's metadata already contains a "threshold" key. -fn has_threshold_in_metadata(ctx: &ChangeMode) -> Result { +fn has_threshold_in_metadata(ctx: &ChangeModeAccountConstraints) -> Result { let mint_view = ctx.mint.to_account_view(); let data = mint_view.try_borrow()?; @@ -118,7 +118,7 @@ fn has_threshold_in_metadata(ctx: &ChangeMode) -> Result { } if ext_type == 18 { - // TokenMetadata — parse additional_metadata + // TokenMetadata - parse additional_metadata let md = &data[pos..pos + ext_len]; let mut mpos = 64; // skip update_authority + mint diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_config.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_config.rs index 32b730a9..bf2e981b 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_config.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_config.rs @@ -6,7 +6,7 @@ use crate::constants::CONFIG_SEED; use crate::state::{write_config, CONFIG_SIZE}; #[derive(Accounts)] -pub struct InitConfig { +pub struct InitConfigAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -15,7 +15,7 @@ pub struct InitConfig { } #[inline(always)] -pub fn handle_init_config(accounts: &mut InitConfig) -> Result<(), ProgramError> { +pub fn handle_init_config(accounts: &mut InitConfigAccountConstraints) -> Result<(), ProgramError> { let (config_pda, bump) = Address::find_program_address(&[CONFIG_SEED], &crate::ID); if accounts.config.to_account_view().address() != &config_pda { diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_mint.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_mint.rs index ae5b2f38..d46e79f1 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_mint.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_mint.rs @@ -15,7 +15,7 @@ impl Id for Token2022 { } #[derive(Accounts)] -pub struct InitMint { +pub struct InitMintAccountConstraints { #[account(mut)] pub payer: Signer, /// The mint account (must also be a signer for create_account). @@ -30,7 +30,7 @@ pub struct InitMint { #[inline(always)] pub fn handle_init_mint( - accounts: &mut InitMint, decimals: u8, + accounts: &mut InitMintAccountConstraints, decimals: u8, freeze_authority: &Address, permanent_delegate: &Address, transfer_hook_authority: &Address, @@ -188,10 +188,10 @@ pub fn handle_init_mint( Ok(()) } -/// Emit a Token-2022 TokenMetadataUpdateField CPI. +/// Emit a Token Extensions TokenMetadataUpdateField CPI. /// Opcode 44, sub-opcode 1, followed by Field::Key (discriminator 2, then borsh /// string for key, then borsh string for value). -fn emit_update_field_cpi(ctx: &InitMint, key: &[u8], value: &[u8]) -> Result<(), ProgramError> { +fn emit_update_field_cpi(ctx: &InitMintAccountConstraints, key: &[u8], value: &[u8]) -> Result<(), ProgramError> { let token_prog = ctx.token_program.to_account_view().address(); let mint_key = ctx.mint.to_account_view().address(); let payer_key = ctx.payer.to_account_view().address(); @@ -225,7 +225,7 @@ fn emit_update_field_cpi(ctx: &InitMint, key: &[u8], value: &[u8]) -> Result<(), /// Top up the mint account if its balance is below the rent minimum for its /// current data size. -fn top_up_rent(ctx: &InitMint) -> Result<(), ProgramError> { +fn top_up_rent(ctx: &InitMintAccountConstraints) -> Result<(), ProgramError> { let mint_view = ctx.mint.to_account_view(); let data_len = mint_view.data_len(); let min_balance = Rent::get()?.try_minimum_balance(data_len)?; @@ -242,7 +242,7 @@ fn top_up_rent(ctx: &InitMint) -> Result<(), ProgramError> { /// Create the ExtraAccountMetaList PDA and populate it with the ABWallet /// extra account meta (PDA seeded by [AB_WALLET_SEED, AccountData(2, 32, 32)]). -fn init_extra_metas(ctx: &mut InitMint) -> Result<(), ProgramError> { +fn init_extra_metas(ctx: &mut InitMintAccountConstraints) -> Result<(), ProgramError> { let mint_key = ctx.mint.to_account_view().address(); // Meta list with 1 extra account = 51 bytes diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_wallet.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_wallet.rs index 48b56d9e..830f2ea8 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_wallet.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/init_wallet.rs @@ -7,7 +7,7 @@ use crate::errors; use crate::state::{read_config_authority, write_ab_wallet, AB_WALLET_SIZE, CONFIG_SIZE}; #[derive(Accounts)] -pub struct InitWallet { +pub struct InitWalletAccountConstraints { #[account(mut)] pub authority: Signer, pub config: UncheckedAccount, @@ -18,7 +18,7 @@ pub struct InitWallet { } #[inline(always)] -pub fn handle_init_wallet(accounts: &mut InitWallet, allowed: bool) -> Result<(), ProgramError> { +pub fn handle_init_wallet(accounts: &mut InitWalletAccountConstraints, allowed: bool) -> Result<(), ProgramError> { // Verify config PDA let (config_pda, _) = Address::find_program_address(&[CONFIG_SEED], &crate::ID); if accounts.config.to_account_view().address() != &config_pda { diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/remove_wallet.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/remove_wallet.rs index a9d31f87..9a14fa79 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/remove_wallet.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/remove_wallet.rs @@ -5,7 +5,7 @@ use crate::errors; use crate::state::{read_config_authority, CONFIG_SIZE}; #[derive(Accounts)] -pub struct RemoveWallet { +pub struct RemoveWalletAccountConstraints { #[account(mut)] pub authority: Signer, pub config: UncheckedAccount, @@ -14,7 +14,7 @@ pub struct RemoveWallet { } #[inline(always)] -pub fn handle_remove_wallet(accounts: &mut RemoveWallet) -> Result<(), ProgramError> { +pub fn handle_remove_wallet(accounts: &mut RemoveWalletAccountConstraints) -> Result<(), ProgramError> { // Verify config PDA let (config_pda, _) = Address::find_program_address(&[CONFIG_SEED], &crate::ID); if accounts.config.to_account_view().address() != &config_pda { diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/tx_hook.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/tx_hook.rs index 4a5362b4..b2cc6c8a 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/tx_hook.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/instructions/tx_hook.rs @@ -3,7 +3,7 @@ use quasar_lang::prelude::*; use crate::errors; use crate::state::{read_wallet_allowed, MODE_ALLOW, MODE_BLOCK, MODE_MIXED, AB_WALLET_SIZE}; -/// Transfer hook handler. Called by Token-2022 during transfers. +/// Transfer hook handler. Called by Token Extensions during transfers. /// /// Account layout (fixed by the SPL transfer hook interface): /// [0] source_token_account @@ -11,9 +11,9 @@ use crate::state::{read_wallet_allowed, MODE_ALLOW, MODE_BLOCK, MODE_MIXED, AB_W /// [2] destination_token_account /// [3] owner_delegate /// [4] extra_account_meta_list -/// [5] ab_wallet — resolved from extra account metas (PDA for destination owner) +/// [5] ab_wallet - resolved from extra account metas (PDA for destination owner) #[derive(Accounts)] -pub struct TxHook { +pub struct TxHookAccountConstraints { pub source_token_account: UncheckedAccount, pub mint: UncheckedAccount, pub destination_token_account: UncheckedAccount, @@ -23,7 +23,7 @@ pub struct TxHook { } #[inline(always)] -pub fn handle_tx_hook(accounts: &mut TxHook, amount: u64) -> Result<(), ProgramError> { +pub fn handle_tx_hook(accounts: &mut TxHookAccountConstraints, amount: u64) -> Result<(), ProgramError> { let mint_view = accounts.mint.to_account_view(); let mint_data = mint_view.try_borrow()?; @@ -51,7 +51,7 @@ pub fn handle_tx_hook(accounts: &mut TxHook, amount: u64) -> Result<(), ProgramE } } -fn decode_wallet_mode(accounts: &TxHook) -> Result { +fn decode_wallet_mode(accounts: &TxHookAccountConstraints) -> Result { let wallet_view = accounts.ab_wallet.to_account_view(); if wallet_view.data_len() == 0 { return Ok(DecodedWalletMode::None); @@ -82,11 +82,11 @@ enum DecodedWalletMode { None, } -/// Parse Token-2022 mint account data to extract the mode from embedded +/// Parse Token Extensions mint account data to extract the mode from embedded /// metadata. The metadata is stored as a TLV extension within the mint /// account. /// -/// Token-2022 mint layout: +/// Token Extensions mint layout: /// [0..82] base Mint state /// [82..164] padding (copy of base) /// [164] AccountType (2 = Mint) diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/lib.rs index 0ad2df70..363da21d 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/lib.rs @@ -15,14 +15,14 @@ mod tests; declare_id!("3ku1ZEGvBEEfhaYsAzBZuecTPEa58ZRhoVqHVGpGxVGi"); -/// Allow/Block List Token — a transfer hook program that enforces allow/block -/// lists on Token-2022 transfers using per-wallet PDA entries and mint +/// Allow/Block List Token - a transfer hook program that enforces allow/block +/// lists on Token Extensions transfers using per-wallet PDA entries and mint /// metadata to control modes (Allow, Block, Mixed/Threshold). #[program] mod quasar_abl_token { use super::*; - /// Create a Token-2022 mint with transfer hook, permanent delegate, + /// Create a Token Extensions mint with transfer hook, permanent delegate, /// metadata pointer, and embedded metadata (including AB mode). /// Also initialises the ExtraAccountMetaList PDA. /// @@ -38,7 +38,7 @@ mod quasar_abl_token { /// uri: [u8; 128], uri_len: u8 #[instruction(discriminator = [1, 0, 0, 0, 0, 0, 0, 0])] pub fn init_mint( - ctx: Ctx, + ctx: Ctx, decimals: u8, freeze_authority: [u8; 32], permanent_delegate: [u8; 32], @@ -74,40 +74,40 @@ mod quasar_abl_token { /// Create the Config PDA with the payer as authority. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 1])] - pub fn init_config(ctx: Ctx) -> Result<(), ProgramError> { + pub fn init_config(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_init_config(&mut ctx.accounts) } /// Attach the transfer hook to an existing mint (sets the hook program_id /// and creates the ExtraAccountMetaList PDA). #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 2])] - pub fn attach_to_mint(ctx: Ctx) -> Result<(), ProgramError> { + pub fn attach_to_mint(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_attach_to_mint(&mut ctx.accounts) } - /// SPL Transfer Hook execute handler. Called by Token-2022 during + /// SPL Transfer Hook execute handler. Called by Token Extensions during /// transfers to enforce allow/block/threshold rules. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn tx_hook(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn tx_hook(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { instructions::handle_tx_hook(&mut ctx.accounts, amount) } /// Create a per-wallet allow/block entry. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 4])] - pub fn init_wallet(ctx: Ctx, allowed: bool) -> Result<(), ProgramError> { + pub fn init_wallet(ctx: Ctx, allowed: bool) -> Result<(), ProgramError> { instructions::handle_init_wallet(&mut ctx.accounts, allowed) } /// Remove a wallet entry, closing the PDA account. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 5])] - pub fn remove_wallet(ctx: Ctx) -> Result<(), ProgramError> { + pub fn remove_wallet(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_remove_wallet(&mut ctx.accounts) } /// Change the allow/block mode on the mint's metadata. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 6])] - pub fn change_mode(ctx: Ctx, mode: u8, threshold: u64) -> Result<(), ProgramError> { + pub fn change_mode(ctx: Ctx, mode: u8, threshold: u64) -> Result<(), ProgramError> { instructions::handle_change_mode(&mut ctx.accounts, mode, threshold) } } diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/state.rs b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/state.rs index 83c1889d..a2f65e8c 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/state.rs +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/src/state.rs @@ -8,7 +8,7 @@ pub const AB_WALLET_SIZE: u64 = 33; /// Total = 33 bytes. pub const CONFIG_SIZE: u64 = 33; -/// Mode discriminator values stored in Token-2022 metadata. +/// Mode discriminator values stored in Token Extensions metadata. pub const MODE_ALLOW: &[u8] = b"Allow"; pub const MODE_BLOCK: &[u8] = b"Block"; pub const MODE_MIXED: &[u8] = b"Mixed"; diff --git a/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/Cargo.toml b/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/Cargo.toml index d621b754..0d7907bf 100644 --- a/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["lib", "cdylib"] name = "block_list" -# pinocchio 0.9.3 — 0.8.x's `nostd_panic_handler!` macro emits `#[no_mangle]` +# pinocchio 0.9.3 - 0.8.x's `nostd_panic_handler!` macro emits `#[no_mangle]` # on a lang item, which rustc 1.89 (current platform-tools v1.52) rejects. # 0.9 fixes that without breaking the surface used here. 0.10 renamed # `Pubkey` → `Address`, which would require porting the rest of the code. diff --git a/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/state/config.rs b/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/state/config.rs index 8a71faa3..16daa969 100644 --- a/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/state/config.rs +++ b/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/state/config.rs @@ -3,7 +3,7 @@ use pinocchio::pubkey::Pubkey; use super::{Discriminator, Transmutable}; -// `#[repr(C, packed)]` keeps the on-chain layout exactly 41 bytes wide. +// `#[repr(C, packed)]` keeps the onchain layout exactly 41 bytes wide. // With plain `#[repr(C)]` the u64 field gets 7 bytes of alignment padding // inserted after the 33-byte (u8 + Pubkey) prefix, making the struct 48 bytes // while `LEN = 41`. The program would then read 7 bytes past the end of the diff --git a/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/token_extensions_utils.rs b/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/token_extensions_utils.rs index bb55d39d..f30b2c4a 100644 --- a/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/token_extensions_utils.rs +++ b/tokens/token-extensions/transfer-hook/block-list/pinocchio/program/src/token_extensions_utils.rs @@ -78,7 +78,7 @@ pub fn is_token_extensions_mint(mint: &AccountInfo) -> bool { // Order of checks matters: read the type byte ONLY after we have proven // the buffer is long enough. The previous implementation indexed first // and length-checked second, which faulted (out-of-bounds) on any account - // shorter than 166 bytes — every mint that isn't a Token Extensions mint hits this. + // shorter than 166 bytes - every mint that isn't a Token Extensions mint hits this. if !mint.is_owned_by(&TOKEN_EXTENSIONS_PROGRAM_ID) { return false; } diff --git a/tokens/token-extensions/transfer-hook/block-list/pinocchio/tests/test.spec.ts b/tokens/token-extensions/transfer-hook/block-list/pinocchio/tests/test.spec.ts index df297f13..cf0fc13b 100644 --- a/tokens/token-extensions/transfer-hook/block-list/pinocchio/tests/test.spec.ts +++ b/tokens/token-extensions/transfer-hook/block-list/pinocchio/tests/test.spec.ts @@ -24,7 +24,7 @@ import { assert } from "chai"; import { FailedTransactionMetadata, LiteSVM, type TransactionMetadata } from "litesvm"; import { before, describe, it } from "mocha"; -// Program ID baked into the on-chain program (`declare_id!` in program/src/lib.rs). +// Program ID baked into the onchain program (`declare_id!` in program/src/lib.rs). const BLOCK_LIST_PROGRAM_ID = new PublicKey("BLoCKLSG2qMQ9YxEyrrKKAQzthvW4Lu8Eyv74axF6mf"); const PROGRAM_SO_PATH = path.resolve(__dirname, "fixtures/block_list.so"); @@ -398,7 +398,7 @@ describe("block-list pinocchio transfer-hook", () => { // Re-issue the transfer with the (now-closed) wallet_block PDA still in // the extra metas. After unblock the wallet_block account no longer - // exists on-chain (lamports drained, data zeroed), so `data_is_empty()` is + // exists onchain (lamports drained, data zeroed), so `data_is_empty()` is // true in the hook and the transfer is no longer blocked. const transferTx = new Transaction().add( ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), diff --git a/tokens/token-extensions/transfer-hook/block-list/readme.md b/tokens/token-extensions/transfer-hook/block-list/readme.md index 2106810e..cc0686cb 100644 --- a/tokens/token-extensions/transfer-hook/block-list/readme.md +++ b/tokens/token-extensions/transfer-hook/block-list/readme.md @@ -2,15 +2,15 @@ A block-list [program](https://solana.com/docs/terminology#program) that implements the [Token Extensions](https://solana.com/docs/terminology#token-extensions-program) transfer-hook `execute` [instruction](https://solana.com/docs/terminology#instruction). -A central authority maintains a block list — a collection of blocked wallets. Token issuers (transfer-hook extension authorities) can wire this program in as their hook and choose an operation mode: filter the source wallet only, or both source and destination. +A central authority maintains a block list - a collection of blocked wallets. Token issuers (transfer-hook extension authorities) can wire this program in as their hook and choose an operation mode: filter the source wallet only, or both source and destination. ## Operation modes The mode depends on whether the block list is empty, plus the issuer's choice. Each mode corresponds to a different `extra-account-metas` [account](https://solana.com/docs/terminology#account) built for the [mint](https://solana.com/docs/terminology#token-mint) (see `setup_extra_metas` below). When the list goes from empty to non-empty, the issuer must call `setup_extra_metas` again. -- **Empty extra metas** — default when the config counter is 0. -- **Check source** — default when the config counter is > 0. -- **Check both source and destination** — optional behavior when the counter is > 0. +- **Empty extra metas** - default when the config counter is 0. +- **Check source** - default when the config counter is > 0. +- **Check both source and destination** - optional behavior when the counter is > 0. ## Accounts @@ -136,7 +136,7 @@ target/debug/block-list-cli setup-extra-metas --check-both-wallets { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs index 6b89d263..7aef4af8 100644 --- a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs +++ b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs @@ -10,7 +10,7 @@ use spl_transfer_hook_interface::instruction::ExecuteInstruction; use crate::{handle_extra_account_metas, handle_extra_account_metas_count, CounterAccount}; #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList<'info> { +pub struct InitializeExtraAccountMetaListAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -19,7 +19,7 @@ pub struct InitializeExtraAccountMetaList<'info> { init, seeds = [b"extra-account-metas", mint.key().as_ref()], bump, - // size_of returns Result with spl's ProgramError — unwrap is safe for known-good input + // size_of returns Result with spl's ProgramError - unwrap is safe for known-good input space = ExtraAccountMetaList::size_of( handle_extra_account_metas_count() ).unwrap(), @@ -34,12 +34,12 @@ pub struct InitializeExtraAccountMetaList<'info> { pub system_program: Program<'info, System>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { let extra_account_metas = handle_extra_account_metas()?; // initialize ExtraAccountMetaList account with extra accounts // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types ExtraAccountMetaList::init::( &mut context.accounts.extra_account_meta_list.try_borrow_mut_data()?, &extra_account_metas, diff --git a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs index 58c5ffa9..31ee75fc 100644 --- a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs @@ -8,7 +8,7 @@ use crate::{check_is_transferring, CounterAccount, TransferError}; // Remaining accounts are the extra accounts required from the ExtraAccountMetaList account // These accounts are provided via CPI to this program from the token2022 program #[derive(Accounts)] -pub struct TransferHook<'info> { +pub struct TransferHookAccountConstraints<'info> { #[account(token::mint = mint, token::authority = owner)] pub source_token: InterfaceAccount<'info, TokenAccount>, pub mint: InterfaceAccount<'info, Mint>, @@ -23,7 +23,7 @@ pub struct TransferHook<'info> { pub counter_account: Account<'info, CounterAccount>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { +pub fn handler(context: Context, amount: u64) -> Result<()> { // Fail this instruction if it is not called from within a transfer hook check_is_transferring(&context)?; diff --git a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/lib.rs b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/lib.rs index 7935a683..7fabbb4b 100644 --- a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/src/lib.rs @@ -33,18 +33,18 @@ pub mod transfer_hook { #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)] pub fn initialize_extra_account_meta_list( - context: Context, + context: Context, ) -> Result<()> { instructions::initialize_extra_account_meta_list::handler(context) } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { + pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { instructions::transfer_hook::handler(context, amount) } } -pub fn check_is_transferring(context: &Context) -> Result<()> { +pub fn check_is_transferring(context: &Context) -> Result<()> { let source_token_info = context.accounts.source_token.to_account_info(); let mut account_data_ref: RefMut<&mut [u8]> = source_token_info.try_borrow_mut_data()?; let mut account = PodStateWithExtensionsMut::::unpack(*account_data_ref)?; @@ -60,7 +60,7 @@ pub fn check_is_transferring(context: &Context) -> Result<()> { // Define extra account metas to store on extra_account_meta_list account pub fn handle_extra_account_metas() -> Result> { // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types Ok(vec![ExtraAccountMeta::new_with_seeds( &[Seed::Literal { bytes: b"counter".to_vec(), diff --git a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/tests/test_transfer_hook_counter.rs b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/tests/test_transfer_hook_counter.rs index 3a921956..ae927cd1 100644 --- a/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/tests/test_transfer_hook_counter.rs +++ b/tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook/tests/test_transfer_hook_counter.rs @@ -95,7 +95,7 @@ fn test_transfer_hook_counter() { let init_extra_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::InitializeExtraAccountMetaList {}.data(), - transfer_hook::accounts::InitializeExtraAccountMetaList { + transfer_hook::accounts::InitializeExtraAccountMetaListAccountConstraints { payer: payer.pubkey(), extra_account_meta_list, mint, @@ -132,11 +132,11 @@ fn test_transfer_hook_counter() { ).unwrap(); svm.expire_blockhash(); - // Step 5: Try calling transfer_hook directly (should fail — not transferring) + // Step 5: Try calling transfer_hook directly (should fail - not transferring) let direct_hook_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::TransferHook { amount: 1 }.data(), - transfer_hook::accounts::TransferHook { + transfer_hook::accounts::TransferHookAccountConstraints { source_token: source_ata, mint, destination_token: dest_ata, diff --git a/tokens/token-extensions/transfer-hook/counter/quasar/README.md b/tokens/token-extensions/transfer-hook/counter/quasar/README.md index 11c23c10..c5da6ae7 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/counter/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Counter (Quasar) +# Transfer Hook - Counter (Quasar) Count transfers in hook-side state. diff --git a/tokens/token-extensions/transfer-hook/counter/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/counter/quasar/src/lib.rs index 225a873b..1d4a5ddf 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/counter/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("1qahDxKHeCLZhbBU2NyMU6vQCQmEUmdeSEBrG5drffK"); /// SPL Transfer Hook Interface discriminators (SHA-256 prefix). /// Execute: sha256("spl-transfer-hook-interface:execute")[:8] @@ -27,15 +27,15 @@ mod quasar_transfer_hook_counter { /// Discriminator = sha256("spl-transfer-hook-interface:initialize-extra-account-metas")[:8] #[instruction(discriminator = [43, 34, 13, 49, 167, 88, 235, 235])] pub fn initialize_extra_account_meta_list( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { handle_initialize_extra_account_meta_list(&mut ctx.accounts) } - /// Transfer hook handler — increments the counter on each transfer. + /// Transfer hook handler - increments the counter on each transfer. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { + pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { handle_transfer_hook(&mut ctx.accounts) } } @@ -45,7 +45,7 @@ mod quasar_transfer_hook_counter { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList { +pub struct InitializeExtraAccountMetaListAccountConstraints { #[account(mut)] pub payer: Signer, /// ExtraAccountMetaList PDA: ["extra-account-metas", mint] @@ -60,7 +60,7 @@ pub struct InitializeExtraAccountMetaList { #[inline(always)] fn handle_initialize_extra_account_meta_list( - accounts: &mut InitializeExtraAccountMetaList, + accounts: &mut InitializeExtraAccountMetaListAccountConstraints, ) -> Result<(), ProgramError> { // ExtraAccountMetaList with 1 extra account: // [8 bytes: Execute discriminator] @@ -164,7 +164,7 @@ fn handle_initialize_extra_account_meta_list( // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct TransferHook { +pub struct TransferHookAccountConstraints { /// Source token account pub source_token: UncheckedAccount, /// Mint @@ -175,13 +175,13 @@ pub struct TransferHook { pub owner: UncheckedAccount, /// ExtraAccountMetaList PDA pub extra_account_meta_list: UncheckedAccount, - /// Counter PDA (extra account resolved by Token-2022) + /// Counter PDA (extra account resolved by Token Extensions) #[account(mut)] pub counter_account: UncheckedAccount, } #[inline(always)] -fn handle_transfer_hook(accounts: &mut TransferHook) -> Result<(), ProgramError> { +fn handle_transfer_hook(accounts: &mut TransferHookAccountConstraints) -> Result<(), ProgramError> { // Read the current counter from the account data let view = unsafe { &mut *(&mut accounts.counter_account as *mut UncheckedAccount diff --git a/tokens/token-extensions/transfer-hook/counter/quasar/src/tests.rs b/tokens/token-extensions/transfer-hook/counter/quasar/src/tests.rs index b468cfe5..fc012c1a 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/counter/quasar/src/tests.rs @@ -139,7 +139,7 @@ fn test_transfer_hook_increments_counter() { data: hook_data, }; - // Don't pass counter_pda or meta_list_pda — they were committed by the init instruction + // Don't pass counter_pda or meta_list_pda - they were committed by the init instruction let result = svm.process_instruction( &hook_ix, &[ diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/Anchor.toml b/tokens/token-extensions/transfer-hook/hello-world/anchor/Anchor.toml index 2ce4e066..09d8c89c 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] transfer_hook = "jY5DfVksJT8Le38LCaQhz5USeiGu4rUeVSS8QRAMoba" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" @@ -18,6 +16,6 @@ wallet = "~/.config/solana/id.json" test = "cargo test" # Transfer-hook tests use the real local validator (not bankrun). -# No external program clones needed — this project doesn't use Metaplex. +# No external program clones needed - this project doesn't use Metaplex. # The previous [[test.validator.clone]] of metaplex was unnecessary and # caused 5-minute timeouts in CI trying to fetch from devnet. diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/README.md b/tokens/token-extensions/transfer-hook/hello-world/anchor/README.md index e9cf4acd..32abbd60 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/README.md +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Hello World (Anchor) +# Transfer Hook - Hello World (Anchor) Minimal transfer hook that runs custom logic on every token transfer. diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/migrations/deploy.ts b/tokens/token-extensions/transfer-hook/hello-world/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize.rs b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize.rs index 0fea6dfb..7cc60c7b 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize.rs @@ -13,7 +13,7 @@ use anchor_spl::token_interface::{ #[derive(Accounts)] #[instruction(_decimals: u8)] -pub struct Initialize<'info> { +pub struct InitializeAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -31,17 +31,17 @@ pub struct Initialize<'info> { } // create a mint account that specifies this program as the transfer hook program -pub fn handler(mut context: Context, _decimals: u8) -> Result<()> { +pub fn handler(mut context: Context, _decimals: u8) -> Result<()> { handle_check_mint_data(&mut context.accounts)?; Ok(()) } // helper to check mint data, and demonstrate how to read mint extension data within a program -fn handle_check_mint_data(accounts: &mut Initialize) -> Result<()> { +fn handle_check_mint_data(accounts: &mut InitializeAccountConstraints) -> Result<()> { let mint = &accounts.mint_account.to_account_info(); let mint_data = mint.data.borrow(); // .map_err() needed because spl-token-2022 uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types let mint_with_extension = StateWithExtensions::::unpack(&mint_data) .map_err(|_| ProgramError::InvalidAccountData)?; let extension_data = mint_with_extension.get_extension::() diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs index f8351695..5a858e59 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs @@ -9,7 +9,7 @@ use spl_transfer_hook_interface::instruction::ExecuteInstruction; use crate::{handle_extra_account_metas, handle_extra_account_metas_count}; #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList<'info> { +pub struct InitializeExtraAccountMetaListAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -18,7 +18,7 @@ pub struct InitializeExtraAccountMetaList<'info> { init, seeds = [b"extra-account-metas", mint.key().as_ref()], bump, - // size_of returns Result with spl's ProgramError — unwrap is safe for known-good input + // size_of returns Result with spl's ProgramError - unwrap is safe for known-good input space = ExtraAccountMetaList::size_of( handle_extra_account_metas_count() ).unwrap(), @@ -31,12 +31,12 @@ pub struct InitializeExtraAccountMetaList<'info> { pub system_program: Program<'info, System>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { let extra_account_metas = handle_extra_account_metas()?; // initialize ExtraAccountMetaList account with extra accounts // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types ExtraAccountMetaList::init::( &mut context.accounts.extra_account_meta_list.try_borrow_mut_data()?, &extra_account_metas, diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs index a3b8a32c..e7b3a5be 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs @@ -8,7 +8,7 @@ use crate::check_is_transferring; // Remaining accounts are the extra accounts required from the ExtraAccountMetaList account // These accounts are provided via CPI to this program from the token2022 program #[derive(Accounts)] -pub struct TransferHook<'info> { +pub struct TransferHookAccountConstraints<'info> { #[account(token::mint = mint, token::authority = owner)] pub source_token: InterfaceAccount<'info, TokenAccount>, pub mint: InterfaceAccount<'info, Mint>, @@ -21,7 +21,7 @@ pub struct TransferHook<'info> { pub extra_account_meta_list: UncheckedAccount<'info>, } -pub fn handler(context: Context, _amount: u64) -> Result<()> { +pub fn handler(context: Context, _amount: u64) -> Result<()> { // Fail this instruction if it is not called from within a transfer hook check_is_transferring(&context)?; diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/lib.rs b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/lib.rs index a51ba39e..9ef5d2be 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/src/lib.rs @@ -29,28 +29,28 @@ pub enum TransferError { pub mod transfer_hook { use super::*; - pub fn initialize(context: Context, decimals: u8) -> Result<()> { + pub fn initialize(context: Context, decimals: u8) -> Result<()> { instructions::initialize::handler(context, decimals) } #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)] pub fn initialize_extra_account_meta_list( - context: Context, + context: Context, ) -> Result<()> { instructions::initialize_extra_account_meta_list::handler(context) } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { + pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { instructions::transfer_hook::handler(context, amount) } } -pub fn check_is_transferring(context: &Context) -> Result<()> { +pub fn check_is_transferring(context: &Context) -> Result<()> { let source_token_info = context.accounts.source_token.to_account_info(); let mut account_data_ref: RefMut<&mut [u8]> = source_token_info.try_borrow_mut_data()?; // .map_err() needed because spl-token-2022 uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types let mut account = PodStateWithExtensionsMut::::unpack(*account_data_ref) .map_err(|_| ProgramError::InvalidAccountData)?; let account_extension = account.get_extension_mut::() diff --git a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/tests/test_transfer_hook.rs b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/tests/test_transfer_hook.rs index 590bafdd..7471118f 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/tests/test_transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook/tests/test_transfer_hook.rs @@ -59,7 +59,7 @@ fn test_transfer_hook_hello_world() { decimals, } .data(), - transfer_hook::accounts::Initialize { + transfer_hook::accounts::InitializeAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), token_program: TOKEN_EXTENSIONS_PROGRAM_ID, @@ -101,7 +101,7 @@ fn test_transfer_hook_hello_world() { let init_extra_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::InitializeExtraAccountMetaList {}.data(), - transfer_hook::accounts::InitializeExtraAccountMetaList { + transfer_hook::accounts::InitializeExtraAccountMetaListAccountConstraints { payer: payer.pubkey(), extra_account_meta_list, mint: mint_keypair.pubkey(), @@ -133,11 +133,11 @@ fn test_transfer_hook_hello_world() { ).unwrap(); svm.expire_blockhash(); - // Step 5: Try calling transfer_hook directly (should fail — not transferring) + // Step 5: Try calling transfer_hook directly (should fail - not transferring) let direct_hook_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::TransferHook { amount: 1 }.data(), - transfer_hook::accounts::TransferHook { + transfer_hook::accounts::TransferHookAccountConstraints { source_token: source_ata, mint: mint_keypair.pubkey(), destination_token: dest_ata, diff --git a/tokens/token-extensions/transfer-hook/hello-world/quasar/README.md b/tokens/token-extensions/transfer-hook/hello-world/quasar/README.md index c0341cae..ba7725bc 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/hello-world/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Hello World (Quasar) +# Transfer Hook - Hello World (Quasar) Minimal transfer hook executed on each transfer. diff --git a/tokens/token-extensions/transfer-hook/hello-world/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/hello-world/quasar/src/lib.rs index f2c366e3..a1a77663 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("jY5DfVksJT8Le38LCaQhz5USeiGu4rUeVSS8QRAMoba"); pub struct Token2022Program; impl Id for Token2022Program { @@ -36,23 +36,23 @@ mod quasar_transfer_hook_hello_world { /// Create a mint with the TransferHook extension pointing to this program. /// Custom discriminator (not part of the transfer hook interface). #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 1])] - pub fn initialize(ctx: Ctx, decimals: u8) -> Result<(), ProgramError> { + pub fn initialize(ctx: Ctx, decimals: u8) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts, decimals) } - /// Create the ExtraAccountMetaList PDA (empty — no extra accounts). + /// Create the ExtraAccountMetaList PDA (empty - no extra accounts). /// Discriminator = sha256("spl-transfer-hook-interface:initialize-extra-account-metas")[:8] #[instruction(discriminator = [43, 34, 13, 49, 167, 88, 235, 235])] pub fn initialize_extra_account_meta_list( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { handle_initialize_extra_account_meta_list(&mut ctx.accounts) } - /// Transfer hook handler — called automatically by Token-2022 during transfers. + /// Transfer hook handler - called automatically by Token Extensions during transfers. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { + pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { handle_transfer_hook(&mut ctx.accounts) } } @@ -62,7 +62,7 @@ mod quasar_transfer_hook_hello_world { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct Initialize { +pub struct InitializeAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -72,13 +72,13 @@ pub struct Initialize { } #[inline(always)] -fn handle_initialize(accounts: &mut Initialize, decimals: u8) -> Result<(), ProgramError> { +fn handle_initialize(accounts: &mut InitializeAccountConstraints, decimals: u8) -> Result<(), ProgramError> { // Mint with TransferHook extension: // 165 (base account + padding) + 1 (account type) + 4 (TLV header) + 64 (extension) = 234 let mint_size: u64 = 234; let lamports = Rent::get()?.try_minimum_balance(mint_size as usize)?; - // 1. Create account owned by Token-2022 + // 1. Create account owned by Token Extensions accounts.system_program .create_account( &accounts.payer, @@ -132,7 +132,7 @@ fn handle_initialize(accounts: &mut Initialize, decimals: u8) -> Result<(), Prog // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList { +pub struct InitializeExtraAccountMetaListAccountConstraints { #[account(mut)] pub payer: Signer, /// ExtraAccountMetaList PDA seeded by ["extra-account-metas", mint] @@ -144,7 +144,7 @@ pub struct InitializeExtraAccountMetaList { #[inline(always)] fn handle_initialize_extra_account_meta_list( - accounts: &mut InitializeExtraAccountMetaList, + accounts: &mut InitializeExtraAccountMetaListAccountConstraints, ) -> Result<(), ProgramError> { use quasar_lang::cpi::Seed; @@ -212,7 +212,7 @@ fn handle_initialize_extra_account_meta_list( // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct TransferHook { +pub struct TransferHookAccountConstraints { /// Source token account pub source_token: UncheckedAccount, /// Mint @@ -226,9 +226,9 @@ pub struct TransferHook { } #[inline(always)] -fn handle_transfer_hook(_accounts: &mut TransferHook) -> Result<(), ProgramError> { +fn handle_transfer_hook(_accounts: &mut TransferHookAccountConstraints) -> Result<(), ProgramError> { // In production, verify the source token's TransferHookAccount.transferring - // flag is set. The Token-2022 program sets this before invoking the hook + // flag is set. The Token Extensions program sets this before invoking the hook // and clears it after, preventing standalone invocation. // // For this hello-world example, we simply log a message. diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/Anchor.toml b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/Anchor.toml index 1843d9c7..62b79b75 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] transfer_hook = "FjcHckEgXcBhFmSGai3FRpDLiT6hbpV893n8iTxVd81g" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/README.md b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/README.md index 426d26d2..76b2157c 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/README.md +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Transfer Cost (Anchor) +# Transfer Hook - Transfer Cost (Anchor) Charge an additional fee on each transfer via hook logic. diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/migrations/deploy.ts b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/Cargo.toml b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/Cargo.toml index 457b2d33..ac507718 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/Cargo.toml @@ -22,7 +22,7 @@ custom-panic = [] [dependencies] anchor-lang = "1.0.0" anchor-spl = "1.0.0" -# SPL crates v3.x-compatible — uses solana-program-error 3.x matching anchor-lang 1.0 +# SPL crates v3.x-compatible - uses solana-program-error 3.x matching anchor-lang 1.0 spl-discriminator = "0.5.2" spl-tlv-account-resolution = "0.11.1" spl-transfer-hook-interface = "2.1.0" diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs index e1887b76..d19d662f 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs @@ -6,7 +6,7 @@ use spl_transfer_hook_interface::instruction::ExecuteInstruction; use crate::{handle_extra_account_metas, handle_extra_account_metas_count, CounterAccount}; #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList<'info> { +pub struct InitializeExtraAccountMetaListAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -15,7 +15,7 @@ pub struct InitializeExtraAccountMetaList<'info> { init, seeds = [b"extra-account-metas", mint.key().as_ref()], bump, - // size_of returns Result with spl's ProgramError — unwrap is safe for known-good input + // size_of returns Result with spl's ProgramError - unwrap is safe for known-good input space = ExtraAccountMetaList::size_of( handle_extra_account_metas_count() ).unwrap(), @@ -28,7 +28,7 @@ pub struct InitializeExtraAccountMetaList<'info> { pub system_program: Program<'info, System>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { let extra_account_metas = handle_extra_account_metas()?; // initialize ExtraAccountMetaList account with extra accounts diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs index 324c79a6..c7f5b9ec 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs @@ -15,9 +15,9 @@ use crate::{check_is_transferring, CounterAccount, TransferError}; // Box used for source_token, destination_token, wsol_mint, // delegate_wsol_token_account, and sender_wsol_token_account to avoid exceeding // the 4096-byte BPF stack frame limit in try_accounts deserialization. -// This struct has 12 accounts — without Box, the generated code uses ~4160 bytes of stack. +// This struct has 12 accounts - without Box, the generated code uses ~4160 bytes of stack. #[derive(Accounts)] -pub struct TransferHook<'info> { +pub struct TransferHookAccountConstraints<'info> { #[account(token::mint = mint, token::authority = owner)] pub source_token: Box>, pub mint: Box>, @@ -53,7 +53,7 @@ pub struct TransferHook<'info> { pub counter_account: Account<'info, CounterAccount>, } -pub fn handler(context: Context, amount: u64) -> Result<()> { +pub fn handler(context: Context, amount: u64) -> Result<()> { // Fail this instruction if it is not called from within a transfer hook check_is_transferring(&context)?; diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/lib.rs b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/lib.rs index 1c4730ab..7c17b8ec 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/src/lib.rs @@ -42,18 +42,18 @@ pub mod transfer_hook { #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)] pub fn initialize_extra_account_meta_list( - context: Context, + context: Context, ) -> Result<()> { instructions::initialize_extra_account_meta_list::handler(context) } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { + pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { instructions::transfer_hook::handler(context, amount) } } -pub fn check_is_transferring(context: &Context) -> Result<()> { +pub fn check_is_transferring(context: &Context) -> Result<()> { let source_token_info = context.accounts.source_token.to_account_info(); let mut account_data_ref: RefMut<&mut [u8]> = source_token_info.try_borrow_mut_data()?; let mut account = PodStateWithExtensionsMut::::unpack(*account_data_ref) diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/tests/test_transfer_hook.rs b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/tests/test_transfer_hook.rs index b8993a82..3912dd92 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/tests/test_transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook/tests/test_transfer_hook.rs @@ -57,7 +57,7 @@ fn test_initialize_extra_account_meta_list() { let init_extra_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::InitializeExtraAccountMetaList {}.data(), - transfer_hook::accounts::InitializeExtraAccountMetaList { + transfer_hook::accounts::InitializeExtraAccountMetaListAccountConstraints { payer: payer.pubkey(), extra_account_meta_list, mint, diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/README.md b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/README.md index 54bee20d..138a5556 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Transfer Cost (Quasar) +# Transfer Hook - Transfer Cost (Quasar) Additional fee on each transfer via the hook. diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/lib.rs index 024ca33c..587714e2 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("FjcHckEgXcBhFmSGai3FRpDLiT6hbpV893n8iTxVd81g"); /// SPL Transfer Hook Interface discriminators (SHA-256 prefix). #[allow(dead_code)] @@ -31,16 +31,16 @@ mod quasar_transfer_hook_cost { /// Discriminator = sha256("spl-transfer-hook-interface:initialize-extra-account-metas")[:8] #[instruction(discriminator = [43, 34, 13, 49, 167, 88, 235, 235])] pub fn initialize_extra_account_meta_list( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { handle_initialize_extra_account_meta_list(&mut ctx.accounts) } - /// Transfer hook handler — validates the amount and increments the counter. + /// Transfer hook handler - validates the amount and increments the counter. /// In the full version, this would also charge a WSOL fee via delegate. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn transfer_hook(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn transfer_hook(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { handle_transfer_hook(&mut ctx.accounts, amount) } } @@ -50,7 +50,7 @@ mod quasar_transfer_hook_cost { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList { +pub struct InitializeExtraAccountMetaListAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -63,7 +63,7 @@ pub struct InitializeExtraAccountMetaList { #[inline(always)] fn handle_initialize_extra_account_meta_list( - accounts: &mut InitializeExtraAccountMetaList, + accounts: &mut InitializeExtraAccountMetaListAccountConstraints, ) -> Result<(), ProgramError> { // Create ExtraAccountMetaList PDA with 1 extra account: counter let meta_list_size: u64 = 51; @@ -138,7 +138,7 @@ fn handle_initialize_extra_account_meta_list( // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct TransferHook { +pub struct TransferHookAccountConstraints { pub source_token: UncheckedAccount, pub mint: UncheckedAccount, pub destination_token: UncheckedAccount, @@ -149,7 +149,7 @@ pub struct TransferHook { } #[inline(always)] -fn handle_transfer_hook(accounts: &mut TransferHook, amount: u64) -> Result<(), ProgramError> { +fn handle_transfer_hook(accounts: &mut TransferHookAccountConstraints, amount: u64) -> Result<(), ProgramError> { // Validate amount if amount > 50 { log("Warning: large transfer amount"); diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/Anchor.toml b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/Anchor.toml index 85b30e5b..29078013 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] transfer_switch = "FjcHckEgXcBhFmSGai3FRpDLiT6hbpV893n8iTxVd81g" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/README.md b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/README.md index a9362235..28fe75c0 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/README.md +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Transfer Switch (Anchor) +# Transfer Hook - Transfer Switch (Anchor) Enable or disable transfers globally with onchain switch state. diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/configure_admin.rs b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/configure_admin.rs index 08b6ba2d..b9fc5800 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/configure_admin.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/configure_admin.rs @@ -1,7 +1,7 @@ use {crate::state::AdminConfig, anchor_lang::prelude::*}; #[derive(Accounts)] -pub struct ConfigureAdmin<'info> { +pub struct ConfigureAdminAccountConstraints<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -22,7 +22,7 @@ pub struct ConfigureAdmin<'info> { pub system_program: Program<'info, System>, } -pub fn handle_is_admin(accounts: &mut ConfigureAdmin) -> Result<()> { +pub fn handle_is_admin(accounts: &mut ConfigureAdminAccountConstraints) -> Result<()> { // check if we are not creating the account for the first time, // ensure it's the admin that is making the change // @@ -38,7 +38,7 @@ pub fn handle_is_admin(accounts: &mut ConfigureAdmin) -> Result<()> { Ok(()) } -pub fn handle_configure_admin(accounts: &mut ConfigureAdmin, bump: u8) -> Result<()> { +pub fn handle_configure_admin(accounts: &mut ConfigureAdminAccountConstraints, bump: u8) -> Result<()> { accounts.admin_config.set_inner(AdminConfig { admin: accounts.new_admin.key(), // set the admin pubkey that can switch transfers on/off is_initialised: true, // let us know an admin has been set diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/initialise_extra_account_metas_list.rs b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/initialise_extra_account_metas_list.rs index 1cd5901e..e97028e6 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/initialise_extra_account_metas_list.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/initialise_extra_account_metas_list.rs @@ -11,7 +11,7 @@ use { }; #[derive(Accounts)] -pub struct InitializeExtraAccountMetas<'info> { +pub struct InitializeExtraAccountMetasAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -29,9 +29,9 @@ pub struct InitializeExtraAccountMetas<'info> { pub system_program: Program<'info, System>, } -pub fn handle_initialize_extra_account_metas_list(accounts: &mut InitializeExtraAccountMetas, bumps: InitializeExtraAccountMetasBumps) -> Result<()> { +pub fn handle_initialize_extra_account_metas_list(accounts: &mut InitializeExtraAccountMetasAccountConstraints, bumps: InitializeExtraAccountMetasAccountConstraintsBumps) -> Result<()> { // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types let account_metas = vec![ // 5 - wallet (sender) config account ExtraAccountMeta::new_with_seeds( diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/switch.rs b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/switch.rs index 4cd633af..f33d67d3 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/switch.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/switch.rs @@ -4,7 +4,7 @@ use { }; #[derive(Accounts)] -pub struct Switch<'info> { +pub struct SwitchAccountConstraints<'info> { /// admin that controls the switch #[account(mut)] pub admin: Signer<'info>, @@ -34,7 +34,7 @@ pub struct Switch<'info> { pub system_program: Program<'info, System>, } -pub fn handle_switch(accounts: &mut Switch, on: bool, bump: u8) -> Result<()> { +pub fn handle_switch(accounts: &mut SwitchAccountConstraints, on: bool, bump: u8) -> Result<()> { // toggle switch on/off for the given wallet // accounts.wallet_switch.set_inner(TransferSwitch { @@ -45,7 +45,7 @@ pub fn handle_switch(accounts: &mut Switch, on: bool, bump: u8) -> Result<()> { Ok(()) } -// admin_config is validated via `seeds=[b"admin-config"], bump` — Anchor +// admin_config is validated via `seeds=[b"admin-config"], bump` - Anchor // re-derives it and fails if it doesn't match, so storing AdminConfig.bump // isn't strictly needed to validate `admin_config` inside `Switch` (the // bump field on AdminConfig is still populated on creation to satisfy the diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/transfer_hook.rs b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/transfer_hook.rs index 1e8c060a..c3567159 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/instructions/transfer_hook.rs @@ -14,7 +14,7 @@ use { }; #[derive(Accounts)] -pub struct TransferHook<'info> { +pub struct TransferHookAccountConstraints<'info> { /// CHECK: Sender token account #[account()] pub source_token_account: UncheckedAccount<'info>, @@ -46,18 +46,18 @@ pub struct TransferHook<'info> { pub wallet_switch: Account<'info, TransferSwitch>, } -pub fn handle_assert_switch_is_on(accounts: &mut TransferHook) -> Result<()> { +pub fn handle_assert_switch_is_on(accounts: &mut TransferHookAccountConstraints) -> Result<()> { if !accounts.wallet_switch.on { return err!(TransferError::SwitchNotOn); } Ok(()) } -pub fn handle_assert_is_transferring(accounts: &mut TransferHook) -> Result<()> { +pub fn handle_assert_is_transferring(accounts: &mut TransferHookAccountConstraints) -> Result<()> { let source_token_info = accounts.source_token_account.to_account_info(); let mut account_data_ref = source_token_info.try_borrow_mut_data()?; // .map_err() needed because spl-token-2022 uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types let mut account = PodStateWithExtensionsMut::::unpack(*account_data_ref) .map_err(|_| ProgramError::InvalidAccountData)?; let account_extension = account.get_extension_mut::() diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/lib.rs b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/lib.rs index 93a476c7..c392c703 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/src/lib.rs @@ -15,7 +15,7 @@ declare_id!("FjcHckEgXcBhFmSGai3FRpDLiT6hbpV893n8iTxVd81g"); pub mod transfer_switch { use super::*; - pub fn configure_admin(mut context: Context) -> Result<()> { + pub fn configure_admin(mut context: Context) -> Result<()> { let bump = context.bumps.admin_config; handle_is_admin(&mut context.accounts)?; handle_configure_admin(&mut context.accounts, bump) @@ -23,18 +23,18 @@ pub mod transfer_switch { #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)] pub fn initialize_extra_account_metas_list( - mut context: Context, + mut context: Context, ) -> Result<()> { handle_initialize_extra_account_metas_list(&mut context.accounts, context.bumps) } - pub fn switch(mut context: Context, on: bool) -> Result<()> { + pub fn switch(mut context: Context, on: bool) -> Result<()> { let bump = context.bumps.wallet_switch; handle_switch(&mut context.accounts, on, bump) } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn transfer_hook(mut context: Context, _amount: u64) -> Result<()> { + pub fn transfer_hook(mut context: Context, _amount: u64) -> Result<()> { handle_assert_is_transferring(&mut context.accounts)?; handle_assert_switch_is_on(&mut context.accounts) } diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/tests/test_transfer_switch.rs b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/tests/test_transfer_switch.rs index a861d913..5696596e 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/tests/test_transfer_switch.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch/tests/test_transfer_switch.rs @@ -91,7 +91,7 @@ fn test_transfer_switch() { let configure_admin_ix = Instruction::new_with_bytes( program_id, &transfer_switch::instruction::ConfigureAdmin {}.data(), - transfer_switch::accounts::ConfigureAdmin { + transfer_switch::accounts::ConfigureAdminAccountConstraints { admin: payer.pubkey(), new_admin: payer.pubkey(), admin_config, @@ -106,7 +106,7 @@ fn test_transfer_switch() { let init_extra_ix = Instruction::new_with_bytes( program_id, &transfer_switch::instruction::InitializeExtraAccountMetasList {}.data(), - transfer_switch::accounts::InitializeExtraAccountMetas { + transfer_switch::accounts::InitializeExtraAccountMetasAccountConstraints { payer: payer.pubkey(), token_mint: mint, extra_account_metas_list: extra_account_meta_list, @@ -121,7 +121,7 @@ fn test_transfer_switch() { let switch_off_ix = Instruction::new_with_bytes( program_id, &transfer_switch::instruction::Switch { on: false }.data(), - transfer_switch::accounts::Switch { + transfer_switch::accounts::SwitchAccountConstraints { admin: payer.pubkey(), wallet: sender.pubkey(), admin_config, @@ -133,7 +133,7 @@ fn test_transfer_switch() { send_transaction_from_instructions(&mut svm, vec![switch_off_ix], &[&payer], &payer.pubkey()).unwrap(); svm.expire_blockhash(); - // Step 6: Try transfer — should FAIL (switch is off) + // Step 6: Try transfer - should FAIL (switch is off) let transfer_amount: u64 = 1 * 10u64.pow(decimals as u32); let extra_accounts = build_hook_accounts( &mint, @@ -164,7 +164,7 @@ fn test_transfer_switch() { let switch_on_ix = Instruction::new_with_bytes( program_id, &transfer_switch::instruction::Switch { on: true }.data(), - transfer_switch::accounts::Switch { + transfer_switch::accounts::SwitchAccountConstraints { admin: payer.pubkey(), wallet: sender.pubkey(), admin_config, @@ -176,7 +176,7 @@ fn test_transfer_switch() { send_transaction_from_instructions(&mut svm, vec![switch_on_ix], &[&payer], &payer.pubkey()).unwrap(); svm.expire_blockhash(); - // Step 8: Transfer — should SUCCEED (switch is on) + // Step 8: Transfer - should SUCCEED (switch is on) transfer_checked_token_extensions( &mut svm, &source_ata, diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/README.md b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/README.md index 7f33b4d7..c2ff7813 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Transfer Switch (Quasar) +# Transfer Hook - Transfer Switch (Quasar) Globally enable or disable transfers. diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/lib.rs index 250156ea..b32a683d 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("FjcHckEgXcBhFmSGai3FRpDLiT6hbpV893n8iTxVd81g"); /// SPL Transfer Hook Interface discriminators (SHA-256 prefix). #[allow(dead_code)] @@ -24,7 +24,7 @@ mod quasar_transfer_hook_switch { /// Set up or change the admin. The first caller becomes admin. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 1])] - pub fn configure_admin(ctx: Ctx) -> Result<(), ProgramError> { + pub fn configure_admin(ctx: Ctx) -> Result<(), ProgramError> { handle_configure_admin(&mut ctx.accounts) } @@ -32,21 +32,21 @@ mod quasar_transfer_hook_switch { /// Discriminator = sha256("spl-transfer-hook-interface:initialize-extra-account-metas")[:8] #[instruction(discriminator = [43, 34, 13, 49, 167, 88, 235, 235])] pub fn initialize_extra_account_metas_list( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { handle_initialize_extra_account_metas_list(&mut ctx.accounts) } /// Toggle the transfer switch for a wallet. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 3])] - pub fn switch(ctx: Ctx, on: u8) -> Result<(), ProgramError> { + pub fn switch(ctx: Ctx, on: u8) -> Result<(), ProgramError> { handle_switch(&mut ctx.accounts, on != 0) } - /// Transfer hook handler — checks the sender's switch is on. + /// Transfer hook handler - checks the sender's switch is on. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { + pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { handle_transfer_hook(&mut ctx.accounts) } } @@ -61,7 +61,7 @@ mod quasar_transfer_hook_switch { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct ConfigureAdmin { +pub struct ConfigureAdminAccountConstraints { #[account(mut)] pub admin: Signer, pub new_admin: UncheckedAccount, @@ -71,7 +71,7 @@ pub struct ConfigureAdmin { } #[inline(always)] -fn handle_configure_admin(accounts: &mut ConfigureAdmin) -> Result<(), ProgramError> { +fn handle_configure_admin(accounts: &mut ConfigureAdminAccountConstraints) -> Result<(), ProgramError> { let view = accounts.admin_config.to_account_view(); let data = view.try_borrow()?; @@ -125,7 +125,7 @@ fn handle_configure_admin(accounts: &mut ConfigureAdmin) -> Result<(), ProgramEr // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct InitializeExtraAccountMetas { +pub struct InitializeExtraAccountMetasAccountConstraints { #[account(mut)] pub payer: Signer, pub token_mint: UncheckedAccount, @@ -136,7 +136,7 @@ pub struct InitializeExtraAccountMetas { #[inline(always)] fn handle_initialize_extra_account_metas_list( - accounts: &mut InitializeExtraAccountMetas, + accounts: &mut InitializeExtraAccountMetasAccountConstraints, ) -> Result<(), ProgramError> { // 1 extra account: wallet switch PDA seeded by [AccountKey(index=3)] (sender/owner) let meta_list_size: u64 = 51; // 8 + 4 + 4 + 35 @@ -170,7 +170,7 @@ fn handle_initialize_extra_account_metas_list( data[8..12].copy_from_slice(&39u32.to_le_bytes()); data[12..16].copy_from_slice(&1u32.to_le_bytes()); - // ExtraAccountMeta: PDA seeded by [AccountKey(index=3)] — the sender/owner + // ExtraAccountMeta: PDA seeded by [AccountKey(index=3)] - the sender/owner data[16] = 1; // PDA from seeds let mut config = [0u8; 32]; config[0] = 1; // 1 seed @@ -189,7 +189,7 @@ fn handle_initialize_extra_account_metas_list( // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct Switch { +pub struct SwitchAccountConstraints { #[account(mut)] pub admin: Signer, pub wallet: UncheckedAccount, @@ -200,7 +200,7 @@ pub struct Switch { } #[inline(always)] -fn handle_switch(accounts: &mut Switch, on: bool) -> Result<(), ProgramError> { +fn handle_switch(accounts: &mut SwitchAccountConstraints, on: bool) -> Result<(), ProgramError> { // Verify admin let config_view = accounts.admin_config.to_account_view(); let config_data = config_view.try_borrow()?; @@ -253,23 +253,23 @@ fn handle_switch(accounts: &mut Switch, on: bool) -> Result<(), ProgramError> { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct TransferHook { +pub struct TransferHookAccountConstraints { pub source_token_account: UncheckedAccount, pub token_mint: UncheckedAccount, pub receiver_token_account: UncheckedAccount, pub wallet: UncheckedAccount, pub extra_account_metas_list: UncheckedAccount, - /// Wallet switch PDA resolved by Token-2022 + /// Wallet switch PDA resolved by Token Extensions pub wallet_switch: UncheckedAccount, } #[inline(always)] -fn handle_transfer_hook(accounts: &mut TransferHook) -> Result<(), ProgramError> { +fn handle_transfer_hook(accounts: &mut TransferHookAccountConstraints) -> Result<(), ProgramError> { let switch_view = accounts.wallet_switch.to_account_view(); let data = switch_view.try_borrow()?; if data.len() < 33 { - log("Switch not initialized — transfers disabled by default"); + log("Switch not initialized - transfers disabled by default"); return Err(ProgramError::UninitializedAccount); } @@ -278,6 +278,6 @@ fn handle_transfer_hook(accounts: &mut TransferHook) -> Result<(), ProgramError> return Err(ProgramError::InvalidArgument); } - log("Transfer switch is ON — transfer allowed"); + log("Transfer switch is ON - transfer allowed"); Ok(()) } diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/tests.rs b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/tests.rs index d489b371..61f3f881 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/src/tests.rs @@ -95,7 +95,7 @@ fn test_transfer_switch_flow() { assert!(result.is_ok(), "switch on failed: {:?}", result.raw_result); println!(" SWITCH ON CU: {}", result.compute_units_consumed); - // 4. Transfer hook with switch ON — should succeed + // 4. Transfer hook with switch ON - should succeed let source_token = Pubkey::new_unique(); let dest_token = Pubkey::new_unique(); @@ -141,7 +141,7 @@ fn test_transfer_switch_flow() { result.print_logs(); assert!(result.is_ok(), "switch off failed: {:?}", result.raw_result); - // 6. Transfer hook with switch OFF — should fail + // 6. Transfer hook with switch OFF - should fail let hook_ix2 = Instruction { program_id: crate::ID, accounts: vec![ diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/Anchor.toml b/tokens/token-extensions/transfer-hook/whitelist/anchor/Anchor.toml index b8d16639..a9dfe972 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/Anchor.toml +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/Anchor.toml @@ -8,8 +8,6 @@ skip-lint = false [programs.localnet] transfer_hook = "DrWbQtYJGtsoRwzKqAbHKHKsCJJfpysudF39GBVFSxub" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "Localnet" wallet = "~/.config/solana/id.json" @@ -18,6 +16,6 @@ wallet = "~/.config/solana/id.json" test = "cargo test" # Transfer-hook tests use the real local validator (not bankrun). -# No external program clones needed — this project doesn't use Metaplex. +# No external program clones needed - this project doesn't use Metaplex. # The previous [[test.validator.clone]] of metaplex was unnecessary and # caused 5-minute timeouts in CI trying to fetch from devnet. diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/README.md b/tokens/token-extensions/transfer-hook/whitelist/anchor/README.md index 98a50dba..36946b19 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/README.md +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/README.md @@ -1,5 +1,5 @@ -# Transfer Hook — Whitelist (Anchor) +# Transfer Hook - Whitelist (Anchor) A whitelist enforced by a [Token Extensions](https://solana.com/docs/terminology#token-extensions-program) transfer hook. The whitelist is stored inline on a single [account](https://solana.com/docs/terminology#account). -This approach doesn't scale: the whitelist eventually runs out of account space. For larger lists, store entries in external [PDAs](https://solana.com/docs/terminology#program-derived-address-pda) (one PDA per whitelisted wallet) — see the [`block-list`](../../block-list/) example for that pattern. +This approach doesn't scale: the whitelist eventually runs out of account space. For larger lists, store entries in external [PDAs](https://solana.com/docs/terminology#program-derived-address-pda) (one PDA per whitelisted wallet) - see the [`block-list`](../../block-list/) example for that pattern. diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/migrations/deploy.ts b/tokens/token-extensions/transfer-hook/whitelist/anchor/migrations/deploy.ts deleted file mode 100644 index 81b3ef43..00000000 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/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. - -const anchor = require("@anchor-lang/core"); - -module.exports = async (provider) => { - // Configure client to use the provider. - anchor.setProvider(provider); - - // Add your deploy script here. -}; diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/add_to_whitelist.rs b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/add_to_whitelist.rs index e5e20fac..86e100ef 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/add_to_whitelist.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/add_to_whitelist.rs @@ -3,7 +3,7 @@ use anchor_lang::prelude::*; use crate::WhiteList; #[derive(Accounts)] -pub struct AddToWhiteList<'info> { +pub struct AddToWhiteListAccountConstraints<'info> { /// CHECK: New account to add to white list #[account()] pub new_account: UncheckedAccount<'info>, @@ -17,7 +17,7 @@ pub struct AddToWhiteList<'info> { pub signer: Signer<'info>, } -pub fn handler(context: Context) -> Result<()> { +pub fn handler(context: Context) -> Result<()> { if context.accounts.white_list.authority != context.accounts.signer.key() { panic!("Only the authority can add to the white list!"); } diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs index 6b9d7367..a8c5f6de 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/initialize_extra_account_meta_list.rs @@ -6,7 +6,7 @@ use spl_transfer_hook_interface::instruction::ExecuteInstruction; use crate::{handle_extra_account_metas, handle_extra_account_metas_count, WhiteList}; #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList<'info> { +pub struct InitializeExtraAccountMetaListAccountConstraints<'info> { #[account(mut)] payer: Signer<'info>, @@ -15,7 +15,7 @@ pub struct InitializeExtraAccountMetaList<'info> { init, seeds = [b"extra-account-metas", mint.key().as_ref()], bump, - // size_of returns Result with spl's ProgramError — unwrap is safe for known-good input + // size_of returns Result with spl's ProgramError - unwrap is safe for known-good input space = ExtraAccountMetaList::size_of( handle_extra_account_metas_count() ).unwrap(), @@ -28,7 +28,7 @@ pub struct InitializeExtraAccountMetaList<'info> { pub white_list: Account<'info, WhiteList>, } -pub fn handler(mut context: Context) -> Result<()> { +pub fn handler(mut context: Context) -> Result<()> { // set authority field on white_list account as payer address context.accounts.white_list.authority = context.accounts.payer.key(); context.accounts.white_list.bump = context.bumps.white_list; @@ -37,7 +37,7 @@ pub fn handler(mut context: Context) -> Result<( // initialize ExtraAccountMetaList account with extra accounts // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types ExtraAccountMetaList::init::( &mut context.accounts.extra_account_meta_list.try_borrow_mut_data()?, &extra_account_metas, diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs index 1f98e583..d0279a60 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/instructions/transfer_hook.rs @@ -8,7 +8,7 @@ use crate::{check_is_transferring, WhiteList}; // Remaining accounts are the extra accounts required from the ExtraAccountMetaList account // These accounts are provided via CPI to this program from the token2022 program #[derive(Accounts)] -pub struct TransferHook<'info> { +pub struct TransferHookAccountConstraints<'info> { #[account(token::mint = mint, token::authority = owner)] pub source_token: InterfaceAccount<'info, TokenAccount>, pub mint: InterfaceAccount<'info, Mint>, @@ -23,7 +23,7 @@ pub struct TransferHook<'info> { pub white_list: Account<'info, WhiteList>, } -pub fn handler(context: Context, _amount: u64) -> Result<()> { +pub fn handler(context: Context, _amount: u64) -> Result<()> { // Fail this instruction if it is not called from within a transfer hook check_is_transferring(&context)?; diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/lib.rs b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/lib.rs index 0645fd1f..08089435 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/src/lib.rs @@ -31,26 +31,26 @@ pub mod transfer_hook { #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)] pub fn initialize_extra_account_meta_list( - context: Context, + context: Context, ) -> Result<()> { instructions::initialize_extra_account_meta_list::handler(context) } #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] - pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { + pub fn transfer_hook(context: Context, amount: u64) -> Result<()> { instructions::transfer_hook::handler(context, amount) } - pub fn add_to_whitelist(context: Context) -> Result<()> { + pub fn add_to_whitelist(context: Context) -> Result<()> { instructions::add_to_whitelist::handler(context) } } -pub fn check_is_transferring(context: &Context) -> Result<()> { +pub fn check_is_transferring(context: &Context) -> Result<()> { let source_token_info = context.accounts.source_token.to_account_info(); let mut account_data_ref: RefMut<&mut [u8]> = source_token_info.try_borrow_mut_data()?; // .map_err() needed because spl-token-2022 uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types let mut account = PodStateWithExtensionsMut::::unpack(*account_data_ref) .map_err(|_| ProgramError::InvalidAccountData)?; let account_extension = account.get_extension_mut::() @@ -66,7 +66,7 @@ pub fn check_is_transferring(context: &Context) -> Result<()> { // Define extra account metas to store on extra_account_meta_list account pub fn handle_extra_account_metas() -> Result> { // .map_err() needed because spl-tlv-account-resolution uses solana-program-error 2.x - // while anchor-lang 1.0 uses 3.x — structurally identical but different semver types + // while anchor-lang 1.0 uses 3.x - structurally identical but different semver types Ok(vec![ExtraAccountMeta::new_with_seeds( &[Seed::Literal { bytes: "white_list".as_bytes().to_vec(), diff --git a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/tests/test_transfer_hook.rs b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/tests/test_transfer_hook.rs index 35152fa7..8911293e 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/tests/test_transfer_hook.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook/tests/test_transfer_hook.rs @@ -88,7 +88,7 @@ fn test_whitelist_transfer_hook() { let init_extra_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::InitializeExtraAccountMetaList {}.data(), - transfer_hook::accounts::InitializeExtraAccountMetaList { + transfer_hook::accounts::InitializeExtraAccountMetaListAccountConstraints { payer: payer.pubkey(), extra_account_meta_list, mint, @@ -104,7 +104,7 @@ fn test_whitelist_transfer_hook() { let add_to_whitelist_ix = Instruction::new_with_bytes( program_id, &transfer_hook::instruction::AddToWhitelist {}.data(), - transfer_hook::accounts::AddToWhiteList { + transfer_hook::accounts::AddToWhiteListAccountConstraints { new_account: dest_ata, white_list: white_list_pda, signer: payer.pubkey(), @@ -114,7 +114,7 @@ fn test_whitelist_transfer_hook() { send_transaction_from_instructions(&mut svm, vec![add_to_whitelist_ix], &[&payer], &payer.pubkey()).unwrap(); svm.expire_blockhash(); - // Step 5: Transfer — should succeed (destination is whitelisted) + // Step 5: Transfer - should succeed (destination is whitelisted) let transfer_amount: u64 = 1 * 10u64.pow(decimals as u32); let extra_accounts = build_hook_accounts( &mint, diff --git a/tokens/token-extensions/transfer-hook/whitelist/quasar/README.md b/tokens/token-extensions/transfer-hook/whitelist/quasar/README.md index cbc37121..8ad529b9 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/README.md +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/README.md @@ -1,4 +1,4 @@ -# Transfer Hook — Whitelist (Quasar) +# Transfer Hook - Whitelist (Quasar) Only whitelisted accounts may receive tokens. diff --git a/tokens/token-extensions/transfer-hook/whitelist/quasar/src/lib.rs b/tokens/token-extensions/transfer-hook/whitelist/quasar/src/lib.rs index 690ff185..212313a6 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/src/lib.rs @@ -9,7 +9,7 @@ use quasar_lang::{ #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("DrWbQtYJGtsoRwzKqAbHKHKsCJJfpysudF39GBVFSxub"); /// SPL Transfer Hook Interface discriminators (SHA-256 prefix). #[allow(dead_code)] @@ -25,21 +25,21 @@ mod quasar_transfer_hook_whitelist { /// Discriminator = sha256("spl-transfer-hook-interface:initialize-extra-account-metas")[:8] #[instruction(discriminator = [43, 34, 13, 49, 167, 88, 235, 235])] pub fn initialize_extra_account_meta_list( - ctx: Ctx, + ctx: Ctx, ) -> Result<(), ProgramError> { handle_initialize(&mut ctx.accounts) } - /// Transfer hook handler — checks if the destination is in the whitelist. + /// Transfer hook handler - checks if the destination is in the whitelist. /// Discriminator = sha256("spl-transfer-hook-interface:execute")[:8] #[instruction(discriminator = [105, 37, 101, 197, 75, 251, 102, 26])] - pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { + pub fn transfer_hook(ctx: Ctx, _amount: u64) -> Result<(), ProgramError> { handle_transfer_hook(&mut ctx.accounts) } /// Add an address to the whitelist. Only callable by the authority. #[instruction(discriminator = [0, 0, 0, 0, 0, 0, 0, 2])] - pub fn add_to_whitelist(ctx: Ctx) -> Result<(), ProgramError> { + pub fn add_to_whitelist(ctx: Ctx) -> Result<(), ProgramError> { handle_add_to_whitelist(&mut ctx.accounts) } } @@ -49,7 +49,7 @@ mod quasar_transfer_hook_whitelist { // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct InitializeExtraAccountMetaList { +pub struct InitializeExtraAccountMetaListAccountConstraints { #[account(mut)] pub payer: Signer, #[account(mut)] @@ -62,7 +62,7 @@ pub struct InitializeExtraAccountMetaList { } #[inline(always)] -pub fn handle_initialize(accounts: &mut InitializeExtraAccountMetaList) -> Result<(), ProgramError> { +pub fn handle_initialize(accounts: &mut InitializeExtraAccountMetaListAccountConstraints) -> Result<(), ProgramError> { // Create ExtraAccountMetaList PDA (1 extra account: whitelist) let meta_list_size: u64 = 51; // 8 + 4 + 4 + 35 let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; @@ -147,7 +147,7 @@ pub fn handle_initialize(accounts: &mut InitializeExtraAccountMetaList) -> Resul // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct TransferHook { +pub struct TransferHookAccountConstraints { pub source_token: UncheckedAccount, pub mint: UncheckedAccount, pub destination_token: UncheckedAccount, @@ -157,7 +157,7 @@ pub struct TransferHook { } #[inline(always)] -pub fn handle_transfer_hook(accounts: &TransferHook) -> Result<(), ProgramError> { +pub fn handle_transfer_hook(accounts: &TransferHookAccountConstraints) -> Result<(), ProgramError> { let wl_view = accounts.white_list.to_account_view(); let data = wl_view.try_borrow()?; @@ -198,7 +198,7 @@ pub fn handle_transfer_hook(accounts: &TransferHook) -> Result<(), ProgramError> // --------------------------------------------------------------------------- #[derive(Accounts)] -pub struct AddToWhitelist { +pub struct AddToWhitelistAccountConstraints { pub signer: Signer, pub new_account: UncheckedAccount, #[account(mut)] @@ -206,7 +206,7 @@ pub struct AddToWhitelist { } #[inline(always)] -pub fn handle_add_to_whitelist(accounts: &mut AddToWhitelist) -> Result<(), ProgramError> { +pub fn handle_add_to_whitelist(accounts: &mut AddToWhitelistAccountConstraints) -> Result<(), ProgramError> { let view = unsafe { &mut *(&mut accounts.white_list as *mut UncheckedAccount as *mut AccountView) diff --git a/tokens/token-extensions/transfer-hook/whitelist/quasar/src/tests.rs b/tokens/token-extensions/transfer-hook/whitelist/quasar/src/tests.rs index c214f41e..1f1f1d4c 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/src/tests.rs @@ -79,7 +79,7 @@ fn test_whitelist_flow() { result.print_logs(); assert!(result.is_ok(), "add_to_whitelist failed: {:?}", result.raw_result); - // 3. Transfer hook with whitelisted destination — should succeed + // 3. Transfer hook with whitelisted destination - should succeed let source_token = Pubkey::new_unique(); let owner = Pubkey::new_unique(); @@ -107,7 +107,7 @@ fn test_whitelist_flow() { assert!(result.is_ok(), "transfer_hook (whitelisted) failed: {:?}", result.raw_result); println!(" TRANSFER_HOOK (allowed) CU: {}", result.compute_units_consumed); - // 4. Transfer hook with non-whitelisted destination — should fail + // 4. Transfer hook with non-whitelisted destination - should fail let bad_dest = Pubkey::new_unique(); let mut hook_data2 = vec![105, 37, 101, 197, 75, 251, 102, 26]; hook_data2.extend_from_slice(&100u64.to_le_bytes()); diff --git a/tokens/token-minter/anchor/Anchor.toml b/tokens/token-minter/anchor/Anchor.toml index ff3b0862..e1ac6290 100644 --- a/tokens/token-minter/anchor/Anchor.toml +++ b/tokens/token-minter/anchor/Anchor.toml @@ -8,14 +8,11 @@ skip-lint = false [programs.localnet] token_minter = "3of89Z9jwek9zrFgpCWc9jZvQvitpVMxpZNsrAD2vQUD" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# Only run bankrun tests — the validator tests (test.ts) need Metaplex Token -# Metadata cloned from mainnet which is too slow/unreliable in CI. -# bankrun.test.ts uses a local fixture (tests/fixtures/token_metadata.so). +# Rust + LiteSVM tests; Metaplex Token Metadata is loaded from a local +# fixture (tests/fixtures/mpl_token_metadata.so), no validator needed. test = "cargo test" diff --git a/tokens/token-minter/anchor/README.md b/tokens/token-minter/anchor/README.md index ee9455f3..d6719642 100644 --- a/tokens/token-minter/anchor/README.md +++ b/tokens/token-minter/anchor/README.md @@ -8,6 +8,7 @@ See also: [Token Minter overview](../README.md) and the [repository catalog](../ - Mint authority on a PDA or signer - Token account initialization +- Amounts: `mint_token` takes `amount` in **minor units**, the raw integer the token program operates on. Clients convert from major units offchain: 1 token with 9 decimals is `1 * 10^9` minor units. The program never scales amounts onchain. ## Setup diff --git a/tokens/token-minter/anchor/programs/token-minter/src/instructions/create.rs b/tokens/token-minter/anchor/programs/token-minter/src/instructions/create.rs index cc363ef6..95be34e0 100644 --- a/tokens/token-minter/anchor/programs/token-minter/src/instructions/create.rs +++ b/tokens/token-minter/anchor/programs/token-minter/src/instructions/create.rs @@ -10,7 +10,7 @@ use { }; #[derive(Accounts)] -pub struct CreateToken<'info> { +pub struct CreateTokenAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -39,7 +39,7 @@ pub struct CreateToken<'info> { } pub fn handle_create_token( - context: Context, + context: Context, token_name: String, token_symbol: String, token_uri: String, diff --git a/tokens/token-minter/anchor/programs/token-minter/src/instructions/mint.rs b/tokens/token-minter/anchor/programs/token-minter/src/instructions/mint.rs index f00ebbd1..7d731da0 100644 --- a/tokens/token-minter/anchor/programs/token-minter/src/instructions/mint.rs +++ b/tokens/token-minter/anchor/programs/token-minter/src/instructions/mint.rs @@ -7,13 +7,15 @@ use { }; #[derive(Accounts)] -pub struct MintToken<'info> { +pub struct MintTokenAccountConstraints<'info> { #[account(mut)] pub mint_authority: Signer<'info>, pub recipient: SystemAccount<'info>, + #[account(mut)] pub mint_account: Account<'info, Mint>, + #[account( init_if_needed, payer = mint_authority, @@ -27,7 +29,15 @@ pub struct MintToken<'info> { pub system_program: Program<'info, System>, } -pub fn handle_mint_token(context: Context, amount: u64) -> Result<()> { +/// Mints `amount` tokens to the recipient's associated token account. +/// +/// `amount` is in minor units (the raw integer the token program operates +/// on). Clients convert from major units, e.g. 1 token with 9 decimals is +/// `1 * 10u64.pow(9)` minor units. +pub fn handle_mint_token( + context: Context, + amount: u64, +) -> Result<()> { msg!("Minting tokens to associated token account..."); msg!("Mint: {}", &context.accounts.mint_account.key()); msg!( @@ -45,7 +55,7 @@ pub fn handle_mint_token(context: Context, amount: u64) -> Result<()> authority: context.accounts.mint_authority.to_account_info(), }, ), - amount * 10u64.pow(context.accounts.mint_account.decimals as u32), // Mint tokens, adjust for decimals + amount, )?; msg!("Token minted successfully."); diff --git a/tokens/token-minter/anchor/programs/token-minter/src/lib.rs b/tokens/token-minter/anchor/programs/token-minter/src/lib.rs index f7415398..8033bddb 100644 --- a/tokens/token-minter/anchor/programs/token-minter/src/lib.rs +++ b/tokens/token-minter/anchor/programs/token-minter/src/lib.rs @@ -10,7 +10,7 @@ pub mod token_minter { use super::*; pub fn create_token( - context: Context, + context: Context, token_name: String, token_symbol: String, token_uri: String, @@ -18,7 +18,11 @@ pub mod token_minter { create::handle_create_token(context, token_name, token_symbol, token_uri) } - pub fn mint_token(context: Context, amount: u64) -> Result<()> { + /// Mint `amount` minor units of the token to the recipient. + pub fn mint_token( + context: Context, + amount: u64, + ) -> Result<()> { mint::handle_mint_token(context, amount) } } diff --git a/tokens/token-minter/anchor/programs/token-minter/tests/test_token_minter.rs b/tokens/token-minter/anchor/programs/token-minter/tests/test_token_minter.rs index b7748020..487a185f 100644 --- a/tokens/token-minter/anchor/programs/token-minter/tests/test_token_minter.rs +++ b/tokens/token-minter/anchor/programs/token-minter/tests/test_token_minter.rs @@ -11,6 +11,16 @@ use { solana_signer::Signer, }; +/// Decimals configured by the program's `mint::decimals` constraint in +/// `CreateTokenAccountConstraints`. +const MINT_DECIMALS: u32 = 9; + +/// Converts a whole-token (major unit) count to minor units, the form the +/// program's `mint_token` handler takes amounts in. +fn to_minor_units(major_units: u64) -> u64 { + major_units.checked_mul(10u64.pow(MINT_DECIMALS)).unwrap() +} + fn metadata_program_id() -> Pubkey { "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" .parse() @@ -85,7 +95,7 @@ fn test_create_token() { token_uri: "https://example.com/token.json".to_string(), } .data(), - token_minter::accounts::CreateToken { + token_minter::accounts::CreateTokenAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), metadata_account, @@ -132,7 +142,7 @@ fn test_create_and_mint_tokens() { token_uri: "https://example.com/token.json".to_string(), } .data(), - token_minter::accounts::CreateToken { + token_minter::accounts::CreateTokenAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), metadata_account, @@ -151,14 +161,17 @@ fn test_create_and_mint_tokens() { ) .unwrap(); - // 2. Mint 100 tokens + // 2. Mint 100 tokens. The handler takes minor units. svm.expire_blockhash(); let ata = derive_ata(&payer.pubkey(), &mint_keypair.pubkey()); let mint_ix = Instruction::new_with_bytes( program_id, - &token_minter::instruction::MintToken { amount: 100 }.data(), - token_minter::accounts::MintToken { + &token_minter::instruction::MintToken { + amount: to_minor_units(100), + } + .data(), + token_minter::accounts::MintTokenAccountConstraints { mint_authority: payer.pubkey(), recipient: payer.pubkey(), mint_account: mint_keypair.pubkey(), @@ -177,7 +190,7 @@ fn test_create_and_mint_tokens() { ) .unwrap(); - // Verify: 100 * 10^9 = 100_000_000_000 tokens minted (9 decimals) + // Verify 100 tokens minted (in minor units) let balance = get_token_account_balance(&svm, &ata).unwrap(); - assert_eq!(balance, 100_000_000_000, "Should have 100 tokens"); + assert_eq!(balance, to_minor_units(100), "Should have 100 tokens"); } diff --git a/tokens/token-minter/quasar/Cargo.toml b/tokens/token-minter/quasar/Cargo.toml index a1443e71..e0c9be17 100644 --- a/tokens/token-minter/quasar/Cargo.toml +++ b/tokens/token-minter/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-token-minter" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] @@ -23,7 +23,7 @@ debug = [] [dependencies] # All quasar deps share one source-id (branch = "master") so trait-impls -# resolve consistently — mixing `{ git = ... }` with `{ git = ..., branch = "master" }` +# resolve consistently - mixing `{ git = ... }` with `{ git = ..., branch = "master" }` # was treated by Cargo as two distinct sources of the same crate. # quasar pin rationale: master HEAD currently fails to compile because zeropod 0.3.x # auto-generates accessor methods that conflict with hand-written ones in quasar-spl diff --git a/tokens/token-minter/quasar/src/instructions/create.rs b/tokens/token-minter/quasar/src/instructions/create.rs index 86bf735c..5b787f7f 100644 --- a/tokens/token-minter/quasar/src/instructions/create.rs +++ b/tokens/token-minter/quasar/src/instructions/create.rs @@ -12,7 +12,7 @@ use { /// constants for `name` / `symbol` / `uri`; this instruction takes them at /// runtime. #[derive(Accounts)] -pub struct CreateToken { +pub struct CreateTokenAccountConstraints { #[account(mut)] pub payer: Signer, #[account( @@ -27,7 +27,7 @@ pub struct CreateToken { ), )] pub mint_account: Account, - /// The metadata PDA — will be initialised by the Metaplex program. + /// The metadata PDA - will be initialised by the Metaplex program. #[account(mut)] pub metadata_account: UncheckedAccount, pub token_program: Program, @@ -38,7 +38,7 @@ pub struct CreateToken { #[inline(always)] pub fn handle_create_token( - accounts: &mut CreateToken, + accounts: &mut CreateTokenAccountConstraints, token_name: &str, token_symbol: &str, token_uri: &str, diff --git a/tokens/token-minter/quasar/src/instructions/mint.rs b/tokens/token-minter/quasar/src/instructions/mint.rs index 029658dd..59e3e054 100644 --- a/tokens/token-minter/quasar/src/instructions/mint.rs +++ b/tokens/token-minter/quasar/src/instructions/mint.rs @@ -3,7 +3,7 @@ use quasar_spl::prelude::*; /// Accounts for minting tokens to a recipient's token account. #[derive(Accounts)] -pub struct MintToken { +pub struct MintTokenAccountConstraints { #[account(mut)] pub mint_authority: Signer, pub recipient: UncheckedAccount, @@ -20,21 +20,24 @@ pub struct MintToken { pub system_program: Program, } +/// Mints `amount` tokens to the recipient's associated token account. +/// +/// `amount` is in minor units (the raw integer the token program operates +/// on). Clients convert from major units, e.g. 1 token with 9 decimals is +/// `1 * 10u64.pow(9)` minor units. #[inline(always)] -pub fn handle_mint_token(accounts: &mut MintToken, amount: u64) -> Result<(), ProgramError> { +pub fn handle_mint_token( + accounts: &mut MintTokenAccountConstraints, + amount: u64, +) -> Result<(), ProgramError> { log("Minting tokens to associated token account..."); - let decimals = accounts.mint_account.decimals(); - let adjusted_amount = amount - .checked_mul(10u64.pow(decimals as u32)) - .ok_or(ProgramError::ArithmeticOverflow)?; - accounts.token_program .mint_to( &accounts.mint_account, &accounts.associated_token_account, &accounts.mint_authority, - adjusted_amount, + amount, ) .invoke()?; diff --git a/tokens/token-minter/quasar/src/lib.rs b/tokens/token-minter/quasar/src/lib.rs index 6a3b061f..7d6e83a1 100644 --- a/tokens/token-minter/quasar/src/lib.rs +++ b/tokens/token-minter/quasar/src/lib.rs @@ -7,23 +7,23 @@ use instructions::*; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("3of89Z9jwek9zrFgpCWc9jZvQvitpVMxpZNsrAD2vQUD"); /// Token minter with Metaplex metadata. /// /// Two instructions: -/// - `create_token` — creates a mint and associated Metaplex metadata account -/// - `mint_token` — mints tokens to a recipient's associated token account +/// - `create_token` - creates a mint and associated Metaplex metadata account +/// - `mint_token` - mints tokens to a recipient's associated token account #[program] mod quasar_token_minter { use super::*; // String capacities follow Metaplex Token Metadata limits: // name ≤ 32, symbol ≤ 10, uri ≤ 200. PodString requires an explicit - // capacity since PR #195 — `String` (no ) is no longer accepted. + // capacity - bare `String` (no ) is not accepted. #[instruction(discriminator = 0)] pub fn create_token( - ctx: Ctx, + ctx: Ctx, token_name: String<32>, token_symbol: String<10>, token_uri: String<200>, @@ -36,8 +36,12 @@ mod quasar_token_minter { ) } + /// Mint `amount` minor units of the token to the recipient. #[instruction(discriminator = 1)] - pub fn mint_token(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn mint_token( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { instructions::handle_mint_token(&mut ctx.accounts, amount) } } diff --git a/tokens/token-minter/quasar/src/tests.rs b/tokens/token-minter/quasar/src/tests.rs index 14433ac3..41398aa7 100644 --- a/tokens/token-minter/quasar/src/tests.rs +++ b/tokens/token-minter/quasar/src/tests.rs @@ -2,6 +2,7 @@ extern crate std; use { alloc::vec, quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, std::println, }; @@ -43,8 +44,18 @@ fn token_account(address: Pubkey, mint_address: Pubkey, owner: Pubkey, amount: u ) } +/// Decimals configured by the mint fixture above, matching the program's +/// `mint(decimals = 9)` constraint in `CreateTokenAccountConstraints`. +const MINT_DECIMALS: u32 = 9; + +/// Converts a whole-token (major unit) count to minor units, the form the +/// program's `mint_token` handler takes amounts in. +fn to_minor_units(major_units: u64) -> u64 { + major_units.checked_mul(10u64.pow(MINT_DECIMALS)).unwrap() +} + /// Build mint_token instruction data. -/// Wire format: [disc=1] [amount: u64 LE] +/// Wire format: [disc=1] [amount: u64 LE, in minor units] fn build_mint_token_data(amount: u64) -> Vec { let mut data = vec![1u8]; data.extend_from_slice(&amount.to_le_bytes()); @@ -66,7 +77,7 @@ fn test_mint_token() { let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; let system_program = quasar_svm::system_program::ID; - let amount = 100u64; + let amount = to_minor_units(100); let data = build_mint_token_data(amount); let instruction = Instruction { @@ -97,5 +108,12 @@ fn test_mint_token() { "mint_token failed: {:?}", result.raw_result ); + + // The recipient's token account balance is the exact minor-unit amount + // requested - the program performs no onchain scaling. + let token_account_after = result.account(&token_addr).unwrap(); + let token_account_state = TokenAccount::unpack_from_slice(&token_account_after.data).unwrap(); + assert_eq!(token_account_state.amount, amount); + println!(" MINT TOKEN CU: {}", result.compute_units_consumed); } diff --git a/tokens/transfer-tokens/README.md b/tokens/transfer-tokens/README.md index 56dc493b..816768a4 100644 --- a/tokens/transfer-tokens/README.md +++ b/tokens/transfer-tokens/README.md @@ -2,6 +2,6 @@ Like minting, token transfers happen between [Associated Token Accounts](https://solana.com/docs/terminology#associated-token-account-ata). -Use the [Classic Token Program](https://solana.com/docs/terminology#token-program)'s `transfer` [instruction handler](https://solana.com/docs/terminology#instruction-handler) to move tokens, given the appropriate permissions. +Use the token program's `transfer_checked` [instruction handler](https://solana.com/docs/terminology#instruction-handler) to move tokens, given the appropriate permissions. `transfer_checked` carries the mint and decimals through the CPI, so a wrong-mint or wrong-decimals account fails the CPI instead of silently moving the wrong quantity. Amounts are passed in minor units, the raw integer the token program operates on. See [Token Minter](../token-minter) and [NFT Minter](../nft-minter) for more on Associated Token Accounts. diff --git a/tokens/transfer-tokens/anchor/Anchor.toml b/tokens/transfer-tokens/anchor/Anchor.toml index 4a333d0d..c6316abf 100644 --- a/tokens/transfer-tokens/anchor/Anchor.toml +++ b/tokens/transfer-tokens/anchor/Anchor.toml @@ -8,14 +8,11 @@ skip-lint = false [programs.localnet] transfer_tokens = "nHi9DdNjuupjQ3c8AJU9sChB5gLbZvTLsJQouY4hU67" -# [registry] section removed — no longer used in Anchor 1.0 - [provider] cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# Only run bankrun tests — the validator tests (test.ts) need Metaplex Token -# Metadata cloned from mainnet which is too slow/unreliable in CI. -# bankrun.test.ts uses a local fixture (tests/fixtures/token_metadata.so). +# Rust + LiteSVM tests; Metaplex Token Metadata is loaded from a local +# fixture (tests/fixtures/mpl_token_metadata.so), no validator needed. test = "cargo test" diff --git a/tokens/transfer-tokens/anchor/README.md b/tokens/transfer-tokens/anchor/README.md index 6a64882d..b173325e 100644 --- a/tokens/transfer-tokens/anchor/README.md +++ b/tokens/transfer-tokens/anchor/README.md @@ -1,13 +1,15 @@ # Transfer Tokens (Anchor) -Transfer tokens between token accounts via CPI to the Classic Token Program. +Transfer tokens between token accounts via CPI to the token program. See also: [Transfer Tokens overview](../README.md) and the [repository catalog](../../../README.md). ## Major concepts - Associated token accounts -- transfer or transfer_checked +- `transfer_checked`, which carries the mint and decimals through the CPI +- `anchor_spl::token_interface` types, so the same program works against both the Classic Token Program and the Token Extensions Program +- Amounts: `mint_token` and `transfer_tokens` take `amount` in **minor units**, the raw integer the token program operates on. Clients convert from major units offchain: 1 token with 9 decimals is `1 * 10^9` minor units. The program never scales amounts onchain. ## Setup diff --git a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/create.rs b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/create.rs index b2916b74..6670117d 100644 --- a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/create.rs +++ b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/create.rs @@ -5,12 +5,12 @@ use { create_metadata_accounts_v3, mpl_token_metadata::types::DataV2, CreateMetadataAccountsV3, Metadata, }, - token::{Mint, Token}, + token_interface::{Mint, TokenInterface}, }, }; #[derive(Accounts)] -pub struct CreateToken<'info> { +pub struct CreateTokenAccountConstraints<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -20,9 +20,9 @@ pub struct CreateToken<'info> { mint::decimals = 9, mint::authority = payer.key(), mint::freeze_authority = payer.key(), - + mint::token_program = token_program, )] - pub mint_account: Account<'info, Mint>, + pub mint_account: InterfaceAccount<'info, Mint>, /// CHECK: Validate address by deriving pda #[account( @@ -33,14 +33,14 @@ pub struct CreateToken<'info> { )] pub metadata_account: UncheckedAccount<'info>, - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, pub token_metadata_program: Program<'info, Metadata>, pub system_program: Program<'info, System>, pub rent: Sysvar<'info, Rent>, } pub fn handle_create_token( - context: Context, + context: Context, token_name: String, token_symbol: String, token_uri: String, diff --git a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/mint.rs b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/mint.rs index 0313b054..f57df043 100644 --- a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/mint.rs +++ b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/mint.rs @@ -2,32 +2,43 @@ use { anchor_lang::prelude::*, anchor_spl::{ associated_token::AssociatedToken, - token::{mint_to, Mint, MintTo, Token, TokenAccount}, + token_interface::{mint_to, Mint, MintTo, TokenAccount, TokenInterface}, }, }; #[derive(Accounts)] -pub struct MintToken<'info> { +pub struct MintTokenAccountConstraints<'info> { #[account(mut)] pub mint_authority: Signer<'info>, pub recipient: SystemAccount<'info>, + #[account(mut)] - pub mint_account: Account<'info, Mint>, + pub mint_account: InterfaceAccount<'info, Mint>, + #[account( init_if_needed, payer = mint_authority, associated_token::mint = mint_account, associated_token::authority = recipient, + associated_token::token_program = token_program, )] - pub associated_token_account: Account<'info, TokenAccount>, + pub associated_token_account: InterfaceAccount<'info, TokenAccount>, - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } -pub fn handle_mint_token(context: Context, amount: u64) -> Result<()> { +/// Mints `amount` tokens to the recipient's associated token account. +/// +/// `amount` is in minor units (the raw integer the token program operates +/// on). Clients convert from major units, e.g. 1 token with 9 decimals is +/// `1 * 10u64.pow(9)` minor units. +pub fn handle_mint_token( + context: Context, + amount: u64, +) -> Result<()> { msg!("Minting tokens to associated token account..."); msg!("Mint: {}", &context.accounts.mint_account.key()); msg!( @@ -45,7 +56,7 @@ pub fn handle_mint_token(context: Context, amount: u64) -> Result<()> authority: context.accounts.mint_authority.to_account_info(), }, ), - amount * 10u64.pow(context.accounts.mint_account.decimals as u32), // Mint tokens + amount, )?; msg!("Token minted successfully."); diff --git a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/transfer.rs b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/transfer.rs index b8e3bea9..b471124d 100644 --- a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/transfer.rs +++ b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/instructions/transfer.rs @@ -2,38 +2,54 @@ use { anchor_lang::prelude::*, anchor_spl::{ associated_token::AssociatedToken, - token::{transfer, Mint, Token, TokenAccount, Transfer}, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }, }; #[derive(Accounts)] -pub struct TransferTokens<'info> { +pub struct TransferTokensAccountConstraints<'info> { #[account(mut)] pub sender: Signer<'info>, + pub recipient: SystemAccount<'info>, #[account(mut)] - pub mint_account: Account<'info, Mint>, + pub mint_account: InterfaceAccount<'info, Mint>, + #[account( mut, associated_token::mint = mint_account, associated_token::authority = sender, + associated_token::token_program = token_program, )] - pub sender_token_account: Account<'info, TokenAccount>, + pub sender_token_account: InterfaceAccount<'info, TokenAccount>, + #[account( init_if_needed, payer = sender, associated_token::mint = mint_account, associated_token::authority = recipient, + associated_token::token_program = token_program, )] - pub recipient_token_account: Account<'info, TokenAccount>, + pub recipient_token_account: InterfaceAccount<'info, TokenAccount>, - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } -pub fn handle_transfer_tokens(context: Context, amount: u64) -> Result<()> { +/// Transfers `amount` tokens from the sender's to the recipient's associated +/// token account. +/// +/// `amount` is in minor units (the raw integer the token program operates +/// on). Clients convert from major units, e.g. 1 token with 9 decimals is +/// `1 * 10u64.pow(9)` minor units. `transfer_checked` carries the mint and +/// decimals through the CPI so a wrong-mint or wrong-decimals account fails +/// the CPI instead of silently moving the wrong quantity. +pub fn handle_transfer_tokens( + context: Context, + amount: u64, +) -> Result<()> { msg!("Transferring tokens..."); msg!( "Mint: {}", @@ -48,17 +64,19 @@ pub fn handle_transfer_tokens(context: Context, amount: u64) -> &context.accounts.recipient_token_account.key() ); - // Invoke the transfer instruction on the token program - transfer( + // Invoke the transfer_checked instruction on the token program + transfer_checked( CpiContext::new( context.accounts.token_program.key(), - Transfer { + TransferChecked { from: context.accounts.sender_token_account.to_account_info(), + mint: context.accounts.mint_account.to_account_info(), to: context.accounts.recipient_token_account.to_account_info(), authority: context.accounts.sender.to_account_info(), }, ), - amount * 10u64.pow(context.accounts.mint_account.decimals as u32), // Transfer amount, adjust for decimals + amount, + context.accounts.mint_account.decimals, )?; msg!("Tokens transferred successfully."); diff --git a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/lib.rs b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/lib.rs index 5930b495..3d008421 100644 --- a/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/lib.rs +++ b/tokens/transfer-tokens/anchor/programs/transfer-tokens/src/lib.rs @@ -11,7 +11,7 @@ pub mod transfer_tokens { use super::*; pub fn create_token( - context: Context, + context: Context, token_title: String, token_symbol: String, token_uri: String, @@ -19,11 +19,19 @@ pub mod transfer_tokens { create::handle_create_token(context, token_title, token_symbol, token_uri) } - pub fn mint_token(context: Context, amount: u64) -> Result<()> { + /// Mint `amount` minor units of the token to the recipient. + pub fn mint_token( + context: Context, + amount: u64, + ) -> Result<()> { mint::handle_mint_token(context, amount) } - pub fn transfer_tokens(context: Context, amount: u64) -> Result<()> { + /// Transfer `amount` minor units of the token from sender to recipient. + pub fn transfer_tokens( + context: Context, + amount: u64, + ) -> Result<()> { transfer::handle_transfer_tokens(context, amount) } } diff --git a/tokens/transfer-tokens/anchor/programs/transfer-tokens/tests/test_transfer_tokens.rs b/tokens/transfer-tokens/anchor/programs/transfer-tokens/tests/test_transfer_tokens.rs index 5a58fa9a..bf0052e9 100644 --- a/tokens/transfer-tokens/anchor/programs/transfer-tokens/tests/test_transfer_tokens.rs +++ b/tokens/transfer-tokens/anchor/programs/transfer-tokens/tests/test_transfer_tokens.rs @@ -9,6 +9,16 @@ use { solana_signer::Signer, }; +/// Decimals configured by the program's `mint::decimals` constraint in +/// `CreateTokenAccountConstraints`. +const MINT_DECIMALS: u32 = 9; + +/// Converts a whole-token (major unit) count to minor units, the form the +/// program's instruction handlers take amounts in. +fn to_minor_units(major_units: u64) -> u64 { + major_units.checked_mul(10u64.pow(MINT_DECIMALS)).unwrap() +} + fn metadata_program_id() -> Pubkey { "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" .parse() @@ -84,7 +94,7 @@ fn test_create_mint_and_transfer() { token_uri: "https://example.com/token.json".to_string(), } .data(), - transfer_tokens::accounts::CreateToken { + transfer_tokens::accounts::CreateTokenAccountConstraints { payer: payer.pubkey(), mint_account: mint_keypair.pubkey(), metadata_account, @@ -109,14 +119,17 @@ fn test_create_mint_and_transfer() { .expect("Mint should exist"); assert!(!mint_account.data.is_empty()); - // 2. Mint tokens (100 tokens to payer's ATA) + // 2. Mint 100 tokens to payer's ATA. The handler takes minor units. svm.expire_blockhash(); let sender_ata = derive_ata(&payer.pubkey(), &mint_keypair.pubkey()); let mint_ix = Instruction::new_with_bytes( program_id, - &transfer_tokens::instruction::MintToken { amount: 100 }.data(), - transfer_tokens::accounts::MintToken { + &transfer_tokens::instruction::MintToken { + amount: to_minor_units(100), + } + .data(), + transfer_tokens::accounts::MintTokenAccountConstraints { mint_authority: payer.pubkey(), recipient: payer.pubkey(), mint_account: mint_keypair.pubkey(), @@ -135,21 +148,24 @@ fn test_create_mint_and_transfer() { ) .unwrap(); - // Verify tokens minted — 100 * 10^9 = 100_000_000_000 (9 decimals) + // Verify 100 tokens minted (in minor units) assert_eq!( get_token_account_balance(&svm, &sender_ata).unwrap(), - 100_000_000_000 + to_minor_units(100) ); - // 3. Transfer tokens (50 tokens to recipient) + // 3. Transfer 50 tokens to recipient. The handler takes minor units. svm.expire_blockhash(); let recipient = Keypair::new(); let recipient_ata = derive_ata(&recipient.pubkey(), &mint_keypair.pubkey()); let transfer_ix = Instruction::new_with_bytes( program_id, - &transfer_tokens::instruction::TransferTokens { amount: 50 }.data(), - transfer_tokens::accounts::TransferTokens { + &transfer_tokens::instruction::TransferTokens { + amount: to_minor_units(50), + } + .data(), + transfer_tokens::accounts::TransferTokensAccountConstraints { sender: payer.pubkey(), recipient: recipient.pubkey(), mint_account: mint_keypair.pubkey(), @@ -169,13 +185,13 @@ fn test_create_mint_and_transfer() { ) .unwrap(); - // Verify: sender 50 tokens, recipient 50 tokens (at 9 decimals) + // Verify: sender 50 tokens, recipient 50 tokens (in minor units) assert_eq!( get_token_account_balance(&svm, &sender_ata).unwrap(), - 50_000_000_000 + to_minor_units(50) ); assert_eq!( get_token_account_balance(&svm, &recipient_ata).unwrap(), - 50_000_000_000 + to_minor_units(50) ); } diff --git a/tokens/transfer-tokens/quasar/Cargo.toml b/tokens/transfer-tokens/quasar/Cargo.toml index e1b2fabd..2846741d 100644 --- a/tokens/transfer-tokens/quasar/Cargo.toml +++ b/tokens/transfer-tokens/quasar/Cargo.toml @@ -3,7 +3,7 @@ name = "quasar-transfer-tokens" version = "0.1.0" edition = "2021" -# Standalone workspace — not part of the root program-examples workspace. +# Standalone workspace - not part of the root program-examples workspace. # Quasar uses a different resolver and dependency tree. [workspace] diff --git a/tokens/transfer-tokens/quasar/src/lib.rs b/tokens/transfer-tokens/quasar/src/lib.rs index bec0f28c..a7690222 100644 --- a/tokens/transfer-tokens/quasar/src/lib.rs +++ b/tokens/transfer-tokens/quasar/src/lib.rs @@ -6,33 +6,40 @@ use quasar_spl::prelude::*; #[cfg(test)] mod tests; -declare_id!("22222222222222222222222222222222222222222222"); +declare_id!("nHi9DdNjuupjQ3c8AJU9sChB5gLbZvTLsJQouY4hU67"); -/// Demonstrates creating a mint, minting tokens, and transferring between accounts. +/// Demonstrates minting tokens and transferring them between accounts. /// -/// The Anchor version uses Metaplex for onchain metadata. Quasar does not have -/// a Metaplex integration crate, so this example focuses on the core SPL Token -/// operations: minting and transferring. +/// The Anchor variant also creates Metaplex metadata for the mint; this +/// variant focuses on the core token operations - minting and transferring - +/// and leaves metadata out. Both handlers take `amount` in minor units (the +/// raw integer the token program operates on); no scaling happens onchain. #[program] mod quasar_transfer_tokens { use super::*; - /// Mint tokens to a recipient's token account. + /// Mint `amount` minor units to a recipient's token account. #[instruction(discriminator = 0)] - pub fn mint_tokens(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn mint_tokens( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { handle_mint_tokens(&mut ctx.accounts, amount) } - /// Transfer tokens from sender to recipient. + /// Transfer `amount` minor units from sender to recipient. #[instruction(discriminator = 1)] - pub fn transfer_tokens(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + pub fn transfer_tokens( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { handle_transfer_tokens(&mut ctx.accounts, amount) } } /// Accounts for minting tokens to a recipient. #[derive(Accounts)] -pub struct MintTokens { +pub struct MintTokensAccountConstraints { #[account(mut)] pub mint_authority: Signer, #[account(mut)] @@ -44,7 +51,10 @@ pub struct MintTokens { } #[inline(always)] -fn handle_mint_tokens(accounts: &mut MintTokens, amount: u64) -> Result<(), ProgramError> { +fn handle_mint_tokens( + accounts: &mut MintTokensAccountConstraints, + amount: u64, +) -> Result<(), ProgramError> { accounts.token_program .mint_to(&accounts.mint, &accounts.recipient_token_account, &accounts.mint_authority, amount) .invoke() @@ -52,7 +62,7 @@ fn handle_mint_tokens(accounts: &mut MintTokens, amount: u64) -> Result<(), Prog /// Accounts for transferring tokens between two token accounts. #[derive(Accounts)] -pub struct TransferTokens { +pub struct TransferTokensAccountConstraints { #[account(mut)] pub sender: Signer, #[account(mut)] @@ -63,7 +73,10 @@ pub struct TransferTokens { } #[inline(always)] -fn handle_transfer_tokens(accounts: &mut TransferTokens, amount: u64) -> Result<(), ProgramError> { +fn handle_transfer_tokens( + accounts: &mut TransferTokensAccountConstraints, + amount: u64, +) -> Result<(), ProgramError> { accounts.token_program .transfer(&accounts.sender_token_account, &accounts.recipient_token_account, &accounts.sender, amount) .invoke() diff --git a/tools/shank-and-codama/native/README.md b/tools/shank-and-codama/native/README.md index 9071c123..a92bbf53 100644 --- a/tools/shank-and-codama/native/README.md +++ b/tools/shank-and-codama/native/README.md @@ -9,14 +9,9 @@ in the language of your choice. This example is a small "car rental service" program. It is annotated with Shank macros, Shank extracts the IDL, and Codama renders a TypeScript client (`@solana/kit`-based) from that IDL. An in-process [LiteSVM](https://github.com/litesvm/litesvm) -test then drives the program through the generated client — no validator or +test then drives the program through the generated client - no validator or devnet required, so it runs in CI. -> This example used to use [Solita](https://github.com/metaplex-foundation/solita) -> to generate the client. Solita is unmaintained and does not work on the current -> toolchain, so it has been replaced with Codama. The Shank half of the lesson is -> unchanged. - ## Shank [Shank](https://github.com/metaplex-foundation/shank) is a set of Rust derive @@ -63,17 +58,16 @@ pnpm generate-idl # runs: shank idl --crate-root ./program --out-dir ./program The IDL lands in `program/idl/car_rental_service.json` (committed to the repo so the client can be regenerated without the Rust CLI). Its `metadata.origin` is `"shank"`, and each instruction carries an explicit single-byte (`u8`) -`discriminant` — this is what distinguishes a Shank IDL from an Anchor IDL. +`discriminant` - this is what distinguishes a Shank IDL from an Anchor IDL. ### A note on PDAs and `#[seeds(...)]` -Shank 0.0.x used a `#[seeds(...)]` attribute on a `ShankAccount` to *generate* -`shank_pda` / `shank_seeds_with_bump` helper methods. As of Shank 0.4.x that PDA +Shank's `#[seeds(...)]` attribute is not used here: on Shank 0.4.x its PDA code-generation produces unparsable tokens and fails to compile, and the seeds -are not emitted into the IDL either. So this example keeps PDA derivation -explicit in `program/src/state/mod.rs` (`Car::find_pda`, `RentalOrder::find_pda`) -and no longer uses the `#[seeds(...)]` attribute. `ShankAccount` is still used — -it is what tells Shank to include the account layout in the IDL. +are not emitted into the IDL either. This example instead keeps PDA derivation +explicit in `program/src/state/mod.rs` (`Car::find_pda`, `RentalOrder::find_pda`). +`ShankAccount` is still used - it is what tells Shank to include the account +layout in the IDL. ## Codama @@ -103,7 +97,7 @@ await codama.accept(renderVisitor(outDir, { deleteFolderBeforeRendering: true }) ``` > Codama also ships `@codama/renderers-rust` if you want a Rust client instead of -> a TypeScript one — swap `renderVisitor` from `@codama/renderers-js` for the Rust +> a TypeScript one - swap `renderVisitor` from `@codama/renderers-js` for the Rust > renderer. Generate the client: @@ -123,6 +117,10 @@ pnpm build-and-test # build, regenerate the client, then run the LiteSVM test ``` The test ([`tests/test.ts`](./tests/test.ts)) loads the compiled `.so` into a -[LiteSVM](https://github.com/litesvm/litesvm) instance and exercises `add_car`, -`book_rental`, and `pick_up_car` through the generated client, asserting on the -resulting on-chain account state. +[LiteSVM](https://github.com/litesvm/litesvm) instance and drives the full +rental lifecycle (`add_car`, `book_rental`, `pick_up_car`, `return_car`) +through the generated client, asserting on the resulting onchain account +state. It also asserts the program's account validation: a payer that did not +sign, a rental account owned by the wrong program, and an out-of-order status +transition (returning a car that was never picked up) are all rejected with +the named errors from `program/src/error.rs`. diff --git a/tools/shank-and-codama/native/program/idl/car_rental_service.json b/tools/shank-and-codama/native/program/idl/car_rental_service.json index bd014148..7329829f 100644 --- a/tools/shank-and-codama/native/program/idl/car_rental_service.json +++ b/tools/shank-and-codama/native/program/idl/car_rental_service.json @@ -14,7 +14,7 @@ { "name": "payer", "isMut": true, - "isSigner": false, + "isSigner": true, "docs": ["Fee payer"] }, { @@ -55,7 +55,7 @@ { "name": "payer", "isMut": true, - "isSigner": false, + "isSigner": true, "docs": ["Fee payer"] }, { @@ -96,7 +96,7 @@ { "name": "payer", "isMut": true, - "isSigner": false, + "isSigner": true, "docs": ["Fee payer"] } ], @@ -124,7 +124,7 @@ { "name": "payer", "isMut": true, - "isSigner": false, + "isSigner": true, "docs": ["Fee payer"] } ], diff --git a/tools/shank-and-codama/native/program/src/error.rs b/tools/shank-and-codama/native/program/src/error.rs new file mode 100644 index 00000000..516f985e --- /dev/null +++ b/tools/shank-and-codama/native/program/src/error.rs @@ -0,0 +1,30 @@ +use solana_program::program_error::ProgramError; + +/// Errors returned by the car rental service program. +/// Codes start at 6000 (the same offset Anchor uses for custom errors), so +/// they never collide with `ProgramError`'s built-in codes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CarRentalError { + /// The car account passed in does not match the PDA derived from the + /// car's make and model. + CarAccountAddressMismatch = 6000, + /// The rental account passed in does not match the PDA derived from the + /// car account and the payer. + RentalAccountAddressMismatch, + /// The payer must sign: the rental PDA is derived from the payer's key, + /// so without this check anyone could act on anyone else's rental. + PayerSignatureMissing, + /// The rental account is not owned by this program, so its data cannot + /// be trusted. + RentalAccountNotOwnedByProgram, + /// A car can only be picked up from a rental in `Created` status. + RentalNotInCreatedStatus, + /// A car can only be returned from a rental in `PickedUp` status. + RentalNotInPickedUpStatus, +} + +impl From for ProgramError { + fn from(error: CarRentalError) -> Self { + ProgramError::Custom(error as u32) + } +} diff --git a/tools/shank-and-codama/native/program/src/instructions/add_car.rs b/tools/shank-and-codama/native/program/src/instructions/add_car.rs index 7b195825..913d777b 100644 --- a/tools/shank-and-codama/native/program/src/instructions/add_car.rs +++ b/tools/shank-and-codama/native/program/src/instructions/add_car.rs @@ -1,4 +1,4 @@ -use crate::state::Car; +use crate::{error::CarRentalError, state::Car}; use { borsh::{BorshDeserialize, BorshSerialize}, solana_program::{ @@ -26,7 +26,9 @@ pub fn add_car(program_id: &Pubkey, accounts: &[AccountInfo], args: AddCarArgs) let system_program = next_account_info(accounts_iter)?; let (car_account_pda, car_account_bump) = Car::find_pda(program_id, &args.make, &args.model); - assert!(&car_account_pda == car_account.key); + if &car_account_pda != car_account.key { + return Err(CarRentalError::CarAccountAddressMismatch.into()); + } let car_data = Car { year: args.year, diff --git a/tools/shank-and-codama/native/program/src/instructions/book_rental.rs b/tools/shank-and-codama/native/program/src/instructions/book_rental.rs index a523ae3d..ad00c207 100644 --- a/tools/shank-and-codama/native/program/src/instructions/book_rental.rs +++ b/tools/shank-and-codama/native/program/src/instructions/book_rental.rs @@ -1,4 +1,7 @@ -use crate::state::{RentalOrder, RentalOrderStatus}; +use crate::{ + error::CarRentalError, + state::{RentalOrder, RentalOrderStatus}, +}; use { borsh::{BorshDeserialize, BorshSerialize}, solana_program::{ @@ -33,7 +36,9 @@ pub fn book_rental( let (rental_order_account_pda, rental_order_account_bump) = RentalOrder::find_pda(program_id, car_account.key, payer.key); - assert!(&rental_order_account_pda == rental_order_account.key); + if &rental_order_account_pda != rental_order_account.key { + return Err(CarRentalError::RentalAccountAddressMismatch.into()); + } let rental_order_data = RentalOrder { car: *car_account.key, diff --git a/tools/shank-and-codama/native/program/src/instructions/mod.rs b/tools/shank-and-codama/native/program/src/instructions/mod.rs index 3572eae5..d35f56c4 100644 --- a/tools/shank-and-codama/native/program/src/instructions/mod.rs +++ b/tools/shank-and-codama/native/program/src/instructions/mod.rs @@ -21,7 +21,7 @@ pub enum CarRentalServiceInstruction { name = "car_account", desc = "The account that will represent the Car being created" )] - #[account(1, writable, name = "payer", desc = "Fee payer")] + #[account(1, writable, signer, name = "payer", desc = "Fee payer")] #[account(2, name = "system_program", desc = "The System Program")] AddCar(AddCarArgs), @@ -36,7 +36,7 @@ pub enum CarRentalServiceInstruction { name = "car_account", desc = "The account representing the Car being rented in this order" )] - #[account(2, writable, name = "payer", desc = "Fee payer")] + #[account(2, writable, signer, name = "payer", desc = "Fee payer")] #[account(3, name = "system_program", desc = "The System Program")] BookRental(BookRentalArgs), @@ -51,7 +51,7 @@ pub enum CarRentalServiceInstruction { name = "car_account", desc = "The account representing the Car being rented in this order" )] - #[account(2, writable, name = "payer", desc = "Fee payer")] + #[account(2, writable, signer, name = "payer", desc = "Fee payer")] PickUpCar, #[account( @@ -65,6 +65,6 @@ pub enum CarRentalServiceInstruction { name = "car_account", desc = "The account representing the Car being rented in this order" )] - #[account(2, writable, name = "payer", desc = "Fee payer")] + #[account(2, writable, signer, name = "payer", desc = "Fee payer")] ReturnCar, } diff --git a/tools/shank-and-codama/native/program/src/instructions/pick_up_car.rs b/tools/shank-and-codama/native/program/src/instructions/pick_up_car.rs index 8637edb1..1dde7662 100644 --- a/tools/shank-and-codama/native/program/src/instructions/pick_up_car.rs +++ b/tools/shank-and-codama/native/program/src/instructions/pick_up_car.rs @@ -1,4 +1,7 @@ -use crate::state::{RentalOrder, RentalOrderStatus}; +use crate::{ + error::CarRentalError, + state::{RentalOrder, RentalOrderStatus}, +}; use { borsh::{BorshDeserialize, BorshSerialize}, solana_program::{ @@ -14,11 +17,31 @@ pub fn pick_up_car(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResu let car_account = next_account_info(accounts_iter)?; let payer = next_account_info(accounts_iter)?; + // The rental PDA is derived from the payer's key, so the payer must sign: + // otherwise anyone could pick up (and later return) someone else's rental + // just by naming the victim as `payer`. + if !payer.is_signer { + return Err(CarRentalError::PayerSignatureMissing.into()); + } + + // Only deserialize accounts this program owns. + if rental_order_account.owner != program_id { + return Err(CarRentalError::RentalAccountNotOwnedByProgram.into()); + } + let (rental_order_account_pda, _) = RentalOrder::find_pda(program_id, car_account.key, payer.key); - assert!(&rental_order_account_pda == rental_order_account.key); + if &rental_order_account_pda != rental_order_account.key { + return Err(CarRentalError::RentalAccountAddressMismatch.into()); + } let rental_order = &mut RentalOrder::try_from_slice(&rental_order_account.data.borrow())?; + + // Valid lifecycle: Created -> PickedUp -> Returned. + if rental_order.status != RentalOrderStatus::Created { + return Err(CarRentalError::RentalNotInCreatedStatus.into()); + } + rental_order.status = RentalOrderStatus::PickedUp; rental_order.serialize(&mut &mut rental_order_account.data.borrow_mut()[..])?; diff --git a/tools/shank-and-codama/native/program/src/instructions/return_car.rs b/tools/shank-and-codama/native/program/src/instructions/return_car.rs index 1962bd3a..c90443a5 100644 --- a/tools/shank-and-codama/native/program/src/instructions/return_car.rs +++ b/tools/shank-and-codama/native/program/src/instructions/return_car.rs @@ -1,4 +1,7 @@ -use crate::state::{RentalOrder, RentalOrderStatus}; +use crate::{ + error::CarRentalError, + state::{RentalOrder, RentalOrderStatus}, +}; use { borsh::{BorshDeserialize, BorshSerialize}, solana_program::{ @@ -14,11 +17,32 @@ pub fn return_car(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResul let car_account = next_account_info(accounts_iter)?; let payer = next_account_info(accounts_iter)?; + // The rental PDA is derived from the payer's key, so the payer must sign: + // otherwise anyone could return someone else's rental just by naming the + // victim as `payer`. + if !payer.is_signer { + return Err(CarRentalError::PayerSignatureMissing.into()); + } + + // Only deserialize accounts this program owns. + if rental_order_account.owner != program_id { + return Err(CarRentalError::RentalAccountNotOwnedByProgram.into()); + } + let (rental_order_account_pda, _) = RentalOrder::find_pda(program_id, car_account.key, payer.key); - assert!(&rental_order_account_pda == rental_order_account.key); + if &rental_order_account_pda != rental_order_account.key { + return Err(CarRentalError::RentalAccountAddressMismatch.into()); + } let rental_order = &mut RentalOrder::try_from_slice(&rental_order_account.data.borrow())?; + + // Valid lifecycle: Created -> PickedUp -> Returned. A car that was never + // picked up cannot be returned. + if rental_order.status != RentalOrderStatus::PickedUp { + return Err(CarRentalError::RentalNotInPickedUpStatus.into()); + } + rental_order.status = RentalOrderStatus::Returned; rental_order.serialize(&mut &mut rental_order_account.data.borrow_mut()[..])?; diff --git a/tools/shank-and-codama/native/program/src/lib.rs b/tools/shank-and-codama/native/program/src/lib.rs index 376b66be..0645925a 100644 --- a/tools/shank-and-codama/native/program/src/lib.rs +++ b/tools/shank-and-codama/native/program/src/lib.rs @@ -1,3 +1,4 @@ +mod error; mod instructions; mod state; diff --git a/tools/shank-and-codama/native/program/src/state/mod.rs b/tools/shank-and-codama/native/program/src/state/mod.rs index cb5fd132..f9ce35f9 100644 --- a/tools/shank-and-codama/native/program/src/state/mod.rs +++ b/tools/shank-and-codama/native/program/src/state/mod.rs @@ -37,7 +37,7 @@ impl Car { } } -#[derive(BorshDeserialize, BorshSerialize, Clone, Debug)] +#[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, PartialEq, Eq)] pub enum RentalOrderStatus { Created, PickedUp, diff --git a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/addCar.ts b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/addCar.ts index eed0d904..cac5ff9a 100644 --- a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/addCar.ts +++ b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/addCar.ts @@ -8,6 +8,7 @@ import { type AccountMeta, + type AccountSignerMeta, type Address, addDecoderSizePrefix, addEncoderSizePrefix, @@ -32,8 +33,10 @@ import { type ReadonlyUint8Array, SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, SolanaError, + type TransactionSigner, transformEncoder, type WritableAccount, + type WritableSignerAccount, } from "@solana/kit"; import { getAccountMetaFactory, type ResolvedInstructionAccount } from "@solana/program-client-core"; import { CAR_RENTAL_SERVICE_PROGRAM_ADDRESS } from "../programs"; @@ -55,7 +58,9 @@ export type AddCarInstruction< InstructionWithAccounts< [ TAccountCarAccount extends string ? WritableAccount : TAccountCarAccount, - TAccountPayer extends string ? WritableAccount : TAccountPayer, + TAccountPayer extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountPayer, TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, ...TRemainingAccounts, ] @@ -107,7 +112,7 @@ export type AddCarInput< /** The account that will represent the Car being created */ carAccount: Address; /** Fee payer */ - payer: Address; + payer: TransactionSigner; /** The System Program */ systemProgram?: Address; year: AddCarInstructionDataArgs["year"]; diff --git a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/bookRental.ts b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/bookRental.ts index d0324b70..520382c8 100644 --- a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/bookRental.ts +++ b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/bookRental.ts @@ -8,6 +8,7 @@ import { type AccountMeta, + type AccountSignerMeta, type Address, addDecoderSizePrefix, addEncoderSizePrefix, @@ -32,8 +33,10 @@ import { type ReadonlyUint8Array, SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, SolanaError, + type TransactionSigner, transformEncoder, type WritableAccount, + type WritableSignerAccount, } from "@solana/kit"; import { getAccountMetaFactory, type ResolvedInstructionAccount } from "@solana/program-client-core"; import { CAR_RENTAL_SERVICE_PROGRAM_ADDRESS } from "../programs"; @@ -57,7 +60,9 @@ export type BookRentalInstruction< [ TAccountRentalAccount extends string ? WritableAccount : TAccountRentalAccount, TAccountCarAccount extends string ? ReadonlyAccount : TAccountCarAccount, - TAccountPayer extends string ? WritableAccount : TAccountPayer, + TAccountPayer extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountPayer, TAccountSystemProgram extends string ? ReadonlyAccount : TAccountSystemProgram, ...TRemainingAccounts, ] @@ -116,7 +121,7 @@ export type BookRentalInput< /** The account representing the Car being rented in this order */ carAccount: Address; /** Fee payer */ - payer: Address; + payer: TransactionSigner; /** The System Program */ systemProgram?: Address; name: BookRentalInstructionDataArgs["name"]; diff --git a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/pickUpCar.ts b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/pickUpCar.ts index a91a2ec0..0915df46 100644 --- a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/pickUpCar.ts +++ b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/pickUpCar.ts @@ -8,6 +8,7 @@ import { type AccountMeta, + type AccountSignerMeta, type Address, combineCodec, type FixedSizeCodec, @@ -24,8 +25,10 @@ import { type ReadonlyUint8Array, SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, SolanaError, + type TransactionSigner, transformEncoder, type WritableAccount, + type WritableSignerAccount, } from "@solana/kit"; import { getAccountMetaFactory, type ResolvedInstructionAccount } from "@solana/program-client-core"; import { CAR_RENTAL_SERVICE_PROGRAM_ADDRESS } from "../programs"; @@ -48,7 +51,9 @@ export type PickUpCarInstruction< [ TAccountRentalAccount extends string ? WritableAccount : TAccountRentalAccount, TAccountCarAccount extends string ? ReadonlyAccount : TAccountCarAccount, - TAccountPayer extends string ? WritableAccount : TAccountPayer, + TAccountPayer extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountPayer, ...TRemainingAccounts, ] >; @@ -85,7 +90,7 @@ export type PickUpCarInput< /** The account representing the Car being rented in this order */ carAccount: Address; /** Fee payer */ - payer: Address; + payer: TransactionSigner; }; export function getPickUpCarInstruction< diff --git a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/returnCar.ts b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/returnCar.ts index d3ec60a8..a6a1c90e 100644 --- a/tools/shank-and-codama/native/tests/generated/src/generated/instructions/returnCar.ts +++ b/tools/shank-and-codama/native/tests/generated/src/generated/instructions/returnCar.ts @@ -8,6 +8,7 @@ import { type AccountMeta, + type AccountSignerMeta, type Address, combineCodec, type FixedSizeCodec, @@ -24,8 +25,10 @@ import { type ReadonlyUint8Array, SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS, SolanaError, + type TransactionSigner, transformEncoder, type WritableAccount, + type WritableSignerAccount, } from "@solana/kit"; import { getAccountMetaFactory, type ResolvedInstructionAccount } from "@solana/program-client-core"; import { CAR_RENTAL_SERVICE_PROGRAM_ADDRESS } from "../programs"; @@ -48,7 +51,9 @@ export type ReturnCarInstruction< [ TAccountRentalAccount extends string ? WritableAccount : TAccountRentalAccount, TAccountCarAccount extends string ? ReadonlyAccount : TAccountCarAccount, - TAccountPayer extends string ? WritableAccount : TAccountPayer, + TAccountPayer extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountPayer, ...TRemainingAccounts, ] >; @@ -85,7 +90,7 @@ export type ReturnCarInput< /** The account representing the Car being rented in this order */ carAccount: Address; /** Fee payer */ - payer: Address; + payer: TransactionSigner; }; export function getReturnCarInstruction< diff --git a/tools/shank-and-codama/native/tests/generated/src/generated/programs/carRentalService.ts b/tools/shank-and-codama/native/tests/generated/src/generated/programs/carRentalService.ts index f054d668..1dbc4e71 100644 --- a/tools/shank-and-codama/native/tests/generated/src/generated/programs/carRentalService.ts +++ b/tools/shank-and-codama/native/tests/generated/src/generated/programs/carRentalService.ts @@ -200,7 +200,7 @@ export function carRentalServiceProgram() { client, getAddCarInstruction({ ...input, - payer: input.payer ?? client.payer.address, + payer: input.payer ?? client.payer, }), ), bookRental: (input) => @@ -208,7 +208,7 @@ export function carRentalServiceProgram() { client, getBookRentalInstruction({ ...input, - payer: input.payer ?? client.payer.address, + payer: input.payer ?? client.payer, }), ), pickUpCar: (input) => @@ -216,7 +216,7 @@ export function carRentalServiceProgram() { client, getPickUpCarInstruction({ ...input, - payer: input.payer ?? client.payer.address, + payer: input.payer ?? client.payer, }), ), returnCar: (input) => @@ -224,7 +224,7 @@ export function carRentalServiceProgram() { client, getReturnCarInstruction({ ...input, - payer: input.payer ?? client.payer.address, + payer: input.payer ?? client.payer, }), ), }, diff --git a/tools/shank-and-codama/native/tests/test.ts b/tools/shank-and-codama/native/tests/test.ts index e74f8368..1cbe6e75 100644 --- a/tools/shank-and-codama/native/tests/test.ts +++ b/tools/shank-and-codama/native/tests/test.ts @@ -2,9 +2,11 @@ // // Runs entirely in CI with no network: the program `.so` is loaded into a // LiteSVM instance and exercised through the Codama-generated client -// (tests/generated). It creates a car (add_car), books a rental -// (book_rental) and picks it up (pick_up_car), asserting on-chain account -// state after each step. +// (tests/generated). It walks the full rental lifecycle (add_car, +// book_rental, pick_up_car, return_car), asserting onchain account state +// after each step, and verifies the program's account validation: a +// non-signing payer, a rental account owned by the wrong program, and an +// invalid status transition are all rejected. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -13,14 +15,16 @@ import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { + AccountRole, type Address, - address, appendTransactionMessageInstruction, + createNoopSigner, createTransactionMessage, generateKeyPairSigner, getAddressEncoder, getProgramDerivedAddress, getUtf8Encoder, + type Instruction, lamports, pipe, setTransactionMessageFeePayerSigner, @@ -35,9 +39,16 @@ import { getAddCarInstruction, getBookRentalInstruction, getPickUpCarInstruction, + getReturnCarInstruction, RentalOrderStatus, } from "./generated/src/generated/index.ts"; +// Custom error codes from program/src/error.rs (CarRentalError). The enum +// starts at 6000, matching Anchor's custom-error offset. +const ERROR_PAYER_SIGNATURE_MISSING = 6002; +const ERROR_RENTAL_ACCOUNT_NOT_OWNED_BY_PROGRAM = 6003; +const ERROR_RENTAL_NOT_IN_PICKED_UP_STATUS = 6005; + const here = dirname(fileURLToPath(import.meta.url)); const programSoPath = join(here, "..", "program", "target", "so", "car_rental_service.so"); @@ -70,14 +81,13 @@ async function rentalPda(programId: Address, car: Address, payer: Address): Prom async function sendIx( svm: LiteSVM, payer: Awaited>, - // deno-lint-ignore no-explicit-any - ix: any, + instruction: Instruction, ) { const tx = await pipe( createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayerSigner(payer, m), (m) => svm.setTransactionMessageLifetimeUsingLatestBlockhash(m), - (m) => appendTransactionMessageInstruction(ix, m), + (m) => appendTransactionMessageInstruction(instruction, m), (m) => signTransactionMessageWithSigners(m), ); const result = svm.sendTransaction(tx); @@ -87,11 +97,31 @@ async function sendIx( return result; } -test("car rental service: add_car, book_rental, pick_up_car", async () => { - const { svm, programId } = loadSvm(); +/** Assert that sending `instruction` fails with the given custom error code. */ +async function expectCustomError( + svm: LiteSVM, + payer: Awaited>, + instruction: Instruction, + errorCode: number, +) { + // The runtime logs custom errors as hex: "custom program error: 0x1772". + const errorCodeHex = `0x${errorCode.toString(16)}`; + await assert.rejects( + sendIx(svm, payer, instruction), + (thrownObject: Error) => thrownObject.message.includes(errorCodeHex), + `expected custom program error ${errorCode} (${errorCodeHex})`, + ); +} - const payer = await generateKeyPairSigner(); - svm.airdrop(payer.address, lamports(10_000_000_000n)); +async function fundedSigner(svm: LiteSVM) { + const signer = await generateKeyPairSigner(); + svm.airdrop(signer.address, lamports(10_000_000_000n)); + return signer; +} + +test("car rental service: full lifecycle add_car -> book_rental -> pick_up_car -> return_car", async () => { + const { svm, programId } = loadSvm(); + const payer = await fundedSigner(svm); // 1. add_car const make = "BMW"; @@ -132,10 +162,130 @@ test("car rental service: add_car, book_rental, pick_up_car", async () => { assert.equal(rental.data.status, RentalOrderStatus.Created); // 3. pick_up_car - await sendIx(svm, payer, getPickUpCarInstruction({ rentalAccount, carAccount, payer: payer.address })); + await sendIx(svm, payer, getPickUpCarInstruction({ rentalAccount, carAccount, payer })); rentalRaw = svm.getAccount(rentalAccount); assert.ok(rentalRaw?.exists, "rental account should still exist"); rental = decodeRentalOrder(rentalRaw); assert.equal(rental.data.status, RentalOrderStatus.PickedUp); + + // 4. return_car + await sendIx(svm, payer, getReturnCarInstruction({ rentalAccount, carAccount, payer })); + + rentalRaw = svm.getAccount(rentalAccount); + assert.ok(rentalRaw?.exists, "rental account should still exist"); + rental = decodeRentalOrder(rentalRaw); + assert.equal(rental.data.status, RentalOrderStatus.Returned); +}); + +test("pick_up_car rejects a payer that did not sign", async () => { + const { svm, programId } = loadSvm(); + const victim = await fundedSigner(svm); + const attacker = await fundedSigner(svm); + + const make = "Tesla"; + const model = "Model 3"; + const carAccount = await carPda(programId, make, model); + await sendIx(svm, victim, getAddCarInstruction({ carAccount, payer: victim, year: 2024, make, model })); + + const rentalAccount = await rentalPda(programId, carAccount, victim.address); + await sendIx( + svm, + victim, + getBookRentalInstruction({ + rentalAccount, + carAccount, + payer: victim, + name: "Wilma Flintstone", + pickUpDate: "02/01/2023 9:00 AM", + returnDate: "02/01/2023 5:00 PM", + price: 250, + }), + ); + + // The attacker names the victim as `payer` but cannot produce the victim's + // signature, so the account meta is demoted to a plain writable account. + const instruction = getPickUpCarInstruction({ + rentalAccount, + carAccount, + payer: createNoopSigner(victim.address), + }); + const instructionWithoutVictimSignature: Instruction = { + ...instruction, + accounts: instruction.accounts.map((account) => + account.address === victim.address ? { address: account.address, role: AccountRole.WRITABLE } : account, + ), + }; + + await expectCustomError(svm, attacker, instructionWithoutVictimSignature, ERROR_PAYER_SIGNATURE_MISSING); + + // The rental is untouched. + const rental = decodeRentalOrder(svm.getAccount(rentalAccount)!); + assert.equal(rental.data.status, RentalOrderStatus.Created); +}); + +test("pick_up_car rejects a rental account not owned by the program", async () => { + const { svm, programId } = loadSvm(); + const payer = await fundedSigner(svm); + + const make = "Volvo"; + const model = "EX30"; + const carAccount = await carPda(programId, make, model); + await sendIx(svm, payer, getAddCarInstruction({ carAccount, payer, year: 2025, make, model })); + + // Plant an account with plausible rental data at the correct PDA address, + // but owned by the system program instead of the rental program. + const rentalAccount = await rentalPda(programId, carAccount, payer.address); + const plantedDataLength = 165; + svm.setAccount({ + address: rentalAccount, + lamports: lamports(10_000_000n), + data: new Uint8Array(plantedDataLength), + programAddress: "11111111111111111111111111111111" as Address, + executable: false, + space: BigInt(plantedDataLength), + }); + + await expectCustomError( + svm, + payer, + getPickUpCarInstruction({ rentalAccount, carAccount, payer }), + ERROR_RENTAL_ACCOUNT_NOT_OWNED_BY_PROGRAM, + ); +}); + +test("return_car rejects a rental that was never picked up", async () => { + const { svm, programId } = loadSvm(); + const payer = await fundedSigner(svm); + + const make = "Kia"; + const model = "EV9"; + const carAccount = await carPda(programId, make, model); + await sendIx(svm, payer, getAddCarInstruction({ carAccount, payer, year: 2023, make, model })); + + const rentalAccount = await rentalPda(programId, carAccount, payer.address); + await sendIx( + svm, + payer, + getBookRentalInstruction({ + rentalAccount, + carAccount, + payer, + name: "Barney Rubble", + pickUpDate: "03/15/2023 10:00 AM", + returnDate: "03/16/2023 10:00 AM", + price: 400, + }), + ); + + // Created -> Returned skips PickedUp and must be rejected. + await expectCustomError( + svm, + payer, + getReturnCarInstruction({ rentalAccount, carAccount, payer }), + ERROR_RENTAL_NOT_IN_PICKED_UP_STATUS, + ); + + const rental = decodeRentalOrder(svm.getAccount(rentalAccount)!); + assert.equal(rental.data.status, RentalOrderStatus.Created); });