From 796779d429f16890129d013681e679d2c8fedcfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:47:46 +0000 Subject: [PATCH 01/14] ci: move Quasar CI to the 0.1.0-release line; legacy metadata job The main matrix now installs quasar-cli from the 0.1.0-release branch head (rev be60fca, matching the rev pinned in every migrated Cargo.toml) and runs cargo generate-lockfile before quasar build, because the 0.1.0 IDL step runs `cargo metadata --locked` and no Quasar example commits a lockfile. tokens/{token-minter,nft-minter,nft-operations} depend on quasar-metadata, which was removed upstream before 0.1.0 with no replacement. They move to .github/.ghaignore (excluded from the main matrix) and build in a new legacy-metadata-examples job that installs the pre-0.1.0 CLI (rev 3d6fb0d8), which still parses their pre-0.1.0 Quasar.toml schema. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- .github/.ghaignore | 6 +++ .github/workflows/quasar.yml | 80 ++++++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/.github/.ghaignore b/.github/.ghaignore index e69de29b..b51ff6fb 100644 --- a/.github/.ghaignore +++ b/.github/.ghaignore @@ -0,0 +1,6 @@ +# Not migrated to Quasar 0.1.0: quasar-metadata was removed upstream before +# the 0.1.0 release with no replacement. These build in the +# legacy-metadata-examples job in quasar.yml with the pre-0.1.0 CLI. +tokens/token-minter/quasar +tokens/nft-minter/quasar +tokens/nft-operations/quasar diff --git a/.github/workflows/quasar.yml b/.github/workflows/quasar.yml index 5f98dbb8..bb572aa6 100644 --- a/.github/workflows/quasar.yml +++ b/.github/workflows/quasar.yml @@ -31,6 +31,7 @@ jobs: changed_projects: ${{ steps.analyze.outputs.changed_projects }} total_projects: ${{ steps.analyze.outputs.total_projects }} matrix: ${{ steps.matrix.outputs.matrix }} + legacy: ${{ steps.changes.outputs.legacy }} steps: - uses: actions/checkout@v5 with: @@ -45,6 +46,11 @@ jobs: - added|modified: '**/quasar/**' workflow: - added|modified: '.github/workflows/quasar.yml' + legacy: + - added|modified: 'tokens/token-minter/quasar/**' + - added|modified: 'tokens/nft-minter/quasar/**' + - added|modified: 'tokens/nft-operations/quasar/**' + - added|modified: '.github/workflows/quasar.yml' - name: Analyze Changes id: analyze run: | @@ -201,9 +207,11 @@ jobs: return 1 fi - # Build every program first. + # Build every program first. The 0.1.0 CLI generates the IDL and + # clients before compiling and runs `cargo metadata --locked`, so + # a lockfile must exist before the first `quasar build`. for d in "${prog_dirs[@]}"; do - if ! ( cd "$d" && quasar build ); then + if ! ( cd "$d" && cargo generate-lockfile && quasar build ); then echo "::error::quasar build failed for $project ($d)" echo "$project: quasar build failed with $solana_version" >> $GITHUB_WORKSPACE/failed_projects.txt cd - > /dev/null @@ -211,7 +219,7 @@ jobs: fi done - # Then run Rust tests (quasar examples use cargo test with quasar-svm). + # Then run Rust tests (quasar examples use cargo test with quasar-test). for d in "${prog_dirs[@]}"; do if ! ( cd "$d" && cargo test ); then echo "::error::cargo test failed for $project ($d)" @@ -259,16 +267,13 @@ jobs: # Pin an exact version rather than a tag like "stable", which setup-solana resolves via the GitHub API (can fail with 429). solana-cli-version: 3.1.14 - name: Install Quasar CLI - # Pinned to quasar rev 3d6fb0d8 (the HEAD this migration was written - # against, immediately after PRs #195 + #196). The next merged PR - # (#198 "idl-redesign-clean", rev 096c8f7c) regressed `quasar build` - # for flat-layout projects: the new IDL flow chdirs to - # `crate_path.parent()` which evaluates to an empty PathBuf when - # the program crate is at the project root (src/lib.rs in `.`), - # causing posix_spawn child chdir("") to fail with ENOENT before - # any compilation begins. Symptom in CI is a bare "Anyhow error". - # Unpin once upstream lands a fix that handles the flat layout. - run: cargo install --git https://github.com/blueshift-gg/quasar --rev 3d6fb0d8 quasar-cli --locked + # Pinned to the 0.1.0-release branch head. crates.io still hosts + # 0.0.0 placeholders for quasar-cli, so the release line installs + # from git. Keep this rev in lockstep with the `rev = "be60fca"` + # pins in every quasar Cargo.toml. Switch to + # `cargo install quasar-cli --version 0.1.0 --locked` once the + # 0.1.0 crates are published. + run: cargo install --git https://github.com/blueshift-gg/quasar --rev be60fca quasar-cli --locked - name: Build and Test with Stable run: | source build_and_test.sh @@ -291,6 +296,55 @@ jobs: if: always() run: sccache --show-stats + legacy-metadata-examples: + # tokens/{token-minter,nft-minter,nft-operations} are NOT migrated to + # Quasar 0.1.0: they depend on quasar-metadata, which was removed + # upstream before the 0.1.0 release with no replacement. They stay on + # the last working pre-0.1.0 revs (quasar 623bb70 / quasar-svm cb7565d, + # listed in .github/.ghaignore so the main matrix skips them) and build + # with the older CLI, which still parses their pre-0.1.0 Quasar.toml + # schema. Migrate them and remove this job once upstream ships a + # metadata story for 0.1.x. + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.legacy == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.9 + - name: Configure sccache + run: | + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV + - name: Cache Cargo registry and git + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-legacy-${{ runner.os }}-${{ hashFiles('tokens/token-minter/quasar/Cargo.toml', 'tokens/nft-minter/quasar/Cargo.toml', 'tokens/nft-operations/quasar/Cargo.toml') }} + restore-keys: | + cargo-legacy-${{ runner.os }}- + - name: Setup Solana Stable + uses: heyAyushh/setup-solana@v5.9 + with: + # Pin an exact version rather than a tag like "stable", which setup-solana resolves via the GitHub API (can fail with 429). + solana-cli-version: 3.1.14 + - name: Install Quasar CLI (pre-0.1.0) + # Rev 3d6fb0d8: the HEAD the original Quasar migration was written + # against (immediately after PRs #195 + #196). The next merged PR + # (#198 "idl-redesign-clean") regressed `quasar build` for + # flat-layout projects, and the 0.1.0 line removed quasar-metadata + # entirely, so these examples need this exact older CLI. + run: cargo install --git https://github.com/blueshift-gg/quasar --rev 3d6fb0d8 quasar-cli --locked + - name: Build and test legacy metadata examples + run: | + for project in tokens/token-minter/quasar tokens/nft-minter/quasar tokens/nft-operations/quasar; do + echo "::group::$project" + ( cd "$project" && quasar build && cargo test ) + echo "::endgroup::" + done + summary: needs: [changes, build-and-test] if: always() From b994f1f8a7dddb4a92da9b9745e3867e91d28a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:47:47 +0000 Subject: [PATCH 02/14] basics/counter: migrate to Quasar 0.1.0 (pilot) First example on the 0.1.0-release line (rev be60fca): Quasar.toml rewritten to the 0.1.0 schema, idl-build feature and "lib" crate-type added for the new IDL build, and tests rewritten from the direct QuasarSVM harness to quasar-test (#[quasar_test], Wallet fixture, crate::cpi instruction builders, typed test.read state assertions). The quasar-svm git dev-dependency and the generated-client path dev-dependency are gone; quasar-test pulls the published quasar-svm 0.1.0 from crates.io. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- basics/counter/quasar/Cargo.toml | 28 +++--- basics/counter/quasar/Quasar.toml | 19 +---- basics/counter/quasar/src/tests.rs | 132 +++++++---------------------- 3 files changed, 43 insertions(+), 136 deletions(-) diff --git a/basics/counter/quasar/Cargo.toml b/basics/counter/quasar/Cargo.toml index aa7d110f..28b1bae5 100644 --- a/basics/counter/quasar/Cargo.toml +++ b/basics/counter/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-counter-client = { path = "target/client/rust/quasar-counter-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/counter/quasar/Quasar.toml b/basics/counter/quasar/Quasar.toml index ae2854a5..ba327cff 100644 --- a/basics/counter/quasar/Quasar.toml +++ b/basics/counter/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_counter" - -[toolchain] -type = "solana" +name = "quasar-counter" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/counter/quasar/src/tests.rs b/basics/counter/quasar/src/tests.rs index 2859c16f..dfa5e221 100644 --- a/basics/counter/quasar/src/tests.rs +++ b/basics/counter/quasar/src/tests.rs @@ -1,107 +1,35 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use quasar_counter_client::{InitializeCounterInstruction, IncrementInstruction}; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_counter.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize_counter() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - // Derive the counter PDA from ["counter", payer]. - let (counter, _) = Pubkey::find_program_address( - &[b"counter", payer.as_ref()], - &Pubkey::from(crate::ID), - ); - - let instruction: Instruction = InitializeCounterInstruction { - payer: Address::from(payer.to_bytes()), - counter: Address::from(counter.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(counter)], - ); - - result.assert_success(); - - // Verify the counter account was created with count = 0. - let counter_account = result.account(&counter).unwrap(); - // Data: 1 byte discriminator (1) + 8 bytes u64 (0) - assert_eq!(counter_account.data.len(), 9); - assert_eq!(counter_account.data[0], 1); // discriminator - assert_eq!(&counter_account.data[1..], &[0u8; 8]); // count = 0 +use { + crate::{ + cpi::{IncrementInstruction, InitializeCounterInstruction}, + state::Counter, + }, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); + +#[quasar_test] +fn initialize_counter_creates_the_pda(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let counter = test.derive_pda(Counter::seeds(&PAYER)); + + // The counter PDA and system program are canonical derivations, so the + // generated instruction only asks for the payer. + test.send(InitializeCounterInstruction { payer: PAYER }).succeeds(); + + let state = test.read::(counter); + assert_eq!(u64::from(state.count), 0); } -#[test] -fn test_increment() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - // Derive the counter PDA. - let (counter, _) = Pubkey::find_program_address( - &[b"counter", payer.as_ref()], - &Pubkey::from(crate::ID), - ); - - // First, initialise the counter. - let init_instruction: Instruction = InitializeCounterInstruction { - payer: Address::from(payer.to_bytes()), - counter: Address::from(counter.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &init_instruction, - &[signer(payer), empty(counter)], - ); - result.assert_success(); - - // Grab updated accounts after init. - let counter_after_init = result.account(&counter).unwrap().clone(); - - // Increment the counter. - let increment_instruction: Instruction = IncrementInstruction { - counter: Address::from(counter.to_bytes()), - } - .into(); +#[quasar_test] +fn increment_advances_the_count(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let counter = test.derive_pda(Counter::seeds(&PAYER)); + test.send(InitializeCounterInstruction { payer: PAYER }).succeeds(); - let result = svm.process_instruction( - &increment_instruction, - &[counter_after_init], - ); - result.assert_success(); + test.send(IncrementInstruction { counter }).succeeds(); - // Verify count = 1. - let counter_account = result.account(&counter).unwrap(); - let count_bytes: [u8; 8] = counter_account.data[1..9].try_into().unwrap(); - let count = u64::from_le_bytes(count_bytes); - assert_eq!(count, 1, "counter should be 1 after one increment"); + let state = test.read::(counter); + assert_eq!(u64::from(state.count), 1); } From ff95b4f67a010a3894363423b6cad4db080b8a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:48:04 +0000 Subject: [PATCH 03/14] basics: migrate remaining examples to Quasar 0.1.0 Same migration as the counter pilot for the 15 remaining basics projects (16 program dirs; cross-program-invocation carries hand/ and lever/): deps repinned to 0.1.0-release rev be60fca (pyth was floating and is now pinned), Quasar.toml on the 0.1.0 schema, idl-build feature and "lib" crate-type added, and every test suite rewritten to quasar-test with all scenarios preserved. Genuine 0.1.0 API breaks fixed along the way: - realloc: zeropod 0.3.3 rejects String<1024> with the default 1-byte length prefix; the message fields become String<1024, 2>. - pda-rent-payer: Seed left the prelude; it now imports quasar_lang::cpi::Seed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- basics/account-data/quasar/CHANGELOG.md | 11 + basics/account-data/quasar/Cargo.toml | 29 +-- basics/account-data/quasar/Quasar.toml | 19 +- basics/account-data/quasar/src/tests.rs | 129 +++------- basics/checking-accounts/quasar/CHANGELOG.md | 11 + basics/checking-accounts/quasar/Cargo.toml | 28 +- basics/checking-accounts/quasar/Quasar.toml | 19 +- basics/checking-accounts/quasar/src/tests.rs | 90 +++---- basics/close-account/quasar/CHANGELOG.md | 11 + basics/close-account/quasar/Cargo.toml | 29 +-- basics/close-account/quasar/Quasar.toml | 19 +- basics/close-account/quasar/src/tests.rs | 227 +++++----------- basics/create-account/quasar/CHANGELOG.md | 11 + basics/create-account/quasar/Cargo.toml | 28 +- basics/create-account/quasar/Quasar.toml | 19 +- basics/create-account/quasar/src/tests.rs | 78 +++--- .../quasar/CHANGELOG.md | 11 + .../quasar/hand/Cargo.toml | 27 +- .../quasar/hand/Quasar.toml | 19 +- .../quasar/hand/src/tests.rs | 156 +++++------ .../quasar/lever/Cargo.toml | 27 +- .../quasar/lever/Quasar.toml | 19 +- .../quasar/lever/src/tests.rs | 178 ++++--------- basics/favorites/quasar/CHANGELOG.md | 11 + basics/favorites/quasar/Cargo.toml | 28 +- basics/favorites/quasar/Quasar.toml | 19 +- basics/favorites/quasar/src/tests.rs | 102 ++------ basics/hello-solana/quasar/CHANGELOG.md | 11 + basics/hello-solana/quasar/Cargo.toml | 28 +- basics/hello-solana/quasar/Quasar.toml | 19 +- basics/hello-solana/quasar/src/tests.rs | 44 +--- basics/pda-rent-payer/quasar/CHANGELOG.md | 13 + basics/pda-rent-payer/quasar/Cargo.toml | 28 +- basics/pda-rent-payer/quasar/Quasar.toml | 19 +- .../src/instructions/create_new_account.rs | 2 +- basics/pda-rent-payer/quasar/src/tests.rs | 185 +++++-------- .../quasar/CHANGELOG.md | 12 + .../processing-instructions/quasar/Cargo.toml | 29 +-- .../quasar/Quasar.toml | 19 +- .../quasar/src/tests.rs | 84 ++---- .../quasar/CHANGELOG.md | 12 + .../quasar/Cargo.toml | 28 +- .../quasar/Quasar.toml | 19 +- .../quasar/src/tests.rs | 146 +++-------- basics/pyth/quasar/CHANGELOG.md | 13 + basics/pyth/quasar/Cargo.toml | 12 +- basics/pyth/quasar/Quasar.toml | 19 +- basics/pyth/quasar/src/tests.rs | 109 +++----- basics/realloc/quasar/CHANGELOG.md | 15 ++ basics/realloc/quasar/Cargo.toml | 29 +-- basics/realloc/quasar/Quasar.toml | 19 +- basics/realloc/quasar/src/lib.rs | 4 +- basics/realloc/quasar/src/state.rs | 2 +- basics/realloc/quasar/src/tests.rs | 185 ++++--------- basics/rent/quasar/CHANGELOG.md | 12 + basics/rent/quasar/Cargo.toml | 27 +- basics/rent/quasar/Quasar.toml | 19 +- basics/rent/quasar/src/tests.rs | 97 ++----- basics/repository-layout/quasar/CHANGELOG.md | 12 + basics/repository-layout/quasar/Cargo.toml | 27 +- basics/repository-layout/quasar/Quasar.toml | 19 +- basics/repository-layout/quasar/src/tests.rs | 242 ++++++------------ basics/transfer-sol/quasar/CHANGELOG.md | 12 + basics/transfer-sol/quasar/Cargo.toml | 28 +- basics/transfer-sol/quasar/Quasar.toml | 19 +- basics/transfer-sol/quasar/src/tests.rs | 201 +++++---------- 66 files changed, 1086 insertions(+), 2089 deletions(-) diff --git a/basics/account-data/quasar/CHANGELOG.md b/basics/account-data/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/account-data/quasar/CHANGELOG.md +++ b/basics/account-data/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/account-data/quasar/Cargo.toml b/basics/account-data/quasar/Cargo.toml index f4c7bd4b..140ad4e4 100644 --- a/basics/account-data/quasar/Cargo.toml +++ b/basics/account-data/quasar/Cargo.toml @@ -14,32 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Not using the generated client: it depends on quasar_lang::client::DynBytes -# which isn't in the published crate yet. Tests build instruction data manually. -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/account-data/quasar/Quasar.toml b/basics/account-data/quasar/Quasar.toml index 7e8c7764..04624ad7 100644 --- a/basics/account-data/quasar/Quasar.toml +++ b/basics/account-data/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_account_data" - -[toolchain] -type = "solana" +name = "quasar-account-data" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/account-data/quasar/src/tests.rs b/basics/account-data/quasar/src/tests.rs index 79eac131..9bd399c9 100644 --- a/basics/account-data/quasar/src/tests.rs +++ b/basics/account-data/quasar/src/tests.rs @@ -1,92 +1,37 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_account_data.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -/// Build the create_address_info instruction data using Quasar's compact -/// wire format: a header containing all fixed fields and length prefixes, -/// followed by a tail with all dynamic byte payloads grouped together. -/// -/// Layout: -/// header: [disc: u8 = 0][house_number: u8][name_len: u8][street_len: u8][city_len: u8] -/// tail: [name bytes][street bytes][city bytes] -/// -/// `String<50>` defaults to a u8 length prefix because MAX (50) fits in a byte. -fn build_create_instruction_data(name: &str, house_number: u8, street: &str, city: &str) -> Vec { - let mut data = Vec::with_capacity(5 + name.len() + street.len() + city.len()); - - // Header - data.push(0u8); // instruction discriminator - data.push(house_number); - data.push(name.len() as u8); - data.push(street.len() as u8); - data.push(city.len() as u8); - - // Tail - data.extend_from_slice(name.as_bytes()); - data.extend_from_slice(street.as_bytes()); - data.extend_from_slice(city.as_bytes()); - - data -} - -#[test] -fn test_create_address_info() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let (address_info, _) = Pubkey::find_program_address( - &[b"address_info", payer.as_ref()], - &Pubkey::from(crate::ID), - ); - - let data = build_create_instruction_data("Alice", 42, "Main Street", "New York"); - - let instruction = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(payer.to_bytes()), true), - solana_instruction::AccountMeta::new(Address::from(address_info.to_bytes()), false), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data, - }; - - let result = svm.process_instruction(&instruction, &[signer(payer), empty(address_info)]); - - result.assert_success(); - - // Verify the account data. - let account = result.account(&address_info).unwrap(); - - // Onchain layout for a Quasar `#[account]` with dynamic fields uses the - // compact "header then tail" format. Length prefixes are grouped in the - // header, the actual bytes follow in the tail. +use { + crate::{cpi::CreateAddressInfoInstruction, state::AddressInfo}, + quasar_lang::client::DynString, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); + +#[quasar_test] +fn create_address_info_stores_the_compact_dynamic_layout(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let address_info = test.derive_pda(AddressInfo::seeds(&PAYER)); + + // The address-info PDA and system program are canonical derivations, so + // the generated instruction only asks for the payer and the args. + test.send(CreateAddressInfoInstruction { + payer: PAYER, + house_number: 42, + name: DynString::new("Alice"), + street: DynString::new("Main Street"), + city: DynString::new("New York"), + }) + .succeeds(); + + // The byte layout is the point of this example: a Quasar `#[account]` + // with dynamic fields uses the compact "header then tail" format. Length + // prefixes are grouped in the header, the actual bytes follow in the + // tail. // header: [disc: 1][house_number: u8][name_len: u8][street_len: u8][city_len: u8] // tail: [name bytes][street bytes][city bytes] - // String<50> defaults to a u8 length prefix because MAX (50) fits in a byte. + // String<50> defaults to a u8 length prefix because MAX (50) fits in a + // byte. + let account = test.account(address_info).unwrap(); assert_eq!(account.data[0], 1, "discriminator"); assert_eq!(account.data[1], 42, "house_number"); let name_len = account.data[2] as usize; @@ -99,7 +44,13 @@ fn test_create_address_info() { let header_end = 5; assert_eq!(&account.data[header_end..header_end + name_len], b"Alice"); let street_start = header_end + name_len; - assert_eq!(&account.data[street_start..street_start + street_len], b"Main Street"); + assert_eq!( + &account.data[street_start..street_start + street_len], + b"Main Street" + ); let city_start = street_start + street_len; - assert_eq!(&account.data[city_start..city_start + city_len], b"New York"); + assert_eq!( + &account.data[city_start..city_start + city_len], + b"New York" + ); } diff --git a/basics/checking-accounts/quasar/CHANGELOG.md b/basics/checking-accounts/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/checking-accounts/quasar/CHANGELOG.md +++ b/basics/checking-accounts/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/checking-accounts/quasar/Cargo.toml b/basics/checking-accounts/quasar/Cargo.toml index f125e39d..f021f3b4 100644 --- a/basics/checking-accounts/quasar/Cargo.toml +++ b/basics/checking-accounts/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-checking-accounts-client = { path = "target/client/rust/quasar-checking-accounts-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/checking-accounts/quasar/Quasar.toml b/basics/checking-accounts/quasar/Quasar.toml index b352a401..001c46ed 100644 --- a/basics/checking-accounts/quasar/Quasar.toml +++ b/basics/checking-accounts/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_checking_accounts" - -[toolchain] -type = "solana" +name = "quasar-checking-accounts" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/checking-accounts/quasar/src/tests.rs b/basics/checking-accounts/quasar/src/tests.rs index 9c462038..7258a980 100644 --- a/basics/checking-accounts/quasar/src/tests.rs +++ b/basics/checking-accounts/quasar/src/tests.rs @@ -1,62 +1,30 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use quasar_checking_accounts_client::CheckAccountsInstruction; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_checking_accounts.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn system_account(address: Pubkey, lamports: u64) -> Account { - Account { - address, - lamports, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn program_owned_account(address: Pubkey, lamports: u64) -> Account { - Account { - address, - lamports, - data: vec![0u8; 32], - owner: Pubkey::from(crate::ID), - executable: false, - } -} - -#[test] -fn test_check_accounts_succeeds() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let account_to_create = Pubkey::new_unique(); - let account_to_change = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let instruction: Instruction = CheckAccountsInstruction { - payer: Address::from(payer.to_bytes()), - account_to_create: Address::from(account_to_create.to_bytes()), - account_to_change: Address::from(account_to_change.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[ - signer(payer), - system_account(account_to_create, 0), - program_owned_account(account_to_change, 1_000_000), - ], - ); - - result.assert_success(); +use {crate::cpi::CheckAccountsInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const ACCOUNT_TO_CREATE: Pubkey = Pubkey::new_from_array([2; 32]); +const ACCOUNT_TO_CHANGE: Pubkey = Pubkey::new_from_array([3; 32]); + +#[quasar_test] +fn check_accounts_accepts_a_valid_account_set(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The account to create stays absent: a missing writable account enters + // the transaction as an empty system account, exactly the "not yet + // created" shape this instruction expects. + // The account to change already exists and is owned by this program. + test.set_account(Account::new( + ACCOUNT_TO_CHANGE, + crate::ID, + 1_000_000, + vec![0u8; 32], + )); + + // The system program is a canonical derivation, so the generated + // instruction only asks for the caller-controlled accounts. + test.send(CheckAccountsInstruction { + payer: PAYER, + account_to_create: ACCOUNT_TO_CREATE, + account_to_change: ACCOUNT_TO_CHANGE, + }) + .succeeds(); } diff --git a/basics/close-account/quasar/CHANGELOG.md b/basics/close-account/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/close-account/quasar/CHANGELOG.md +++ b/basics/close-account/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/close-account/quasar/Cargo.toml b/basics/close-account/quasar/Cargo.toml index 592665f4..bee4ab64 100644 --- a/basics/close-account/quasar/Cargo.toml +++ b/basics/close-account/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-close-account" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,30 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/close-account/quasar/Quasar.toml b/basics/close-account/quasar/Quasar.toml index 86103ea5..289c974f 100644 --- a/basics/close-account/quasar/Quasar.toml +++ b/basics/close-account/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_close_account" - -[toolchain] -type = "solana" +name = "quasar-close-account" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/close-account/quasar/src/tests.rs b/basics/close-account/quasar/src/tests.rs index 3ef71dc0..3776cfa2 100644 --- a/basics/close-account/quasar/src/tests.rs +++ b/basics/close-account/quasar/src/tests.rs @@ -1,181 +1,78 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_close_account.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, +use { + crate::{ + cpi::{CloseUserInstruction, CreateUserInstruction}, + state::User, + }, + quasar_lang::{client::DynString, prelude::QuasarError}, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const USER: Pubkey = Pubkey::new_from_array([1; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([2; 32]); + +fn create_user_instruction(user: Pubkey) -> CreateUserInstruction { + CreateUserInstruction { + user, + name: DynString::new("Alice"), } } -/// Build create_user instruction data using Quasar's compact wire format -/// (header then tail). `String<50>` defaults to a u8 length prefix. -/// -/// header: [disc: u8 = 0][name_len: u8] -/// tail: [name bytes] -fn build_create_instruction(name: &str) -> Vec { - let mut data = Vec::with_capacity(2 + name.len()); - data.push(0u8); // discriminator - data.push(name.len() as u8); - data.extend_from_slice(name.as_bytes()); - data -} - -#[test] -fn test_create_user() { - let mut svm = setup(); +#[quasar_test] +fn create_user_stores_bump_owner_and_name(test: &mut Test) { + test.add(Wallet::new().at(USER)); + let user_account = test.derive_pda(User::seeds(&USER)); - let user = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let program_id = Pubkey::from(crate::ID); + // The user PDA and system program are canonical derivations, so the + // generated instruction only asks for the signer and the name. + test.send(create_user_instruction(USER)).succeeds(); - let (user_account, _) = - Pubkey::find_program_address(&[b"USER", user.as_ref()], &program_id); - - let create_ix = Instruction { - program_id, - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(user.to_bytes()), true), - solana_instruction::AccountMeta::new(Address::from(user_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(user), empty(user_account)]); - result.assert_success(); - - // Verify user account was created with correct data. - let account = result.account(&user_account).unwrap(); + // The byte layout is part of what this example demonstrates: + // [disc(1)] [ZC: bump(1) + user(32)] [name: u8 prefix + bytes] + let account = test.account(user_account).unwrap(); assert_eq!(account.data[0], 1, "discriminator should be 1"); - - // Data layout: [disc(1)] [ZC: bump(1) + user(32)] [name: u8 prefix + bytes] let bump = account.data[1]; assert_ne!(bump, 0, "bump should be nonzero"); - - // User address starts at offset 2 - let stored_user = &account.data[2..34]; - assert_eq!(stored_user, user.as_ref(), "stored user should match signer"); - - // Name: u8 prefix at offset 34, then "Alice" (5 bytes) + assert_eq!( + &account.data[2..34], + USER.as_ref(), + "stored user should match signer" + ); assert_eq!(account.data[34], 5, "name length"); assert_eq!(&account.data[35..40], b"Alice", "name data"); } -#[test] -fn test_close_user() { - let mut svm = setup(); - - let user = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let program_id = Pubkey::from(crate::ID); - - let (user_account, _) = - Pubkey::find_program_address(&[b"USER", user.as_ref()], &program_id); - - // Create user first - let create_ix = Instruction { - program_id, - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(user.to_bytes()), true), - solana_instruction::AccountMeta::new(Address::from(user_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(user), empty(user_account)]); - result.assert_success(); - - let user_after_create = result.account(&user).unwrap().clone(); - let account_after_create = result.account(&user_account).unwrap().clone(); - - // Close user - let close_ix = Instruction { - program_id, - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(user.to_bytes()), true), - solana_instruction::AccountMeta::new(Address::from(user_account.to_bytes()), false), - ], - data: vec![1u8], // discriminator = 1 - }; - - let result = - svm.process_instruction(&close_ix, &[user_after_create, account_after_create]); - result.assert_success(); - - // QuasarSvm doesn't reflect all close-account state changes in test results. - // The raw pointer writes that zero the discriminator, drain lamports, reassign - // owner, and resize data are applied to the BPF input buffer but aren't read back - // by the TransactionContext in the test harness. - // - // 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). +#[quasar_test] +fn close_user_drains_the_account_back_to_the_user(test: &mut Test) { + test.add(Wallet::new().at(USER)); + let user_account = test.derive_pda(User::seeds(&USER)); + test.send(create_user_instruction(USER)).succeeds(); + let rent = test.lamports(user_account); + assert_ne!(rent, 0, "creation should have funded the PDA"); + let user_lamports = test.lamports(USER); + + test.send(CloseUserInstruction { user: USER }) + .succeeds() + .is_closed(user_account) + .has_lamports(USER, user_lamports + rent); } -#[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], +#[quasar_test] +fn close_user_rejects_a_non_owner(test: &mut Test) { + test.add(Wallet::new().at(USER)); + test.add(Wallet::new().at(ATTACKER)); + let victim_account = test.derive_pda(User::seeds(&USER)); + test.send(create_user_instruction(USER)).succeeds(); + + // The attacker signs as `user` but passes the victim's account: the PDA + // derivation check must reject it before any lamports move. Account + // order matches CloseUserAccountConstraints: [user, user_account]. + let mut instruction: Instruction = CloseUserInstruction { user: ATTACKER }.into(); + instruction.accounts[1].pubkey = victim_account; + + test.send(instruction).fails_with(QuasarError::InvalidPda); + assert!( + test.account(victim_account).is_some_and(|account| !account.data.is_empty()), + "the victim's account must survive the failed close" ); - result.assert_error(quasar_svm::ProgramError::Custom( - quasar_lang::prelude::QuasarError::InvalidPda as u32, - )); } diff --git a/basics/create-account/quasar/CHANGELOG.md b/basics/create-account/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/create-account/quasar/CHANGELOG.md +++ b/basics/create-account/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/create-account/quasar/Cargo.toml b/basics/create-account/quasar/Cargo.toml index f3c737b0..ace0e06c 100644 --- a/basics/create-account/quasar/Cargo.toml +++ b/basics/create-account/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-create-account-client = { path = "target/client/rust/quasar-create-account-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/create-account/quasar/Quasar.toml b/basics/create-account/quasar/Quasar.toml index e4228914..c481016e 100644 --- a/basics/create-account/quasar/Quasar.toml +++ b/basics/create-account/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_create_account" - -[toolchain] -type = "solana" +name = "quasar-create-account" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/create-account/quasar/src/tests.rs b/basics/create-account/quasar/src/tests.rs index 16cfb9c7..3607d743 100644 --- a/basics/create-account/quasar/src/tests.rs +++ b/basics/create-account/quasar/src/tests.rs @@ -1,52 +1,34 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use quasar_create_account_client::CreateSystemAccountInstruction; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_create_account.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_create_system_account() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let new_account = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let instruction: Instruction = CreateSystemAccountInstruction { - payer: Address::from(payer.to_bytes()), - new_account: Address::from(new_account.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(new_account)], - ); - - result.assert_success(); +use {crate::cpi::CreateSystemAccountInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const NEW_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); + +#[quasar_test] +fn create_system_account_funds_a_rent_exempt_account(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // NEW_ACCOUNT stays absent: a missing writable account enters the + // transaction as an empty system account, the exact shape the system + // program's create_account CPI expects. + + // The system program is a canonical derivation, so the generated + // instruction only asks for the two signers. + test.send(CreateSystemAccountInstruction { + payer: PAYER, + new_account: NEW_ACCOUNT, + }) + .succeeds(); // Verify the new account exists and is owned by the system program. - let account = result.account(&new_account).unwrap(); - assert_eq!(account.owner, system_program, "account should be system-owned"); - assert!(account.lamports > 0, "account should have rent-exempt lamports"); + let account = test.account(NEW_ACCOUNT).unwrap(); + assert_eq!( + account.owner, + system_program::ID, + "account should be system-owned" + ); + assert!( + account.lamports > 0, + "account should have rent-exempt lamports" + ); assert_eq!(account.data.len(), 0, "account should have zero data"); } diff --git a/basics/cross-program-invocation/quasar/CHANGELOG.md b/basics/cross-program-invocation/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/cross-program-invocation/quasar/CHANGELOG.md +++ b/basics/cross-program-invocation/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/cross-program-invocation/quasar/hand/Cargo.toml b/basics/cross-program-invocation/quasar/hand/Cargo.toml index e1fddec0..d1d87a58 100644 --- a/basics/cross-program-invocation/quasar/hand/Cargo.toml +++ b/basics/cross-program-invocation/quasar/hand/Cargo.toml @@ -13,30 +13,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/cross-program-invocation/quasar/hand/Quasar.toml b/basics/cross-program-invocation/quasar/hand/Quasar.toml index 1ecfdff8..b4205b6f 100644 --- a/basics/cross-program-invocation/quasar/hand/Quasar.toml +++ b/basics/cross-program-invocation/quasar/hand/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_hand" - -[toolchain] -type = "solana" +name = "quasar-hand" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/cross-program-invocation/quasar/hand/src/tests.rs b/basics/cross-program-invocation/quasar/hand/src/tests.rs index 753f0105..92185050 100644 --- a/basics/cross-program-invocation/quasar/hand/src/tests.rs +++ b/basics/cross-program-invocation/quasar/hand/src/tests.rs @@ -1,81 +1,55 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -/// Lever program's program ID - must match the lever's declare_id!(). -fn lever_program_id() -> Pubkey { - Pubkey::from(crate::LEVER_PROGRAM_ID) -} +use { + crate::cpi::PullLeverInstruction, + quasar_lang::client::DynString, + quasar_test::prelude::*, +}; /// PowerStatus discriminator from the lever program. const POWER_STATUS_DISCRIMINATOR: u8 = 1; -fn setup() -> QuasarSvm { - let hand_elf = include_bytes!("../target/deploy/quasar_hand.so"); - let lever_elf = include_bytes!("../../lever/target/deploy/quasar_lever.so"); - QuasarSvm::new() - .with_program(&Pubkey::from(crate::ID), hand_elf) - .with_program(&lever_program_id(), lever_elf) +/// The lever program's ID as a test-harness Pubkey. +fn lever_program_id() -> Pubkey { + Pubkey::from(crate::LEVER_PROGRAM_ID) } -fn power_account(address: Pubkey, is_on: bool) -> Account { - // Account data: [discriminator: u8] [is_on: u8] - let data = vec![POWER_STATUS_DISCRIMINATOR, if is_on { 1 } else { 0 }]; - Account { - address, - lamports: 1_000_000_000, - data, - owner: lever_program_id(), - executable: false, - } +/// Load the lever program next to the hand program. quasar-test only +/// auto-loads sibling `.so` files from this project's own target/deploy +/// directory, so the lever ELF is read from the lever project at runtime. +fn load_lever(test: &mut Test) -> Pubkey { + test.add(Program::new( + lever_program_id(), + &std::fs::read("../lever/target/deploy/quasar_lever.so").unwrap(), + )); + Pubkey::find_program_address(&[b"power"], &lever_program_id()).0 } -/// Build pull_lever instruction data (discriminator = 0). -/// -/// Wire format: [discriminator = 0] [name: u8 length prefix + bytes]. -/// -/// The hand's pull_lever instruction takes `String<50>`, which Quasar -/// serialises with a single-byte length prefix. The CPI builder in -/// `pull_lever.rs` re-serialises the same name into the lever's -/// instruction data using the same u8 prefix. -fn build_pull_lever(name: &str) -> Vec { - let mut data = Vec::with_capacity(2 + name.len()); - data.push(0u8); // discriminator = 0 - data.push(name.len() as u8); - data.extend_from_slice(name.as_bytes()); - data +/// Install the lever's power account. Account data: +/// [discriminator: u8] [is_on: u8] +fn add_power_account(test: &mut Test, address: Pubkey, is_on: bool) { + test.set_account(Account::new( + address, + lever_program_id(), + 1_000_000_000, + vec![POWER_STATUS_DISCRIMINATOR, u8::from(is_on)], + )); } -#[test] -fn test_pull_lever_turns_on() { - let mut svm = setup(); - - let (power_addr, _bump) = Pubkey::find_program_address(&[b"power"], &lever_program_id()); - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new( - Address::from(power_addr.to_bytes()), - false, - ), - solana_instruction::AccountMeta::new_readonly( - Address::from(lever_program_id().to_bytes()), - false, - ), - ], - data: build_pull_lever("Alice"), - }; - - // The lever program account is provided by the SVM (loaded via with_program). - // Only the power data account needs to be passed explicitly. - let result = svm.process_instruction( - &ix, - &[power_account(power_addr, false)], - ); - - result.assert_success(); - - let logs = result.logs.join("\n"); +#[quasar_test] +fn pull_lever_turns_the_power_on(test: &mut Test) { + let power = load_lever(test); + // Start with power off. + add_power_account(test, power, false); + + // The lever program account is a canonical derivation + // (Program), so the generated instruction only asks for + // the power account and the name. + let outcome = test.send(PullLeverInstruction { + power, + name: DynString::new("Alice"), + }); + outcome.succeeds(); + + let logs = outcome.logs().join("\n"); assert!(logs.contains("Hand is pulling"), "hand should log"); assert!(logs.contains("pulling the power switch"), "lever should log"); assert!(logs.contains("now on"), "power should turn on"); @@ -85,48 +59,32 @@ fn test_pull_lever_turns_on() { // this (e.g. "\0\0\0Al" instead of "Alice"). assert!( logs.contains("Alice"), - "name should round-trip through hand → lever CPI; logs: {logs}" + "name should round-trip through hand -> lever CPI; logs: {logs}" ); - let account = result.account(&power_addr).unwrap(); + let account = test.account(power).unwrap(); assert_eq!(account.data[1], 1, "power should be on"); } -#[test] -fn test_pull_lever_turns_off() { - let mut svm = setup(); - - let (power_addr, _bump) = Pubkey::find_program_address(&[b"power"], &lever_program_id()); - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new( - Address::from(power_addr.to_bytes()), - false, - ), - solana_instruction::AccountMeta::new_readonly( - Address::from(lever_program_id().to_bytes()), - false, - ), - ], - data: build_pull_lever("Bob"), - }; - - let result = svm.process_instruction( - &ix, - &[power_account(power_addr, true)], - ); +#[quasar_test] +fn pull_lever_turns_the_power_off(test: &mut Test) { + let power = load_lever(test); + // Start with power on. + add_power_account(test, power, true); - result.assert_success(); + let outcome = test.send(PullLeverInstruction { + power, + name: DynString::new("Bob"), + }); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("now off"), "power should turn off"); assert!( logs.contains("Bob"), - "name should round-trip through hand → lever CPI; logs: {logs}" + "name should round-trip through hand -> lever CPI; logs: {logs}" ); - let account = result.account(&power_addr).unwrap(); + let account = test.account(power).unwrap(); assert_eq!(account.data[1], 0, "power should be off"); } diff --git a/basics/cross-program-invocation/quasar/lever/Cargo.toml b/basics/cross-program-invocation/quasar/lever/Cargo.toml index 355fc55e..46746067 100644 --- a/basics/cross-program-invocation/quasar/lever/Cargo.toml +++ b/basics/cross-program-invocation/quasar/lever/Cargo.toml @@ -13,30 +13,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/cross-program-invocation/quasar/lever/Quasar.toml b/basics/cross-program-invocation/quasar/lever/Quasar.toml index a3aa238b..9a340f7d 100644 --- a/basics/cross-program-invocation/quasar/lever/Quasar.toml +++ b/basics/cross-program-invocation/quasar/lever/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_lever" - -[toolchain] -type = "solana" +name = "quasar-lever" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/cross-program-invocation/quasar/lever/src/tests.rs b/basics/cross-program-invocation/quasar/lever/src/tests.rs index ed2c32e1..3168acf1 100644 --- a/basics/cross-program-invocation/quasar/lever/src/tests.rs +++ b/basics/cross-program-invocation/quasar/lever/src/tests.rs @@ -1,116 +1,43 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -/// Lever program discriminator for PowerStatus account (must match -/// #[account(discriminator = 1)]). -const POWER_STATUS_DISCRIMINATOR: u8 = 1; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_lever.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -/// Derive the power PDA address. -fn power_pda() -> (Pubkey, u8) { - Pubkey::find_program_address(&[b"power"], &Pubkey::from(crate::ID)) -} - -fn empty_pda(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn power_account(address: Pubkey, is_on: bool) -> Account { - // Account data: [discriminator: u8] [is_on: u8] - let data = vec![POWER_STATUS_DISCRIMINATOR, if is_on { 1 } else { 0 }]; - Account { - address, - lamports: 1_000_000_000, - data, - owner: Pubkey::from(crate::ID), - executable: false, - } -} - -/// Build initialize instruction data (discriminator = 0). -fn build_initialize() -> Vec { - vec![0u8] -} - -/// Build switch_power instruction data (discriminator = 1). -/// -/// Wire format: [discriminator = 1] [name: u8 length prefix + bytes]. -/// -/// The lever's switch_power instruction takes `String<50>`, which Quasar -/// serialises with a single-byte length prefix (matching every other -/// Quasar program: account-data, close-account, rent, realloc, -/// repository-layout). An earlier version of this builder used a u32 -/// length prefix, which produced a malformed payload that happened to -/// pass because the handler ignored the deserialised name. -fn build_switch_power(name: &str) -> Vec { - let mut data = Vec::with_capacity(2 + name.len()); - data.push(1u8); // discriminator = 1 - data.push(name.len() as u8); - data.extend_from_slice(name.as_bytes()); - data -} - -#[test] -fn test_initialize_lever() { - let mut svm = setup(); - let payer = Pubkey::new_unique(); - let (power_addr, _bump) = power_pda(); - let system_program = quasar_svm::system_program::ID; - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(payer.to_bytes()), true), - solana_instruction::AccountMeta::new(Address::from(power_addr.to_bytes()), false), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data: build_initialize(), - }; - - let result = svm.process_instruction(&ix, &[signer(payer), empty_pda(power_addr)]); - result.assert_success(); - - // Power should be off (false) after initialization. - let account = result.account(&power_addr).unwrap(); +use { + crate::{ + cpi::{InitializeInstruction, SwitchPowerInstruction}, + state::{PowerStatus, PowerStatusData}, + }, + quasar_lang::{client::DynString, prelude::PodBool}, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); + +#[quasar_test] +fn initialize_creates_the_power_status_switched_off(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let power = test.derive_pda(PowerStatus::seeds()); + + // The power PDA and system program are canonical derivations, so the + // generated instruction only asks for the payer. + test.send(InitializeInstruction { payer: PAYER }).succeeds(); + + let account = test.account(power).unwrap(); assert_eq!(account.data.len(), 2, "discriminator + is_on"); - assert_eq!(account.data[1], 0, "power should be off initially"); + let state = test.read::(power); + assert!(!bool::from(state.is_on), "power should be off initially"); } -#[test] -fn test_switch_power_on() { - let mut svm = setup(); - let (power_addr, _bump) = power_pda(); - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(power_addr.to_bytes()), false), - ], - data: build_switch_power("Alice"), - }; - +#[quasar_test] +fn switch_power_turns_the_power_on(test: &mut Test) { + let power = test.derive_pda(PowerStatus::seeds()); // Start with power off. - let result = svm.process_instruction(&ix, &[power_account(power_addr, false)]); - result.assert_success(); + test.write(power, PowerStatusData { is_on: PodBool::from(false) }); + + let outcome = test.send(SwitchPowerInstruction { + power, + name: DynString::new("Alice"), + }); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("pulling the power switch"), "should log switch"); assert!(logs.contains("now on"), "should say power is on"); // Verifies wire format: a stale u32 length prefix would corrupt the @@ -120,34 +47,29 @@ fn test_switch_power_on() { "deserialised name should round-trip exactly; logs: {logs}" ); - let account = result.account(&power_addr).unwrap(); - assert_eq!(account.data[1], 1, "power should now be on"); + let state = test.read::(power); + assert!(bool::from(state.is_on), "power should now be on"); } -#[test] -fn test_switch_power_off() { - let mut svm = setup(); - let (power_addr, _bump) = power_pda(); - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(power_addr.to_bytes()), false), - ], - data: build_switch_power("Bob"), - }; - +#[quasar_test] +fn switch_power_turns_the_power_off(test: &mut Test) { + let power = test.derive_pda(PowerStatus::seeds()); // Start with power on. - let result = svm.process_instruction(&ix, &[power_account(power_addr, true)]); - result.assert_success(); + test.write(power, PowerStatusData { is_on: PodBool::from(true) }); + + let outcome = test.send(SwitchPowerInstruction { + power, + name: DynString::new("Bob"), + }); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("now off"), "should say power is off"); assert!( logs.contains("Bob"), "deserialised name should round-trip exactly; logs: {logs}" ); - let account = result.account(&power_addr).unwrap(); - assert_eq!(account.data[1], 0, "power should now be off"); + let state = test.read::(power); + assert!(!bool::from(state.is_on), "power should now be off"); } diff --git a/basics/favorites/quasar/CHANGELOG.md b/basics/favorites/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/favorites/quasar/CHANGELOG.md +++ b/basics/favorites/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/favorites/quasar/Cargo.toml b/basics/favorites/quasar/Cargo.toml index a4b0a16d..73deeccf 100644 --- a/basics/favorites/quasar/Cargo.toml +++ b/basics/favorites/quasar/Cargo.toml @@ -3,6 +3,7 @@ name = "quasar-favorites" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. [workspace] [lints.rust.unexpected_cfgs] @@ -12,30 +13,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/favorites/quasar/Quasar.toml b/basics/favorites/quasar/Quasar.toml index 93604701..881ca1b1 100644 --- a/basics/favorites/quasar/Quasar.toml +++ b/basics/favorites/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_favorites" - -[toolchain] -type = "solana" +name = "quasar-favorites" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/favorites/quasar/src/tests.rs b/basics/favorites/quasar/src/tests.rs index 78d56563..97b63ab9 100644 --- a/basics/favorites/quasar/src/tests.rs +++ b/basics/favorites/quasar/src/tests.rs @@ -1,81 +1,33 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_favorites.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -/// Build set_favorites instruction data using Quasar's compact wire format -/// (header then tail). `String<50>` defaults to a u8 length prefix. -/// -/// header: [disc: u8 = 0][number: u64 LE][color_len: u8] -/// tail: [color bytes] -fn build_set_favorites(number: u64, color: &str) -> Vec { - let mut data = Vec::with_capacity(10 + color.len()); - - // Header - data.push(0u8); // discriminator - data.extend_from_slice(&number.to_le_bytes()); - data.push(color.len() as u8); - - // Tail - data.extend_from_slice(color.as_bytes()); - - data -} - -#[test] -fn test_set_favorites() { - let mut svm = setup(); - - let user = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let program_id = Pubkey::from(crate::ID); - - let (favorites, _) = - Pubkey::find_program_address(&[b"favorites", user.as_ref()], &program_id); - - let ix = Instruction { - program_id, - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(user.to_bytes()), true), - solana_instruction::AccountMeta::new(Address::from(favorites.to_bytes()), false), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data: build_set_favorites(42, "blue"), - }; - - let result = svm.process_instruction(&ix, &[signer(user), empty(favorites)]); - result.assert_success(); - - // Verify stored data. - let account = result.account(&favorites).unwrap(); - - // Data layout: [disc(1)] [ZC: number(8 bytes)] [color: u8 prefix + bytes] +use { + crate::{cpi::SetFavoritesInstruction, state::Favorites}, + quasar_lang::client::DynString, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const USER: Pubkey = Pubkey::new_from_array([1; 32]); + +#[quasar_test] +fn set_favorites_stores_number_and_color(test: &mut Test) { + test.add(Wallet::new().at(USER)); + let favorites = test.derive_pda(Favorites::seeds(&USER)); + + // The favorites PDA and system program are canonical derivations, so the + // generated instruction only asks for the user and the args. + test.send(SetFavoritesInstruction { + user: USER, + number: 42, + color: DynString::new("blue"), + }) + .succeeds(); + + // The byte layout is part of what this example demonstrates: a fixed + // field (number) followed by a dynamic field (color). + // [disc(1)] [ZC: number(8 bytes)] [color: u8 prefix + bytes] + let account = test.account(favorites).unwrap(); assert_eq!(account.data[0], 1, "discriminator"); - let number = u64::from_le_bytes(account.data[1..9].try_into().unwrap()); assert_eq!(number, 42, "favourite number"); - - // color: u8 prefix at offset 9, then "blue" (4 bytes) assert_eq!(account.data[9], 4, "color length"); assert_eq!(&account.data[10..14], b"blue", "color data"); } diff --git a/basics/hello-solana/quasar/CHANGELOG.md b/basics/hello-solana/quasar/CHANGELOG.md index 4f116388..9aba38c5 100644 --- a/basics/hello-solana/quasar/CHANGELOG.md +++ b/basics/hello-solana/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/hello-solana/quasar/Cargo.toml b/basics/hello-solana/quasar/Cargo.toml index 64627c62..7312f406 100644 --- a/basics/hello-solana/quasar/Cargo.toml +++ b/basics/hello-solana/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-hello-solana-client = { path = "target/client/rust/quasar-hello-solana-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/hello-solana/quasar/Quasar.toml b/basics/hello-solana/quasar/Quasar.toml index 381e0c01..f6592b92 100644 --- a/basics/hello-solana/quasar/Quasar.toml +++ b/basics/hello-solana/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_hello_solana" - -[toolchain] -type = "solana" +name = "quasar-hello-solana" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/hello-solana/quasar/src/tests.rs b/basics/hello-solana/quasar/src/tests.rs index d831c52d..0b365b5d 100644 --- a/basics/hello-solana/quasar/src/tests.rs +++ b/basics/hello-solana/quasar/src/tests.rs @@ -1,40 +1,18 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; +use {crate::cpi::HelloInstruction, quasar_test::prelude::*}; -use quasar_hello_solana_client::HelloInstruction; +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_hello_solana.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -#[test] -fn test_hello() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - - let instruction: Instruction = HelloInstruction { - payer: Address::from(payer.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[Account { - address: payer, - lamports: 1_000_000_000, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - }], - ); +#[quasar_test] +fn hello_logs_the_greeting(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); - result.assert_success(); + let outcome = test.send(HelloInstruction { payer: PAYER }); + outcome.succeeds(); - // The program only logs; assert it emitted its greeting, not just that the - // transaction succeeded. - let logs = result.logs.join("\n"); + // The program only logs; assert it emitted its greeting, not just that + // the transaction succeeded. + let logs = outcome.logs().join("\n"); assert!( logs.contains("Hello, Solana!"), "expected the program to log its greeting, got:\n{logs}" diff --git a/basics/pda-rent-payer/quasar/CHANGELOG.md b/basics/pda-rent-payer/quasar/CHANGELOG.md index 4f116388..c997a0b1 100644 --- a/basics/pda-rent-payer/quasar/CHANGELOG.md +++ b/basics/pda-rent-payer/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. One program-source + fix: `Seed` left the 0.1.0 prelude, so `create_new_account.rs` now imports + `quasar_lang::cpi::Seed` explicitly. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/pda-rent-payer/quasar/Cargo.toml b/basics/pda-rent-payer/quasar/Cargo.toml index a8ef1dd3..1173b398 100644 --- a/basics/pda-rent-payer/quasar/Cargo.toml +++ b/basics/pda-rent-payer/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-pda-rent-payer-client = { path = "target/client/rust/quasar-pda-rent-payer-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/pda-rent-payer/quasar/Quasar.toml b/basics/pda-rent-payer/quasar/Quasar.toml index 2afdab05..b78454ea 100644 --- a/basics/pda-rent-payer/quasar/Quasar.toml +++ b/basics/pda-rent-payer/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_pda_rent_payer" - -[toolchain] -type = "solana" +name = "quasar-pda-rent-payer" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 0da52274..8cb15b64 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 @@ -1,6 +1,6 @@ use { crate::instructions::init_rent_vault::RentVault, - quasar_lang::{prelude::*, sysvars::Sysvar}, + quasar_lang::{cpi::Seed, prelude::*, sysvars::Sysvar}, }; /// Accounts for creating a new account funded by the rent vault PDA. diff --git a/basics/pda-rent-payer/quasar/src/tests.rs b/basics/pda-rent-payer/quasar/src/tests.rs index 69ef7d6d..f7c040f8 100644 --- a/basics/pda-rent-payer/quasar/src/tests.rs +++ b/basics/pda-rent-payer/quasar/src/tests.rs @@ -1,129 +1,74 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use quasar_pda_rent_payer_client::{InitRentVaultInstruction, CreateNewAccountInstruction}; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_pda_rent_payer.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_init_rent_vault() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let fund_amount: u64 = 5_000_000_000; // 5 SOL - - // Derive the rent vault PDA from ["rent_vault"]. - let (rent_vault, _) = Pubkey::find_program_address( - &[b"rent_vault"], - &Pubkey::from(crate::ID), - ); - - let instruction: Instruction = InitRentVaultInstruction { - payer: Address::from(payer.to_bytes()), - rent_vault: Address::from(rent_vault.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - fund_lamports: fund_amount, - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(rent_vault)], - ); - - result.assert_success(); - - // Verify the vault received funds. - let vault_account = result.account(&rent_vault).unwrap(); - assert_eq!(vault_account.lamports, fund_amount); +use { + crate::{ + cpi::{CreateNewAccountInstruction, InitRentVaultInstruction}, + instructions::init_rent_vault::RentVault, + }, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const NEW_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); + +const FUND_AMOUNT: u64 = 5_000_000_000; // 5 SOL + +#[quasar_test] +fn init_rent_vault_funds_the_vault_pda(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let rent_vault = test.derive_pda(RentVault::seeds()); + + // The rent-vault PDA and system program are canonical derivations, so + // the generated instruction only asks for the payer and the amount. + test.send(InitRentVaultInstruction { + payer: PAYER, + fund_lamports: FUND_AMOUNT, + }) + .succeeds() + .has_lamports(rent_vault, FUND_AMOUNT); } -#[test] -fn test_create_new_account_from_vault() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let fund_amount: u64 = 5_000_000_000; +#[quasar_test] +fn create_new_account_is_paid_for_by_the_vault(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let rent_vault = test.derive_pda(RentVault::seeds()); + + // Step 1: fund the rent vault. + test.send(InitRentVaultInstruction { + payer: PAYER, + fund_lamports: FUND_AMOUNT, + }) + .succeeds(); + + // Step 2: create a new account funded by the vault. NEW_ACCOUNT stays + // absent: a missing writable account enters the transaction as an empty + // system account, which is the "not yet created" signer shape the + // create_account CPI expects. + test.send(CreateNewAccountInstruction { + new_account: NEW_ACCOUNT, + }) + .succeeds(); - // Derive the rent vault PDA. - let (rent_vault, _) = Pubkey::find_program_address( - &[b"rent_vault"], - &Pubkey::from(crate::ID), + // Verify the new account was created. + let new_account = test.account(NEW_ACCOUNT).unwrap(); + assert_eq!( + new_account.owner, + system_program::ID, + "new account should be system-owned" ); - - // Step 1: Fund the rent vault. - let init_instruction: Instruction = InitRentVaultInstruction { - payer: Address::from(payer.to_bytes()), - rent_vault: Address::from(rent_vault.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - fund_lamports: fund_amount, - } - .into(); - - let result = svm.process_instruction( - &init_instruction, - &[signer(payer), empty(rent_vault)], + assert!( + new_account.lamports > 0, + "new account should have rent-exempt lamports" ); - result.assert_success(); - - // Grab updated vault account. - let vault_after_init = result.account(&rent_vault).unwrap().clone(); - - // Step 2: Create a new account funded by the vault. - let new_account = Pubkey::new_unique(); - - let create_instruction: Instruction = CreateNewAccountInstruction { - new_account: Address::from(new_account.to_bytes()), - rent_vault: Address::from(rent_vault.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - // The new_account must be a signer but have zero lamports (not yet created). - let new_account_entry = Account { - address: new_account, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - }; - - let result = svm.process_instruction( - &create_instruction, - &[new_account_entry, vault_after_init], + assert_eq!( + new_account.data.len(), + 0, + "new account should have zero data" ); - result.assert_success(); - - // Verify the new account was created. - let new_acc = result.account(&new_account).unwrap(); - assert_eq!(new_acc.owner, system_program, "new account should be system-owned"); - assert!(new_acc.lamports > 0, "new account should have rent-exempt lamports"); - assert_eq!(new_acc.data.len(), 0, "new account should have zero data"); - - // Verify the vault balance decreased. - let vault_after = result.account(&rent_vault).unwrap(); - assert!( - vault_after.lamports < fund_amount, - "vault should have less lamports after paying rent" + // Verify the vault paid for it. + assert_eq!( + test.lamports(rent_vault), + FUND_AMOUNT - new_account.lamports, + "vault should have paid the new account's rent" ); } diff --git a/basics/processing-instructions/quasar/CHANGELOG.md b/basics/processing-instructions/quasar/CHANGELOG.md index 4f116388..9b97251b 100644 --- a/basics/processing-instructions/quasar/CHANGELOG.md +++ b/basics/processing-instructions/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; the hand-built + wire-format instruction encoding in tests was replaced by the generated + `GoToParkInstruction` builder. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/processing-instructions/quasar/Cargo.toml b/basics/processing-instructions/quasar/Cargo.toml index 1383454f..b74bd5ae 100644 --- a/basics/processing-instructions/quasar/Cargo.toml +++ b/basics/processing-instructions/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-processing-instructions" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,30 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/processing-instructions/quasar/Quasar.toml b/basics/processing-instructions/quasar/Quasar.toml index c11d53cb..78ddf264 100644 --- a/basics/processing-instructions/quasar/Quasar.toml +++ b/basics/processing-instructions/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_processing_instructions" - -[toolchain] -type = "solana" +name = "quasar-processing-instructions" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/processing-instructions/quasar/src/tests.rs b/basics/processing-instructions/quasar/src/tests.rs index 1359cb47..e585d4d4 100644 --- a/basics/processing-instructions/quasar/src/tests.rs +++ b/basics/processing-instructions/quasar/src/tests.rs @@ -1,74 +1,36 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; +use {crate::cpi::GoToParkInstruction, quasar_test::prelude::*}; -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_processing_instructions.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -/// Build go_to_park instruction data. -/// Wire format: [disc=0] [ZC: height(u32)] [name: u32 prefix + bytes] -fn build_go_to_park(name: &str, height: u32) -> Vec { - let mut data = vec![0u8]; // discriminator = 0 - - // Fixed ZC: height - data.extend_from_slice(&height.to_le_bytes()); +// Deterministic addresses keep tests independent of discovery order. +const USER: Pubkey = Pubkey::new_from_array([1; 32]); - // Dynamic String: name - data.extend_from_slice(&(name.len() as u32).to_le_bytes()); - data.extend_from_slice(name.as_bytes()); +#[quasar_test] +fn tall_visitor_is_allowed_on_the_ride(test: &mut Test) { + test.add(Wallet::new().at(USER)); - data -} - -#[test] -fn test_tall_enough() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new_readonly( - Address::from(user.to_bytes()), - true, - ), - ], - data: build_go_to_park("Alice", 6), - }; + let outcome = test.send(GoToParkInstruction { + signer: USER, + height: 6, + name: "Alice".to_string().into(), + }); + outcome.succeeds(); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); - - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("Welcome to the park!"), "should welcome"); assert!(logs.contains("tall enough to ride"), "should say tall enough"); } -#[test] -fn test_not_tall_enough() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new_readonly( - Address::from(user.to_bytes()), - true, - ), - ], - data: build_go_to_park("Bob", 3), - }; +#[quasar_test] +fn short_visitor_is_turned_away(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(GoToParkInstruction { + signer: USER, + height: 3, + name: "Bob".to_string().into(), + }); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("Welcome to the park!"), "should welcome"); assert!(logs.contains("NOT tall enough"), "should say not tall enough"); } diff --git a/basics/program-derived-addresses/quasar/CHANGELOG.md b/basics/program-derived-addresses/quasar/CHANGELOG.md index 4f116388..9b49263f 100644 --- a/basics/program-derived-addresses/quasar/CHANGELOG.md +++ b/basics/program-derived-addresses/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency and the generated-client + path dev-dependency are gone; state is now verified with typed + `test.read::` reads alongside the byte-layout checks. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/program-derived-addresses/quasar/Cargo.toml b/basics/program-derived-addresses/quasar/Cargo.toml index 93ea6999..b17c35ff 100644 --- a/basics/program-derived-addresses/quasar/Cargo.toml +++ b/basics/program-derived-addresses/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-program-derived-addresses-client = { path = "target/client/rust/quasar-program-derived-addresses-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/program-derived-addresses/quasar/Quasar.toml b/basics/program-derived-addresses/quasar/Quasar.toml index c87c6899..08935086 100644 --- a/basics/program-derived-addresses/quasar/Quasar.toml +++ b/basics/program-derived-addresses/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_program_derived_addresses" - -[toolchain] -type = "solana" +name = "quasar-program-derived-addresses" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/program-derived-addresses/quasar/src/tests.rs b/basics/program-derived-addresses/quasar/src/tests.rs index a61d0b10..0830d30a 100644 --- a/basics/program-derived-addresses/quasar/src/tests.rs +++ b/basics/program-derived-addresses/quasar/src/tests.rs @@ -1,119 +1,51 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use quasar_program_derived_addresses_client::{ - CreatePageVisitsInstruction, IncrementPageVisitsInstruction, +use { + crate::{ + cpi::{CreatePageVisitsInstruction, IncrementPageVisitsInstruction}, + state::PageVisits, + }, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_program_derived_addresses.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); -#[test] -fn test_create_page_visits() { - let mut svm = setup(); +#[quasar_test] +fn create_page_visits_initializes_the_pda(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let page_visits = test.derive_pda(PageVisits::seeds(&PAYER)); - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - // Derive the page visits PDA from ["page_visits", payer]. - let (page_visits, _) = Pubkey::find_program_address( - &[b"page_visits", payer.as_ref()], - &Pubkey::from(crate::ID), - ); - - let instruction: Instruction = CreatePageVisitsInstruction { - payer: Address::from(payer.to_bytes()), - page_visits: Address::from(page_visits.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(page_visits)], - ); + // The page-visits PDA and system program are canonical derivations, so + // the generated instruction only asks for the payer. + test.send(CreatePageVisitsInstruction { payer: PAYER }).succeeds(); - result.assert_success(); + // Byte layout is part of what this example demonstrates: + // 1 byte discriminator (1) + 8 bytes u64 count (0). + let account = test.account(page_visits).unwrap(); + assert_eq!(account.data.len(), 9); + assert_eq!(account.data[0], 1); // discriminator + assert_eq!(&account.data[1..], &[0u8; 8]); // page_visits = 0 - // Verify the page visits account was created with count = 0. - let pv_account = result.account(&page_visits).unwrap(); - // Data: 1 byte discriminator (1) + 8 bytes u64 (0) - assert_eq!(pv_account.data.len(), 9); - assert_eq!(pv_account.data[0], 1); // discriminator - assert_eq!(&pv_account.data[1..], &[0u8; 8]); // page_visits = 0 + let state = test.read::(page_visits); + assert_eq!(u64::from(state.page_visits), 0); } -#[test] -fn test_increment_page_visits() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - // Derive the page visits PDA. - let (page_visits, _) = Pubkey::find_program_address( - &[b"page_visits", payer.as_ref()], - &Pubkey::from(crate::ID), - ); - - // First, create the page visits account. - let create_instruction: Instruction = CreatePageVisitsInstruction { - payer: Address::from(payer.to_bytes()), - page_visits: Address::from(page_visits.to_bytes()), - system_program: Address::from(system_program.to_bytes()), - } - .into(); - - let result = svm.process_instruction( - &create_instruction, - &[signer(payer), empty(page_visits)], - ); - result.assert_success(); - - // Grab updated page_visits account after init. - let pv_after_init = result.account(&page_visits).unwrap().clone(); - - // Increment page visits. - let increment_instruction: Instruction = IncrementPageVisitsInstruction { - user: Address::from(payer.to_bytes()), - page_visits: Address::from(page_visits.to_bytes()), - } - .into(); +#[quasar_test] +fn increment_page_visits_advances_the_count(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let page_visits = test.derive_pda(PageVisits::seeds(&PAYER)); + test.send(CreatePageVisitsInstruction { payer: PAYER }).succeeds(); // The user account is only used for PDA derivation, not as a signer. - let user_account = Account { - address: payer, - lamports: 10_000_000_000, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - }; - - let result = svm.process_instruction( - &increment_instruction, - &[user_account, pv_after_init], + test.send(IncrementPageVisitsInstruction { + user: PAYER, + page_visits, + }) + .succeeds(); + + let state = test.read::(page_visits); + assert_eq!( + u64::from(state.page_visits), + 1, + "page_visits should be 1 after one increment" ); - result.assert_success(); - - // Verify page_visits = 1. - let pv_account = result.account(&page_visits).unwrap(); - let count_bytes: [u8; 8] = pv_account.data[1..9].try_into().unwrap(); - let count = u64::from_le_bytes(count_bytes); - assert_eq!(count, 1, "page_visits should be 1 after one increment"); } diff --git a/basics/pyth/quasar/CHANGELOG.md b/basics/pyth/quasar/CHANGELOG.md index 4f116388..7a22cb6f 100644 --- a/basics/pyth/quasar/CHANGELOG.md +++ b/basics/pyth/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature added, and tests rewritten + from the direct QuasarSVM harness to `quasar-test` (`#[quasar_test]` fixtures, + the generated `ReadPriceInstruction` builder, `Outcome` assertions). The + previously floating `quasar-lang`/`quasar-svm` git dependencies are now + pinned (`quasar-svm` is gone entirely). The hand-built mock Pyth + `PriceUpdateV2` oracle account (owner + raw byte layout) is preserved via + `test.set_account(Account::new(...))`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/pyth/quasar/Cargo.toml b/basics/pyth/quasar/Cargo.toml index 1687eb6f..b9205616 100644 --- a/basics/pyth/quasar/Cargo.toml +++ b/basics/pyth/quasar/Cargo.toml @@ -14,17 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } 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"] } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/pyth/quasar/Quasar.toml b/basics/pyth/quasar/Quasar.toml index 61d6f2ee..bcbfda0b 100644 --- a/basics/pyth/quasar/Quasar.toml +++ b/basics/pyth/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_pyth_example" - -[toolchain] -type = "solana" +name = "quasar-pyth-example" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/pyth/quasar/src/tests.rs b/basics/pyth/quasar/src/tests.rs index f71a2517..724f69ea 100644 --- a/basics/pyth/quasar/src/tests.rs +++ b/basics/pyth/quasar/src/tests.rs @@ -1,18 +1,18 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use crate::instructions::{ - PythExampleError, MAXIMUM_PRICE_AGE_SECONDS, PYTH_RECEIVER_PROGRAM_ID, +use { + crate::{ + cpi::ReadPriceInstruction, + instructions::{PythExampleError, MAXIMUM_PRICE_AGE_SECONDS, PYTH_RECEIVER_PROGRAM_ID}, + }, + quasar_test::prelude::*, }; +// Deterministic addresses keep tests independent of discovery order. +const PRICE_UPDATE: Pubkey = Pubkey::new_from_array([1; 32]); +const WRONG_OWNER: Pubkey = Pubkey::new_from_array([2; 32]); + /// 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) -} - /// Build a minimal mock PriceUpdateV2 account body (133 bytes). /// /// Layout: @@ -28,7 +28,7 @@ fn setup() -> QuasarSvm { /// [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 -fn build_mock_price_update_account() -> Vec { +fn build_mock_price_update_data() -> Vec { let discriminator: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; let mut data = Vec::with_capacity(133); @@ -48,75 +48,50 @@ fn build_mock_price_update_account() -> Vec { data } -fn price_update_account(address: Pubkey, owner: Pubkey) -> Account { - Account { - address, - lamports: 1_000_000_000, - data: build_mock_price_update_account(), +/// Install a hand-built Pyth oracle account (owner + raw data) into the world. +fn add_price_update_account(test: &mut Test, owner: Pubkey) { + test.set_account(Account::new( + PRICE_UPDATE, owner, - executable: false, - } -} - -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], // read_price discriminator - } + 1_000_000_000, + build_mock_price_update_data(), + )); } -#[test] -fn test_read_price() { - let mut svm = setup(); - +#[quasar_test] +fn read_price_accepts_a_fresh_price(test: &mut Test) { // 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)); + test.warp_to_timestamp(MOCK_PUBLISH_TIME + MAXIMUM_PRICE_AGE_SECONDS); + add_price_update_account(test, Pubkey::from(PYTH_RECEIVER_PROGRAM_ID)); - let result = - svm.process_instruction(&read_price_instruction(price_update), &[price_account]); - result.assert_success(); + test.send(ReadPriceInstruction { + price_update: PRICE_UPDATE, + }) + .succeeds(); } -#[test] -fn test_read_price_rejects_stale_price() { - let mut svm = setup(); - +#[quasar_test] +fn read_price_rejects_a_stale_price(test: &mut Test) { // 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)); + test.warp_to_timestamp(MOCK_PUBLISH_TIME + MAXIMUM_PRICE_AGE_SECONDS + 1); + add_price_update_account(test, 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.send(ReadPriceInstruction { + price_update: PRICE_UPDATE, + }) + .fails_with(PythExampleError::PriceTooOld); } -#[test] -fn test_read_price_rejects_wrong_owner() { - let mut svm = setup(); - - svm.warp_to_timestamp(MOCK_PUBLISH_TIME); +#[quasar_test] +fn read_price_rejects_an_account_with_the_wrong_owner(test: &mut Test) { + test.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()); + add_price_update_account(test, WRONG_OWNER); - let result = - svm.process_instruction(&read_price_instruction(price_update), &[price_account]); - result.assert_error(quasar_svm::ProgramError::Custom( - PythExampleError::PriceUpdateNotOwnedByPythReceiver as u32, - )); + test.send(ReadPriceInstruction { + price_update: PRICE_UPDATE, + }) + .fails_with(PythExampleError::PriceUpdateNotOwnedByPythReceiver); } diff --git a/basics/realloc/quasar/CHANGELOG.md b/basics/realloc/quasar/CHANGELOG.md index 4f116388..5b843bd9 100644 --- a/basics/realloc/quasar/CHANGELOG.md +++ b/basics/realloc/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone. The message field + and instruction args changed from `String<1024>` to `String<1024, 2>`: + zeropod 0.3.3 enforces at compile time that the capacity fits the length + prefix, so a 1024-byte string needs a 2-byte (u16) prefix — the account and + instruction wire layout now carries a u16 LE length instead of u8. The + update test now also verifies the realloc'd account contents. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/realloc/quasar/Cargo.toml b/basics/realloc/quasar/Cargo.toml index 641fe593..ff00c68c 100644 --- a/basics/realloc/quasar/Cargo.toml +++ b/basics/realloc/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-realloc" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,30 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/realloc/quasar/Quasar.toml b/basics/realloc/quasar/Quasar.toml index b1b68fdb..38ed5506 100644 --- a/basics/realloc/quasar/Quasar.toml +++ b/basics/realloc/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_realloc" - -[toolchain] -type = "solana" +name = "quasar-realloc" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/realloc/quasar/src/lib.rs b/basics/realloc/quasar/src/lib.rs index ecd4062a..56f543b3 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, 2>) -> 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, 2>) -> Result<(), ProgramError> { instructions::handle_update(&mut ctx.accounts, message) } } diff --git a/basics/realloc/quasar/src/state.rs b/basics/realloc/quasar/src/state.rs index 183de8ec..791c697c 100644 --- a/basics/realloc/quasar/src/state.rs +++ b/basics/realloc/quasar/src/state.rs @@ -5,5 +5,5 @@ use quasar_lang::prelude::*; /// the current account size, making explicit realloc unnecessary. #[account(discriminator = 1, set_inner)] pub struct MessageAccount { - pub message: String<1024>, + pub message: String<1024, 2>, } diff --git a/basics/realloc/quasar/src/tests.rs b/basics/realloc/quasar/src/tests.rs index 5b9f6350..97938028 100644 --- a/basics/realloc/quasar/src/tests.rs +++ b/basics/realloc/quasar/src/tests.rs @@ -1,137 +1,62 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_realloc.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -/// Build initialize instruction data using Quasar's compact wire format. -/// `String<1024>` defaults to a u8 length prefix (the second `String` generic -/// argument is the prefix type and its default is `u8`). -/// -/// header: [disc: u8 = 0][message_len: u8] -/// tail: [message bytes] -fn build_initialize(message: &str) -> Vec { - let mut data = Vec::with_capacity(2 + message.len()); - data.push(0u8); // discriminator - data.push(message.len() as u8); - data.extend_from_slice(message.as_bytes()); - data -} - -/// Build update instruction data using the same compact wire format. -fn build_update(message: &str) -> Vec { - let mut data = Vec::with_capacity(2 + message.len()); - data.push(1u8); // discriminator - data.push(message.len() as u8); - data.extend_from_slice(message.as_bytes()); - data -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let message_account = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(payer.to_bytes()), true), - solana_instruction::AccountMeta::new( - Address::from(message_account.to_bytes()), - true, - ), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data: build_initialize("Hello, World!"), - }; - - let result = svm.process_instruction(&ix, &[signer(payer), empty(message_account)]); - result.assert_success(); - - // Verify: disc(1) + message (u8 prefix + bytes) - let account = result.account(&message_account).unwrap(); +use { + crate::cpi::{InitializeInstruction, UpdateInstruction}, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +// The message account is a random keypair (not a PDA) - same as the Anchor +// version - so the caller passes its address explicitly. +const MESSAGE_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Assert the account holds the expected message in Quasar's compact wire +/// layout: disc(1 byte = 1) + u16 LE length prefix + message bytes. +/// `String<1024, 2>` uses a 2-byte length prefix: zeropod 0.3.3 rejects a +/// capacity that exceeds the prefix's range at compile time, so a 1024-byte +/// string can no longer use the default u8 prefix. Byte layout is part of +/// what this example demonstrates, so it is checked directly. +fn assert_message(test: &Test, expected: &str) { + let account = test.account(MESSAGE_ACCOUNT).unwrap(); assert_eq!(account.data[0], 1, "discriminator"); - - let msg_len = account.data[1] as usize; - assert_eq!(msg_len, 13); - assert_eq!(&account.data[2..2 + msg_len], b"Hello, World!"); + let msg_len = u16::from_le_bytes(account.data[1..3].try_into().unwrap()) as usize; + assert_eq!(msg_len, expected.len()); + assert_eq!(&account.data[3..3 + msg_len], expected.as_bytes()); } -#[test] -fn test_update_longer_message() { - let mut svm = setup(); +#[quasar_test] +fn initialize_stores_the_message(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); - let payer = Pubkey::new_unique(); - let message_account = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let program_id = Pubkey::from(crate::ID); + test.send(InitializeInstruction { + payer: PAYER, + message_account: MESSAGE_ACCOUNT, + message: "Hello, World!".to_string().into(), + }) + .succeeds(); - // Initialize with short message - let init_ix = Instruction { - program_id, - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(payer.to_bytes()), true), - solana_instruction::AccountMeta::new( - Address::from(message_account.to_bytes()), - true, - ), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data: build_initialize("Hi"), - }; - - let result = svm.process_instruction(&init_ix, &[signer(payer), empty(message_account)]); - result.assert_success(); - - 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 - let update_ix = Instruction { - program_id, - accounts: vec![ - solana_instruction::AccountMeta::new(Address::from(payer.to_bytes()), true), - solana_instruction::AccountMeta::new( - Address::from(message_account.to_bytes()), - false, - ), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data: build_update("Hello, this is a much longer message!"), - }; - - let result = svm.process_instruction(&update_ix, &[payer_after_init, msg_after_init]); - result.assert_success(); + assert_message(test, "Hello, World!"); +} - // Note: QuasarSvm may not fully reflect realloc changes (data length change) - // in test results. The realloc is handled by set_inner which modifies the - // RuntimeAccount data_len field directly. Onchain this works correctly. +#[quasar_test] +fn update_with_a_longer_message_reallocs(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + + // Initialize with a short message. + test.send(InitializeInstruction { + payer: PAYER, + message_account: MESSAGE_ACCOUNT, + message: "Hi".to_string().into(), + }) + .succeeds(); + + // Update with a longer message - set_inner grows the account (realloc). + let longer = "Hello, this is a much longer message!"; + test.send(UpdateInstruction { + payer: PAYER, + message_account: MESSAGE_ACCOUNT, + message: longer.to_string().into(), + }) + .succeeds(); + + assert_message(test, longer); } diff --git a/basics/rent/quasar/CHANGELOG.md b/basics/rent/quasar/CHANGELOG.md index 4f116388..74ed866e 100644 --- a/basics/rent/quasar/CHANGELOG.md +++ b/basics/rent/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, the generated `CreateSystemAccountInstruction` + builder, `Outcome` assertions). The `quasar-svm` git dev-dependency is gone; + the hand-built wire-format instruction encoding in tests was replaced by the + generated builder. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/rent/quasar/Cargo.toml b/basics/rent/quasar/Cargo.toml index 89580c34..44e8d268 100644 --- a/basics/rent/quasar/Cargo.toml +++ b/basics/rent/quasar/Cargo.toml @@ -14,30 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/rent/quasar/Quasar.toml b/basics/rent/quasar/Quasar.toml index ce63074d..fbc9ed6c 100644 --- a/basics/rent/quasar/Quasar.toml +++ b/basics/rent/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_rent" - -[toolchain] -type = "solana" +name = "quasar-rent" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/rent/quasar/src/tests.rs b/basics/rent/quasar/src/tests.rs index f27427e3..d4bb570b 100644 --- a/basics/rent/quasar/src/tests.rs +++ b/basics/rent/quasar/src/tests.rs @@ -1,85 +1,28 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; +use {crate::cpi::CreateSystemAccountInstruction, quasar_test::prelude::*}; -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_rent.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -/// Build create_system_account instruction data using Quasar's compact -/// wire format (header then tail). `String<50>` defaults to a u8 length -/// prefix (the second `String` generic argument is the prefix type). -/// -/// header: [disc: u8 = 0][name_len: u8][address_len: u8] -/// tail: [name bytes][address bytes] -fn build_create_system_account(name: &str, address: &str) -> Vec { - let mut data = Vec::with_capacity(3 + name.len() + address.len()); +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +// The new account is a fresh keypair whose address the caller chooses. +const NEW_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); - // Header - data.push(0u8); // discriminator - data.push(name.len() as u8); - data.push(address.len() as u8); - - // Tail - data.extend_from_slice(name.as_bytes()); - data.extend_from_slice(address.as_bytes()); - - data -} - -#[test] -fn test_create_system_account_for_address_data() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let new_account = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; +#[quasar_test] +fn create_system_account_sized_for_address_data(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); let name = "Joe C"; let address = "123 Main St"; - let ix = Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new( - Address::from(payer.to_bytes()), - true, - ), - solana_instruction::AccountMeta::new( - Address::from(new_account.to_bytes()), - true, - ), - solana_instruction::AccountMeta::new_readonly( - Address::from(system_program.to_bytes()), - false, - ), - ], - data: build_create_system_account(name, address), - }; - - let result = svm.process_instruction( - &ix, - &[signer(payer), empty(new_account)], - ); - - result.assert_success(); - - // Verify the account was created with the expected data size. - let account = result.account(&new_account).unwrap(); + let outcome = test.send(CreateSystemAccountInstruction { + payer: PAYER, + new_account: NEW_ACCOUNT, + name: name.to_string().into(), + address: address.to_string().into(), + }); + outcome.succeeds(); + + // Verify the account was created with the expected data size: + // borsh-style 4-byte length prefix + bytes for each String field. + let account = test.account(NEW_ACCOUNT).unwrap(); let expected_space = 4 + name.len() + 4 + address.len(); assert_eq!( account.data.len(), @@ -88,7 +31,7 @@ fn test_create_system_account_for_address_data() { ); assert!(account.lamports > 0, "account should have rent-exempt lamports"); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("Creating a system account"), "should log creation"); assert!(logs.contains("Account created successfully"), "should log success"); } diff --git a/basics/repository-layout/quasar/CHANGELOG.md b/basics/repository-layout/quasar/CHANGELOG.md index 4f116388..fd1f535c 100644 --- a/basics/repository-layout/quasar/CHANGELOG.md +++ b/basics/repository-layout/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; the hand-built + wire-format instruction encoding in tests was replaced by the generated + `GoOnRide`/`PlayGame`/`EatFood` instruction builders. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/repository-layout/quasar/Cargo.toml b/basics/repository-layout/quasar/Cargo.toml index a7b3a7c1..ec7ca3bd 100644 --- a/basics/repository-layout/quasar/Cargo.toml +++ b/basics/repository-layout/quasar/Cargo.toml @@ -14,30 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/repository-layout/quasar/Quasar.toml b/basics/repository-layout/quasar/Quasar.toml index 16496422..2cd6f148 100644 --- a/basics/repository-layout/quasar/Quasar.toml +++ b/basics/repository-layout/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_carnival" - -[toolchain] -type = "solana" +name = "quasar-carnival" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/repository-layout/quasar/src/tests.rs b/basics/repository-layout/quasar/src/tests.rs index 90a99334..c25eeab6 100644 --- a/basics/repository-layout/quasar/src/tests.rs +++ b/basics/repository-layout/quasar/src/tests.rs @@ -1,212 +1,134 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_carnival.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -/// Build go_on_ride instruction data using Quasar's compact wire format -/// (header then tail). `String<50>` defaults to a u8 length prefix. -/// -/// header: [disc: u8 = 0][height: u32 LE][ticket_count: u32 LE][name_len: u8][ride_name_len: u8] -/// tail: [name bytes][ride_name bytes] -fn build_go_on_ride(name: &str, height: u32, ticket_count: u32, ride_name: &str) -> Vec { - let mut data = Vec::with_capacity(11 + name.len() + ride_name.len()); - - // Header - data.push(0u8); // discriminator - data.extend_from_slice(&height.to_le_bytes()); - data.extend_from_slice(&ticket_count.to_le_bytes()); - data.push(name.len() as u8); - data.push(ride_name.len() as u8); - - // Tail - data.extend_from_slice(name.as_bytes()); - data.extend_from_slice(ride_name.as_bytes()); - - data -} - -/// Build play_game instruction data using the same compact wire format. -/// -/// header: [disc: u8 = 1][ticket_count: u32 LE][name_len: u8][game_name_len: u8] -/// tail: [name bytes][game_name bytes] -fn build_play_game(name: &str, ticket_count: u32, game_name: &str) -> Vec { - let mut data = Vec::with_capacity(7 + name.len() + game_name.len()); - - // Header - data.push(1u8); // discriminator - data.extend_from_slice(&ticket_count.to_le_bytes()); - data.push(name.len() as u8); - data.push(game_name.len() as u8); - - // Tail - data.extend_from_slice(name.as_bytes()); - data.extend_from_slice(game_name.as_bytes()); - - data +use { + crate::cpi::{EatFoodInstruction, GoOnRideInstruction, PlayGameInstruction}, + quasar_test::prelude::*, +}; + +// Deterministic addresses keep tests independent of discovery order. +const USER: Pubkey = Pubkey::new_from_array([1; 32]); + +fn go_on_ride(name: &str, height: u32, ticket_count: u32, ride_name: &str) -> GoOnRideInstruction { + GoOnRideInstruction { + payer: USER, + height, + ticket_count, + name: name.to_string().into(), + ride_name: ride_name.to_string().into(), + } } -/// Build eat_food instruction data using the same compact wire format. -/// -/// header: [disc: u8 = 2][ticket_count: u32 LE][name_len: u8][food_stand_name_len: u8] -/// tail: [name bytes][food_stand_name bytes] -fn build_eat_food(name: &str, ticket_count: u32, food_stand_name: &str) -> Vec { - let mut data = Vec::with_capacity(7 + name.len() + food_stand_name.len()); - - // Header - data.push(2u8); // discriminator - data.extend_from_slice(&ticket_count.to_le_bytes()); - data.push(name.len() as u8); - data.push(food_stand_name.len() as u8); - - // Tail - data.extend_from_slice(name.as_bytes()); - data.extend_from_slice(food_stand_name.as_bytes()); - - data +fn play_game(name: &str, ticket_count: u32, game_name: &str) -> PlayGameInstruction { + PlayGameInstruction { + payer: USER, + ticket_count, + name: name.to_string().into(), + game_name: game_name.to_string().into(), + } } -fn make_ix(data: Vec, user: Pubkey) -> Instruction { - Instruction { - program_id: Pubkey::from(crate::ID), - accounts: vec![ - solana_instruction::AccountMeta::new_readonly( - Address::from(user.to_bytes()), - true, - ), - ], - data, +fn eat_food(name: &str, ticket_count: u32, food_stand_name: &str) -> EatFoodInstruction { + EatFoodInstruction { + payer: USER, + ticket_count, + name: name.to_string().into(), + food_stand_name: food_stand_name.to_string().into(), } } -#[test] -fn test_go_on_ride_success() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_go_on_ride("Alice", 60, 5, "Ferris Wheel"); - let ix = make_ix(data, user); +#[quasar_test] +fn tall_rider_with_tickets_boards_the_ride(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(go_on_ride("Alice", 60, 5, "Ferris Wheel")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("about to go on a ride"), "should announce ride"); assert!(logs.contains("Welcome aboard"), "should welcome aboard"); } -#[test] -fn test_go_on_ride_not_tall_enough() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_go_on_ride("Bob", 40, 5, "Ferris Wheel"); - let ix = make_ix(data, user); +#[quasar_test] +fn short_rider_is_turned_away(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(go_on_ride("Bob", 40, 5, "Ferris Wheel")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("not tall enough"), "should reject short rider"); } -#[test] -fn test_go_on_ride_not_enough_tickets() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_go_on_ride("Charlie", 60, 1, "Zero Gravity"); - let ix = make_ix(data, user); +#[quasar_test] +fn rider_without_enough_tickets_is_turned_away(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(go_on_ride("Charlie", 60, 1, "Zero Gravity")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("enough tickets"), "should reject insufficient tickets"); } -#[test] -fn test_go_on_ride_upside_down() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_go_on_ride("Dave", 65, 5, "Zero Gravity"); - let ix = make_ix(data, user); +#[quasar_test] +fn upside_down_ride_warns_the_rider(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(go_on_ride("Dave", 65, 5, "Zero Gravity")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("upside down"), "should warn about upside down"); } -#[test] -fn test_play_game_success() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_play_game("Alice", 5, "Ring Toss"); - let ix = make_ix(data, user); +#[quasar_test] +fn player_with_tickets_plays_the_game(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(play_game("Alice", 5, "Ring Toss")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("about to play"), "should announce game"); assert!(logs.contains("what you got"), "should encourage player"); } -#[test] -fn test_play_game_not_enough_tickets() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_play_game("Bob", 1, "Ring Toss"); - let ix = make_ix(data, user); +#[quasar_test] +fn player_without_enough_tickets_is_turned_away(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(play_game("Bob", 1, "Ring Toss")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("enough tickets"), "should reject insufficient tickets"); } -#[test] -fn test_eat_food_success() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_eat_food("Alice", 3, "Larry's Pizza"); - let ix = make_ix(data, user); +#[quasar_test] +fn visitor_with_tickets_eats_at_the_stand(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(eat_food("Alice", 3, "Larry's Pizza")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("food stand"), "should welcome to food stand"); assert!(logs.contains("Enjoy"), "should say enjoy"); } -#[test] -fn test_eat_food_not_enough_tickets() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_eat_food("Bob", 0, "Larry's Pizza"); - let ix = make_ix(data, user); +#[quasar_test] +fn visitor_without_enough_tickets_cannot_eat(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - result.assert_success(); + let outcome = test.send(eat_food("Bob", 0, "Larry's Pizza")); + outcome.succeeds(); - let logs = result.logs.join("\n"); + let logs = outcome.logs().join("\n"); assert!(logs.contains("enough tickets"), "should reject insufficient tickets"); } -#[test] -fn test_invalid_ride_name() { - let mut svm = setup(); - let user = Pubkey::new_unique(); - let data = build_go_on_ride("Eve", 60, 5, "Nonexistent Ride"); - let ix = make_ix(data, user); +#[quasar_test] +fn unknown_ride_name_is_rejected(test: &mut Test) { + test.add(Wallet::new().at(USER)); - let result = svm.process_instruction(&ix, &[signer(user)]); - assert!(result.raw_result.is_err(), "should fail for unknown ride"); + test.send(go_on_ride("Eve", 60, 5, "Nonexistent Ride")) + .fails(ProgramError::InvalidInstructionData); } diff --git a/basics/transfer-sol/quasar/CHANGELOG.md b/basics/transfer-sol/quasar/CHANGELOG.md index 4f116388..0c3ed4a8 100644 --- a/basics/transfer-sol/quasar/CHANGELOG.md +++ b/basics/transfer-sol/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions such as `has_lamports`). The `quasar-svm` git dev-dependency and + the generated-client path dev-dependency are gone; program-owned payer / + recipient accounts are installed with `test.set_account(Account::new(...))`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/basics/transfer-sol/quasar/Cargo.toml b/basics/transfer-sol/quasar/Cargo.toml index 19a16c23..c7f7f904 100644 --- a/basics/transfer-sol/quasar/Cargo.toml +++ b/basics/transfer-sol/quasar/Cargo.toml @@ -14,31 +14,23 @@ check-cfg = [ ] [lib] -crate-type = ["cdylib"] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. +crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-transfer-sol-client = { path = "target/client/rust/quasar-transfer-sol-client" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -solana-account = { version = "3.4.0" } -solana-address = { version = "2.2.0", features = ["decode"] } -solana-instruction = { version = "3.2.0", features = ["bincode"] } -solana-pubkey = { version = "4.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/basics/transfer-sol/quasar/Quasar.toml b/basics/transfer-sol/quasar/Quasar.toml index 8d0bcab7..b6b3c9ce 100644 --- a/basics/transfer-sol/quasar/Quasar.toml +++ b/basics/transfer-sol/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_transfer_sol" - -[toolchain] -type = "solana" +name = "quasar-transfer-sol" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/basics/transfer-sol/quasar/src/tests.rs b/basics/transfer-sol/quasar/src/tests.rs index bdeb9c65..2495d749 100644 --- a/basics/transfer-sol/quasar/src/tests.rs +++ b/basics/transfer-sol/quasar/src/tests.rs @@ -1,178 +1,93 @@ -use quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}; -use solana_address::Address; - -use crate::instructions::TransferSolError; -use quasar_transfer_sol_client::{ - TransferSolWithCpiInstruction, TransferSolWithProgramInstruction, +use { + crate::{ + cpi::{TransferSolWithCpiInstruction, TransferSolWithProgramInstruction}, + instructions::TransferSolError, + }, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = include_bytes!("../target/deploy/quasar_transfer_sol.so"); - QuasarSvm::new().with_program(&Pubkey::from(crate::ID), elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const RECIPIENT: Pubkey = Pubkey::new_from_array([2; 32]); -fn system_account(address: Pubkey, lamports: u64) -> Account { - Account { +/// Install a zero-data account owned by this program: direct lamport +/// manipulation is only allowed on program-owned accounts. +fn add_program_owned_account(test: &mut Test, address: Pubkey, lamports: u64) { + test.set_account(Account::new( address, + Pubkey::from(crate::ID), lamports, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } + vec![], + )); } -#[test] -fn test_transfer_sol_with_cpi() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; +#[quasar_test] +fn transfer_with_cpi_moves_lamports(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); let amount = 1_000_000_000; // 1 SOL - let instruction: Instruction = TransferSolWithCpiInstruction { - payer: Address::from(payer.to_bytes()), - recipient: Address::from(recipient.to_bytes()), - system_program: Address::from(system_program.to_bytes()), + // The recipient starts empty: missing writable accounts enter the + // transaction as empty system accounts automatically. + test.send(TransferSolWithCpiInstruction { + payer: PAYER, + recipient: RECIPIENT, amount, - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[signer(payer), system_account(recipient, 0)], - ); - - result.assert_success(); - - // Verify balances after transfer. - let payer_account = result.account(&payer).unwrap(); - assert_eq!(payer_account.lamports, 10_000_000_000 - amount); - - let recipient_account = result.account(&recipient).unwrap(); - assert_eq!(recipient_account.lamports, amount); + }) + .succeeds() + .has_lamports(PAYER, DEFAULT_WALLET_LAMPORTS - amount) + .has_lamports(RECIPIENT, amount); } -#[test] -fn test_transfer_sol_with_program() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); +#[quasar_test] +fn transfer_with_program_moves_lamports_directly(test: &mut Test) { let amount = 500_000_000; // 0.5 SOL // The payer must be owned by our program for direct lamport manipulation. - let payer_account = Account { - address: payer, - lamports: 2_000_000_000, - data: vec![], - owner: Pubkey::from(crate::ID), - executable: false, - }; + add_program_owned_account(test, PAYER, 2_000_000_000); + add_program_owned_account(test, RECIPIENT, 1_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()), + test.send(TransferSolWithProgramInstruction { + payer: PAYER, + recipient: RECIPIENT, amount, - } - .into(); - - let result = svm.process_instruction( - &instruction, - &[payer_account, recipient_account], - ); - - result.assert_success(); - - // Verify balances. - let payer_after = result.account(&payer).unwrap(); - assert_eq!(payer_after.lamports, 2_000_000_000 - amount); - - let recipient_after = result.account(&recipient).unwrap(); - assert_eq!(recipient_after.lamports, 1_000_000_000 + amount); + }) + .succeeds() + .has_lamports(PAYER, 2_000_000_000 - amount) + .has_lamports(RECIPIENT, 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(); +#[quasar_test] +fn transfer_with_program_rejects_a_foreign_owned_payer(test: &mut Test) { 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, - }; + test.add(Wallet::new().at(PAYER).lamports(2_000_000_000)); + add_program_owned_account(test, RECIPIENT, 1_000_000_000); - let instruction: Instruction = TransferSolWithProgramInstruction { - payer: Address::from(payer.to_bytes()), - recipient: Address::from(recipient.to_bytes()), + test.send(TransferSolWithProgramInstruction { + payer: PAYER, + recipient: RECIPIENT, amount, - } - .into(); - - let result = svm.process_instruction(&instruction, &[payer_account, recipient_account]); - result.assert_error(quasar_svm::ProgramError::Custom( - TransferSolError::PayerNotOwnedByProgram as u32, - )); + }) + .fails_with(TransferSolError::PayerNotOwnedByProgram); } -#[test] -fn test_transfer_sol_with_program_rejects_insufficient_funds() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); +#[quasar_test] +fn transfer_with_program_rejects_insufficient_funds(test: &mut Test) { 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, - }; + add_program_owned_account(test, PAYER, payer_lamports); + add_program_owned_account(test, RECIPIENT, 1_000_000_000); - let instruction: Instruction = TransferSolWithProgramInstruction { - payer: Address::from(payer.to_bytes()), - recipient: Address::from(recipient.to_bytes()), + test.send(TransferSolWithProgramInstruction { + payer: PAYER, + recipient: RECIPIENT, amount, - } - .into(); - - let result = svm.process_instruction(&instruction, &[payer_account, recipient_account]); - result.assert_error(quasar_svm::ProgramError::Custom( - TransferSolError::InsufficientFunds as u32, - )); + }) + .fails_with(TransferSolError::InsufficientFunds); // No lamports moved. - let payer_after = result.account(&payer).unwrap(); - assert_eq!(payer_after.lamports, payer_lamports); + assert_eq!(test.lamports(PAYER), payer_lamports); } From 2e92c20ef9503a94da9357f48ed241746cc164bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:48:04 +0000 Subject: [PATCH 04/14] compression: migrate examples to Quasar 0.1.0 cnft-burn, cnft-vault, and cutils move from floating git deps to the pinned 0.1.0-release rev be60fca, get the 0.1.0 Quasar.toml schema, idl-build feature and "lib" crate-type, and quasar-test dev-deps. cnft-vault's six test scenarios are fully ported (Bubblegum borsh/keccak helpers kept verbatim; harness layer now uses Program fixtures and crate::cpi builders); cnft-burn and cutils keep their placeholder test files, reworded for quasar-test. 0.1.0 API break fixed: quasar_lang::pda::based_try_find_program_address was renamed to try_find_program_address (cutils bubblegum_types). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- compression/cnft-burn/quasar/CHANGELOG.md | 12 + compression/cnft-burn/quasar/Cargo.toml | 12 +- compression/cnft-burn/quasar/Quasar.toml | 19 +- compression/cnft-burn/quasar/src/tests.rs | 9 +- compression/cnft-vault/quasar/CHANGELOG.md | 14 + compression/cnft-vault/quasar/Cargo.toml | 15 +- compression/cnft-vault/quasar/Quasar.toml | 19 +- compression/cnft-vault/quasar/src/tests.rs | 359 +++++++----------- compression/cutils/quasar/CHANGELOG.md | 15 + compression/cutils/quasar/Cargo.toml | 12 +- compression/cutils/quasar/Quasar.toml | 19 +- .../cutils/quasar/src/bubblegum_types.rs | 2 +- compression/cutils/quasar/src/tests.rs | 7 +- 13 files changed, 216 insertions(+), 298 deletions(-) diff --git a/compression/cnft-burn/quasar/CHANGELOG.md b/compression/cnft-burn/quasar/CHANGELOG.md index 4f116388..5c7db69e 100644 --- a/compression/cnft-burn/quasar/CHANGELOG.md +++ b/compression/cnft-burn/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema and the `idl-build` feature added. The + previously-floating `quasar-lang` git dependency is now pinned by rev. The + `quasar-svm` git dev-dependency (and the unused `solana-address` dev-dep) are + gone, replaced by `quasar-test`; the tests file remains a placeholder (the + Bubblegum/Account-Compression CPI flows are still covered by the Anchor + twin's LiteSVM suite) and now points at the `quasar-test` porting path. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/compression/cnft-burn/quasar/Cargo.toml b/compression/cnft-burn/quasar/Cargo.toml index b4d5fb98..b0885da9 100644 --- a/compression/cnft-burn/quasar/Cargo.toml +++ b/compression/cnft-burn/quasar/Cargo.toml @@ -14,20 +14,26 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } # 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" } [dev-dependencies] -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } -solana-address = { version = "2.2.0", features = ["decode"] } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/compression/cnft-burn/quasar/Quasar.toml b/compression/cnft-burn/quasar/Quasar.toml index 09f8d979..0bfb7f64 100644 --- a/compression/cnft-burn/quasar/Quasar.toml +++ b/compression/cnft-burn/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_cnft_burn" - -[toolchain] -type = "solana" +name = "quasar-cnft-burn" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/compression/cnft-burn/quasar/src/tests.rs b/compression/cnft-burn/quasar/src/tests.rs index 0f15d10c..30b5e048 100644 --- a/compression/cnft-burn/quasar/src/tests.rs +++ b/compression/cnft-burn/quasar/src/tests.rs @@ -1,5 +1,6 @@ -// No tests yet: the instruction handlers CPI into external programs -// (Bubblegum, SPL Account Compression) and a QuasarSVM harness that loads +// No tests yet: the instruction handler CPIs into external programs +// (Bubblegum, SPL Account Compression) and a quasar-test world 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/. +// suite covers the same flows. TODO: port that suite to quasar-test, loading +// the fixture .so files under ../anchor/tests/fixtures/ via +// test.add(Program::new(id, &std::fs::read(path).unwrap())). diff --git a/compression/cnft-vault/quasar/CHANGELOG.md b/compression/cnft-vault/quasar/CHANGELOG.md index 4f116388..d8b8b860 100644 --- a/compression/cnft-vault/quasar/CHANGELOG.md +++ b/compression/cnft-vault/quasar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature added, and tests rewritten + from the direct QuasarSVM harness to `quasar-test` (`#[quasar_test]` + fixtures, `crate::cpi` instruction builders, `Outcome` assertions). The + previously-floating `quasar-lang` git dependency is now pinned by rev; the + `quasar-svm` git dev-dependency, the generated-client path dev-dependency, + and the unused `solana-address` dev-dep are gone. The borsh/keccak + Bubblegum-mirroring helpers (metadata hashing, merkle-tree account layout) + are unchanged; only the harness layer moved. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/compression/cnft-vault/quasar/Cargo.toml b/compression/cnft-vault/quasar/Cargo.toml index db8b0e30..9d1859ca 100644 --- a/compression/cnft-vault/quasar/Cargo.toml +++ b/compression/cnft-vault/quasar/Cargo.toml @@ -14,26 +14,29 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } # 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" } [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" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } # 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"] } diff --git a/compression/cnft-vault/quasar/Quasar.toml b/compression/cnft-vault/quasar/Quasar.toml index 504c0082..282e5aec 100644 --- a/compression/cnft-vault/quasar/Quasar.toml +++ b/compression/cnft-vault/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_cnft_vault" - -[toolchain] -type = "solana" +name = "quasar-cnft-vault" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/compression/cnft-vault/quasar/src/tests.rs b/compression/cnft-vault/quasar/src/tests.rs index c65c6a05..70ff4ffb 100644 --- a/compression/cnft-vault/quasar/src/tests.rs +++ b/compression/cnft-vault/quasar/src/tests.rs @@ -1,13 +1,13 @@ -//! QuasarSVM integration tests for the cnft-vault Quasar program. +//! quasar-test 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: +//! Ported from the Anchor twin's LiteSVM suite. The test world 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 +//! installed 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 @@ -25,13 +25,13 @@ extern crate std; use { - borsh::BorshSerialize, - quasar_cnft_vault_client::{ - InitializeVaultInstruction, QuasarCnftVaultError, WithdrawCnftInstruction, - WithdrawTwoCnftsInstruction, + crate::{ + cpi::{InitializeVaultInstruction, WithdrawCnftInstruction, WithdrawTwoCnftsInstruction}, + error::VaultError, + state::Vault, }, - quasar_svm::{Account, Instruction, ProgramError, Pubkey, QuasarSvm}, - solana_instruction::AccountMeta, + borsh::BorshSerialize, + quasar_test::prelude::*, solana_keccak_hasher::hashv, std::{string::ToString, vec, vec::Vec}, }; @@ -53,10 +53,19 @@ const MINT_V1_DISC: [u8; 8] = [145, 98, 192, 118, 184, 147, 118, 104]; 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. +/// Lamports for the prefabricated tree accounts; comfortably above rent +/// exemption for every account size used here. const FUNDING_LAMPORTS: u64 = 1_000_000_000; +// Deterministic addresses avoid Pubkey::new_unique(), whose global counter +// produces different values depending on test binary layout / discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const AUTHORITY: Pubkey = Pubkey::new_from_array([2; 32]); +const RECIPIENT: Pubkey = Pubkey::new_from_array([3; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([4; 32]); +const MERKLE_TREE_1: Pubkey = Pubkey::new_from_array([5; 32]); +const MERKLE_TREE_2: Pubkey = Pubkey::new_from_array([6; 32]); + // ---- MetadataArgs (mirrors mpl_bubblegum::types::MetadataArgs borsh layout) ---- #[derive(BorshSerialize, Clone)] @@ -159,26 +168,6 @@ fn read_current_root(data: &[u8]) -> [u8; 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 @@ -192,65 +181,52 @@ struct TreeWithVaultCnft { 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, +/// Load the external program fixtures (shared with the Anchor twin's LiteSVM +/// suite), fund the actors, and initialize the vault PDA with `AUTHORITY` as +/// its stored withdraw authority. Returns the vault PDA. +fn setup_vault(test: &mut Test) -> Pubkey { + test.add(Program::new( + BUBBLEGUM_ID, + &std::fs::read("../anchor/tests/fixtures/mpl_bubblegum.so").unwrap(), + )); + test.add(Program::new( + COMPRESSION_ID, + &std::fs::read("../anchor/tests/fixtures/spl_account_compression.so").unwrap(), + )); + test.add(Program::new( + NOOP_ID, + &std::fs::read("../anchor/tests/fixtures/spl_noop.so").unwrap(), + )); + test.add(Wallet::new().at(PAYER)); + test.add(Wallet::new().at(AUTHORITY)); + + let vault = test.derive_pda(Vault::seeds()); + + // The vault PDA and system program are canonical derivations, so the + // generated instruction only asks for the authority (who also pays). + test.send(InitializeVaultInstruction { + authority: AUTHORITY, + }) + .succeeds(); + + vault } -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(); - +/// Create a Bubblegum tree at `merkle_tree` and mint one cNFT into the vault. +fn create_tree_with_vault_cnft( + test: &mut Test, + vault: Pubkey, + merkle_tree: Pubkey, +) -> TreeWithVaultCnft { // 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, - }; + test.set_account(Account::new( + merkle_tree, + COMPRESSION_ID, + FUNDING_LAMPORTS, + vec![0; TREE_ACCOUNT_SIZE], + )); // 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); @@ -261,11 +237,11 @@ fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultC 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(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), + AccountMeta::new_readonly(system_program::ID, false), ], data: { let mut d = CREATE_TREE_CONFIG_DISC.to_vec(); @@ -275,18 +251,12 @@ fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultC d }, }; - context - .svm - .process_instruction( - &create_tree_instruction, - &[signer(payer), empty(tree_config), tree_account], - ) - .assert_success(); + test.send(create_tree_instruction).succeeds(); // 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(), + address: PAYER.to_bytes(), verified: false, share: 100, }; @@ -310,14 +280,14 @@ fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultC 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_readonly(vault, false), + AccountMeta::new_readonly(vault, 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(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), + AccountMeta::new_readonly(system_program::ID, false), ], data: { let mut d = MINT_V1_DISC.to_vec(); @@ -325,10 +295,7 @@ fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultC d }, }; - context - .svm - .process_instruction(&mint_instruction, &[]) - .assert_success(); + test.send(mint_instruction).succeeds(); // Recompute data_hash and creator_hash exactly as Bubblegum does. let data_hash = hash_metadata(&metadata); @@ -338,7 +305,7 @@ fn create_tree_with_vault_cnft(context: &mut VaultTestContext) -> TreeWithVaultC 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 tree_data = test.account(merkle_tree).unwrap().data; let root = read_current_root(&tree_data); TreeWithVaultCnft { @@ -365,6 +332,8 @@ fn transfer_args(tree: &TreeWithVaultCnft) -> Vec { args } +/// Proof-node addresses enter the transaction as readonly metas; the runtime +/// materializes the missing accounts as empty system accounts. fn proof_metas(nodes: &[[u8; 32]]) -> Vec { nodes .iter() @@ -372,34 +341,21 @@ fn proof_metas(nodes: &[[u8; 32]]) -> Vec { .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 { + // The vault PDA and system program are canonical derivations, so the + // generated instruction omits them. 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(); @@ -409,9 +365,7 @@ fn build_withdraw_cnft_instruction( instruction } -#[allow(clippy::too_many_arguments)] fn build_withdraw_two_cnfts_instruction( - context: &VaultTestContext, signer: Pubkey, tree1: &TreeWithVaultCnft, tree2: &TreeWithVaultCnft, @@ -423,7 +377,6 @@ fn build_withdraw_two_cnfts_instruction( 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, @@ -433,7 +386,6 @@ fn build_withdraw_two_cnfts_instruction( log_wrapper: NOOP_ID, compression_program: COMPRESSION_ID, bubblegum_program: BUBBLEGUM_ID, - system_program: quasar_svm::system_program::ID, remaining_accounts, } .into(); @@ -445,127 +397,83 @@ fn build_withdraw_two_cnfts_instruction( 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(); +#[quasar_test] +fn initialize_vault_stores_authority(test: &mut Test) { + let vault = setup_vault(test); - // 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); + let (_, expected_bump) = test.derive_pda_with_bump(Vault::seeds()); + let state = test.read::(vault); + assert_eq!(state.authority, AUTHORITY); + assert_eq!(state.bump, 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(); +#[quasar_test] +fn withdraw_cnft_by_authority_succeeds_and_replay_fails(test: &mut Test) { + let vault = setup_vault(test); + let tree = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_1); - let instruction = - build_withdraw_cnft_instruction(&context, context.authority, &tree, recipient); + let instruction = build_withdraw_cnft_instruction(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 - ); + test.send(instruction.clone()).succeeds(); // 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(), + test.send(instruction).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 = Pubkey::new_unique(); +#[quasar_test] +fn withdraw_cnft_rejected_for_non_authority(test: &mut Test) { + let vault = setup_vault(test); + let tree = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_1); // 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); + test.add(Wallet::new().at(ATTACKER)); + let instruction = build_withdraw_cnft_instruction(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.send(instruction) + .fails_with(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 = Pubkey::new_unique(); +#[quasar_test] +fn withdraw_two_cnfts_by_authority_succeeds_and_replay_fails(test: &mut Test) { + let vault = setup_vault(test); + let tree1 = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_1); + let tree2 = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_2); let instruction = build_withdraw_two_cnfts_instruction( - &context, - context.authority, + AUTHORITY, &tree1, &tree2, - recipient, + 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 - ); + test.send(instruction).succeeds(); // 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]), - ); + let replay_instruction = build_withdraw_cnft_instruction(AUTHORITY, &tree1, RECIPIENT); assert!( - !replay.is_ok(), + test.send(replay_instruction).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 = Pubkey::new_unique(); +#[quasar_test] +fn withdraw_two_cnfts_rejects_out_of_range_proof_length(test: &mut Test) { + let vault = setup_vault(test); + let tree1 = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_1); + let tree2 = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_2); // Claim one more proof node for tree1 than the instruction supplies in // total: the bounds check must return ProofLengthMismatch instead of @@ -574,44 +482,35 @@ fn test_withdraw_two_cnfts_rejects_out_of_range_proof_length() { let out_of_range_proof_1_length = supplied_proof_nodes + 1; let instruction = build_withdraw_two_cnfts_instruction( - &context, - context.authority, + AUTHORITY, &tree1, &tree2, - recipient, + 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.send(instruction) + .fails_with(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 = Pubkey::new_unique(); +#[quasar_test] +fn withdraw_two_cnfts_rejects_inconsistent_proof_lengths(test: &mut Test) { + let vault = setup_vault(test); + let tree1 = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_1); + let tree2 = create_tree_with_vault_cnft(test, vault, MERKLE_TREE_2); // 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, + AUTHORITY, &tree1, &tree2, - recipient, + 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)); + test.send(instruction) + .fails_with(VaultError::ProofLengthMismatch); } diff --git a/compression/cutils/quasar/CHANGELOG.md b/compression/cutils/quasar/CHANGELOG.md index 4f116388..55358644 100644 --- a/compression/cutils/quasar/CHANGELOG.md +++ b/compression/cutils/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema and the `idl-build` feature added. The + previously-floating `quasar-lang` git dependency is now pinned by rev. The + `quasar-svm` git dev-dependency (and the unused `solana-address` dev-dep) are + gone, replaced by `quasar-test`; the tests file remains a placeholder (the + Bubblegum/Account-Compression CPI flows are still covered by the Anchor + twin's LiteSVM suite) and now points at the `quasar-test` porting path. +- `bubblegum_types::get_asset_id` now calls + `quasar_lang::pda::try_find_program_address`; 0.1.0 renamed the former + `based_try_find_program_address`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/compression/cutils/quasar/Cargo.toml b/compression/cutils/quasar/Cargo.toml index 94384e5a..810366aa 100644 --- a/compression/cutils/quasar/Cargo.toml +++ b/compression/cutils/quasar/Cargo.toml @@ -14,19 +14,25 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -quasar-lang = { git = "https://github.com/blueshift-gg/quasar" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } # 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" } [dev-dependencies] -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } -solana-address = { version = "2.2.0", features = ["decode"] } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/compression/cutils/quasar/Quasar.toml b/compression/cutils/quasar/Quasar.toml index dbb18580..358ec6e9 100644 --- a/compression/cutils/quasar/Quasar.toml +++ b/compression/cutils/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_cutils" - -[toolchain] -type = "solana" +name = "quasar-cutils" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/compression/cutils/quasar/src/bubblegum_types.rs b/compression/cutils/quasar/src/bubblegum_types.rs index 95240ba7..8b10e03d 100644 --- a/compression/cutils/quasar/src/bubblegum_types.rs +++ b/compression/cutils/quasar/src/bubblegum_types.rs @@ -106,7 +106,7 @@ pub fn get_asset_id(tree: &Address, nonce: u64) -> Address { let nonce_bytes = nonce.to_le_bytes(); let seeds: &[&[u8]] = &[b"asset", tree.as_ref(), &nonce_bytes]; let (pda, _bump) = - quasar_lang::pda::based_try_find_program_address(seeds, &crate::MPL_BUBBLEGUM_ID) + quasar_lang::pda::try_find_program_address(seeds, &crate::MPL_BUBBLEGUM_ID) .expect("asset PDA derivation failed"); pda } diff --git a/compression/cutils/quasar/src/tests.rs b/compression/cutils/quasar/src/tests.rs index 0f15d10c..5f8f2565 100644 --- a/compression/cutils/quasar/src/tests.rs +++ b/compression/cutils/quasar/src/tests.rs @@ -1,5 +1,6 @@ // No tests yet: the instruction handlers CPI into external programs -// (Bubblegum, SPL Account Compression) and a QuasarSVM harness that loads +// (Bubblegum, SPL Account Compression) and a quasar-test world 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/. +// suite covers the same flows. TODO: port that suite to quasar-test, loading +// the fixture .so files under ../anchor/tests/fixtures/ via +// test.add(Program::new(id, &std::fs::read(path).unwrap())). From 117261b815a6dd8d98bb53e0f7b899b5fd3fb7f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:48:05 +0000 Subject: [PATCH 05/14] tokens: migrate base token examples to Quasar 0.1.0 create-token, external-delegate-token-master, pda-mint-authority, and transfer-tokens move to the 0.1.0-release rev be60fca with the 0.1.0 Quasar.toml schema and quasar-test suites (Mint/TokenAccount fixtures, has_tokens/has_supply balance assertions, typed state reads). Tests that decode raw SPL accounts use spl-token 9, which shares quasar-test's solana-pubkey major. Genuine 0.1.0 API breaks fixed: - quasar_spl::initialize_mint2 free function is gone; mints initialize via the TokenCpi trait method on the token_program handle. - Seed left the prelude (import from quasar_lang::cpi). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- tokens/create-token/quasar/CHANGELOG.md | 14 + tokens/create-token/quasar/Cargo.toml | 28 +- tokens/create-token/quasar/Quasar.toml | 19 +- tokens/create-token/quasar/src/lib.rs | 14 +- tokens/create-token/quasar/src/tests.rs | 183 ++--- .../quasar/CHANGELOG.md | 15 + .../quasar/Cargo.toml | 27 +- .../quasar/Quasar.toml | 19 +- .../quasar/src/lib.rs | 2 +- .../quasar/src/tests.rs | 653 ++++++------------ tokens/pda-mint-authority/quasar/CHANGELOG.md | 16 + tokens/pda-mint-authority/quasar/Cargo.toml | 28 +- tokens/pda-mint-authority/quasar/Quasar.toml | 19 +- tokens/pda-mint-authority/quasar/src/lib.rs | 16 +- tokens/pda-mint-authority/quasar/src/tests.rs | 176 ++--- tokens/transfer-tokens/quasar/CHANGELOG.md | 12 + tokens/transfer-tokens/quasar/Cargo.toml | 25 +- tokens/transfer-tokens/quasar/Quasar.toml | 19 +- tokens/transfer-tokens/quasar/src/tests.rs | 165 ++--- 19 files changed, 454 insertions(+), 996 deletions(-) diff --git a/tokens/create-token/quasar/CHANGELOG.md b/tokens/create-token/quasar/CHANGELOG.md index 4f116388..c21bc96b 100644 --- a/tokens/create-token/quasar/CHANGELOG.md +++ b/tokens/create-token/quasar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature added, and tests rewritten + from the direct QuasarSVM harness to `quasar-test` (`#[quasar_test]` + fixtures, `crate::cpi` instruction builders, `Outcome` assertions). The + `quasar-svm` git dev-dependency is gone; compute-unit prints were dropped + pending recalibration under 0.1.0. +- `initialize_mint2` is now invoked through quasar-spl's `TokenCpi` trait + (`token_program.initialize_mint2(...)`); 0.1.0 removed the free-function + re-export. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/create-token/quasar/Cargo.toml b/tokens/create-token/quasar/Cargo.toml index db851d0c..0f24b3bc 100644 --- a/tokens/create-token/quasar/Cargo.toml +++ b/tokens/create-token/quasar/Cargo.toml @@ -14,29 +14,27 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +# Decodes the created SPL mint in tests (same crate quasar-test's fixtures +# use, so the Pubkey types line up). +spl-token = { version = "9", default-features = false, features = ["no-entrypoint"] } diff --git a/tokens/create-token/quasar/Quasar.toml b/tokens/create-token/quasar/Quasar.toml index f41cddeb..718d290d 100644 --- a/tokens/create-token/quasar/Quasar.toml +++ b/tokens/create-token/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_create_token" - -[toolchain] -type = "solana" +name = "quasar-create-token" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/create-token/quasar/src/lib.rs b/tokens/create-token/quasar/src/lib.rs index bbbb486f..b39c0e5e 100644 --- a/tokens/create-token/quasar/src/lib.rs +++ b/tokens/create-token/quasar/src/lib.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(test), no_std)] use quasar_lang::{prelude::*, sysvars::Sysvar}; -use quasar_spl::{initialize_mint2, prelude::*}; +use quasar_spl::prelude::*; #[cfg(test)] mod tests; @@ -89,14 +89,10 @@ fn handle_create_token( ) .invoke()?; - initialize_mint2( - accounts.token_program.to_account_view(), - accounts.mint.to_account_view(), - decimals, - &payer_address, - None, - ) - .invoke() + accounts + .token_program + .initialize_mint2(&accounts.mint, decimals, &payer_address, None) + .invoke() } #[inline(always)] diff --git a/tokens/create-token/quasar/src/tests.rs b/tokens/create-token/quasar/src/tests.rs index 8e99ad1a..25e6e3fb 100644 --- a/tokens/create-token/quasar/src/tests.rs +++ b/tokens/create-token/quasar/src/tests.rs @@ -1,158 +1,61 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::cpi::{CreateTokenInstruction, MintTokensInstruction}, + quasar_test::prelude::*, + spl_token::{solana_program::program_pack::Pack, state::Mint as MintState}, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_create_token.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn mint(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 9, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} - -fn token_account(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() - }, - ) -} - -/// Build create_token instruction data. -/// Wire format: [discriminator: u8 = 0] [decimals: u8] -fn build_create_token_data(decimals: u8) -> Vec { - vec![0u8, decimals] -} +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([3; 32]); -/// Build mint_tokens instruction data. -/// Wire format: [discriminator: u8 = 1] [amount: u64 LE] -fn build_mint_tokens_data(amount: u64) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data -} - -#[test] -fn test_create_token() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint_address = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; +#[quasar_test] +fn create_token_initializes_the_mint_with_requested_decimals(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); // 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(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint_address)], - ); + // The mint is a fresh keypair account that must co-sign create_account. + // The accounts struct types it UncheckedAccount, so the generated builder + // marks it writable only; flip the signer bit on the built instruction + // (field order: payer, mint, token_program, system_program). + let mut instruction: Instruction = CreateTokenInstruction { + payer: PAYER, + mint: MINT, + decimals: requested_decimals, + } + .into(); + instruction.accounts[1].is_signer = true; - assert!(result.is_ok(), "create_token failed: {:?}", result.raw_result); + test.send(instruction).succeeds(); - // 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"); + // The created mint must carry the requested decimals and the payer as + // its mint authority. + let mint_account = test.account(MINT).expect("mint should exist"); + let mint_state = MintState::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); + assert_eq!(mint_state.mint_authority, Some(PAYER).into()); } -#[test] -fn test_mint_tokens() { - let mut svm = setup(); - - let authority = Pubkey::new_unique(); - let mint_address = Pubkey::new_unique(); - let token_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; +#[quasar_test] +fn mint_tokens_mints_the_exact_minor_unit_amount(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + test.add(Mint::new(PAYER).at(MINT).decimals(9)); + test.add(TokenAccount::new(MINT, PAYER).at(TOKEN_ACCOUNT)); let amount = 1_000_000_000u64; - let data = build_mint_tokens_data(amount); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(authority.into(), true), - solana_instruction::AccountMeta::new(mint_address.into(), false), - solana_instruction::AccountMeta::new(token_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(authority), - mint(mint_address, authority), - token_account(token_addr, mint_address, authority, 0), - ], - ); - - 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); + // The handler mints exactly the minor-unit amount passed: no decimal + // scaling. + test.send(MintTokensInstruction { + authority: PAYER, + mint: MINT, + token_account: TOKEN_ACCOUNT, + amount, + }) + .succeeds() + .has_tokens(TOKEN_ACCOUNT, amount) + .has_supply(MINT, amount); } diff --git a/tokens/external-delegate-token-master/quasar/CHANGELOG.md b/tokens/external-delegate-token-master/quasar/CHANGELOG.md index 4f116388..f49973f2 100644 --- a/tokens/external-delegate-token-master/quasar/CHANGELOG.md +++ b/tokens/external-delegate-token-master/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature added, and tests rewritten + from the direct QuasarSVM harness to `quasar-test` (`#[quasar_test]` + fixtures, `crate::cpi` instruction builders, `Outcome` assertions, typed + `test.read::` state checks). The `quasar-svm`, + `spl-token-interface`, and `solana-program-pack` dev-dependencies are gone + (libsecp256k1 stays for the Ethereum-signature path); compute-unit prints + were dropped pending recalibration under 0.1.0. +- `Seed` is now imported from `quasar_lang::cpi`; 0.1.0 removed it from the + prelude. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/external-delegate-token-master/quasar/Cargo.toml b/tokens/external-delegate-token-master/quasar/Cargo.toml index dd101fd1..bf01ee8c 100644 --- a/tokens/external-delegate-token-master/quasar/Cargo.toml +++ b/tokens/external-delegate-token-master/quasar/Cargo.toml @@ -14,36 +14,29 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pin both quasar deps to the same source-id (branch = "master") so trait -# impls resolve consistently. -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } solana-define-syscall = "4.0" solana-keccak-hasher = "3.1" [dev-dependencies] +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } # Signs test transfer authorizations with a fixed secp256k1 key so tests can # exercise the Ethereum-signature path end to end. libsecp256k1 = "0.7.2" -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } diff --git a/tokens/external-delegate-token-master/quasar/Quasar.toml b/tokens/external-delegate-token-master/quasar/Quasar.toml index 077536de..b990d7bd 100644 --- a/tokens/external-delegate-token-master/quasar/Quasar.toml +++ b/tokens/external-delegate-token-master/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_external_delegate_token_master" - -[toolchain] -type = "solana" +name = "quasar-external-delegate-token-master" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/external-delegate-token-master/quasar/src/lib.rs b/tokens/external-delegate-token-master/quasar/src/lib.rs index 73953211..121061c4 100644 --- a/tokens/external-delegate-token-master/quasar/src/lib.rs +++ b/tokens/external-delegate-token-master/quasar/src/lib.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(test), no_std)] -use quasar_lang::prelude::*; +use quasar_lang::{cpi::Seed, prelude::*}; use quasar_spl::prelude::*; #[cfg(test)] diff --git a/tokens/external-delegate-token-master/quasar/src/tests.rs b/tokens/external-delegate-token-master/quasar/src/tests.rs index a44f1a45..958495f7 100644 --- a/tokens/external-delegate-token-master/quasar/src/tests.rs +++ b/tokens/external-delegate-token-master/quasar/src/tests.rs @@ -1,13 +1,16 @@ extern crate std; use { - 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}, + crate::{ + cpi::{ + AuthorityTransferInstruction, InitializeInstruction, SetEthereumAddressInstruction, + TransferTokensInstruction, + }, + ExternalDelegateError, UserAccount, UserPda, + }, + quasar_test::prelude::*, + std::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; @@ -16,84 +19,16 @@ const TRANSFER_AMOUNT: u64 = 500_000_000; /// 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() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, SIGNER_LAMPORTS) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -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) -} +// Deterministic addresses avoid Pubkey::new_unique(), whose global counter +// produces different values depending on test binary layout / discovery order. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const USER_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); +const MALLORY: Pubkey = Pubkey::new_from_array([3; 32]); +const MINT: Pubkey = Pubkey::new_from_array([4; 32]); +const USER_TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([5; 32]); +const RECIPIENT_TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([6; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([7; 32]); +const ATTACKER_TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([8; 32]); fn delegate_secret_key() -> libsecp256k1::SecretKey { libsecp256k1::SecretKey::parse(&DELEGATE_SECP256K1_PRIVATE_KEY).unwrap() @@ -143,466 +78,282 @@ fn sign_transfer_authorization( 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(), - } +/// Fund the authority and initialize the user account. The init target enters +/// the transaction as an empty system account automatically; the generated +/// builder marks it as a required signer (fresh keypair account). +fn initialize_user_account(test: &mut Test) { + test.add(Wallet::new().at(AUTHORITY)); + test.send(InitializeInstruction { + user_account: USER_ACCOUNT, + authority: AUTHORITY, + }) + .succeeds(); } -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, -} +/// 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_world(test: &mut Test) -> Pubkey { + initialize_user_account(test); + test.send(SetEthereumAddressInstruction { + user_account: USER_ACCOUNT, + authority: AUTHORITY, + ethereum_address: ethereum_address_of(&delegate_secret_key()), + }) + .succeeds(); -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(), - } + // Load token state directly: a mint, the PDA-owned funded token account, + // and the empty recipient token account. + let user_pda = test.derive_pda(UserPda::seeds(&USER_ACCOUNT)); + test.add( + Mint::new(AUTHORITY) + .at(MINT) + .supply(MINT_AMOUNT) + .decimals(MINT_DECIMALS), + ); + test.add( + TokenAccount::new(MINT, user_pda) + .at(USER_TOKEN_ACCOUNT) + .amount(MINT_AMOUNT), + ); + test.add(TokenAccount::new(MINT, AUTHORITY).at(RECIPIENT_TOKEN_ACCOUNT)); + user_pda } +/// Transfer instruction for the Ethereum-signature path. The user PDA and +/// token program are canonical derivations, so the generated instruction +/// omits them. 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), +) -> TransferTokensInstruction { + TransferTokensInstruction { + user_account: USER_ACCOUNT, + authority, + mint: MINT, + user_token_account: USER_TOKEN_ACCOUNT, + recipient_token_account, + 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(); +#[quasar_test] +fn initialize_creates_user_account_with_zero_state(test: &mut Test) { + initialize_user_account(test); - 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); + let state = test.read::(USER_ACCOUNT); + assert_eq!(state.authority, AUTHORITY); assert_eq!(state.ethereum_address, [0u8; 20]); - assert_eq!(state.nonce, 0); + assert_eq!(u64::from(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(); +#[quasar_test] +fn set_ethereum_address_stores_the_address(test: &mut Test) { + initialize_user_account(test); 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); + test.send(SetEthereumAddressInstruction { + user_account: USER_ACCOUNT, + authority: AUTHORITY, + ethereum_address, + }) + .succeeds(); assert_eq!( - read_user_account(&svm, &user_account).ethereum_address, + test.read::(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(); +#[quasar_test] +fn set_ethereum_address_wrong_authority_fails(test: &mut Test) { + initialize_user_account(test); + test.add(Wallet::new().at(MALLORY)); - 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)], - ); + let result = test.send(SetEthereumAddressInstruction { + user_account: USER_ACCOUNT, + authority: MALLORY, + ethereum_address: ethereum_address_of(&delegate_secret_key()), + }); assert!( - !result.is_ok(), + result.is_err(), "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(); +#[quasar_test] +fn transfer_tokens_with_valid_signature_moves_tokens_and_increments_nonce(test: &mut Test) { + setup_transfer_world(test); let message = build_transfer_authorization_message( - &fixture.user_account, + &USER_ACCOUNT, TRANSFER_AMOUNT, - &fixture.recipient_token_account, + &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); + test.send(transfer_tokens_instruction( + AUTHORITY, + RECIPIENT_TOKEN_ACCOUNT, + TRANSFER_AMOUNT, + signature, + )) + .succeeds() + .has_tokens(RECIPIENT_TOKEN_ACCOUNT, TRANSFER_AMOUNT) + .has_tokens(USER_TOKEN_ACCOUNT, MINT_AMOUNT - TRANSFER_AMOUNT); - 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); + assert_eq!(u64::from(test.read::(USER_ACCOUNT).nonce), 1); } -#[test] -fn test_transfer_tokens_replayed_signature_fails() { - let (mut svm, fixture) = setup_transfer_fixture(); +#[quasar_test] +fn transfer_tokens_replayed_signature_fails(test: &mut Test) { + setup_transfer_world(test); let message = build_transfer_authorization_message( - &fixture.user_account, + &USER_ACCOUNT, TRANSFER_AMOUNT, - &fixture.recipient_token_account, + &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, + let instruction: Instruction = transfer_tokens_instruction( + AUTHORITY, + RECIPIENT_TOKEN_ACCOUNT, TRANSFER_AMOUNT, signature, - ); - svm.process_instruction(&instruction, &[]).assert_success(); + ) + .into(); + test.send(instruction.clone()).succeeds(); // 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, - )); + test.send(instruction) + .fails_with(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); + assert_eq!(test.tokens(RECIPIENT_TOKEN_ACCOUNT), TRANSFER_AMOUNT); + assert_eq!(u64::from(test.read::(USER_ACCOUNT).nonce), 1); } -#[test] -fn test_transfer_tokens_signature_over_different_amount_fails() { - let (mut svm, fixture) = setup_transfer_fixture(); +#[quasar_test] +fn transfer_tokens_signature_over_different_amount_fails(test: &mut Test) { + setup_transfer_world(test); let authorized_amount = TRANSFER_AMOUNT; let attempted_amount = MINT_AMOUNT; let message = build_transfer_authorization_message( - &fixture.user_account, + &USER_ACCOUNT, authorized_amount, - &fixture.recipient_token_account, + &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.send(transfer_tokens_instruction( + AUTHORITY, + RECIPIENT_TOKEN_ACCOUNT, + attempted_amount, + signature, + )) + .fails_with(ExternalDelegateError::InvalidSignature); + + assert_eq!(test.tokens(RECIPIENT_TOKEN_ACCOUNT), 0); + assert_eq!(u64::from(test.read::(USER_ACCOUNT).nonce), 0); } -#[test] -fn test_transfer_tokens_signature_over_different_recipient_fails() { - let (mut svm, fixture) = setup_transfer_fixture(); +#[quasar_test] +fn transfer_tokens_signature_over_different_recipient_fails(test: &mut Test) { + setup_transfer_world(test); // 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)); + test.add(TokenAccount::new(MINT, ATTACKER).at(ATTACKER_TOKEN_ACCOUNT)); let message = build_transfer_authorization_message( - &fixture.user_account, + &USER_ACCOUNT, TRANSFER_AMOUNT, - &fixture.recipient_token_account, + &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.send(transfer_tokens_instruction( + AUTHORITY, + ATTACKER_TOKEN_ACCOUNT, + TRANSFER_AMOUNT, + signature, + )) + .fails_with(ExternalDelegateError::InvalidSignature); + + assert_eq!(test.tokens(ATTACKER_TOKEN_ACCOUNT), 0); } -#[test] -fn test_transfer_tokens_wrong_solana_authority_fails() { - let (mut svm, fixture) = setup_transfer_fixture(); +#[quasar_test] +fn transfer_tokens_wrong_solana_authority_fails(test: &mut Test) { + setup_transfer_world(test); + test.add(Wallet::new().at(MALLORY)); // 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, + &USER_ACCOUNT, TRANSFER_AMOUNT, - &fixture.recipient_token_account, + &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)], - ); + let result = test.send(transfer_tokens_instruction( + MALLORY, + RECIPIENT_TOKEN_ACCOUNT, + TRANSFER_AMOUNT, + signature, + )); assert!( - !result.is_ok(), + result.is_err(), "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_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: build_authority_transfer_data(TRANSFER_AMOUNT), - }; - let result = svm.process_instruction(&instruction, &[]); - result.assert_success(); - println!(" AUTHORITY_TRANSFER 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 - ); -} - -#[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_eq!(test.tokens(RECIPIENT_TOKEN_ACCOUNT), 0); + assert_eq!(u64::from(test.read::(USER_ACCOUNT).nonce), 0); +} + +#[quasar_test] +fn authority_transfer_moves_tokens(test: &mut Test) { + setup_transfer_world(test); + + test.send(AuthorityTransferInstruction { + user_account: USER_ACCOUNT, + authority: AUTHORITY, + mint: MINT, + user_token_account: USER_TOKEN_ACCOUNT, + recipient_token_account: RECIPIENT_TOKEN_ACCOUNT, + amount: TRANSFER_AMOUNT, + }) + .succeeds() + .has_tokens(RECIPIENT_TOKEN_ACCOUNT, TRANSFER_AMOUNT) + .has_tokens(USER_TOKEN_ACCOUNT, MINT_AMOUNT - TRANSFER_AMOUNT); +} + +#[quasar_test] +fn authority_transfer_wrong_authority_fails(test: &mut Test) { + setup_transfer_world(test); + test.add(Wallet::new().at(MALLORY)); + + let result = test.send(AuthorityTransferInstruction { + user_account: USER_ACCOUNT, + authority: MALLORY, + mint: MINT, + user_token_account: USER_TOKEN_ACCOUNT, + recipient_token_account: RECIPIENT_TOKEN_ACCOUNT, + amount: TRANSFER_AMOUNT, + }); assert!( - !result.is_ok(), + result.is_err(), "a signer other than user_account.authority must be rejected" ); - assert_eq!(token_balance(&svm, &fixture.recipient_token_account), 0); + assert_eq!(test.tokens(RECIPIENT_TOKEN_ACCOUNT), 0); } diff --git a/tokens/pda-mint-authority/quasar/CHANGELOG.md b/tokens/pda-mint-authority/quasar/CHANGELOG.md index 4f116388..871abe0e 100644 --- a/tokens/pda-mint-authority/quasar/CHANGELOG.md +++ b/tokens/pda-mint-authority/quasar/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature added, and tests rewritten + from the direct QuasarSVM harness to `quasar-test` (`#[quasar_test]` + fixtures, `crate::cpi` instruction builders, `Outcome` assertions). The + `quasar-svm`, `spl-token-interface`, and `solana-program-pack` + dev-dependencies are gone (`spl-token` decodes the created mint); + compute-unit prints were dropped pending recalibration under 0.1.0. +- 0.1.0 API fixes in the program source: `Seed` is now imported from + `quasar_lang::cpi` (no longer in the prelude), and `initialize_mint2` is + invoked through quasar-spl's `TokenCpi` trait + (`token_program.initialize_mint2(...)`). + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/pda-mint-authority/quasar/Cargo.toml b/tokens/pda-mint-authority/quasar/Cargo.toml index 4139633a..902be730 100644 --- a/tokens/pda-mint-authority/quasar/Cargo.toml +++ b/tokens/pda-mint-authority/quasar/Cargo.toml @@ -13,29 +13,27 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +# Decodes the created SPL mint in tests (same crate quasar-test's fixtures +# use, so the Pubkey types line up). +spl-token = { version = "9", default-features = false, features = ["no-entrypoint"] } diff --git a/tokens/pda-mint-authority/quasar/Quasar.toml b/tokens/pda-mint-authority/quasar/Quasar.toml index bc309a3c..96aba304 100644 --- a/tokens/pda-mint-authority/quasar/Quasar.toml +++ b/tokens/pda-mint-authority/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_pda_mint_authority" - -[toolchain] -type = "solana" +name = "quasar-pda-mint-authority" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/pda-mint-authority/quasar/src/lib.rs b/tokens/pda-mint-authority/quasar/src/lib.rs index dc218121..f67971d9 100644 --- a/tokens/pda-mint-authority/quasar/src/lib.rs +++ b/tokens/pda-mint-authority/quasar/src/lib.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(test), no_std)] -use quasar_lang::{prelude::*, sysvars::Sysvar}; -use quasar_spl::{initialize_mint2, prelude::*}; +use quasar_lang::{cpi::Seed, prelude::*, sysvars::Sysvar}; +use quasar_spl::prelude::*; #[cfg(test)] mod tests; @@ -85,14 +85,10 @@ fn handle_create_mint( ) .invoke_signed(seeds)?; - initialize_mint2( - accounts.token_program.to_account_view(), - accounts.mint.to_account_view(), - decimals, - &mint_address, - None, - ) - .invoke() + accounts + .token_program + .initialize_mint2(&accounts.mint, decimals, &mint_address, None) + .invoke() } /// Mint tokens to a token account, signing with the PDA mint authority. diff --git a/tokens/pda-mint-authority/quasar/src/tests.rs b/tokens/pda-mint-authority/quasar/src/tests.rs index 2d51ded0..143323f9 100644 --- a/tokens/pda-mint-authority/quasar/src/tests.rs +++ b/tokens/pda-mint-authority/quasar/src/tests.rs @@ -1,158 +1,60 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::{ + cpi::{CreateMintInstruction, MintTokensInstruction}, + MintPda, + }, + quasar_test::prelude::*, + spl_token::{solana_program::program_pack::Pack, state::Mint as MintState}, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_pda_mint_authority.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) -} +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 9, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} - -fn token_account(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() - }, - ) -} - -/// Build create_mint instruction data. -/// Wire format: [discriminator: u8 = 0] [decimals: u8] -fn build_create_mint_data(decimals: u8) -> Vec { - vec![0u8, decimals] -} - -/// Build mint_tokens instruction data. -/// Wire format: [discriminator: u8 = 1] [amount: u64 LE] -fn build_mint_tokens_data(amount: u64) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data -} - -#[test] -fn test_create_mint() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let (mint_pda, _) = Pubkey::find_program_address(&[b"mint"], &crate::ID); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; +#[quasar_test] +fn create_mint_initializes_the_pda_mint_as_its_own_authority(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let mint_pda = test.derive_pda(MintPda::seeds()); // 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 `CreateMintAccountConstraints` struct: - // payer, mint, token_program, system_program. - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction(&instruction, &[signer(payer), empty(mint_pda)]); - assert!(result.is_ok(), "create_mint failed: {:?}", result.raw_result); + // The mint PDA and both programs are canonical derivations, so the + // generated instruction only asks for the payer. + test.send(CreateMintInstruction { + payer: PAYER, + decimals: requested_decimals, + }) + .succeeds(); // 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"); + let created_mint = test.account(mint_pda).expect("mint should exist"); + let mint_state = MintState::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); } -#[test] -fn test_mint_with_pda_authority() { - let mut svm = setup(); +#[quasar_test] +fn mint_tokens_mints_with_the_pda_authority(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let mint_pda = test.derive_pda(MintPda::seeds()); - let payer = Pubkey::new_unique(); - let (mint_pda, _) = Pubkey::find_program_address(&[b"mint"], &crate::ID); - let token_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; + // The mint authority is the mint PDA itself. + test.add(Mint::new(mint_pda).at(mint_pda).decimals(9)); + test.add(TokenAccount::new(mint_pda, PAYER).at(TOKEN_ACCOUNT)); let amount = 1_000_000_000u64; - let data = build_mint_tokens_data(amount); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint_pda.into(), false), - solana_instruction::AccountMeta::new(token_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(payer), - // The mint authority is the mint_pda itself - mint_account(mint_pda, mint_pda), - token_account(token_addr, mint_pda, payer, 0), - ], - ); - - 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); + // The handler mints exactly the minor-unit amount passed: no decimal + // scaling. + test.send(MintTokensInstruction { + payer: PAYER, + token_account: TOKEN_ACCOUNT, + amount, + }) + .succeeds() + .has_tokens(TOKEN_ACCOUNT, amount) + .has_supply(mint_pda, amount); } diff --git a/tokens/transfer-tokens/quasar/CHANGELOG.md b/tokens/transfer-tokens/quasar/CHANGELOG.md index 4f116388..d6bb05b4 100644 --- a/tokens/transfer-tokens/quasar/CHANGELOG.md +++ b/tokens/transfer-tokens/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature added, and tests rewritten + from the direct QuasarSVM harness to `quasar-test` (`#[quasar_test]` + fixtures, `crate::cpi` instruction builders, `Outcome` assertions with + token-balance and mint-supply checks). The `quasar-svm`, + `spl-token-interface`, and `solana-program-pack` dev-dependencies are gone; + compute-unit prints were dropped pending recalibration under 0.1.0. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/transfer-tokens/quasar/Cargo.toml b/tokens/transfer-tokens/quasar/Cargo.toml index 967ed16c..64d6b6c3 100644 --- a/tokens/transfer-tokens/quasar/Cargo.toml +++ b/tokens/transfer-tokens/quasar/Cargo.toml @@ -14,29 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/transfer-tokens/quasar/Quasar.toml b/tokens/transfer-tokens/quasar/Quasar.toml index 7cf3db56..a8c66cc3 100644 --- a/tokens/transfer-tokens/quasar/Quasar.toml +++ b/tokens/transfer-tokens/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_transfer_tokens" - -[toolchain] -type = "solana" +name = "quasar-transfer-tokens" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/transfer-tokens/quasar/src/tests.rs b/tokens/transfer-tokens/quasar/src/tests.rs index a9dd5cee..4c397707 100644 --- a/tokens/transfer-tokens/quasar/src/tests.rs +++ b/tokens/transfer-tokens/quasar/src/tests.rs @@ -1,134 +1,55 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::cpi::{MintTokensInstruction, TransferTokensInstruction}, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_tokens.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 9, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} - -fn token_account(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() - }, - ) -} - -/// Build mint_tokens instruction data. -/// Wire format: [discriminator: u8 = 0] [amount: u64 LE] -fn build_mint_data(amount: u64) -> Vec { - let mut data = vec![0u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data -} - -/// Build transfer_tokens instruction data. -/// Wire format: [discriminator: u8 = 1] [amount: u64 LE] -fn build_transfer_data(amount: u64) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data -} - -#[test] -fn test_mint_tokens() { - let mut svm = setup(); +// Deterministic addresses keep tests independent of discovery order. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const SENDER: Pubkey = Pubkey::new_from_array([2; 32]); +const RECIPIENT: Pubkey = Pubkey::new_from_array([3; 32]); +const MINT: Pubkey = Pubkey::new_from_array([4; 32]); +const SENDER_TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([5; 32]); +const RECIPIENT_TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([6; 32]); - let authority = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let recipient_ta = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; +#[quasar_test] +fn mint_tokens_mints_the_exact_minor_unit_amount(test: &mut Test) { + test.add(Wallet::new().at(AUTHORITY)); + test.add(Mint::new(AUTHORITY).at(MINT).decimals(9)); + test.add(TokenAccount::new(MINT, AUTHORITY).at(RECIPIENT_TOKEN_ACCOUNT)); let amount = 1_000_000_000u64; - let data = build_mint_data(amount); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(authority.into(), true), - solana_instruction::AccountMeta::new(mint_addr.into(), false), - solana_instruction::AccountMeta::new(recipient_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; - let result = svm.process_instruction( - &instruction, - &[ - signer(authority), - mint_account(mint_addr, authority), - token_account(recipient_ta, mint_addr, authority, 0), - ], + test.send(MintTokensInstruction { + mint_authority: AUTHORITY, + mint: MINT, + recipient_token_account: RECIPIENT_TOKEN_ACCOUNT, + amount, + }) + .succeeds() + .has_tokens(RECIPIENT_TOKEN_ACCOUNT, amount) + .has_supply(MINT, amount); +} + +#[quasar_test] +fn transfer_tokens_moves_tokens_between_accounts(test: &mut Test) { + test.add(Wallet::new().at(SENDER)); + test.add(Mint::new(AUTHORITY).at(MINT).decimals(9).supply(10_000)); + test.add( + TokenAccount::new(MINT, SENDER) + .at(SENDER_TOKEN_ACCOUNT) + .amount(10_000), ); - - assert!(result.is_ok(), "mint_tokens failed: {:?}", result.raw_result); - println!(" MINT TOKENS CU: {}", result.compute_units_consumed); -} - -#[test] -fn test_transfer_tokens() { - let mut svm = setup(); - - let sender = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let sender_ta = Pubkey::new_unique(); - let recipient_ta = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; + test.add(TokenAccount::new(MINT, RECIPIENT).at(RECIPIENT_TOKEN_ACCOUNT)); let amount = 500u64; - let data = build_transfer_data(amount); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(sender.into(), true), - solana_instruction::AccountMeta::new(sender_ta.into(), false), - solana_instruction::AccountMeta::new(recipient_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(sender), - token_account(sender_ta, mint_addr, sender, 10_000), - token_account(recipient_ta, mint_addr, recipient, 0), - ], - ); - assert!(result.is_ok(), "transfer_tokens failed: {:?}", result.raw_result); - println!(" TRANSFER TOKENS CU: {}", result.compute_units_consumed); + test.send(TransferTokensInstruction { + sender: SENDER, + sender_token_account: SENDER_TOKEN_ACCOUNT, + recipient_token_account: RECIPIENT_TOKEN_ACCOUNT, + amount, + }) + .succeeds() + .has_tokens(SENDER_TOKEN_ACCOUNT, 10_000 - amount) + .has_tokens(RECIPIENT_TOKEN_ACCOUNT, amount); } From 93bede42625f1999b06f3a93dd7eb57f9fa38e26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:48:15 +0000 Subject: [PATCH 06/14] tokens: mark metadata examples as not migrated to Quasar 0.1.0 token-minter, nft-minter, and nft-operations depend on quasar-metadata, which was removed upstream before the 0.1.0 release with no replacement. Their manifests and changelogs now say so explicitly: they stay on the pre-0.1.0 pins (quasar 623bb70 / quasar-svm cb7565d) and build in the legacy-metadata-examples CI job with the older CLI until upstream ships a metadata story for 0.1.x. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- tokens/nft-minter/quasar/CHANGELOG.md | 12 ++++++++++++ tokens/nft-minter/quasar/Cargo.toml | 8 ++++++++ tokens/nft-operations/quasar/CHANGELOG.md | 12 ++++++++++++ tokens/nft-operations/quasar/Cargo.toml | 8 ++++++++ tokens/token-minter/quasar/CHANGELOG.md | 12 ++++++++++++ tokens/token-minter/quasar/Cargo.toml | 8 ++++++++ 6 files changed, 60 insertions(+) diff --git a/tokens/nft-minter/quasar/CHANGELOG.md b/tokens/nft-minter/quasar/CHANGELOG.md index 4f116388..2eefa60e 100644 --- a/tokens/nft-minter/quasar/CHANGELOG.md +++ b/tokens/nft-minter/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog + +## [2026-07-22] + +### Changed + +- Deliberately NOT migrated to Quasar 0.1.0: this example depends on + `quasar-metadata`, which was removed upstream before the 0.1.0 release with + no replacement. It stays on the pre-0.1.0 pins (quasar `623bb70` / + quasar-svm `cb7565d`) and builds in the `legacy-metadata-examples` CI job + with the older quasar CLI. Migrate once upstream ships a metadata story for + 0.1.x. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/nft-minter/quasar/Cargo.toml b/tokens/nft-minter/quasar/Cargo.toml index 5f916d1a..ff7bcbba 100644 --- a/tokens/nft-minter/quasar/Cargo.toml +++ b/tokens/nft-minter/quasar/Cargo.toml @@ -1,3 +1,11 @@ +# NOT migrated to Quasar 0.1.0: this example depends on quasar-metadata, +# which was removed from the quasar repo before the 0.1.0 release (no +# replacement shipped). It stays pinned to the last working pre-0.1.0 revs +# (quasar 623bb70 / quasar-svm cb7565d) and is built in CI by the separate +# legacy-metadata-examples job in .github/workflows/quasar.yml, which +# installs the older quasar CLI (rev 3d6fb0d8) that still parses this +# project's pre-0.1.0 Quasar.toml. Migrate once upstream ships a metadata +# story for 0.1.x. [package] name = "quasar-nft-minter" version = "0.1.0" diff --git a/tokens/nft-operations/quasar/CHANGELOG.md b/tokens/nft-operations/quasar/CHANGELOG.md index 4f116388..2eefa60e 100644 --- a/tokens/nft-operations/quasar/CHANGELOG.md +++ b/tokens/nft-operations/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog + +## [2026-07-22] + +### Changed + +- Deliberately NOT migrated to Quasar 0.1.0: this example depends on + `quasar-metadata`, which was removed upstream before the 0.1.0 release with + no replacement. It stays on the pre-0.1.0 pins (quasar `623bb70` / + quasar-svm `cb7565d`) and builds in the `legacy-metadata-examples` CI job + with the older quasar CLI. Migrate once upstream ships a metadata story for + 0.1.x. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/nft-operations/quasar/Cargo.toml b/tokens/nft-operations/quasar/Cargo.toml index 25aa9f10..0ebdbbbf 100644 --- a/tokens/nft-operations/quasar/Cargo.toml +++ b/tokens/nft-operations/quasar/Cargo.toml @@ -1,3 +1,11 @@ +# NOT migrated to Quasar 0.1.0: this example depends on quasar-metadata, +# which was removed from the quasar repo before the 0.1.0 release (no +# replacement shipped). It stays pinned to the last working pre-0.1.0 revs +# (quasar 623bb70 / quasar-svm cb7565d) and is built in CI by the separate +# legacy-metadata-examples job in .github/workflows/quasar.yml, which +# installs the older quasar CLI (rev 3d6fb0d8) that still parses this +# project's pre-0.1.0 Quasar.toml. Migrate once upstream ships a metadata +# story for 0.1.x. [package] name = "quasar-nft-operations" version = "0.1.0" diff --git a/tokens/token-minter/quasar/CHANGELOG.md b/tokens/token-minter/quasar/CHANGELOG.md index 4f116388..2eefa60e 100644 --- a/tokens/token-minter/quasar/CHANGELOG.md +++ b/tokens/token-minter/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog + +## [2026-07-22] + +### Changed + +- Deliberately NOT migrated to Quasar 0.1.0: this example depends on + `quasar-metadata`, which was removed upstream before the 0.1.0 release with + no replacement. It stays on the pre-0.1.0 pins (quasar `623bb70` / + quasar-svm `cb7565d`) and builds in the `legacy-metadata-examples` CI job + with the older quasar CLI. Migrate once upstream ships a metadata story for + 0.1.x. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/tokens/token-minter/quasar/Cargo.toml b/tokens/token-minter/quasar/Cargo.toml index 7662901e..70ec4d74 100644 --- a/tokens/token-minter/quasar/Cargo.toml +++ b/tokens/token-minter/quasar/Cargo.toml @@ -1,3 +1,11 @@ +# NOT migrated to Quasar 0.1.0: this example depends on quasar-metadata, +# which was removed from the quasar repo before the 0.1.0 release (no +# replacement shipped). It stays pinned to the last working pre-0.1.0 revs +# (quasar 623bb70 / quasar-svm cb7565d) and is built in CI by the separate +# legacy-metadata-examples job in .github/workflows/quasar.yml, which +# installs the older quasar CLI (rev 3d6fb0d8) that still parses this +# project's pre-0.1.0 Quasar.toml. Migrate once upstream ships a metadata +# story for 0.1.x. [package] name = "quasar-token-minter" version = "0.1.0" From a89f61c066d1da73f160bfca6eeb70c243a108de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:51:26 +0000 Subject: [PATCH 07/14] tokens/token-extensions: migrate to Quasar 0.1.0 All 11 token-extensions examples (basics, cpi-guard, default-account-state, group, immutable-owner, interest-bearing, memo-transfer, mint-close-authority, non-transferable, permanent-delegate, transfer-fee) move to the 0.1.0-release rev be60fca: 0.1.0 Quasar.toml schema, idl-build feature, "lib" crate-type, and quasar-test suites with Token-2022 fixtures (.token_program(TokenProgram::Token2022)). Every scenario ported; several bare is_ok assertions strengthened to has_tokens/has_supply and account-layout checks. No program-source changes were needed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- .../token-extensions/basics/quasar/Cargo.toml | 25 +-- .../basics/quasar/Quasar.toml | 19 +- .../basics/quasar/src/tests.rs | 175 ++++++------------ .../cpi-guard/quasar/Cargo.toml | 26 ++- .../cpi-guard/quasar/Quasar.toml | 19 +- .../cpi-guard/quasar/src/tests.rs | 117 ++++-------- .../default-account-state/quasar/Cargo.toml | 26 ++- .../default-account-state/quasar/Quasar.toml | 19 +- .../default-account-state/quasar/src/tests.rs | 78 +++----- .../token-extensions/group/quasar/Cargo.toml | 26 ++- .../token-extensions/group/quasar/Quasar.toml | 19 +- .../group/quasar/src/tests.rs | 78 +++----- .../immutable-owner/quasar/Cargo.toml | 26 ++- .../immutable-owner/quasar/Quasar.toml | 19 +- .../immutable-owner/quasar/src/tests.rs | 103 ++++------- .../interest-bearing/quasar/Cargo.toml | 26 ++- .../interest-bearing/quasar/Quasar.toml | 19 +- .../interest-bearing/quasar/src/tests.rs | 84 +++------ .../memo-transfer/quasar/Cargo.toml | 26 ++- .../memo-transfer/quasar/Quasar.toml | 19 +- .../memo-transfer/quasar/src/tests.rs | 102 ++++------ .../mint-close-authority/quasar/Cargo.toml | 26 ++- .../mint-close-authority/quasar/Quasar.toml | 19 +- .../mint-close-authority/quasar/src/tests.rs | 101 ++++------ .../non-transferable/quasar/Cargo.toml | 26 ++- .../non-transferable/quasar/Quasar.toml | 19 +- .../non-transferable/quasar/src/tests.rs | 79 +++----- .../permanent-delegate/quasar/Cargo.toml | 26 ++- .../permanent-delegate/quasar/Quasar.toml | 19 +- .../permanent-delegate/quasar/src/tests.rs | 78 +++----- .../transfer-fee/quasar/Cargo.toml | 26 ++- .../transfer-fee/quasar/Quasar.toml | 19 +- .../transfer-fee/quasar/src/tests.rs | 86 +++------ 33 files changed, 498 insertions(+), 1077 deletions(-) diff --git a/tokens/token-extensions/basics/quasar/Cargo.toml b/tokens/token-extensions/basics/quasar/Cargo.toml index 27b7124a..09294d5a 100644 --- a/tokens/token-extensions/basics/quasar/Cargo.toml +++ b/tokens/token-extensions/basics/quasar/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" # Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -13,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/basics/quasar/Quasar.toml b/tokens/token-extensions/basics/quasar/Quasar.toml index 473b47b5..cd924b45 100644 --- a/tokens/token-extensions/basics/quasar/Quasar.toml +++ b/tokens/token-extensions/basics/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_basics" - -[toolchain] -type = "solana" +name = "quasar-token-2022-basics" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/basics/quasar/src/tests.rs b/tokens/token-extensions/basics/quasar/src/tests.rs index f69c52b2..d37d0288 100644 --- a/tokens/token-extensions/basics/quasar/src/tests.rs +++ b/tokens/token-extensions/basics/quasar/src/tests.rs @@ -1,124 +1,71 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::cpi::{MintTokenInstruction, TransferTokenInstruction}, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_basics.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account_with_program( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 6, - is_initialized: true, - freeze_authority: None.into(), - }, - &quasar_svm::SPL_TOKEN_2022_PROGRAM_ID, - ) -} - -fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { - quasar_svm::token::create_keyed_token_account_with_program( - &address, - &TokenAccount { - mint, - owner, - amount, - state: AccountState::Initialized, - ..TokenAccount::default() - }, - &quasar_svm::SPL_TOKEN_2022_PROGRAM_ID, - ) -} - -#[test] -fn test_mint_token() { - let mut svm = setup(); - - let authority = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let receiver_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - - let amount = 1_000_000u64; - let mut data = vec![0u8]; // discriminator = 0 - data.extend_from_slice(&amount.to_le_bytes()); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(authority.into(), true), - solana_instruction::AccountMeta::new(mint_addr.into(), false), - solana_instruction::AccountMeta::new(receiver_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(authority), - mint_account(mint_addr, authority), - token_account(receiver_addr, mint_addr, authority, 0), - ], +// Deterministic addresses keep tests independent of discovery order. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const RECEIVER_TA: Pubkey = Pubkey::new_from_array([3; 32]); +const SENDER: Pubkey = Pubkey::new_from_array([4; 32]); +const FROM_TA: Pubkey = Pubkey::new_from_array([5; 32]); +const TO_TA: Pubkey = Pubkey::new_from_array([6; 32]); + +#[quasar_test] +fn mint_token_mints_to_the_receiver(test: &mut Test) { + test.add(Wallet::new().at(AUTHORITY)); + test.add( + Mint::new(AUTHORITY) + .at(MINT) + .decimals(6) + .token_program(TokenProgram::Token2022), + ); + test.add( + TokenAccount::new(MINT, AUTHORITY) + .at(RECEIVER_TA) + .token_program(TokenProgram::Token2022), ); - result.print_logs(); - assert!(result.is_ok(), "mint_token failed: {:?}", result.raw_result); - println!(" MINT TOKEN CU: {}", result.compute_units_consumed); + test.send(MintTokenInstruction { + authority: AUTHORITY, + mint: MINT, + receiver: RECEIVER_TA, + amount: 1_000_000, + }) + .succeeds() + .has_tokens(RECEIVER_TA, 1_000_000) + .has_supply(MINT, 1_000_000); } -#[test] -fn test_transfer_token() { - let mut svm = setup(); - - let sender = Pubkey::new_unique(); - let from_addr = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let to_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - - let amount = 500u64; - let mut data = vec![1u8]; // discriminator = 1 - data.extend_from_slice(&amount.to_le_bytes()); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(sender.into(), true), - solana_instruction::AccountMeta::new(from_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_addr.into(), false), - solana_instruction::AccountMeta::new(to_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(sender), - token_account(from_addr, mint_addr, sender, 1_000), - mint_account(mint_addr, sender), - token_account(to_addr, mint_addr, sender, 0), - ], +#[quasar_test] +fn transfer_token_moves_tokens_via_transfer_checked(test: &mut Test) { + test.add(Wallet::new().at(SENDER)); + test.add( + Mint::new(SENDER) + .at(MINT) + .decimals(6) + .token_program(TokenProgram::Token2022), + ); + test.add( + TokenAccount::new(MINT, SENDER) + .at(FROM_TA) + .amount(1_000) + .token_program(TokenProgram::Token2022), + ); + test.add( + TokenAccount::new(MINT, SENDER) + .at(TO_TA) + .token_program(TokenProgram::Token2022), ); - result.print_logs(); - assert!(result.is_ok(), "transfer_token failed: {:?}", result.raw_result); - println!(" TRANSFER TOKEN CU: {}", result.compute_units_consumed); + test.send(TransferTokenInstruction { + sender: SENDER, + from: FROM_TA, + mint: MINT, + to: TO_TA, + amount: 500, + }) + .succeeds() + .has_tokens(FROM_TA, 500) + .has_tokens(TO_TA, 500); } diff --git a/tokens/token-extensions/cpi-guard/quasar/Cargo.toml b/tokens/token-extensions/cpi-guard/quasar/Cargo.toml index e0ce6a67..1ee9097e 100644 --- a/tokens/token-extensions/cpi-guard/quasar/Cargo.toml +++ b/tokens/token-extensions/cpi-guard/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-cpi-guard" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/cpi-guard/quasar/Quasar.toml b/tokens/token-extensions/cpi-guard/quasar/Quasar.toml index 7762ee66..973b1562 100644 --- a/tokens/token-extensions/cpi-guard/quasar/Quasar.toml +++ b/tokens/token-extensions/cpi-guard/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_cpi_guard" - -[toolchain] -type = "solana" +name = "quasar-token-2022-cpi-guard" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/cpi-guard/quasar/src/tests.rs b/tokens/token-extensions/cpi-guard/quasar/src/tests.rs index a2f0b383..72c8337f 100644 --- a/tokens/token-extensions/cpi-guard/quasar/src/tests.rs +++ b/tokens/token-extensions/cpi-guard/quasar/src/tests.rs @@ -1,82 +1,41 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_cpi_guard.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account_with_program( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 1_000, - decimals: 9, - is_initialized: true, - freeze_authority: None.into(), - }, - &quasar_svm::SPL_TOKEN_2022_PROGRAM_ID, - ) -} - -fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { - quasar_svm::token::create_keyed_token_account_with_program( - &address, - &TokenAccount { - mint, - owner, - amount, - state: AccountState::Initialized, - ..TokenAccount::default() - }, - &quasar_svm::SPL_TOKEN_2022_PROGRAM_ID, - ) -} - -/// Test CPI transfer_checked (without CPI guard - should succeed). -#[test] -fn test_cpi_transfer() { - let mut svm = setup(); - - let sender = Pubkey::new_unique(); - let sender_ta = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let recipient_ta = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(sender.into(), true), - solana_instruction::AccountMeta::new(sender_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_addr.into(), false), - solana_instruction::AccountMeta::new(recipient_ta.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(sender), - token_account(sender_ta, mint_addr, sender, 100), - mint_account(mint_addr, sender), - token_account(recipient_ta, mint_addr, sender, 0), - ], +use {crate::cpi::CpiTransferInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const SENDER: Pubkey = Pubkey::new_from_array([1; 32]); +const SENDER_TA: Pubkey = Pubkey::new_from_array([2; 32]); +const MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const RECIPIENT_TA: Pubkey = Pubkey::new_from_array([4; 32]); + +/// CPI transfer_checked without CPI Guard enabled succeeds. +#[quasar_test] +fn cpi_transfer_succeeds_without_cpi_guard(test: &mut Test) { + test.add(Wallet::new().at(SENDER)); + test.add( + Mint::new(SENDER) + .at(MINT) + .supply(1_000) + .decimals(9) + .token_program(TokenProgram::Token2022), + ); + test.add( + TokenAccount::new(MINT, SENDER) + .at(SENDER_TA) + .amount(100) + .token_program(TokenProgram::Token2022), + ); + test.add( + TokenAccount::new(MINT, SENDER) + .at(RECIPIENT_TA) + .token_program(TokenProgram::Token2022), ); - result.print_logs(); - assert!(result.is_ok(), "cpi_transfer failed: {:?}", result.raw_result); - println!(" CPI TRANSFER CU: {}", result.compute_units_consumed); + test.send(CpiTransferInstruction { + sender: SENDER, + sender_token_account: SENDER_TA, + mint_account: MINT, + recipient_token_account: RECIPIENT_TA, + }) + .succeeds() + .has_tokens(SENDER_TA, 99) + .has_tokens(RECIPIENT_TA, 1); } diff --git a/tokens/token-extensions/default-account-state/quasar/Cargo.toml b/tokens/token-extensions/default-account-state/quasar/Cargo.toml index 77be7749..0e03ed1b 100644 --- a/tokens/token-extensions/default-account-state/quasar/Cargo.toml +++ b/tokens/token-extensions/default-account-state/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-default-account-state" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/default-account-state/quasar/Quasar.toml b/tokens/token-extensions/default-account-state/quasar/Quasar.toml index aa0790d3..6f9975c6 100644 --- a/tokens/token-extensions/default-account-state/quasar/Quasar.toml +++ b/tokens/token-extensions/default-account-state/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_default_account_state" - -[toolchain] -type = "solana" +name = "quasar-token-2022-default-account-state" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/default-account-state/quasar/src/tests.rs b/tokens/token-extensions/default-account-state/quasar/src/tests.rs index 57e5700d..ae016fed 100644 --- a/tokens/token-extensions/default-account-state/quasar/src/tests.rs +++ b/tokens/token-extensions/default-account-state/quasar/src/tests.rs @@ -1,55 +1,25 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_default_account_state.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a Token-2022 mint with the DefaultAccountState +/// extension set to frozen. +#[quasar_test] +fn initialize_creates_a_frozen_by_default_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account type + 4 TLV header + 1 DefaultAccountState byte. + assert_eq!(mint.data.len(), 171); } diff --git a/tokens/token-extensions/group/quasar/Cargo.toml b/tokens/token-extensions/group/quasar/Cargo.toml index d97deff4..e5f989c4 100644 --- a/tokens/token-extensions/group/quasar/Cargo.toml +++ b/tokens/token-extensions/group/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-group" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/group/quasar/Quasar.toml b/tokens/token-extensions/group/quasar/Quasar.toml index 35ebaaab..fe3d70db 100644 --- a/tokens/token-extensions/group/quasar/Quasar.toml +++ b/tokens/token-extensions/group/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_group" - -[toolchain] -type = "solana" +name = "quasar-token-2022-group" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/group/quasar/src/tests.rs b/tokens/token-extensions/group/quasar/src/tests.rs index 4904bde3..403ce776 100644 --- a/tokens/token-extensions/group/quasar/src/tests.rs +++ b/tokens/token-extensions/group/quasar/src/tests.rs @@ -1,55 +1,25 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_group.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize_group() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize_group failed: {:?}", result.raw_result); - println!(" INITIALIZE GROUP CU: {}", result.compute_units_consumed); +use {crate::cpi::InitializeGroupInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a Token-2022 mint with the GroupPointer extension. +#[quasar_test] +fn initialize_group_creates_a_group_pointer_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeGroupInstruction { + payer: PAYER, + mint_account: MINT, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // Base mint padded to 165 + account-type byte + GroupPointer TLV + // (2 type + 2 len + 64 data) = 234 bytes. + assert_eq!(mint.data.len(), 234); } diff --git a/tokens/token-extensions/immutable-owner/quasar/Cargo.toml b/tokens/token-extensions/immutable-owner/quasar/Cargo.toml index db33836e..a890838f 100644 --- a/tokens/token-extensions/immutable-owner/quasar/Cargo.toml +++ b/tokens/token-extensions/immutable-owner/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-immutable-owner" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/immutable-owner/quasar/Quasar.toml b/tokens/token-extensions/immutable-owner/quasar/Quasar.toml index 08281b6f..29377abc 100644 --- a/tokens/token-extensions/immutable-owner/quasar/Quasar.toml +++ b/tokens/token-extensions/immutable-owner/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_immutable_owner" - -[toolchain] -type = "solana" +name = "quasar-token-2022-immutable-owner" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/immutable-owner/quasar/src/tests.rs b/tokens/token-extensions/immutable-owner/quasar/src/tests.rs index 472fa5c4..b84812b7 100644 --- a/tokens/token-extensions/immutable-owner/quasar/src/tests.rs +++ b/tokens/token-extensions/immutable-owner/quasar/src/tests.rs @@ -1,72 +1,35 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{AccountState, Mint}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_immutable_owner.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account_with_program( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 2, - is_initialized: true, - freeze_authority: None.into(), - }, - &quasar_svm::SPL_TOKEN_2022_PROGRAM_ID, - ) -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let token_acc = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(token_acc.into(), true), - solana_instruction::AccountMeta::new_readonly(mint_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(token_acc), mint_account(mint_addr, payer)], +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); +const MINT: Pubkey = Pubkey::new_from_array([3; 32]); + +/// Initialize creates a Token-2022 token account with the ImmutableOwner +/// extension. +#[quasar_test] +fn initialize_creates_an_immutable_owner_token_account(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + test.add( + Mint::new(PAYER) + .at(MINT) + .decimals(2) + .token_program(TokenProgram::Token2022), ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); + // The token account enters the transaction as an empty system account; + // the program creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + token_account: TOKEN_ACCOUNT, + mint_account: MINT, + }) + .succeeds() + .has_tokens(TOKEN_ACCOUNT, 0); + + let token_account = test.account(TOKEN_ACCOUNT).expect("token account exists"); + assert_eq!(token_account.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account-type byte + 4 TLV header (ImmutableOwner is + // zero-size) = 170 bytes. + assert_eq!(token_account.data.len(), 170); } diff --git a/tokens/token-extensions/interest-bearing/quasar/Cargo.toml b/tokens/token-extensions/interest-bearing/quasar/Cargo.toml index b4e433d9..ca8236e6 100644 --- a/tokens/token-extensions/interest-bearing/quasar/Cargo.toml +++ b/tokens/token-extensions/interest-bearing/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-interest-bearing" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/interest-bearing/quasar/Quasar.toml b/tokens/token-extensions/interest-bearing/quasar/Quasar.toml index 8af9ff08..6b774d50 100644 --- a/tokens/token-extensions/interest-bearing/quasar/Quasar.toml +++ b/tokens/token-extensions/interest-bearing/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_interest_bearing" - -[toolchain] -type = "solana" +name = "quasar-token-2022-interest-bearing" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/interest-bearing/quasar/src/tests.rs b/tokens/token-extensions/interest-bearing/quasar/src/tests.rs index 83b46b69..7fd6cb17 100644 --- a/tokens/token-extensions/interest-bearing/quasar/src/tests.rs +++ b/tokens/token-extensions/interest-bearing/quasar/src/tests.rs @@ -1,59 +1,27 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_interest_bearing.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let rate: i16 = 500; - let mut data = vec![0u8]; // discriminator = 0 - data.extend_from_slice(&rate.to_le_bytes()); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a Token-2022 mint with the InterestBearingConfig +/// extension. +#[quasar_test] +fn initialize_creates_an_interest_bearing_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + rate: 500, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account-type byte + 4 TLV header + // + 52 InterestBearingConfig bytes = 222 bytes. + assert_eq!(mint.data.len(), 222); } diff --git a/tokens/token-extensions/memo-transfer/quasar/Cargo.toml b/tokens/token-extensions/memo-transfer/quasar/Cargo.toml index 8f266a56..a030adfb 100644 --- a/tokens/token-extensions/memo-transfer/quasar/Cargo.toml +++ b/tokens/token-extensions/memo-transfer/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-memo-transfer" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/memo-transfer/quasar/Quasar.toml b/tokens/token-extensions/memo-transfer/quasar/Quasar.toml index 90a416bd..176909f4 100644 --- a/tokens/token-extensions/memo-transfer/quasar/Quasar.toml +++ b/tokens/token-extensions/memo-transfer/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_memo_transfer" - -[toolchain] -type = "solana" +name = "quasar-token-2022-memo-transfer" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/memo-transfer/quasar/src/tests.rs b/tokens/token-extensions/memo-transfer/quasar/src/tests.rs index 8aa0d196..2ffd7954 100644 --- a/tokens/token-extensions/memo-transfer/quasar/src/tests.rs +++ b/tokens/token-extensions/memo-transfer/quasar/src/tests.rs @@ -1,72 +1,34 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::Mint, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_memo_transfer.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account_with_program( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 2, - is_initialized: true, - freeze_authority: None.into(), - }, - &quasar_svm::SPL_TOKEN_2022_PROGRAM_ID, - ) -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let token_acc = Pubkey::new_unique(); - let mint_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(token_acc.into(), true), - solana_instruction::AccountMeta::new_readonly(mint_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(token_acc), mint_account(mint_addr, payer)], +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([2; 32]); +const MINT: Pubkey = Pubkey::new_from_array([3; 32]); + +/// Initialize creates a Token-2022 token account with the MemoTransfer +/// extension enabled. +#[quasar_test] +fn initialize_creates_a_memo_transfer_token_account(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + test.add( + Mint::new(PAYER) + .at(MINT) + .decimals(2) + .token_program(TokenProgram::Token2022), ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); + // The token account enters the transaction as an empty system account; + // the program creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + token_account: TOKEN_ACCOUNT, + mint_account: MINT, + }) + .succeeds() + .has_tokens(TOKEN_ACCOUNT, 0); + + let token_account = test.account(TOKEN_ACCOUNT).expect("token account exists"); + assert_eq!(token_account.owner, SPL_TOKEN_2022_PROGRAM_ID); + // Token account allocated with room for the MemoTransfer extension. + assert_eq!(token_account.data.len(), 300); } diff --git a/tokens/token-extensions/mint-close-authority/quasar/Cargo.toml b/tokens/token-extensions/mint-close-authority/quasar/Cargo.toml index acca197c..1b1a0b94 100644 --- a/tokens/token-extensions/mint-close-authority/quasar/Cargo.toml +++ b/tokens/token-extensions/mint-close-authority/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-mint-close-authority" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/mint-close-authority/quasar/Quasar.toml b/tokens/token-extensions/mint-close-authority/quasar/Quasar.toml index f2464070..7c840b47 100644 --- a/tokens/token-extensions/mint-close-authority/quasar/Quasar.toml +++ b/tokens/token-extensions/mint-close-authority/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_mint_close_authority" - -[toolchain] -type = "solana" +name = "quasar-token-2022-mint-close-authority" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/mint-close-authority/quasar/src/tests.rs b/tokens/token-extensions/mint-close-authority/quasar/src/tests.rs index d4f7c584..5fc3d11d 100644 --- a/tokens/token-extensions/mint-close-authority/quasar/src/tests.rs +++ b/tokens/token-extensions/mint-close-authority/quasar/src/tests.rs @@ -1,73 +1,36 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{CloseInstruction, InitializeInstruction}, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_mint_close_authority.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize_and_close() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - // Initialize - let init_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &init_ix, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); - - // Close - let close_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - ], - data: vec![1u8], - }; - - let close_result = svm.process_instruction_chain(&[close_ix.clone()], &result.accounts); - - close_result.print_logs(); - assert!(close_result.is_ok(), "close failed: {:?}", close_result.raw_result); - println!(" CLOSE CU: {}", close_result.compute_units_consumed); +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a mint with the MintCloseAuthority extension, and close +/// then reclaims its lamports to the authority. +#[quasar_test] +fn initialize_then_close_reclaims_the_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account-type byte + 4 TLV header + // + 32 MintCloseAuthority bytes = 202 bytes. + assert_eq!(mint.data.len(), 202); + + test.send(CloseInstruction { + authority: PAYER, + mint_account: MINT, + }) + .succeeds() + .is_closed(MINT); } diff --git a/tokens/token-extensions/non-transferable/quasar/Cargo.toml b/tokens/token-extensions/non-transferable/quasar/Cargo.toml index ada97c52..ac1e56e9 100644 --- a/tokens/token-extensions/non-transferable/quasar/Cargo.toml +++ b/tokens/token-extensions/non-transferable/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-non-transferable" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/non-transferable/quasar/Quasar.toml b/tokens/token-extensions/non-transferable/quasar/Quasar.toml index 73cad7a1..818dc8ac 100644 --- a/tokens/token-extensions/non-transferable/quasar/Quasar.toml +++ b/tokens/token-extensions/non-transferable/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_non_transferable" - -[toolchain] -type = "solana" +name = "quasar-token-2022-non-transferable" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/non-transferable/quasar/src/tests.rs b/tokens/token-extensions/non-transferable/quasar/src/tests.rs index 1ec3de46..d3b99fae 100644 --- a/tokens/token-extensions/non-transferable/quasar/src/tests.rs +++ b/tokens/token-extensions/non-transferable/quasar/src/tests.rs @@ -1,55 +1,26 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_non_transferable.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a Token-2022 mint with the NonTransferable extension +/// (soulbound token). +#[quasar_test] +fn initialize_creates_a_non_transferable_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account-type byte + 4 TLV header (NonTransferable is + // zero-size) = 170 bytes. + assert_eq!(mint.data.len(), 170); } diff --git a/tokens/token-extensions/permanent-delegate/quasar/Cargo.toml b/tokens/token-extensions/permanent-delegate/quasar/Cargo.toml index f1ff0253..2866619a 100644 --- a/tokens/token-extensions/permanent-delegate/quasar/Cargo.toml +++ b/tokens/token-extensions/permanent-delegate/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-permanent-delegate" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/permanent-delegate/quasar/Quasar.toml b/tokens/token-extensions/permanent-delegate/quasar/Quasar.toml index 93e326de..4859d1b3 100644 --- a/tokens/token-extensions/permanent-delegate/quasar/Quasar.toml +++ b/tokens/token-extensions/permanent-delegate/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_permanent_delegate" - -[toolchain] -type = "solana" +name = "quasar-token-2022-permanent-delegate" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/permanent-delegate/quasar/src/tests.rs b/tokens/token-extensions/permanent-delegate/quasar/src/tests.rs index 7b265890..b3dd790d 100644 --- a/tokens/token-extensions/permanent-delegate/quasar/src/tests.rs +++ b/tokens/token-extensions/permanent-delegate/quasar/src/tests.rs @@ -1,55 +1,25 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_permanent_delegate.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0u8], - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a Token-2022 mint with the PermanentDelegate extension. +#[quasar_test] +fn initialize_creates_a_permanent_delegate_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account-type byte + 4 TLV header + // + 32 PermanentDelegate bytes = 202 bytes. + assert_eq!(mint.data.len(), 202); } diff --git a/tokens/token-extensions/transfer-fee/quasar/Cargo.toml b/tokens/token-extensions/transfer-fee/quasar/Cargo.toml index a4c398be..24c9e629 100644 --- a/tokens/token-extensions/transfer-fee/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-fee/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-token-2022-transfer-fee" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,29 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-fee/quasar/Quasar.toml b/tokens/token-extensions/transfer-fee/quasar/Quasar.toml index b508dfb0..9a8018d7 100644 --- a/tokens/token-extensions/transfer-fee/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-fee/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_2022_transfer_fee" - -[toolchain] -type = "solana" +name = "quasar-token-2022-transfer-fee" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-extensions/transfer-fee/quasar/src/tests.rs b/tokens/token-extensions/transfer-fee/quasar/src/tests.rs index a767a792..6ad67356 100644 --- a/tokens/token-extensions/transfer-fee/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-fee/quasar/src/tests.rs @@ -1,61 +1,27 @@ -extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, -}; - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_2022_transfer_fee.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - let basis_points: u16 = 100; // 1% - let max_fee: u64 = 1_000_000; - let mut data = vec![0u8]; // discriminator = 0 - data.extend_from_slice(&basis_points.to_le_bytes()); - data.extend_from_slice(&max_fee.to_le_bytes()); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); +use {crate::cpi::InitializeInstruction, quasar_test::prelude::*}; + +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); + +/// Initialize creates a Token-2022 mint with the TransferFeeConfig extension. +#[quasar_test] +fn initialize_creates_a_transfer_fee_mint(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + // The mint enters the transaction as an empty system account; the program + // creates and initializes it via CPI. + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + transfer_fee_basis_points: 100, // 1% + maximum_fee: 1_000_000, + }) + .succeeds(); + + let mint = test.account(MINT).expect("mint account exists"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + // 165 base + 1 account-type byte + 4 TLV header + // + 108 TransferFeeConfig bytes = 278 bytes. + assert_eq!(mint.data.len(), 278); } From 7166eb988bc74366e73a65e67ad01be61e6e8014 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:54:20 +0000 Subject: [PATCH 08/14] tokens/transfer-hook: migrate to Quasar 0.1.0 All 7 transfer-hook examples (account-data-as-seed, allow-block-list-token, counter, hello-world, transfer-cost, transfer-switch, whitelist) move to the 0.1.0-release rev be60fca: the crates.io quasar-svm = "0.1" dev-dep is deleted in favor of quasar-test, Quasar.toml moves to the 0.1.0 schema, and the flow tests are rewritten to #[quasar_test] with crate::cpi builders. Two negative assertions were strengthened from bare is_err() to the exact ProgramError the handlers return. Genuine 0.1.0 API break fixed across 6 of the 7 programs: Address::find_program_address no longer exists; PDA derivation now uses quasar_lang::pda::try_find_program_address. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- .../account-data-as-seed/quasar/Cargo.toml | 21 +- .../account-data-as-seed/quasar/Quasar.toml | 19 +- .../account-data-as-seed/quasar/src/lib.rs | 6 +- .../account-data-as-seed/quasar/src/tests.rs | 139 ++++------- .../allow-block-list-token/quasar/Cargo.toml | 28 ++- .../allow-block-list-token/quasar/Quasar.toml | 16 +- .../quasar/src/instructions/attach_to_mint.rs | 4 +- .../quasar/src/instructions/init_config.rs | 2 +- .../quasar/src/instructions/init_mint.rs | 4 +- .../quasar/src/instructions/init_wallet.rs | 6 +- .../quasar/src/instructions/remove_wallet.rs | 2 +- .../transfer-hook/counter/quasar/Cargo.toml | 21 +- .../transfer-hook/counter/quasar/Quasar.toml | 19 +- .../transfer-hook/counter/quasar/src/lib.rs | 6 +- .../transfer-hook/counter/quasar/src/tests.rs | 210 +++++------------ .../hello-world/quasar/Cargo.toml | 23 +- .../hello-world/quasar/Quasar.toml | 19 +- .../hello-world/quasar/src/lib.rs | 4 +- .../hello-world/quasar/src/tests.rs | 200 +++++----------- .../transfer-cost/quasar/Cargo.toml | 25 +- .../transfer-cost/quasar/Quasar.toml | 16 +- .../transfer-cost/quasar/src/lib.rs | 6 +- .../transfer-cost/quasar/src/tests.rs | 135 ++++------- .../transfer-switch/quasar/Cargo.toml | 25 +- .../transfer-switch/quasar/Quasar.toml | 16 +- .../transfer-switch/quasar/src/lib.rs | 9 +- .../transfer-switch/quasar/src/tests.rs | 220 ++++++------------ .../transfer-hook/whitelist/quasar/Cargo.toml | 25 +- .../whitelist/quasar/Quasar.toml | 16 +- .../transfer-hook/whitelist/quasar/src/lib.rs | 7 +- .../whitelist/quasar/src/tests.rs | 189 +++++---------- 31 files changed, 481 insertions(+), 957 deletions(-) diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Cargo.toml index 77f3d843..ecbf82df 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-transfer-hook-account-data-as-seed" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,23 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Quasar.toml index 155b2232..a8cf67e3 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_transfer_hook_account_data_as_seed" - -[toolchain] -type = "solana" +name = "quasar-transfer-hook-account-data-as-seed" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 d9f22485..0b8ad54b 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 @@ -76,10 +76,10 @@ pub fn handle_initialize_extra_account_meta_list(accounts: &mut InitializeExtraA let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; let mint_address = accounts.mint.to_account_view().address(); - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[b"extra-account-metas", mint_address.as_ref()], &crate::ID, - ); + )?; if accounts.extra_account_meta_list.to_account_view().address() != &expected_pda { return Err(ProgramError::InvalidSeeds); @@ -136,7 +136,7 @@ pub fn handle_initialize_extra_account_meta_list(accounts: &mut InitializeExtraA let counter_lamports = Rent::get()?.try_minimum_balance(counter_size as usize)?; let (counter_pda, counter_bump) = - Address::find_program_address(&[b"counter", payer_address.as_ref()], &crate::ID); + quasar_lang::pda::try_find_program_address(&[b"counter", payer_address.as_ref()], &crate::ID)?; if accounts.counter_account.to_account_view().address() != &counter_pda { return Err(ProgramError::InvalidSeeds); diff --git a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/tests.rs b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/tests.rs index 11b45853..c6e8e55a 100644 --- a/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/account-data-as-seed/quasar/src/tests.rs @@ -1,99 +1,52 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{InitializeExtraAccountMetaListInstruction, TransferHookInstruction}, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_hook_account_data_as_seed.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const SOURCE_TOKEN: Pubkey = Pubkey::new_from_array([3; 32]); +const DESTINATION_TOKEN: Pubkey = Pubkey::new_from_array([4; 32]); +const OWNER: Pubkey = Pubkey::new_from_array([5; 32]); + +/// (extra_account_meta_list, counter) PDAs. The program derives these with +/// raw seed literals, so the test mirrors the derivation directly. +fn pdas() -> (Pubkey, Pubkey) { + let program_id: Pubkey = crate::ID.into(); + let (meta_list, _) = + Pubkey::find_program_address(&[b"extra-account-metas", MINT.as_ref()], &program_id); + let (counter, _) = Pubkey::find_program_address(&[b"counter", PAYER.as_ref()], &program_id); + (meta_list, counter) } -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize_and_transfer_hook() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let (meta_list_pda, _) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - - let (counter_pda, _) = Pubkey::find_program_address( - &[b"counter", payer.as_ref()], - &crate::ID.into(), - ); - - // Initialize - let init_data = vec![43, 34, 13, 49, 167, 88, 235, 235]; - let init_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: init_data, - }; - - let result = svm.process_instruction( - &init_ix, - &[signer(payer), empty(meta_list_pda), empty(mint), empty(counter_pda)], - ); - result.print_logs(); - assert!(result.is_ok(), "init failed: {:?}", result.raw_result); - println!(" INIT CU: {}", result.compute_units_consumed); - - // Transfer hook - let source_token = Pubkey::new_unique(); - let destination_token = Pubkey::new_unique(); - let owner = Pubkey::new_unique(); - - let mut hook_data = vec![105, 37, 101, 197, 75, 251, 102, 26]; - hook_data.extend_from_slice(&1u64.to_le_bytes()); - - let hook_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(destination_token.into(), false), - solana_instruction::AccountMeta::new_readonly(owner.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - ], - data: hook_data, - }; - - let result = svm.process_instruction( - &hook_ix, - &[empty(source_token), empty(destination_token), signer(owner)], - ); - result.print_logs(); - assert!(result.is_ok(), "transfer_hook failed: {:?}", result.raw_result); - println!(" TRANSFER_HOOK CU: {}", result.compute_units_consumed); - - let counter_account = svm.get_account(&counter_pda).expect("counter missing"); - let counter = u64::from_le_bytes(counter_account.data[8..16].try_into().unwrap()); - assert_eq!(counter, 1, "counter should be 1"); +#[quasar_test] +fn initialize_then_transfer_hook_increments_the_counter(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let (meta_list, counter) = pdas(); + + test.send(InitializeExtraAccountMetaListInstruction { + payer: PAYER, + extra_account_meta_list: meta_list, + mint: MINT, + counter_account: counter, + }) + .succeeds(); + + test.send(TransferHookInstruction { + source_token: SOURCE_TOKEN, + mint: MINT, + destination_token: DESTINATION_TOKEN, + owner: OWNER, + extra_account_meta_list: meta_list, + counter_account: counter, + _amount: 1, + }) + .succeeds(); + + // Layout is [8-byte header][u64 counter]; the byte offset is the point of + // this program, so check the bytes directly. + let account = test.account(counter).expect("counter missing"); + let count = u64::from_le_bytes(account.data[8..16].try_into().unwrap()); + assert_eq!(count, 1, "counter should be 1"); } 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 7b9b29b9..ecb78883 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 @@ -3,31 +3,35 @@ name = "quasar-abl-token" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] level = "warn" -check-cfg = ['cfg(target_os, values("solana"))'] +check-cfg = [ + 'cfg(target_os, values("solana"))', +] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] -# Removed `alloc` feature - the upstream `quasar-lang` master no longer -# exposes it, and nothing in this crate depends on alloc. +alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Quasar.toml index b0b54269..36c03343 100644 --- a/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/allow-block-list-token/quasar/Quasar.toml @@ -1,19 +1,9 @@ [project] -name = "quasar_abl_token" - -[toolchain] -type = "solana" +name = "quasar-abl-token" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = ["test", "tests::"] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 b23cd9a3..0f7f8255 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 @@ -62,10 +62,10 @@ pub fn handle_attach_to_mint(accounts: &mut AttachToMintAccountConstraints) -> R let meta_list_size: u64 = 51; let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[META_LIST_ACCOUNT_SEED, mint_key.as_ref()], &crate::ID, - ); + )?; if accounts.extra_metas_account.to_account_view().address() != &expected_pda { return Err(ProgramError::InvalidSeeds); } 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 bf2e981b..afe2e30d 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 @@ -16,7 +16,7 @@ pub struct InitConfigAccountConstraints { #[inline(always)] pub fn handle_init_config(accounts: &mut InitConfigAccountConstraints) -> Result<(), ProgramError> { - let (config_pda, bump) = Address::find_program_address(&[CONFIG_SEED], &crate::ID); + let (config_pda, bump) = quasar_lang::pda::try_find_program_address(&[CONFIG_SEED], &crate::ID)?; if accounts.config.to_account_view().address() != &config_pda { return Err(ProgramError::InvalidSeeds); 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 d46e79f1..ea6a20ed 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 @@ -249,10 +249,10 @@ fn init_extra_metas(ctx: &mut InitMintAccountConstraints) -> Result<(), ProgramE let meta_list_size: u64 = 51; let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[META_LIST_ACCOUNT_SEED, mint_key.as_ref()], &crate::ID, - ); + )?; if ctx.extra_metas_account.to_account_view().address() != &expected_pda { return Err(ProgramError::InvalidSeeds); } 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 830f2ea8..26361bf0 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 @@ -20,7 +20,7 @@ pub struct InitWalletAccountConstraints { #[inline(always)] 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); + let (config_pda, _) = quasar_lang::pda::try_find_program_address(&[CONFIG_SEED], &crate::ID)?; if accounts.config.to_account_view().address() != &config_pda { return Err(ProgramError::InvalidSeeds); } @@ -39,10 +39,10 @@ pub fn handle_init_wallet(accounts: &mut InitWalletAccountConstraints, allowed: // Create ABWallet PDA let wallet_key = accounts.wallet.to_account_view().address(); - let (ab_wallet_pda, bump) = Address::find_program_address( + let (ab_wallet_pda, bump) = quasar_lang::pda::try_find_program_address( &[AB_WALLET_SEED, wallet_key.as_ref()], &crate::ID, - ); + )?; if accounts.ab_wallet.to_account_view().address() != &ab_wallet_pda { return Err(ProgramError::InvalidSeeds); } 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 9a14fa79..3770bc5c 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 @@ -16,7 +16,7 @@ pub struct RemoveWalletAccountConstraints { #[inline(always)] pub fn handle_remove_wallet(accounts: &mut RemoveWalletAccountConstraints) -> Result<(), ProgramError> { // Verify config PDA - let (config_pda, _) = Address::find_program_address(&[CONFIG_SEED], &crate::ID); + let (config_pda, _) = quasar_lang::pda::try_find_program_address(&[CONFIG_SEED], &crate::ID)?; if accounts.config.to_account_view().address() != &config_pda { return Err(ProgramError::InvalidSeeds); } diff --git a/tokens/token-extensions/transfer-hook/counter/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/counter/quasar/Cargo.toml index 5bb7eea7..2d855dd0 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/counter/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-transfer-hook-counter" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,23 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/counter/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/counter/quasar/Quasar.toml index c6615952..773ae206 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/counter/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_transfer_hook_counter" - -[toolchain] -type = "solana" +name = "quasar-transfer-hook-counter" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 1d4a5ddf..71716ad8 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/counter/quasar/src/lib.rs @@ -73,10 +73,10 @@ fn handle_initialize_extra_account_meta_list( // Derive ExtraAccountMetaList PDA let mint_address = accounts.mint.to_account_view().address(); - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[b"extra-account-metas", mint_address.as_ref()], &crate::ID, - ); + )?; let meta_list_address = accounts.extra_account_meta_list.to_account_view().address(); if meta_list_address != &expected_pda { @@ -131,7 +131,7 @@ fn handle_initialize_extra_account_meta_list( let counter_lamports = Rent::get()?.try_minimum_balance(counter_size as usize)?; let (counter_pda, counter_bump) = - Address::find_program_address(&[b"counter"], &crate::ID); + quasar_lang::pda::try_find_program_address(&[b"counter"], &crate::ID)?; let counter_address = accounts.counter_account.to_account_view().address(); if counter_address != &counter_pda { 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 fc012c1a..12ee5445 100644 --- a/tokens/token-extensions/transfer-hook/counter/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/counter/quasar/src/tests.rs @@ -1,169 +1,63 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{InitializeExtraAccountMetaListInstruction, TransferHookInstruction}, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_hook_counter.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const SOURCE_TOKEN: Pubkey = Pubkey::new_from_array([3; 32]); +const DESTINATION_TOKEN: Pubkey = Pubkey::new_from_array([4; 32]); +const OWNER: Pubkey = Pubkey::new_from_array([5; 32]); + +/// (extra_account_meta_list, counter) PDAs. The program derives these with +/// raw seed literals, so the test mirrors the derivation directly. +fn pdas() -> (Pubkey, Pubkey) { + let program_id: Pubkey = crate::ID.into(); + let (meta_list, _) = + Pubkey::find_program_address(&[b"extra-account-metas", MINT.as_ref()], &program_id); + let (counter, _) = Pubkey::find_program_address(&[b"counter"], &program_id); + (meta_list, counter) } -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_initialize_extra_account_meta_list() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; +fn initialize(test: &mut Test) -> (Pubkey, Pubkey) { + test.add(Wallet::new().at(PAYER)); + let (meta_list, counter) = pdas(); - // Derive ExtraAccountMetaList PDA - let (meta_list_pda, _) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); + test.send(InitializeExtraAccountMetaListInstruction { + payer: PAYER, + extra_account_meta_list: meta_list, + mint: MINT, + counter_account: counter, + }) + .succeeds(); - // Derive counter PDA - let (counter_pda, _) = Pubkey::find_program_address(&[b"counter"], &crate::ID.into()); - - // InitializeExtraAccountMetaList discriminator - let data = vec![43, 34, 13, 49, 167, 88, 235, 235]; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(payer), - empty(meta_list_pda), - empty(mint), - empty(counter_pda), - ], - ); - - result.print_logs(); - assert!( - result.is_ok(), - "initialize_extra_account_meta_list failed: {:?}", - result.raw_result - ); - println!( - " INIT_EXTRA_ACCOUNT_METAS CU: {}", - result.compute_units_consumed - ); + (meta_list, counter) } -#[test] -fn test_transfer_hook_increments_counter() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - // First, initialize the extra account meta list and counter - let (meta_list_pda, _) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - let (counter_pda, _) = Pubkey::find_program_address(&[b"counter"], &crate::ID.into()); - - let init_data = vec![43, 34, 13, 49, 167, 88, 235, 235]; - let init_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: init_data, - }; - - let result = svm.process_instruction( - &init_ix, - &[ - signer(payer), - empty(meta_list_pda), - empty(mint), - empty(counter_pda), - ], - ); - assert!(result.is_ok(), "init failed: {:?}", result.raw_result); - - // Now call transfer_hook - let source_token = Pubkey::new_unique(); - let destination_token = Pubkey::new_unique(); - let owner = Pubkey::new_unique(); - - // Execute discriminator + amount (1u64 LE) - let mut hook_data = vec![105, 37, 101, 197, 75, 251, 102, 26]; - hook_data.extend_from_slice(&1u64.to_le_bytes()); - - let hook_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(destination_token.into(), false), - solana_instruction::AccountMeta::new_readonly(owner.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - ], - data: hook_data, - }; - - // Don't pass counter_pda or meta_list_pda - they were committed by the init instruction - let result = svm.process_instruction( - &hook_ix, - &[ - empty(source_token), - empty(mint), - empty(destination_token), - signer(owner), - ], - ); - - result.print_logs(); - assert!( - result.is_ok(), - "transfer_hook failed: {:?}", - result.raw_result - ); - println!( - " TRANSFER_HOOK CU: {}", - result.compute_units_consumed - ); +#[quasar_test] +fn initialize_creates_meta_list_and_counter(test: &mut Test) { + initialize(test); +} - // Verify counter was incremented - let counter_account = svm.get_account(&counter_pda).expect("counter account missing"); - let counter = u64::from_le_bytes(counter_account.data[8..16].try_into().unwrap()); - assert_eq!(counter, 1, "counter should be 1 after one transfer"); - println!(" Counter value: {}", counter); +#[quasar_test] +fn transfer_hook_increments_counter(test: &mut Test) { + let (meta_list, counter) = initialize(test); + + test.send(TransferHookInstruction { + source_token: SOURCE_TOKEN, + mint: MINT, + destination_token: DESTINATION_TOKEN, + owner: OWNER, + extra_account_meta_list: meta_list, + counter_account: counter, + _amount: 1, + }) + .succeeds(); + + // Layout is [8-byte header][u64 counter]; the byte offset is the point of + // this program, so check the bytes directly. + let account = test.account(counter).expect("counter account missing"); + let count = u64::from_le_bytes(account.data[8..16].try_into().unwrap()); + assert_eq!(count, 1, "counter should be 1 after one transfer"); } diff --git a/tokens/token-extensions/transfer-hook/hello-world/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/hello-world/quasar/Cargo.toml index 45b7263a..2c97b87a 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/hello-world/quasar/Cargo.toml @@ -3,6 +3,8 @@ name = "quasar-transfer-hook-hello-world" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] @@ -12,25 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/hello-world/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/hello-world/quasar/Quasar.toml index f8d4ac48..65414181 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/hello-world/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_transfer_hook_hello_world" - -[toolchain] -type = "solana" +name = "quasar-transfer-hook-hello-world" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 a1a77663..a0063fe7 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 @@ -158,10 +158,10 @@ fn handle_initialize_extra_account_meta_list( // Derive PDA let mint_address = accounts.mint.to_account_view().address(); - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[b"extra-account-metas", mint_address.as_ref()], &crate::ID, - ); + )?; let meta_list_address = accounts.extra_account_meta_list.to_account_view().address(); if meta_list_address != &expected_pda { diff --git a/tokens/token-extensions/transfer-hook/hello-world/quasar/src/tests.rs b/tokens/token-extensions/transfer-hook/hello-world/quasar/src/tests.rs index 2fde5dd5..70183422 100644 --- a/tokens/token-extensions/transfer-hook/hello-world/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/hello-world/quasar/src/tests.rs @@ -1,155 +1,65 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{ + InitializeExtraAccountMetaListInstruction, InitializeInstruction, TransferHookInstruction, + }, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_hook_hello_world.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const SOURCE_TOKEN: Pubkey = Pubkey::new_from_array([3; 32]); +const DESTINATION_TOKEN: Pubkey = Pubkey::new_from_array([4; 32]); +const OWNER: Pubkey = Pubkey::new_from_array([5; 32]); + +/// ExtraAccountMetaList PDA. The program derives it with raw seed literals, +/// so the test mirrors the derivation directly. +fn meta_list_pda() -> Pubkey { + let program_id: Pubkey = crate::ID.into(); + Pubkey::find_program_address(&[b"extra-account-metas", MINT.as_ref()], &program_id).0 } -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) +#[quasar_test] +fn initialize_creates_a_mint_with_the_transfer_hook_extension(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + + test.send(InitializeInstruction { + payer: PAYER, + mint_account: MINT, + decimals: 2, + }) + .succeeds(); + + // The mint is now owned by Token-2022 and sized for the extension TLV. + let mint = test.account(MINT).expect("mint missing"); + assert_eq!(mint.owner, SPL_TOKEN_2022_PROGRAM_ID); + assert_eq!(mint.data.len(), 234); } -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } +#[quasar_test] +fn initialize_extra_account_meta_list_creates_the_pda(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + + // The mint does not need to exist for PDA derivation, just an address. + test.send(InitializeExtraAccountMetaListInstruction { + payer: PAYER, + extra_account_meta_list: meta_list_pda(), + mint: MINT, + }) + .succeeds(); } -#[test] -fn test_initialize_mint_with_transfer_hook() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_2022_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - - // 8-byte discriminator [0,0,0,0,0,0,0,1] + decimals = 2 - let data = vec![0, 0, 0, 0, 0, 0, 0, 1, 2]; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(mint.into(), true), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(mint)], - ); - - result.print_logs(); - assert!(result.is_ok(), "initialize failed: {:?}", result.raw_result); - println!(" INITIALIZE CU: {}", result.compute_units_consumed); -} - -#[test] -fn test_initialize_extra_account_meta_list() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - // Derive the ExtraAccountMetaList PDA - let (meta_list_pda, _bump) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - - // InitializeExtraAccountMetaList discriminator - let data = vec![43, 34, 13, 49, 167, 88, 235, 235]; - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - // mint doesn't need to exist for PDA derivation, just needs an address - let result = svm.process_instruction( - &instruction, - &[signer(payer), empty(meta_list_pda), empty(mint)], - ); - - result.print_logs(); - assert!( - result.is_ok(), - "initialize_extra_account_meta_list failed: {:?}", - result.raw_result - ); - println!( - " INIT_EXTRA_ACCOUNT_METAS CU: {}", - result.compute_units_consumed - ); -} - -#[test] -fn test_transfer_hook() { - let mut svm = setup(); - - let source_token = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let destination_token = Pubkey::new_unique(); - let owner = Pubkey::new_unique(); - - // Derive ExtraAccountMetaList PDA - let (meta_list_pda, _bump) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - - // Execute discriminator + amount (1u64 LE) - let mut data = vec![105, 37, 101, 197, 75, 251, 102, 26]; - data.extend_from_slice(&1u64.to_le_bytes()); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(destination_token.into(), false), - solana_instruction::AccountMeta::new_readonly(owner.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - empty(source_token), - empty(mint), - empty(destination_token), - signer(owner), - empty(meta_list_pda), - ], - ); - - result.print_logs(); - assert!( - result.is_ok(), - "transfer_hook failed: {:?}", - result.raw_result - ); - println!(" TRANSFER_HOOK CU: {}", result.compute_units_consumed); +#[quasar_test] +fn transfer_hook_logs_and_succeeds(test: &mut Test) { + test.add(Wallet::new().at(OWNER)); + + test.send(TransferHookInstruction { + source_token: SOURCE_TOKEN, + mint: MINT, + destination_token: DESTINATION_TOKEN, + owner: OWNER, + extra_account_meta_list: meta_list_pda(), + _amount: 1, + }) + .succeeds(); } diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Cargo.toml index 3d807501..dd51d981 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Cargo.toml @@ -3,30 +3,35 @@ name = "quasar-transfer-hook-cost" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] level = "warn" -check-cfg = ['cfg(target_os, values("solana"))'] +check-cfg = [ + 'cfg(target_os, values("solana"))', +] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Quasar.toml index eecc7543..1079c19c 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/Quasar.toml @@ -1,19 +1,9 @@ [project] -name = "quasar_transfer_hook_cost" - -[toolchain] -type = "solana" +name = "quasar-transfer-hook-cost" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = ["test", "tests::"] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 587714e2..501e7778 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 @@ -70,10 +70,10 @@ fn handle_initialize_extra_account_meta_list( let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; let mint_address = accounts.mint.to_account_view().address(); - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[b"extra-account-metas", mint_address.as_ref()], &crate::ID, - ); + )?; if accounts.extra_account_meta_list.to_account_view().address() != &expected_pda { return Err(ProgramError::InvalidSeeds); } @@ -114,7 +114,7 @@ fn handle_initialize_extra_account_meta_list( let counter_lamports = Rent::get()?.try_minimum_balance(counter_size as usize)?; let (counter_pda, counter_bump) = - Address::find_program_address(&[b"counter"], &crate::ID); + quasar_lang::pda::try_find_program_address(&[b"counter"], &crate::ID)?; if accounts.counter_account.to_account_view().address() != &counter_pda { return Err(ProgramError::InvalidSeeds); } diff --git a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/tests.rs b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/tests.rs index 04eb4aa7..68e99c08 100644 --- a/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/transfer-cost/quasar/src/tests.rs @@ -1,95 +1,52 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{InitializeExtraAccountMetaListInstruction, TransferHookInstruction}, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_hook_cost.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const SOURCE_TOKEN: Pubkey = Pubkey::new_from_array([3; 32]); +const DESTINATION_TOKEN: Pubkey = Pubkey::new_from_array([4; 32]); +const OWNER: Pubkey = Pubkey::new_from_array([5; 32]); + +/// (extra_account_meta_list, counter) PDAs. The program derives these with +/// raw seed literals, so the test mirrors the derivation directly. +fn pdas() -> (Pubkey, Pubkey) { + let program_id: Pubkey = crate::ID.into(); + let (meta_list, _) = + Pubkey::find_program_address(&[b"extra-account-metas", MINT.as_ref()], &program_id); + let (counter, _) = Pubkey::find_program_address(&[b"counter"], &program_id); + (meta_list, counter) } -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -#[test] -fn test_transfer_cost_flow() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let (meta_list_pda, _) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - let (counter_pda, _) = Pubkey::find_program_address(&[b"counter"], &crate::ID.into()); - - // Initialize - let init_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![43, 34, 13, 49, 167, 88, 235, 235], - }; - - let result = svm.process_instruction( - &init_ix, - &[signer(payer), empty(meta_list_pda), empty(mint), empty(counter_pda)], - ); - result.print_logs(); - assert!(result.is_ok(), "init failed: {:?}", result.raw_result); - println!(" INIT CU: {}", result.compute_units_consumed); - - // Transfer hook with amount = 25 (below threshold) - let source_token = Pubkey::new_unique(); - let destination_token = Pubkey::new_unique(); - let owner = Pubkey::new_unique(); - - let mut hook_data = vec![105, 37, 101, 197, 75, 251, 102, 26]; - hook_data.extend_from_slice(&25u64.to_le_bytes()); - - let hook_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(destination_token.into(), false), - solana_instruction::AccountMeta::new_readonly(owner.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new(counter_pda.into(), false), - ], - data: hook_data, - }; - - let result = svm.process_instruction( - &hook_ix, - &[empty(source_token), empty(destination_token), signer(owner)], - ); - result.print_logs(); - assert!(result.is_ok(), "transfer_hook failed: {:?}", result.raw_result); - println!(" TRANSFER_HOOK CU: {}", result.compute_units_consumed); - - // Verify counter - let counter_account = svm.get_account(&counter_pda).expect("counter missing"); - assert_eq!(counter_account.data[8], 1, "counter should be 1"); - println!(" Counter value: {}", counter_account.data[8]); +#[quasar_test] +fn initialize_then_hook_increments_the_transfer_counter(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let (meta_list, counter) = pdas(); + + test.send(InitializeExtraAccountMetaListInstruction { + payer: PAYER, + extra_account_meta_list: meta_list, + mint: MINT, + counter_account: counter, + }) + .succeeds(); + + // Transfer hook with amount = 25 (below the large-transfer threshold). + test.send(TransferHookInstruction { + source_token: SOURCE_TOKEN, + mint: MINT, + destination_token: DESTINATION_TOKEN, + owner: OWNER, + extra_account_meta_list: meta_list, + counter_account: counter, + amount: 25, + }) + .succeeds(); + + // Layout is [8-byte header][u8 counter]; the byte offset is the point of + // this program, so check the byte directly. + let account = test.account(counter).expect("counter missing"); + assert_eq!(account.data[8], 1, "counter should be 1"); } diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Cargo.toml index b00af28b..f9abccd3 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Cargo.toml @@ -3,30 +3,35 @@ name = "quasar-transfer-hook-switch" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] level = "warn" -check-cfg = ['cfg(target_os, values("solana"))'] +check-cfg = [ + 'cfg(target_os, values("solana"))', +] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Quasar.toml index f77bb1d2..51fd37c0 100644 --- a/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/transfer-switch/quasar/Quasar.toml @@ -1,19 +1,9 @@ [project] -name = "quasar_transfer_hook_switch" - -[toolchain] -type = "solana" +name = "quasar-transfer-hook-switch" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = ["test", "tests::"] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 b32a683d..45462b56 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 @@ -86,7 +86,8 @@ fn handle_configure_admin(accounts: &mut ConfigureAdminAccountConstraints) -> Re drop(data); // Create or reuse admin_config PDA - let (admin_config_pda, bump) = Address::find_program_address(&[b"admin-config"], &crate::ID); + let (admin_config_pda, bump) = + quasar_lang::pda::try_find_program_address(&[b"admin-config"], &crate::ID)?; if accounts.admin_config.to_account_view().address() != &admin_config_pda { return Err(ProgramError::InvalidSeeds); } @@ -143,10 +144,10 @@ fn handle_initialize_extra_account_metas_list( let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; let mint_address = accounts.token_mint.to_account_view().address(); - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[b"extra-account-metas", mint_address.as_ref()], &crate::ID, - ); + )?; if accounts.extra_account_metas_list.to_account_view().address() != &expected_pda { return Err(ProgramError::InvalidSeeds); } @@ -217,7 +218,7 @@ fn handle_switch(accounts: &mut SwitchAccountConstraints, on: bool) -> Result<() // Create wallet switch PDA if needed let wallet_address = accounts.wallet.to_account_view().address(); let (switch_pda, switch_bump) = - Address::find_program_address(&[wallet_address.as_ref()], &crate::ID); + quasar_lang::pda::try_find_program_address(&[wallet_address.as_ref()], &crate::ID)?; if accounts.wallet_switch.to_account_view().address() != &switch_pda { return Err(ProgramError::InvalidSeeds); } 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 61f3f881..f162f69b 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 @@ -1,164 +1,88 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{ + ConfigureAdminInstruction, InitializeExtraAccountMetasListInstruction, SwitchInstruction, + TransferHookInstruction, + }, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_hook_switch.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) -} +// Deterministic addresses keep tests independent of discovery order. +const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); +const NEW_ADMIN: Pubkey = Pubkey::new_from_array([2; 32]); +const WALLET: Pubkey = Pubkey::new_from_array([3; 32]); +const MINT: Pubkey = Pubkey::new_from_array([4; 32]); +const SOURCE_TOKEN: Pubkey = Pubkey::new_from_array([5; 32]); +const DEST_TOKEN: Pubkey = Pubkey::new_from_array([6; 32]); -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) +/// (admin_config, extra_account_metas_list, wallet_switch) PDAs. The program +/// derives these with raw seed literals, so the test mirrors the derivation. +fn pdas() -> (Pubkey, Pubkey, Pubkey) { + let program_id: Pubkey = crate::ID.into(); + let (admin_config, _) = Pubkey::find_program_address(&[b"admin-config"], &program_id); + let (meta_list, _) = + Pubkey::find_program_address(&[b"extra-account-metas", MINT.as_ref()], &program_id); + let (wallet_switch, _) = Pubkey::find_program_address(&[WALLET.as_ref()], &program_id); + (admin_config, meta_list, wallet_switch) } -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, +fn hook_instruction(meta_list: Pubkey, wallet_switch: Pubkey) -> TransferHookInstruction { + TransferHookInstruction { + source_token_account: SOURCE_TOKEN, + token_mint: MINT, + receiver_token_account: DEST_TOKEN, + wallet: WALLET, + extra_account_metas_list: meta_list, + wallet_switch, + _amount: 100, } } -#[test] -fn test_transfer_switch_flow() { - let mut svm = setup(); - - let admin = Pubkey::new_unique(); - let new_admin = Pubkey::new_unique(); - let wallet = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let (admin_config_pda, _) = - Pubkey::find_program_address(&[b"admin-config"], &crate::ID.into()); - let (meta_list_pda, _) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - let (wallet_switch_pda, _) = - Pubkey::find_program_address(&[wallet.as_ref()], &crate::ID.into()); - - // 1. Configure admin - let config_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(admin.into(), true), - solana_instruction::AccountMeta::new_readonly(new_admin.into(), false), - solana_instruction::AccountMeta::new(admin_config_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0, 0, 0, 0, 0, 0, 0, 1], - }; - let result = svm.process_instruction(&config_ix, &[signer(admin), empty(admin_config_pda)]); - result.print_logs(); - assert!(result.is_ok(), "configure_admin failed: {:?}", result.raw_result); - println!(" CONFIGURE_ADMIN CU: {}", result.compute_units_consumed); - - // 2. Initialize extra account metas - let init_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(admin.into(), true), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![43, 34, 13, 49, 167, 88, 235, 235], - }; - let result = svm.process_instruction(&init_ix, &[signer(admin), empty(mint), empty(meta_list_pda)]); - result.print_logs(); - assert!(result.is_ok(), "init_metas failed: {:?}", result.raw_result); +#[quasar_test] +fn transfer_switch_gates_transfers_per_wallet(test: &mut Test) { + test.add(Wallet::new().at(ADMIN)); + test.add(Wallet::new().at(NEW_ADMIN)); + let (admin_config, meta_list, wallet_switch) = pdas(); - // 3. Turn switch ON for wallet (new_admin is now the admin in the config) - // discriminator [0,0,0,0,0,0,0,3] + on=1 (u8) - let switch_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(new_admin.into(), true), - solana_instruction::AccountMeta::new_readonly(wallet.into(), false), - solana_instruction::AccountMeta::new_readonly(admin_config_pda.into(), false), - solana_instruction::AccountMeta::new(wallet_switch_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0, 0, 0, 0, 0, 0, 0, 3, 1], - }; - let result = svm.process_instruction( - &switch_ix, - &[signer(new_admin), empty(wallet), empty(wallet_switch_pda)], - ); - result.print_logs(); - assert!(result.is_ok(), "switch on failed: {:?}", result.raw_result); - println!(" SWITCH ON CU: {}", result.compute_units_consumed); + // 1. Configure admin: the first caller installs NEW_ADMIN as the admin. + test.send(ConfigureAdminInstruction { + admin: ADMIN, + new_admin: NEW_ADMIN, + admin_config, + }) + .succeeds(); - // 4. Transfer hook with switch ON - should succeed - let source_token = Pubkey::new_unique(); - let dest_token = Pubkey::new_unique(); + // 2. Initialize the extra account metas list. + test.send(InitializeExtraAccountMetasListInstruction { + payer: ADMIN, + token_mint: MINT, + extra_account_metas_list: meta_list, + }) + .succeeds(); - let mut hook_data = vec![105, 37, 101, 197, 75, 251, 102, 26]; - hook_data.extend_from_slice(&100u64.to_le_bytes()); + // 3. Turn the switch ON for the wallet (NEW_ADMIN is now the admin). + test.send(SwitchInstruction { + admin: NEW_ADMIN, + wallet: WALLET, + admin_config, + wallet_switch, + on: 1, + }) + .succeeds(); - let hook_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(dest_token.into(), false), - solana_instruction::AccountMeta::new_readonly(wallet.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(wallet_switch_pda.into(), false), - ], - data: hook_data.clone(), - }; - let result = svm.process_instruction( - &hook_ix, - &[empty(source_token), empty(dest_token), signer(wallet)], - ); - result.print_logs(); - assert!(result.is_ok(), "transfer_hook (switch on) failed: {:?}", result.raw_result); - println!(" TRANSFER_HOOK (on) CU: {}", result.compute_units_consumed); + // 4. Transfer hook with the switch ON succeeds. + test.send(hook_instruction(meta_list, wallet_switch)).succeeds(); - // 5. Turn switch OFF (new_admin is the admin) - let switch_off_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(new_admin.into(), true), - solana_instruction::AccountMeta::new_readonly(wallet.into(), false), - solana_instruction::AccountMeta::new_readonly(admin_config_pda.into(), false), - solana_instruction::AccountMeta::new(wallet_switch_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![0, 0, 0, 0, 0, 0, 0, 3, 0], - }; - let result = svm.process_instruction( - &switch_off_ix, - &[signer(new_admin), empty(wallet)], - ); - result.print_logs(); - assert!(result.is_ok(), "switch off failed: {:?}", result.raw_result); + // 5. Turn the switch OFF. + test.send(SwitchInstruction { + admin: NEW_ADMIN, + wallet: WALLET, + admin_config, + wallet_switch, + on: 0, + }) + .succeeds(); - // 6. Transfer hook with switch OFF - should fail - let hook_ix2 = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(dest_token.into(), false), - solana_instruction::AccountMeta::new_readonly(wallet.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(wallet_switch_pda.into(), false), - ], - data: hook_data, - }; - let result = svm.process_instruction( - &hook_ix2, - &[empty(source_token), empty(dest_token), signer(wallet)], - ); - result.print_logs(); - assert!(result.is_err(), "transfer_hook should fail with switch off"); - println!(" TRANSFER_HOOK (off) correctly rejected"); + // 6. Transfer hook with the switch OFF is rejected. + test.send(hook_instruction(meta_list, wallet_switch)) + .fails(ProgramError::InvalidArgument); } diff --git a/tokens/token-extensions/transfer-hook/whitelist/quasar/Cargo.toml b/tokens/token-extensions/transfer-hook/whitelist/quasar/Cargo.toml index 1ac9da7d..6a699b6f 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/Cargo.toml +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/Cargo.toml @@ -3,30 +3,35 @@ name = "quasar-transfer-hook-whitelist" version = "0.1.0" edition = "2021" +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. [workspace] [lints.rust.unexpected_cfgs] level = "warn" -check-cfg = ['cfg(target_os, values("solana"))'] +check-cfg = [ + 'cfg(target_os, values("solana"))', +] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -quasar-svm = { version = "0.1" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-extensions/transfer-hook/whitelist/quasar/Quasar.toml b/tokens/token-extensions/transfer-hook/whitelist/quasar/Quasar.toml index b546447c..4989df02 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/Quasar.toml +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/Quasar.toml @@ -1,19 +1,9 @@ [project] -name = "quasar_transfer_hook_whitelist" - -[toolchain] -type = "solana" +name = "quasar-transfer-hook-whitelist" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = ["test", "tests::"] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 212313a6..50a71e7e 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/src/lib.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/src/lib.rs @@ -68,10 +68,10 @@ pub fn handle_initialize(accounts: &mut InitializeExtraAccountMetaListAccountCon let lamports = Rent::get()?.try_minimum_balance(meta_list_size as usize)?; let mint_address = accounts.mint.to_account_view().address(); - let (expected_pda, bump) = Address::find_program_address( + let (expected_pda, bump) = quasar_lang::pda::try_find_program_address( &[b"extra-account-metas", mint_address.as_ref()], &crate::ID, - ); + )?; if accounts.extra_account_meta_list.to_account_view().address() != &expected_pda { return Err(ProgramError::InvalidSeeds); @@ -114,7 +114,8 @@ pub fn handle_initialize(accounts: &mut InitializeExtraAccountMetaListAccountCon let wl_size: u64 = 400; let wl_lamports = Rent::get()?.try_minimum_balance(wl_size as usize)?; - let (wl_pda, wl_bump) = Address::find_program_address(&[b"white_list"], &crate::ID); + let (wl_pda, wl_bump) = + quasar_lang::pda::try_find_program_address(&[b"white_list"], &crate::ID)?; if accounts.white_list.to_account_view().address() != &wl_pda { return Err(ProgramError::InvalidSeeds); } 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 1f1f1d4c..689a4c41 100644 --- a/tokens/token-extensions/transfer-hook/whitelist/quasar/src/tests.rs +++ b/tokens/token-extensions/transfer-hook/whitelist/quasar/src/tests.rs @@ -1,135 +1,72 @@ -extern crate std; use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - std::println, + crate::cpi::{ + AddToWhitelistInstruction, InitializeExtraAccountMetaListInstruction, + TransferHookInstruction, + }, + quasar_test::prelude::*, }; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_transfer_hook_whitelist.so").unwrap(); - QuasarSvm::new().with_program(&crate::ID, &elf) +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const SOURCE_TOKEN: Pubkey = Pubkey::new_from_array([3; 32]); +const DESTINATION_TOKEN: Pubkey = Pubkey::new_from_array([4; 32]); +const OWNER: Pubkey = Pubkey::new_from_array([5; 32]); +const BAD_DEST: Pubkey = Pubkey::new_from_array([6; 32]); + +/// (extra_account_meta_list, white_list) PDAs. The program derives these with +/// raw seed literals, so the test mirrors the derivation directly. +fn pdas() -> (Pubkey, Pubkey) { + let program_id: Pubkey = crate::ID.into(); + let (meta_list, _) = + Pubkey::find_program_address(&[b"extra-account-metas", MINT.as_ref()], &program_id); + let (white_list, _) = Pubkey::find_program_address(&[b"white_list"], &program_id); + (meta_list, white_list) } -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, +fn hook_instruction( + destination_token: Pubkey, + meta_list: Pubkey, + white_list: Pubkey, +) -> TransferHookInstruction { + TransferHookInstruction { + source_token: SOURCE_TOKEN, + mint: MINT, + destination_token, + owner: OWNER, + extra_account_meta_list: meta_list, + white_list, + _amount: 100, } } -#[test] -fn test_whitelist_flow() { - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let (meta_list_pda, _) = Pubkey::find_program_address( - &[b"extra-account-metas", mint.as_ref()], - &crate::ID.into(), - ); - let (white_list_pda, _) = - Pubkey::find_program_address(&[b"white_list"], &crate::ID.into()); - - // 1. Initialize - let init_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new(white_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data: vec![43, 34, 13, 49, 167, 88, 235, 235], - }; - - let result = svm.process_instruction( - &init_ix, - &[signer(payer), empty(meta_list_pda), empty(mint), empty(white_list_pda)], - ); - result.print_logs(); - assert!(result.is_ok(), "init failed: {:?}", result.raw_result); - println!(" INIT CU: {}", result.compute_units_consumed); - - // 2. Add destination to whitelist - let destination_token = Pubkey::new_unique(); - let add_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(destination_token.into(), false), - solana_instruction::AccountMeta::new(white_list_pda.into(), false), - ], - data: vec![0, 0, 0, 0, 0, 0, 0, 2], - }; - - let result = svm.process_instruction( - &add_ix, - &[signer(payer), empty(destination_token)], - ); - result.print_logs(); - assert!(result.is_ok(), "add_to_whitelist failed: {:?}", result.raw_result); - - // 3. Transfer hook with whitelisted destination - should succeed - let source_token = Pubkey::new_unique(); - let owner = Pubkey::new_unique(); - - let mut hook_data = vec![105, 37, 101, 197, 75, 251, 102, 26]; - hook_data.extend_from_slice(&100u64.to_le_bytes()); - - let hook_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(destination_token.into(), false), - solana_instruction::AccountMeta::new_readonly(owner.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(white_list_pda.into(), false), - ], - data: hook_data, - }; - - let result = svm.process_instruction( - &hook_ix, - &[empty(source_token), empty(destination_token), signer(owner)], - ); - result.print_logs(); - 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 - 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()); - - let bad_hook_ix = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(source_token.into(), false), - solana_instruction::AccountMeta::new_readonly(mint.into(), false), - solana_instruction::AccountMeta::new_readonly(bad_dest.into(), false), - solana_instruction::AccountMeta::new_readonly(owner.into(), false), - solana_instruction::AccountMeta::new_readonly(meta_list_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(white_list_pda.into(), false), - ], - data: hook_data2, - }; - - let result = svm.process_instruction( - &bad_hook_ix, - &[empty(source_token), empty(bad_dest), signer(owner)], - ); - result.print_logs(); - assert!(result.is_err(), "transfer_hook should fail for non-whitelisted destination"); - println!(" TRANSFER_HOOK (blocked) correctly rejected"); +#[quasar_test] +fn whitelist_gates_transfers_by_destination(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let (meta_list, white_list) = pdas(); + + // 1. Initialize the meta list and whitelist (payer becomes authority). + test.send(InitializeExtraAccountMetaListInstruction { + payer: PAYER, + extra_account_meta_list: meta_list, + mint: MINT, + white_list, + }) + .succeeds(); + + // 2. Add the destination token account to the whitelist. + test.send(AddToWhitelistInstruction { + signer: PAYER, + new_account: DESTINATION_TOKEN, + white_list, + }) + .succeeds(); + + // 3. Transfer hook with a whitelisted destination succeeds. + test.send(hook_instruction(DESTINATION_TOKEN, meta_list, white_list)) + .succeeds(); + + // 4. Transfer hook with a non-whitelisted destination is rejected. + test.send(hook_instruction(BAD_DEST, meta_list, white_list)) + .fails(ProgramError::InvalidArgument); } From 9d0bc6eaec1841efe6772d2ae807e408259de40a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:32:19 +0000 Subject: [PATCH 09/14] finance: migrate examples to Quasar 0.1.0 All 9 finance examples (10 program dirs; vault-strategy carries mock-swap-router) move to the 0.1.0-release rev be60fca with the 0.1.0 Quasar.toml schema, idl-build feature, and quasar-test suites. All ~78 scenarios ported with their domain-math helpers and exact-amount assertions preserved; byte-offset state checks became typed test.read assertions except where byte layout is the tested behavior. Special cases: - lending: its interest math reads Clock::get()?.slot and quasar-test has no slot warp, so the two slot-warp scenarios stay on a trimmed low-level harness with a crates.io quasar-svm = "=0.1.0" dev-dep (commented in Cargo.toml); the .so is read at runtime, not include_bytes. - prop-amm: the stale-price scenario pins the Clock sysvar account at slot 1000 via test.set_account. - betting-market: the self-referential Bet PDA constraint (address = Bet::seeds(&bet.outcome, ...)) is inexpressible in 0.1.0; the handlers now make the identical canonical-PDA check explicitly with Bet::find_address. Recurring 0.1.0 API breaks fixed: Seed imports move to quasar_lang::cpi, and initialize_mint2/initialize_account3 free functions become TokenCpi trait methods. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- finance/betting-market/quasar/CHANGELOG.md | 16 + finance/betting-market/quasar/Cargo.toml | 23 +- finance/betting-market/quasar/Quasar.toml | 19 +- .../quasar/src/instructions/claim_refund.rs | 2 +- .../quasar/src/instructions/claim_winnings.rs | 2 +- .../src/instructions/close_losing_bet.rs | 2 +- .../quasar/src/instructions/shared.rs | 1 + finance/betting-market/quasar/src/tests.rs | 746 +++------ finance/escrow/quasar/CHANGELOG.md | 13 + finance/escrow/quasar/Cargo.toml | 26 +- finance/escrow/quasar/Quasar.toml | 19 +- .../quasar/src/instructions/cancel_offer.rs | 1 + .../quasar/src/instructions/take_offer.rs | 1 + finance/escrow/quasar/src/tests.rs | 751 +++------- finance/lending/quasar/CHANGELOG.md | 21 + finance/lending/quasar/Cargo.toml | 32 +- finance/lending/quasar/Quasar.toml | 19 +- .../lending/quasar/src/instructions/admin.rs | 25 +- .../quasar/src/instructions/position.rs | 2 +- .../lending/quasar/src/instructions/supply.rs | 2 +- finance/lending/quasar/src/tests.rs | 1128 ++++++++------ finance/order-book/quasar/CHANGELOG.md | 14 + finance/order-book/quasar/Cargo.toml | 25 +- finance/order-book/quasar/Quasar.toml | 19 +- .../src/instructions/admin/withdraw_fees.rs | 1 + .../quasar/src/instructions/place_order.rs | 1 + .../quasar/src/instructions/settle_funds.rs | 1 + finance/order-book/quasar/src/tests.rs | 821 ++++------ finance/perpetual-futures/quasar/CHANGELOG.md | 13 + finance/perpetual-futures/quasar/Cargo.toml | 24 +- finance/perpetual-futures/quasar/Quasar.toml | 19 +- .../quasar/src/instructions/add_liquidity.rs | 1 + .../quasar/src/instructions/close_position.rs | 1 + .../quasar/src/instructions/collect_fees.rs | 1 + .../src/instructions/liquidate_position.rs | 1 + .../src/instructions/remove_liquidity.rs | 1 + finance/perpetual-futures/quasar/src/tests.rs | 783 ++++------ finance/prop-amm/quasar/CHANGELOG.md | 15 + finance/prop-amm/quasar/Cargo.toml | 25 +- finance/prop-amm/quasar/Quasar.toml | 19 +- .../prop-amm/quasar/src/instructions/swap.rs | 1 + .../src/instructions/withdraw_inventory.rs | 1 + finance/prop-amm/quasar/src/tests.rs | 782 ++++------ finance/token-fundraiser/quasar/CHANGELOG.md | 15 + finance/token-fundraiser/quasar/Cargo.toml | 28 +- finance/token-fundraiser/quasar/Quasar.toml | 19 +- .../src/instructions/check_contributions.rs | 1 + .../quasar/src/instructions/refund.rs | 1 + finance/token-fundraiser/quasar/src/tests.rs | 715 +++------ finance/token-swap/quasar/CHANGELOG.md | 16 + finance/token-swap/quasar/Cargo.toml | 26 +- finance/token-swap/quasar/Quasar.toml | 19 +- .../src/instructions/claim_admin_fees.rs | 1 + .../src/instructions/deposit_liquidity.rs | 1 + .../quasar/src/instructions/swap_tokens.rs | 1 + .../src/instructions/withdraw_liquidity.rs | 1 + finance/token-swap/quasar/src/tests.rs | 1330 +++++------------ finance/vault-strategy/quasar/CHANGELOG.md | 20 + .../quasar/mock-swap-router/Cargo.toml | 22 +- .../quasar/mock-swap-router/Quasar.toml | 19 +- .../src/instructions/swap_asset_for_usdc.rs | 1 + .../src/instructions/swap_usdc_for_asset.rs | 1 + .../quasar/mock-swap-router/src/tests.rs | 292 +--- .../quasar/vault-strategy/Cargo.toml | 22 +- .../quasar/vault-strategy/Quasar.toml | 19 +- .../src/instructions/collect_fees.rs | 1 + .../src/instructions/deposit.rs | 1 + .../src/instructions/rebalance.rs | 1 + .../src/instructions/withdraw.rs | 1 + .../quasar/vault-strategy/src/tests.rs | 679 +++------ 70 files changed, 3182 insertions(+), 5491 deletions(-) diff --git a/finance/betting-market/quasar/CHANGELOG.md b/finance/betting-market/quasar/CHANGELOG.md index 4f116388..9306abd0 100644 --- a/finance/betting-market/quasar/CHANGELOG.md +++ b/finance/betting-market/quasar/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. Program-source + fixes for 0.1.0: `Seed` is now imported from `quasar_lang::cpi` in + `instructions/shared.rs`, and the self-referential `Bet` PDA constraint + (`Bet::seeds(&bet.outcome, ...)`) became + `Bet::find_address(bet.outcome, *bettor.address(), &crate::ID)` — the same + canonical-PDA check, expressed in a form 0.1.0's client codegen supports. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/betting-market/quasar/Cargo.toml b/finance/betting-market/quasar/Cargo.toml index 7a8d3edd..fabf55fd 100644 --- a/finance/betting-market/quasar/Cargo.toml +++ b/finance/betting-market/quasar/Cargo.toml @@ -14,27 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pinned to match the finance/escrow Quasar example: 623bb70 is the last rev on -# master before a zeropod 0.3 bump that breaks quasar-spl. Unpin (back to -# branch = "master") once upstream lands the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -solana-address = { version = "2.2.0" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/betting-market/quasar/Quasar.toml b/finance/betting-market/quasar/Quasar.toml index e04d5cf2..8b53063f 100644 --- a/finance/betting-market/quasar/Quasar.toml +++ b/finance/betting-market/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_betting_market" - -[toolchain] -type = "solana" +name = "quasar-betting-market" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/betting-market/quasar/src/instructions/claim_refund.rs b/finance/betting-market/quasar/src/instructions/claim_refund.rs index 7ca8273d..3c389f51 100644 --- a/finance/betting-market/quasar/src/instructions/claim_refund.rs +++ b/finance/betting-market/quasar/src/instructions/claim_refund.rs @@ -25,7 +25,7 @@ pub struct ClaimRefundAccountConstraints { close(dest = bettor), has_one(bettor), has_one(event), - address = Bet::seeds(&bet.outcome, bettor.address()), + address = Bet::find_address(bet.outcome, *bettor.address(), &crate::ID), )] pub bet: Account, diff --git a/finance/betting-market/quasar/src/instructions/claim_winnings.rs b/finance/betting-market/quasar/src/instructions/claim_winnings.rs index ad76d000..62ac3837 100644 --- a/finance/betting-market/quasar/src/instructions/claim_winnings.rs +++ b/finance/betting-market/quasar/src/instructions/claim_winnings.rs @@ -25,7 +25,7 @@ pub struct ClaimWinningsAccountConstraints { close(dest = bettor), has_one(bettor), has_one(event), - address = Bet::seeds(&bet.outcome, bettor.address()), + address = Bet::find_address(bet.outcome, *bettor.address(), &crate::ID), )] pub bet: Account, diff --git a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs index ec59970e..49d4ff68 100644 --- a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs @@ -20,7 +20,7 @@ pub struct CloseLosingBetAccountConstraints { close(dest = bettor), has_one(bettor), has_one(event), - address = Bet::seeds(&bet.outcome, bettor.address()), + address = Bet::find_address(bet.outcome, *bettor.address(), &crate::ID), )] pub bet: Account, diff --git a/finance/betting-market/quasar/src/instructions/shared.rs b/finance/betting-market/quasar/src/instructions/shared.rs index 1dffcd4e..24d38640 100644 --- a/finance/betting-market/quasar/src/instructions/shared.rs +++ b/finance/betting-market/quasar/src/instructions/shared.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_spl::prelude::*; diff --git a/finance/betting-market/quasar/src/tests.rs b/finance/betting-market/quasar/src/tests.rs index 726b8e4d..63af50b1 100644 --- a/finance/betting-market/quasar/src/tests.rs +++ b/finance/betting-market/quasar/src/tests.rs @@ -1,396 +1,60 @@ -//! QuasarSVM integration tests. They drive the real program instructions +//! quasar-test integration tests. They drive the real program instructions //! end-to-end: initialize the config, open an event, add outcomes, place bets, //! settle, and claim, asserting on-chain state and token balances at each step. -//! -//! Multi-step flows use `process_instruction_chain`, which runs several -//! instructions atomically over a shared, evolving account set. - -extern crate std; use { - alloc::vec, - alloc::vec::Vec, - quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, - solana_program_pack::Pack, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::{ + cpi::{ + AddOutcomeInstruction, CancelEventInstruction, ClaimRefundInstruction, + ClaimWinningsInstruction, CloseLosingBetInstruction, CreateEventInstruction, + InitializeConfigInstruction, PlaceBetInstruction, SettleEventInstruction, + }, + state::{Bet, Config, Event, EventStatus, EventVaultPda, Outcome, User}, + }, + quasar_test::prelude::*, }; -use crate::state::{BET_SEED, CONFIG_SEED, EVENT_SEED, OUTCOME_SEED, USER_SEED}; +// Deterministic addresses keep tests independent of discovery order. +const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); +const FEE_RECIPIENT: Pubkey = Pubkey::new_from_array([2; 32]); +const TOKEN_MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const FEE_RECIPIENT_TOKEN: Pubkey = Pubkey::new_from_array([4; 32]); +const BETTOR_A: Pubkey = Pubkey::new_from_array([5; 32]); +const BETTOR_B: Pubkey = Pubkey::new_from_array([6; 32]); +const TOKEN_A: Pubkey = Pubkey::new_from_array([7; 32]); +const TOKEN_B: Pubkey = Pubkey::new_from_array([8; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([9; 32]); const FEE_BPS: u16 = 100; // 1% const DECIMALS: u8 = 6; -const STARTING_LAMPORTS: u64 = 1_000_000_000; const STARTING_TOKENS: u64 = 1_000; - -fn program_id() -> Pubkey { - Pubkey::new_from_array(crate::ID.to_bytes()) -} - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_betting_market.so").unwrap(); - QuasarSvm::new() - .with_program(&program_id(), &elf) - .with_token_program() -} - -fn rent_id() -> Pubkey { - quasar_svm::solana_sdk_ids::sysvar::rent::ID -} -fn token_program_id() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn system_program_id() -> Pubkey { - quasar_svm::system_program::ID -} - -fn signer_account(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) -} - -fn empty_account(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: system_program_id(), - executable: false, - } -} - -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 1_000_000_000, - decimals: DECIMALS, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} - -fn token_account(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_amount(account: &Account) -> u64 { - TokenAccount::unpack(&account.data).unwrap().amount -} - -fn config_pda() -> Pubkey { - Pubkey::find_program_address(&[CONFIG_SEED], &program_id()).0 -} -fn event_pda(event_id: u64) -> Pubkey { - Pubkey::find_program_address(&[EVENT_SEED, &event_id.to_le_bytes()], &program_id()).0 -} -fn vault_pda(event: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[b"vault", event.as_ref()], &program_id()).0 -} -fn outcome_pda(event: &Pubkey, index: u8) -> Pubkey { - Pubkey::find_program_address(&[OUTCOME_SEED, event.as_ref(), &[index]], &program_id()).0 -} -fn bet_pda(outcome: &Pubkey, bettor: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[BET_SEED, outcome.as_ref(), bettor.as_ref()], &program_id()).0 -} -fn user_pda(bettor: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[USER_SEED, bettor.as_ref()], &program_id()).0 -} - -// --- Instruction data builders (discriminator byte + args). Strings use -// Quasar's compact wire format: a u8 length prefix then the bytes. --- - -fn initialize_config_data(fee_bps: u16, fee_recipient: &Pubkey) -> Vec { - let mut data = vec![0u8]; - data.extend_from_slice(&fee_bps.to_le_bytes()); - data.extend_from_slice(fee_recipient.as_ref()); - data -} -fn create_event_data(event_id: u64, description: &str) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&event_id.to_le_bytes()); - data.push(description.len() as u8); - data.extend_from_slice(description.as_bytes()); - data -} -fn add_outcome_data(label: &str) -> Vec { - let mut data = vec![2u8]; - data.push(label.len() as u8); - data.extend_from_slice(label.as_bytes()); - data -} -fn place_bet_data(amount: u64) -> Vec { - let mut data = vec![3u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data -} -fn settle_event_data(winning_outcome_index: u8) -> Vec { - vec![4u8, winning_outcome_index] -} -fn claim_winnings_data() -> Vec { - vec![5u8] -} -fn close_losing_bet_data() -> Vec { - vec![6u8] -} -fn cancel_event_data() -> Vec { - vec![7u8] -} -fn claim_refund_data() -> Vec { - vec![8u8] -} - -struct Fixture { - admin: Pubkey, - token_mint: Pubkey, - config: Pubkey, - fee_recipient: Pubkey, - fee_recipient_token: Pubkey, -} - -fn fixture() -> Fixture { - let admin = Pubkey::new_unique(); - let fee_recipient = Pubkey::new_unique(); - Fixture { - admin, - token_mint: Pubkey::new_unique(), - config: config_pda(), - fee_recipient, - fee_recipient_token: Pubkey::new_unique(), - } -} - -fn initialize_config_ix(fx: &Fixture) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(fx.admin, true), - AccountMeta::new_readonly(fx.token_mint, false), - AccountMeta::new(fx.config, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: initialize_config_data(FEE_BPS, &fx.fee_recipient), - } -} - -fn create_event_ix(fx: &Fixture, event_id: u64, event: &Pubkey, vault: &Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(fx.admin, true), - AccountMeta::new(fx.config, false), - AccountMeta::new_readonly(fx.token_mint, false), - AccountMeta::new(*event, false), - AccountMeta::new(*vault, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: create_event_data(event_id, "Team A vs Team B"), - } -} - -fn add_outcome_ix(fx: &Fixture, event: &Pubkey, outcome: &Pubkey, label: &str) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(fx.admin, true), - AccountMeta::new_readonly(fx.config, false), - AccountMeta::new(*event, false), - AccountMeta::new(*outcome, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: add_outcome_data(label), - } -} - -#[allow(clippy::too_many_arguments)] -fn place_bet_ix( - fx: &Fixture, - bettor: &Pubkey, - event: &Pubkey, - outcome: &Pubkey, - bettor_token: &Pubkey, - vault: &Pubkey, - bet: &Pubkey, - user: &Pubkey, - amount: u64, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(*bettor, true), - AccountMeta::new_readonly(fx.config, false), - AccountMeta::new_readonly(fx.token_mint, false), - AccountMeta::new(*event, false), - AccountMeta::new(*outcome, false), - AccountMeta::new(*bettor_token, false), - AccountMeta::new(*vault, false), - AccountMeta::new(*bet, false), - AccountMeta::new(*user, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: place_bet_data(amount), - } -} - -fn settle_event_ix( - fx: &Fixture, - event: &Pubkey, - winning_outcome: &Pubkey, - vault: &Pubkey, - winning_outcome_index: u8, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(fx.admin, true), - AccountMeta::new_readonly(fx.config, false), - AccountMeta::new_readonly(fx.token_mint, false), - AccountMeta::new(*event, false), - AccountMeta::new_readonly(*winning_outcome, false), - AccountMeta::new(*vault, false), - AccountMeta::new(fx.fee_recipient_token, false), - AccountMeta::new_readonly(token_program_id(), false), - ], - data: settle_event_data(winning_outcome_index), - } -} - -fn claim_winnings_ix( - fx: &Fixture, - bettor: &Pubkey, - event: &Pubkey, - bet: &Pubkey, - user: &Pubkey, - bettor_token: &Pubkey, - vault: &Pubkey, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(*bettor, true), - AccountMeta::new_readonly(fx.token_mint, false), - AccountMeta::new_readonly(*event, false), - AccountMeta::new(*bet, false), - AccountMeta::new(*user, false), - AccountMeta::new(*bettor_token, false), - AccountMeta::new(*vault, false), - AccountMeta::new_readonly(token_program_id(), false), - ], - data: claim_winnings_data(), - } -} - -fn close_losing_bet_ix( - bettor: &Pubkey, - event: &Pubkey, - bet: &Pubkey, - user: &Pubkey, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(*bettor, true), - AccountMeta::new_readonly(*event, false), - AccountMeta::new(*bet, false), - AccountMeta::new(*user, false), - ], - data: close_losing_bet_data(), - } -} - -fn claim_refund_ix( - fx: &Fixture, - bettor: &Pubkey, - event: &Pubkey, - bet: &Pubkey, - user: &Pubkey, - bettor_token: &Pubkey, - vault: &Pubkey, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(*bettor, true), - AccountMeta::new_readonly(fx.token_mint, false), - AccountMeta::new_readonly(*event, false), - AccountMeta::new(*bet, false), - AccountMeta::new(*user, false), - AccountMeta::new(*bettor_token, false), - AccountMeta::new(*vault, false), - AccountMeta::new_readonly(token_program_id(), false), - ], - data: claim_refund_data(), - } -} - -fn cancel_event_ix(fx: &Fixture, event: &Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new_readonly(fx.admin, true), - AccountMeta::new_readonly(fx.config, false), - AccountMeta::new(*event, false), - ], - data: cancel_event_data(), - } -} - -// --- Account field offsets (dense 1-byte discriminator + tight packing). --- -const EVENT_STATUS_OFFSET: usize = 1 + 8 + 1 + 8; -const EVENT_WINNING_INDEX_OFFSET: usize = 1 + 8 + 1 + 8 + 1 + 2; -const EVENT_WINNING_POOL_OFFSET: usize = 1 + 8 + 1 + 8 + 1 + 2 + 1; -const EVENT_DISTRIBUTABLE_OFFSET: usize = 1 + 8 + 1 + 8 + 1 + 2 + 1 + 8; -// User: disc(1) authority(32) bump(1) bet_count(1) ... -const USER_BET_COUNT_OFFSET: usize = 1 + 32 + 1; - -fn read_u64(data: &[u8], offset: usize) -> u64 { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&data[offset..offset + 8]); - u64::from_le_bytes(bytes) -} - -const STATUS_SETTLED: u8 = 1; -const STATUS_CANCELLED: u8 = 2; - -#[test] -fn test_initialize_config() { - let mut svm = setup(); - let fx = fixture(); - - let accounts = vec![ - signer_account(fx.admin), - mint_account(fx.token_mint, fx.admin), - empty_account(fx.config), - ]; - - let result = svm.process_instruction(&initialize_config_ix(&fx), &accounts); - assert!(result.is_ok(), "initialize_config failed: {:?}", result.raw_result); - - let config = result.account(&fx.config).unwrap(); - assert_eq!(config.data[0], 1, "config discriminator"); - assert_eq!(&config.data[1..33], fx.admin.as_ref(), "admin"); - assert_eq!(&config.data[33..65], fx.token_mint.as_ref(), "token_mint"); - assert_eq!(&config.data[65..97], fx.fee_recipient.as_ref(), "fee_recipient"); - assert_eq!(&config.data[97..99], &FEE_BPS.to_le_bytes(), "fee_bps"); - - println!(" INITIALIZE_CONFIG CU: {}", result.compute_units_consumed); +const EVENT_ID: u64 = 1; + +/// Register the admin, the stake mint, and the fee recipient's token account, +/// then initialize the config. +fn base_world(test: &mut Test) { + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(TOKEN_MINT).decimals(DECIMALS)); + test.add(TokenAccount::new(TOKEN_MINT, FEE_RECIPIENT).at(FEE_RECIPIENT_TOKEN)); + test.send(InitializeConfigInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + fee_bps: FEE_BPS, + fee_recipient: FEE_RECIPIENT, + }) + .succeeds(); +} + +#[quasar_test] +fn initialize_config_records_admin_mint_and_fee(test: &mut Test) { + base_world(test); + + let config = test.derive_pda(Config::seeds()); + let state = test.read::(config); + assert_eq!(state.admin, ADMIN, "admin"); + assert_eq!(state.token_mint, TOKEN_MINT, "token_mint"); + assert_eq!(state.fee_recipient, FEE_RECIPIENT, "fee_recipient"); + assert_eq!(u16::from(state.fee_bps), FEE_BPS, "fee_bps"); } /// Full parimutuel flow: two bettors stake on opposing outcomes, the admin @@ -400,174 +64,198 @@ fn test_initialize_config() { /// A stakes 100 on outcome 0; B stakes 300 on outcome 1; outcome 1 wins. /// losing_pool = 100, fee = 1, distributable = 99. B's winnings = 300*99/300 = /// 99, payout = 399. Fee recipient gets 1. Vault ends empty. -#[test] -fn test_full_lifecycle() { - let mut svm = setup(); - let fx = fixture(); - - let event_id = 1u64; - let event = event_pda(event_id); - let vault = vault_pda(&event); - let outcome0 = outcome_pda(&event, 0); - let outcome1 = outcome_pda(&event, 1); +#[quasar_test] +fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(BETTOR_A)); + test.add(Wallet::new().at(BETTOR_B)); + test.add( + TokenAccount::new(TOKEN_MINT, BETTOR_A) + .at(TOKEN_A) + .amount(STARTING_TOKENS), + ); + test.add( + TokenAccount::new(TOKEN_MINT, BETTOR_B) + .at(TOKEN_B) + .amount(STARTING_TOKENS), + ); - let bettor_a = Pubkey::new_unique(); - let bettor_b = Pubkey::new_unique(); - let token_a = Pubkey::new_unique(); - let token_b = Pubkey::new_unique(); - let user_a = user_pda(&bettor_a); - let user_b = user_pda(&bettor_b); - let bet_a = bet_pda(&outcome0, &bettor_a); - let bet_b = bet_pda(&outcome1, &bettor_b); + let event = test.derive_pda(Event::seeds(EVENT_ID)); + let vault = test.derive_pda(EventVaultPda::seeds(&event)); + let outcome0 = test.derive_pda(Outcome::seeds(&event, 0)); + let outcome1 = test.derive_pda(Outcome::seeds(&event, 1)); + let bet_a = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); + let bet_b = test.derive_pda(Bet::seeds(&outcome1, &BETTOR_B)); + let user_a = test.derive_pda(User::seeds(&BETTOR_A)); + let user_b = test.derive_pda(User::seeds(&BETTOR_B)); const STAKE_A: u64 = 100; const STAKE_B: u64 = 300; - const FEE: u64 = 1; // ceil? no - floor(100 * 100 / 10000) = 1 + const FEE: u64 = 1; // floor(100 * 100 / 10000) = 1 const PAYOUT_B: u64 = STAKE_B + 99; // stake + winnings(99) - let accounts = vec![ - signer_account(fx.admin), - mint_account(fx.token_mint, fx.admin), - empty_account(fx.config), - empty_account(event), - empty_account(vault), - empty_account(outcome0), - empty_account(outcome1), - signer_account(bettor_a), - token_account(token_a, fx.token_mint, bettor_a, STARTING_TOKENS), - empty_account(user_a), - empty_account(bet_a), - signer_account(bettor_b), - token_account(token_b, fx.token_mint, bettor_b, STARTING_TOKENS), - empty_account(user_b), - empty_account(bet_b), - token_account(fx.fee_recipient_token, fx.token_mint, fx.fee_recipient, 0), - ]; - - let instructions = vec![ - initialize_config_ix(&fx), - create_event_ix(&fx, event_id, &event, &vault), - add_outcome_ix(&fx, &event, &outcome0, "Team A"), - add_outcome_ix(&fx, &event, &outcome1, "Team B"), - place_bet_ix(&fx, &bettor_a, &event, &outcome0, &token_a, &vault, &bet_a, &user_a, STAKE_A), - place_bet_ix(&fx, &bettor_b, &event, &outcome1, &token_b, &vault, &bet_b, &user_b, STAKE_B), - settle_event_ix(&fx, &event, &outcome1, &vault, 1), - claim_winnings_ix(&fx, &bettor_b, &event, &bet_b, &user_b, &token_b, &vault), - close_losing_bet_ix(&bettor_a, &event, &bet_a, &user_a), - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!(result.is_ok(), "lifecycle chain failed: {:?}", result.raw_result); + test.send(CreateEventInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + event_id: EVENT_ID, + description: "Team A vs Team B".to_string().into(), + }) + .succeeds(); + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 0, + label: "Team A".to_string().into(), + }) + .succeeds(); + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 1, + label: "Team B".to_string().into(), + }) + .succeeds(); + test.send(PlaceBetInstruction { + bettor: BETTOR_A, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + outcome_index_seed: 0, + bettor_token_account: TOKEN_A, + amount: STAKE_A, + }) + .succeeds(); + test.send(PlaceBetInstruction { + bettor: BETTOR_B, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + outcome_index_seed: 1, + bettor_token_account: TOKEN_B, + amount: STAKE_B, + }) + .succeeds(); + test.send(SettleEventInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + fee_recipient_token_account: FEE_RECIPIENT_TOKEN, + winning_outcome_index: 1, + }) + .succeeds(); + test.send(ClaimWinningsInstruction { + bettor: BETTOR_B, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet: bet_b, + bettor_token_account: TOKEN_B, + }) + .succeeds() + .is_closed(bet_b); + test.send(CloseLosingBetInstruction { + bettor: BETTOR_A, + event_event_id_seed: EVENT_ID, + bet: bet_a, + }) + .succeeds() + .is_closed(bet_a); // Event settled with the recorded figures. - let event_data = &result.account(&event).unwrap().data; - assert_eq!(event_data[EVENT_STATUS_OFFSET], STATUS_SETTLED, "status settled"); - assert_eq!(event_data[EVENT_WINNING_INDEX_OFFSET], 1, "winning index"); - assert_eq!(read_u64(event_data, EVENT_WINNING_POOL_OFFSET), STAKE_B, "winning pool"); - assert_eq!(read_u64(event_data, EVENT_DISTRIBUTABLE_OFFSET), 99, "distributable"); - - // Token movements. - assert_eq!(token_amount(result.account(&fx.fee_recipient_token).unwrap()), FEE); + let event_state = test.read::(event); + assert_eq!(event_state.status, EventStatus::Settled as u8, "status settled"); + assert_eq!(event_state.winning_outcome_index, 1, "winning index"); + assert_eq!(u64::from(event_state.winning_pool), STAKE_B, "winning pool"); assert_eq!( - token_amount(result.account(&token_b).unwrap()), - STARTING_TOKENS - STAKE_B + PAYOUT_B + u64::from(event_state.distributable_losing_pool), + 99, + "distributable" ); - assert_eq!(token_amount(result.account(&token_a).unwrap()), STARTING_TOKENS - STAKE_A); - assert_eq!(token_amount(result.account(&vault).unwrap()), 0, "vault drained"); - // Both bets closed; user indexes emptied. - assert_eq!(result.account(&bet_a).map(|a| a.lamports).unwrap_or(0), 0, "bet A closed"); - assert_eq!(result.account(&bet_b).map(|a| a.lamports).unwrap_or(0), 0, "bet B closed"); - assert_eq!(result.account(&user_a).unwrap().data[USER_BET_COUNT_OFFSET], 0); - assert_eq!(result.account(&user_b).unwrap().data[USER_BET_COUNT_OFFSET], 0); + // Token movements. + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), FEE); + assert_eq!(test.tokens(TOKEN_B), STARTING_TOKENS - STAKE_B + PAYOUT_B); + assert_eq!(test.tokens(TOKEN_A), STARTING_TOKENS - STAKE_A); + assert_eq!(test.tokens(vault), 0, "vault drained"); - println!(" LIFECYCLE CU: {}", result.compute_units_consumed); + // Both bets closed; user indexes emptied. + assert_eq!(test.read::(user_a).bet_count, 0); + assert_eq!(test.read::(user_b).bet_count, 0); } /// A cancelled event refunds each bettor their exact stake. -#[test] -fn test_cancel_and_refund() { - let mut svm = setup(); - let fx = fixture(); +#[quasar_test] +fn cancelled_event_refunds_the_exact_stake(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(BETTOR_A)); + test.add( + TokenAccount::new(TOKEN_MINT, BETTOR_A) + .at(TOKEN_A) + .amount(STARTING_TOKENS), + ); - let event_id = 1u64; - let event = event_pda(event_id); - let vault = vault_pda(&event); - let outcome0 = outcome_pda(&event, 0); - let bettor = Pubkey::new_unique(); - let token = Pubkey::new_unique(); - let user = user_pda(&bettor); - let bet = bet_pda(&outcome0, &bettor); + let event = test.derive_pda(Event::seeds(EVENT_ID)); + let vault = test.derive_pda(EventVaultPda::seeds(&event)); + let outcome0 = test.derive_pda(Outcome::seeds(&event, 0)); + let bet = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); const STAKE: u64 = 250; - let accounts = vec![ - signer_account(fx.admin), - mint_account(fx.token_mint, fx.admin), - empty_account(fx.config), - empty_account(event), - empty_account(vault), - empty_account(outcome0), - signer_account(bettor), - token_account(token, fx.token_mint, bettor, STARTING_TOKENS), - empty_account(user), - empty_account(bet), - ]; - - let instructions = vec![ - initialize_config_ix(&fx), - create_event_ix(&fx, event_id, &event, &vault), - add_outcome_ix(&fx, &event, &outcome0, "Only"), - place_bet_ix(&fx, &bettor, &event, &outcome0, &token, &vault, &bet, &user, STAKE), - cancel_event_ix(&fx, &event), - claim_refund_ix(&fx, &bettor, &event, &bet, &user, &token, &vault), - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!(result.is_ok(), "cancel/refund chain failed: {:?}", result.raw_result); + test.send(CreateEventInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + event_id: EVENT_ID, + description: "Team A vs Team B".to_string().into(), + }) + .succeeds(); + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 0, + label: "Only".to_string().into(), + }) + .succeeds(); + test.send(PlaceBetInstruction { + bettor: BETTOR_A, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + outcome_index_seed: 0, + bettor_token_account: TOKEN_A, + amount: STAKE, + }) + .succeeds(); + test.send(CancelEventInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + }) + .succeeds(); + test.send(ClaimRefundInstruction { + bettor: BETTOR_A, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet, + bettor_token_account: TOKEN_A, + }) + .succeeds() + .is_closed(bet); - assert_eq!(result.account(&event).unwrap().data[EVENT_STATUS_OFFSET], STATUS_CANCELLED); + assert_eq!( + test.read::(event).status, + EventStatus::Cancelled as u8 + ); // The bettor got their exact stake back and the bet closed. - assert_eq!(token_amount(result.account(&token).unwrap()), STARTING_TOKENS); - assert_eq!(token_amount(result.account(&vault).unwrap()), 0); - assert_eq!(result.account(&bet).map(|a| a.lamports).unwrap_or(0), 0, "bet closed"); - - println!(" CANCEL/REFUND CU: {}", result.compute_units_consumed); + assert_eq!(test.tokens(TOKEN_A), STARTING_TOKENS); + assert_eq!(test.tokens(vault), 0); } /// Only the config admin may open an event. -#[test] -fn test_create_event_rejects_non_admin() { - let mut svm = setup(); - let fx = fixture(); - let attacker = Pubkey::new_unique(); - - let event_id = 1u64; - let event = event_pda(event_id); - let vault = vault_pda(&event); - - // Init config with the real admin, then have an attacker try create_event. - let mut attacker_fx = fixture(); - attacker_fx.admin = attacker; - attacker_fx.token_mint = fx.token_mint; - attacker_fx.config = fx.config; - - let accounts = vec![ - signer_account(fx.admin), - signer_account(attacker), - mint_account(fx.token_mint, fx.admin), - empty_account(fx.config), - empty_account(event), - empty_account(vault), - ]; - - let instructions = vec![ - initialize_config_ix(&fx), - create_event_ix(&attacker_fx, event_id, &event, &vault), - ]; - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!( - !result.is_ok(), - "create_event must reject a signer who is not the config admin" - ); +#[quasar_test] +fn create_event_rejects_a_non_admin_signer(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(ATTACKER)); + + test.send(CreateEventInstruction { + admin: ATTACKER, + token_mint: TOKEN_MINT, + event_id: EVENT_ID, + description: "Team A vs Team B".to_string().into(), + }) + .fails_with(crate::errors::BettingError::Unauthorized); } diff --git a/finance/escrow/quasar/CHANGELOG.md b/finance/escrow/quasar/CHANGELOG.md index 4f116388..d5bbad0b 100644 --- a/finance/escrow/quasar/CHANGELOG.md +++ b/finance/escrow/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. Program-source + fix for 0.1.0: `Seed` is no longer in the prelude, so `take_offer.rs` and + `cancel_offer.rs` now import it from `quasar_lang::cpi`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/escrow/quasar/Cargo.toml b/finance/escrow/quasar/Cargo.toml index b98ecf4d..50e78c92 100644 --- a/finance/escrow/quasar/Cargo.toml +++ b/finance/escrow/quasar/Cargo.toml @@ -14,30 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -solana-address = { version = "2.2.0" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/escrow/quasar/Quasar.toml b/finance/escrow/quasar/Quasar.toml index 90a78b55..19b57566 100644 --- a/finance/escrow/quasar/Quasar.toml +++ b/finance/escrow/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_escrow" - -[toolchain] -type = "solana" +name = "quasar-escrow" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/escrow/quasar/src/instructions/cancel_offer.rs b/finance/escrow/quasar/src/instructions/cancel_offer.rs index 17d4a5af..633bf758 100644 --- a/finance/escrow/quasar/src/instructions/cancel_offer.rs +++ b/finance/escrow/quasar/src/instructions/cancel_offer.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::state::Offer, quasar_lang::prelude::*, quasar_spl::prelude::*, diff --git a/finance/escrow/quasar/src/instructions/take_offer.rs b/finance/escrow/quasar/src/instructions/take_offer.rs index f41fd1d3..c818c75f 100644 --- a/finance/escrow/quasar/src/instructions/take_offer.rs +++ b/finance/escrow/quasar/src/instructions/take_offer.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::state::Offer, quasar_lang::prelude::*, quasar_spl::prelude::*, diff --git a/finance/escrow/quasar/src/tests.rs b/finance/escrow/quasar/src/tests.rs index b3c678dc..c1ffe373 100644 --- a/finance/escrow/quasar/src/tests.rs +++ b/finance/escrow/quasar/src/tests.rs @@ -1,582 +1,291 @@ -extern crate std; +//! quasar-test integration tests: make an offer (deposit into the vault), +//! take it (swap the tokens, close the offer and vault back to the maker), +//! cancel it, and reject substituted accounts and non-maker signers. + 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, + crate::{ + cpi::{CancelOfferInstruction, MakeOfferInstruction, TakeOfferInstruction}, + state::{Offer, OfferData}, + }, + quasar_test::prelude::*, }; +// Deterministic addresses keep tests independent of discovery order. +const MAKER: Pubkey = Pubkey::new_from_array([1; 32]); +const TAKER: Pubkey = Pubkey::new_from_array([2; 32]); +const TOKEN_MINT_A: Pubkey = Pubkey::new_from_array([3; 32]); +const TOKEN_MINT_B: Pubkey = Pubkey::new_from_array([4; 32]); +const MAKER_TOKEN_ACCOUNT_A: Pubkey = Pubkey::new_from_array([5; 32]); +const MAKER_TOKEN_ACCOUNT_B: Pubkey = Pubkey::new_from_array([6; 32]); +const VAULT: Pubkey = Pubkey::new_from_array([7; 32]); +const TAKER_TOKEN_ACCOUNT_A: Pubkey = Pubkey::new_from_array([8; 32]); +const TAKER_TOKEN_ACCOUNT_B: Pubkey = Pubkey::new_from_array([9; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([10; 32]); +const ATTACKER_TOKEN_ACCOUNT_A: Pubkey = Pubkey::new_from_array([11; 32]); +const WRONG_MINT: Pubkey = Pubkey::new_from_array([12; 32]); +const WRONG_VAULT: Pubkey = Pubkey::new_from_array([13; 32]); + 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() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn mint(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 1_000_000_000, - decimals: 9, - 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_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: 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(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: 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, - } -} - -/// Mark specific account indices as signers on an instruction. -fn with_signers(mut ix: Instruction, indices: &[usize]) -> Instruction { - for &i in indices { - ix.accounts[i].is_signer = true; - } - ix -} - -/// Build make_offer instruction data. -/// 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 -} - -/// Build take_offer instruction data. -/// Wire format: [discriminator: u8 = 1] -fn build_take_offer_data() -> Vec { - vec![1u8] -} - -/// Build cancel_offer instruction data. -/// Wire format: [discriminator: u8 = 2] -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, +/// Register the maker and both mints. +fn base_world(test: &mut Test) { + test.add(Wallet::new().at(MAKER)); + test.add( + Mint::new(MAKER) + .at(TOKEN_MINT_A) + .supply(1_000_000_000) + .decimals(9), + ); + test.add( + Mint::new(MAKER) + .at(TOKEN_MINT_B) + .supply(1_000_000_000) + .decimals(9), + ); } -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(), +/// Register a live offer holding `DEPOSIT_AMOUNT` vault tokens, exactly as +/// `make_offer` leaves it. +fn live_offer(test: &mut Test) -> Pubkey { + let (offer, bump) = test.derive_pda_with_bump(Offer::seeds(&MAKER, OFFER_ID)); + test.write( 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(); - - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; - let maker = 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 maker_token_account_b = Pubkey::new_unique(); - let vault = Pubkey::new_unique(); - 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(OFFER_ID, DEPOSIT_AMOUNT, RECEIVE_AMOUNT); - - 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_readonly(token_mint_b.into(), false), - solana_instruction::AccountMeta::new(maker_token_account_a.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, + OfferData { + id: OFFER_ID.into(), + maker: MAKER, + token_mint_a: TOKEN_MINT_A, + token_mint_b: TOKEN_MINT_B, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: VAULT, + receive: RECEIVE_AMOUNT.into(), + bump, }, - &[5, 6], // maker_token_account_b, vault as signers for create_account CPI ); + test.add(TokenAccount::new(TOKEN_MINT_A, offer).at(VAULT).amount(DEPOSIT_AMOUNT)); + offer +} - let result = svm.process_instruction( - &instruction, - &[ - signer(maker), - empty(offer), - mint(token_mint_a, maker), - mint(token_mint_b, maker), - token(maker_token_account_a, token_mint_a, maker, 1_000_000), - empty(maker_token_account_b), - empty(vault), - ], - ); - - assert!(result.is_ok(), "make_offer failed: {:?}", result.raw_result); - - // 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..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" +#[quasar_test] +fn make_offer_records_the_offer_and_funds_the_vault(test: &mut Test) { + base_world(test); + test.add( + TokenAccount::new(TOKEN_MINT_A, MAKER) + .at(MAKER_TOKEN_ACCOUNT_A) + .amount(1_000_000), ); - 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"); - + let (offer, bump) = test.derive_pda_with_bump(Offer::seeds(&MAKER, OFFER_ID)); + + test.send(MakeOfferInstruction { + maker: MAKER, + token_mint_a: TOKEN_MINT_A, + token_mint_b: TOKEN_MINT_B, + maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: VAULT, + id: OFFER_ID, + deposit: DEPOSIT_AMOUNT, + receive: RECEIVE_AMOUNT, + }) + .succeeds() // The deposit landed in the vault. + .has_tokens(VAULT, DEPOSIT_AMOUNT); + + // Verify the recorded offer state. + let state = test.read::(offer); + assert_eq!(u64::from(state.id), OFFER_ID, "id"); + assert_eq!(state.maker, MAKER, "maker"); + assert_eq!(state.token_mint_a, TOKEN_MINT_A, "token_mint_a"); + assert_eq!(state.token_mint_b, TOKEN_MINT_B, "token_mint_b"); assert_eq!( - token_amount(result.account(&vault).unwrap()), - DEPOSIT_AMOUNT + state.maker_token_account_b, MAKER_TOKEN_ACCOUNT_B, + "maker_token_account_b" ); - - println!(" MAKE_OFFER CU: {}", result.compute_units_consumed); + assert_eq!(state.vault, VAULT, "vault"); + assert_eq!(u64::from(state.receive), RECEIVE_AMOUNT, "receive"); + assert_eq!(state.bump, bump, "bump"); } -#[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); - +#[quasar_test] +fn take_offer_swaps_tokens_and_returns_rent_to_the_maker(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(TAKER)); + let offer = live_offer(test); + test.add( + TokenAccount::new(TOKEN_MINT_B, TAKER) + .at(TAKER_TOKEN_ACCOUNT_B) + .amount(10_000), + ); + test.add(TokenAccount::new(TOKEN_MINT_B, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); + + // Rent destinations are asserted exactly: the maker paid the offer and + // vault rent in make_offer and must recover both on close. + let offer_rent = test.lamports(offer); + let vault_rent = test.lamports(VAULT); + let maker_lamports_before = test.lamports(MAKER); + let taker_lamports_before = test.lamports(TAKER); + + test.send(TakeOfferInstruction { + taker: TAKER, + offer_id_seed: OFFER_ID, + maker: MAKER, + token_mint_a: TOKEN_MINT_A, + token_mint_b: TOKEN_MINT_B, + taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, + taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: VAULT, + }) + .succeeds() // 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 - ); - + .has_tokens(TAKER_TOKEN_ACCOUNT_A, DEPOSIT_AMOUNT) + .has_tokens(MAKER_TOKEN_ACCOUNT_B, 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"); + .is_closed(offer) + .is_closed(VAULT); - // 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, + test.lamports(MAKER), + maker_lamports_before + offer_rent + vault_rent, "maker must recover the offer and vault rent" ); - let taker_lamports = result.account(&fx.taker).unwrap().lamports; assert!( - taker_lamports <= STARTING_LAMPORTS, + test.lamports(TAKER) <= taker_lamports_before, "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); +#[quasar_test] +fn take_offer_rejects_a_mint_that_does_not_match_the_offer(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(TAKER)); + live_offer(test); + test.add( + TokenAccount::new(TOKEN_MINT_B, TAKER) + .at(TAKER_TOKEN_ACCOUNT_B) + .amount(10_000), + ); + test.add(TokenAccount::new(TOKEN_MINT_B, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); // 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); + test.add(Mint::new(MAKER).at(WRONG_MINT).supply(1_000_000_000).decimals(9)); + + let result = test.send(TakeOfferInstruction { + taker: TAKER, + offer_id_seed: OFFER_ID, + maker: MAKER, + token_mint_a: WRONG_MINT, + token_mint_b: TOKEN_MINT_B, + taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, + taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: VAULT, + }); assert!( - !result.is_ok(), + result.is_err(), "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); +#[quasar_test] +fn take_offer_rejects_a_vault_that_does_not_match_the_offer(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(TAKER)); + let offer = live_offer(test); + test.add( + TokenAccount::new(TOKEN_MINT_B, TAKER) + .at(TAKER_TOKEN_ACCOUNT_B) + .amount(10_000), + ); + test.add(TokenAccount::new(TOKEN_MINT_B, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); // 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); + test.add( + TokenAccount::new(TOKEN_MINT_A, offer) + .at(WRONG_VAULT) + .amount(DEPOSIT_AMOUNT), + ); - let instruction = build_take_offer_instruction(&fx, fx.token_mint_a, wrong_vault); - let result = svm.process_instruction(&instruction, &accounts); + let result = test.send(TakeOfferInstruction { + taker: TAKER, + offer_id_seed: OFFER_ID, + maker: MAKER, + token_mint_a: TOKEN_MINT_A, + token_mint_b: TOKEN_MINT_B, + taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, + taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: WRONG_VAULT, + }); assert!( - !result.is_ok(), + result.is_err(), "take_offer must reject a vault that does not match the offer state" ); } -#[test] -fn test_cancel_offer() { - let mut svm = setup(); - - let maker = 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 maker_token_account_b = Pubkey::new_unique(); - let vault = Pubkey::new_unique(); - let (offer, offer_bump) = derive_offer(&maker, OFFER_ID); - let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - - 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(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), - // 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(), - "cancel_offer failed: {:?}", - result.raw_result - ); - +#[quasar_test] +fn cancel_offer_returns_deposit_and_rent_to_the_maker(test: &mut Test) { + base_world(test); + let offer = live_offer(test); + // Pre-created with a zero balance so the maker's tokens can be compared + // exactly after the cancel. + test.add(TokenAccount::new(TOKEN_MINT_A, MAKER).at(MAKER_TOKEN_ACCOUNT_A)); + + let offer_rent = test.lamports(offer); + let vault_rent = test.lamports(VAULT); + let maker_lamports_before = test.lamports(MAKER); + + test.send(CancelOfferInstruction { + maker: MAKER, + offer_id_seed: OFFER_ID, + token_mint_a: TOKEN_MINT_A, + maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, + vault: VAULT, + }) + .succeeds() // 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"); + .has_tokens(MAKER_TOKEN_ACCOUNT_A, DEPOSIT_AMOUNT) + // The offer and vault are closed. + .is_closed(offer) + .is_closed(VAULT); - 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, + test.lamports(MAKER), + maker_lamports_before + offer_rent + vault_rent, "maker must recover the offer and vault rent" ); - - println!(" CANCEL_OFFER CU: {}", result.compute_units_consumed); } -#[test] -fn test_cancel_offer_rejects_non_maker() { - let mut svm = setup(); - - 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 attacker_token_account_a = Pubkey::new_unique(); - let maker_token_account_b = Pubkey::new_unique(); - let vault = Pubkey::new_unique(); - let (offer, offer_bump) = derive_offer(&maker, OFFER_ID); - let rent = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - - // 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(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), - token(attacker_token_account_a, token_mint_a, attacker, 0), - token(vault, token_mint_a, offer, DEPOSIT_AMOUNT), - ], - ); +#[quasar_test] +fn cancel_offer_rejects_a_signer_who_is_not_the_maker(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(ATTACKER)); + let offer = live_offer(test); + test.add(TokenAccount::new(TOKEN_MINT_A, ATTACKER).at(ATTACKER_TOKEN_ACCOUNT_A)); + + // The attacker signs as the "maker" but passes the real maker's offer. + // has_one(maker) and the offer's PDA seeds both fail to match. The + // builder would derive the attacker's own (nonexistent) offer PDA, so + // the real offer address is substituted at the instruction level. + let mut ix: Instruction = CancelOfferInstruction { + maker: ATTACKER, + offer_id_seed: OFFER_ID, + token_mint_a: TOKEN_MINT_A, + maker_token_account_a: ATTACKER_TOKEN_ACCOUNT_A, + vault: VAULT, + } + .into(); + // Account order = the accounts-struct field order: maker, offer, ... + ix.accounts[1].pubkey = offer; + let result = test.send(ix); assert!( - !result.is_ok(), + result.is_err(), "cancel_offer must reject a signer who is not the offer's maker" ); } diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index f9f4b2b3..d0f2b2e3 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and most tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). Compute-unit assertions were dropped pending recalibration + under 0.1.0. Program-source fixes for 0.1.0: `Seed` is now imported from + `quasar_lang::cpi`, and the removed `quasar_spl::initialize_account3` / + `initialize_mint2` free functions became `TokenCpi` trait method calls on + `token_program`. The two slot-warp scenarios + (`interest_accrues_and_lifts_share_value`, + `protocol_fees_accrue_and_owner_can_collect`) keep a direct + `quasar-svm = "=0.1.0"` (crates.io) dev-dependency: interest accrual is + computed from `Clock::get()?.slot`, and quasar-test exposes no slot warp + (`warp_to_timestamp` only sets `unix_timestamp`), so they drive + `QuasarSvm` + `sysvars.warp_to_slot` directly, loading the compiled `.so` + at runtime. + ## 0.1.0 Initial Quasar port of the Kamino/Solend-style borrow/lend program. diff --git a/finance/lending/quasar/Cargo.toml b/finance/lending/quasar/Cargo.toml index 080dd86e..2fa4885c 100644 --- a/finance/lending/quasar/Cargo.toml +++ b/finance/lending/quasar/Cargo.toml @@ -14,26 +14,34 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pinned to rev 623bb70 for the same reason as the other Quasar examples: master -# HEAD fails to compile because zeropod 0.3.x generates accessor methods that -# conflict with hand-written ones in quasar-spl. Unpin once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +# Direct quasar-svm dev-dependency, unique to this example: the program +# computes interest from `Clock::get()?.slot`, and two test scenarios must +# warp the SLOT. quasar-test only exposes `warp_to_timestamp` (which leaves +# the slot untouched), so those scenarios drive the low-level QuasarSvm +# harness directly via `svm.sysvars.warp_to_slot(...)`. Same crates.io +# version quasar-test itself depends on, so the two harnesses share types. +quasar-svm = "=0.1.0" +# For decoding/constructing raw SPL accounts in the low-level slot-warp +# scenarios; matches quasar-svm 0.1.0's own spl-token major. +spl-token = { version = "9", default-features = false, features = ["no-entrypoint"] } diff --git a/finance/lending/quasar/Quasar.toml b/finance/lending/quasar/Quasar.toml index c3e8d514..370b5931 100644 --- a/finance/lending/quasar/Quasar.toml +++ b/finance/lending/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_lending" - -[toolchain] -type = "solana" +name = "quasar-lending" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index ab35f8de..d9dfca49 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -10,8 +10,8 @@ use { Reserve, ReserveInner, ShareMintPda, }, }, - quasar_lang::{prelude::*, sysvars::Sysvar}, - quasar_spl::{initialize_account3, initialize_mint2, prelude::*}, + quasar_lang::{cpi::Seed, prelude::*, sysvars::Sysvar}, + quasar_spl::prelude::*, }; // --------------------------------------------------------------------------- @@ -118,13 +118,9 @@ impl InitReserve { self.token_program.address(), ) .invoke_signed(&vault_seeds)?; - initialize_account3( - self.token_program.to_account_view(), - self.liquidity_vault.to_account_view(), - self.liquidity_mint.to_account_view(), - &reserve_address, - ) - .invoke()?; + self.token_program + .initialize_account3(&self.liquidity_vault, &self.liquidity_mint, &reserve_address) + .invoke()?; // Create the share-token mint PDA (authority = reserve, same decimals). let mint_bump = [bumps.share_mint]; @@ -142,14 +138,9 @@ impl InitReserve { self.token_program.address(), ) .invoke_signed(&mint_seeds)?; - initialize_mint2( - self.token_program.to_account_view(), - self.share_mint.to_account_view(), - decimals, - &reserve_address, - None, - ) - .invoke()?; + self.token_program + .initialize_mint2(&self.share_mint, decimals, &reserve_address, None) + .invoke()?; self.reserve.set_inner(ReserveInner { lending_market: *self.lending_market.address(), diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 1cfd5aad..3c790b14 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -9,7 +9,7 @@ use { LendingMarket, Obligation, ObligationInner, ObligationVaultPda, PriceFeed, Reserve, }, }, - quasar_lang::prelude::*, + quasar_lang::{cpi::Seed, prelude::*}, quasar_spl::prelude::*, }; diff --git a/finance/lending/quasar/src/instructions/supply.rs b/finance/lending/quasar/src/instructions/supply.rs index 85678f03..477f15fa 100644 --- a/finance/lending/quasar/src/instructions/supply.rs +++ b/finance/lending/quasar/src/instructions/supply.rs @@ -5,7 +5,7 @@ use { math::{mul_div_floor, net_total_liquidity}, state::Reserve, }, - quasar_lang::prelude::*, + quasar_lang::{cpi::Seed, prelude::*}, quasar_spl::prelude::*, }; diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 6504d18f..7bfb985f 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -1,11 +1,20 @@ -extern crate std; +//! Integration tests. Most scenarios drive the program through `quasar-test` +//! (`#[quasar_test]` fixtures + `crate::cpi` builders). The two scenarios that +//! must warp the SLOT (interest accrual is computed from `Clock::get()?.slot`) +//! keep the low-level QuasarSvm harness — see `slot_warp` at the bottom. use { - alloc::{vec, vec::Vec}, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - solana_instruction::AccountMeta, - spl_token_interface::state::{Account as SplToken, AccountState, Mint as SplMint}, - std::fs, + crate::{ + cpi::{ + BorrowObligationLiquidityInstruction, DepositObligationCollateralInstruction, + DepositReserveLiquidityInstruction, InitLendingMarketInstruction, + InitObligationInstruction, InitReserveInstruction, LiquidateObligationInstruction, + RedeemReserveCollateralInstruction, RepayObligationLiquidityInstruction, + SetPriceInstruction, + }, + state::{LendingMarket, LiquidityVaultPda, Obligation, Reserve, ShareMintPda}, + }, + quasar_test::prelude::*, }; // Prices are passed as `mantissa * 10^-18` (Switchboard-shaped). @@ -40,72 +49,8 @@ const OWNER_BORROW: Pubkey = Pubkey::new_from_array([17; 32]); // Per-owner market index this market is seeded from (owner's market 0). const MARKET_ID: u64 = 0; -fn token_program() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn system_program() -> Pubkey { - quasar_svm::system_program::ID -} - -fn pda(seeds: &[&[u8]]) -> (Pubkey, u8) { - Pubkey::find_program_address(seeds, &crate::ID) -} - -fn meta(address: Pubkey, writable: bool, signer: bool) -> AccountMeta { - if writable { - let mut m = AccountMeta::new(address.into(), signer); - m.is_signer = signer; - m - } else { - AccountMeta::new_readonly(address.into(), signer) - } -} - -fn system(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: system_program(), - executable: false, - } -} -fn mint(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &SplMint { - mint_authority: Some(authority).into(), - supply: 1_000_000_000_000, - decimals: DECIMALS, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} -fn token(address: Pubkey, the_mint: Pubkey, owner: Pubkey, amount: u64) -> Account { - quasar_svm::token::create_keyed_token_account( - &address, - &SplToken { - mint: the_mint, - owner, - amount, - state: AccountState::Initialized, - ..SplToken::default() - }, - ) -} - -/// Read an SPL token account's amount from committed bytes (offset 64..72). -fn balance(result: &quasar_svm::ExecutionResult, address: Pubkey) -> u64 { - let account = result.account(&address).expect("account present"); - u64::from_le_bytes(account.data[64..72].try_into().unwrap()) -} - -struct World { - svm: QuasarSvm, +/// Every PDA the scenarios touch, derived from the typed seeds. +struct Pdas { market: Pubkey, collateral_reserve: Pubkey, collateral_vault: Pubkey, @@ -116,416 +61,715 @@ struct World { borrow_share_mint: Pubkey, borrow_price: Pubkey, obligation: Pubkey, - obligation_vault: Pubkey, } -impl World { - fn new() -> Self { - let elf = fs::read("target/deploy/quasar_lending.so").unwrap(); - let mut svm = QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program(); - - let (market, _) = pda(&[b"lending_market", &MARKET_ID.to_le_bytes()]); - let (collateral_reserve, _) = pda(&[b"reserve", market.as_ref(), COLLATERAL_MINT.as_ref()]); - let (borrow_reserve, _) = pda(&[b"reserve", market.as_ref(), BORROW_MINT.as_ref()]); - let (collateral_vault, _) = pda(&[b"liquidity_vault", collateral_reserve.as_ref()]); - let (borrow_vault, _) = pda(&[b"liquidity_vault", borrow_reserve.as_ref()]); - let (collateral_share_mint, _) = pda(&[b"share_mint", collateral_reserve.as_ref()]); - let (borrow_share_mint, _) = pda(&[b"share_mint", borrow_reserve.as_ref()]); - // Feed PDAs are seeded by (market, mint) — scoped to the market, not to any individual. - let (collateral_price, _) = pda(&[b"price_feed", market.as_ref(), COLLATERAL_MINT.as_ref()]); - let (borrow_price, _) = pda(&[b"price_feed", market.as_ref(), BORROW_MINT.as_ref()]); - let (obligation, _) = pda(&[b"obligation", market.as_ref(), BORROWER.as_ref()]); - let (obligation_vault, _) = - pda(&[b"obligation_vault", collateral_reserve.as_ref(), obligation.as_ref()]); - - for account in [ - system(OWNER), - system(SUPPLIER), - system(BORROWER), - system(LIQUIDATOR), - mint(COLLATERAL_MINT, OWNER), - mint(BORROW_MINT, OWNER), - mint(QUOTE_MINT, OWNER), - // PDAs created by the program. - empty(market), - empty(collateral_reserve), - empty(borrow_reserve), - empty(collateral_vault), - empty(borrow_vault), - empty(collateral_share_mint), - empty(borrow_share_mint), - empty(collateral_price), - empty(borrow_price), - empty(obligation), - empty(obligation_vault), - // Funded user token accounts. - token(SUPPLIER_BORROW, BORROW_MINT, SUPPLIER, 1_000 * UNIT), - token(SUPPLIER_BORROW_SHARE, borrow_share_mint, SUPPLIER, 0), - token(BORROWER_COLLATERAL, COLLATERAL_MINT, BORROWER, 1_000 * UNIT), - token(BORROWER_COLLATERAL_SHARE, collateral_share_mint, BORROWER, 0), - token(BORROWER_BORROW, BORROW_MINT, BORROWER, 0), - token(LIQUIDATOR_BORROW, BORROW_MINT, LIQUIDATOR, 1_000 * UNIT), - token(LIQUIDATOR_COLLATERAL_SHARE, collateral_share_mint, LIQUIDATOR, 0), - // Where the market owner receives collected protocol fees. - token(OWNER_BORROW, BORROW_MINT, OWNER, 0), - ] { - svm.set_account(account); - } - - World { - svm, - market, - collateral_reserve, - collateral_vault, - collateral_share_mint, - collateral_price, - borrow_reserve, - borrow_vault, - borrow_share_mint, - borrow_price, - obligation, - obligation_vault, - } - } - - fn run(&mut self, data: Vec, metas: Vec) -> quasar_svm::ExecutionResult { - let instruction = Instruction { - program_id: crate::ID, - accounts: metas, - data, - }; - self.svm.process_instruction(&instruction, &[]) - } - - fn init_market(&mut self) { - // Instruction data: [discriminator 0][market_id u64 LE]. - let mut data = vec![0u8]; - data.extend_from_slice(&MARKET_ID.to_le_bytes()); - let metas = vec![ - meta(OWNER, true, true), - meta(self.market, true, false), - meta(QUOTE_MINT, false, false), - meta(system_program(), false, false), - ]; - self.run(data, metas).assert_success(); +fn pdas(test: &Test) -> Pdas { + let market = test.derive_pda(LendingMarket::seeds(MARKET_ID)); + let collateral_reserve = test.derive_pda(Reserve::seeds(&market, &COLLATERAL_MINT)); + let borrow_reserve = test.derive_pda(Reserve::seeds(&market, &BORROW_MINT)); + Pdas { + market, + collateral_reserve, + collateral_vault: test.derive_pda(LiquidityVaultPda::seeds(&collateral_reserve)), + collateral_share_mint: test.derive_pda(ShareMintPda::seeds(&collateral_reserve)), + // Feed PDAs are seeded by (market, mint) — scoped to the market, not + // to any individual. + collateral_price: test.derive_pda(crate::state::PriceFeed::seeds(&market, &COLLATERAL_MINT)), + borrow_reserve, + borrow_vault: test.derive_pda(LiquidityVaultPda::seeds(&borrow_reserve)), + borrow_share_mint: test.derive_pda(ShareMintPda::seeds(&borrow_reserve)), + borrow_price: test.derive_pda(crate::state::PriceFeed::seeds(&market, &BORROW_MINT)), + obligation: test.derive_pda(Obligation::seeds(&market, &BORROWER)), } +} - fn set_price(&mut self, the_mint: Pubkey, price_feed: Pubkey, mantissa: i128) { - let mut data = vec![2u8]; - data.extend_from_slice(&mantissa.to_le_bytes()); - data.extend_from_slice(&EXP.to_le_bytes()); - let metas = vec![ - meta(OWNER, true, true), - meta(self.market, false, false), - meta(price_feed, true, false), - meta(the_mint, false, false), - meta(system_program(), false, false), - ]; - self.run(data, metas).assert_success(); +/// Wallets, mints, and funded user token accounts (mirrors the low-level +/// harness's world). +fn base_world(test: &mut Test) -> Pdas { + let w = pdas(test); + for wallet in [OWNER, SUPPLIER, BORROWER, LIQUIDATOR] { + test.add(Wallet::new().at(wallet)); } - - #[allow(clippy::too_many_arguments)] - fn init_reserve(&mut self, the_mint: Pubkey, reserve: Pubkey, vault: Pubkey, share: Pubkey, price: Pubkey) { - // 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, 10% reserve - // factor, kink 80%, 2% / 20% / 150% APR curve. - let config: [u16; 9] = [7_500, 8_000, 500, 5_000, 1_000, 8_000, 200, 2_000, 15_000]; - let mut data = vec![1u8]; - for value in config { - data.extend_from_slice(&value.to_le_bytes()); - } - let metas = vec![ - meta(OWNER, true, true), - meta(self.market, false, false), - meta(reserve, true, false), - meta(the_mint, false, false), - meta(vault, true, false), - meta(share, true, false), - meta(price, false, false), - meta(token_program(), false, false), - meta(system_program(), false, false), - ]; - self.run(data, metas).assert_success(); - } - - fn setup_markets(&mut self) { - self.init_market(); - self.set_price(COLLATERAL_MINT, self.collateral_price, dollars(1)); - self.set_price(BORROW_MINT, self.borrow_price, dollars(1)); - self.init_reserve(COLLATERAL_MINT, self.collateral_reserve, self.collateral_vault, self.collateral_share_mint, self.collateral_price); - self.init_reserve(BORROW_MINT, self.borrow_reserve, self.borrow_vault, self.borrow_share_mint, self.borrow_price); + for the_mint in [COLLATERAL_MINT, BORROW_MINT, QUOTE_MINT] { + test.add( + Mint::new(OWNER) + .at(the_mint) + .supply(1_000_000_000_000) + .decimals(DECIMALS), + ); } + test.add( + TokenAccount::new(BORROW_MINT, SUPPLIER) + .at(SUPPLIER_BORROW) + .amount(1_000 * UNIT), + ); + test.add(TokenAccount::new(w.borrow_share_mint, SUPPLIER).at(SUPPLIER_BORROW_SHARE)); + test.add( + TokenAccount::new(COLLATERAL_MINT, BORROWER) + .at(BORROWER_COLLATERAL) + .amount(1_000 * UNIT), + ); + test.add(TokenAccount::new(w.collateral_share_mint, BORROWER).at(BORROWER_COLLATERAL_SHARE)); + test.add(TokenAccount::new(BORROW_MINT, BORROWER).at(BORROWER_BORROW)); + test.add( + TokenAccount::new(BORROW_MINT, LIQUIDATOR) + .at(LIQUIDATOR_BORROW) + .amount(1_000 * UNIT), + ); + test.add(TokenAccount::new(w.collateral_share_mint, LIQUIDATOR).at(LIQUIDATOR_COLLATERAL_SHARE)); + // Where the market owner receives collected protocol fees. + test.add(TokenAccount::new(BORROW_MINT, OWNER).at(OWNER_BORROW)); + w +} - #[allow(clippy::too_many_arguments)] - fn deposit( - &mut self, - supplier: Pubkey, - reserve: Pubkey, - the_mint: Pubkey, - vault: Pubkey, - share: Pubkey, - supplier_liq: Pubkey, - supplier_share: Pubkey, - amount: u64, - ) -> quasar_svm::ExecutionResult { - let mut data = vec![3u8]; - data.extend_from_slice(&amount.to_le_bytes()); - let metas = vec![ - meta(supplier, true, true), - meta(reserve, true, false), - meta(the_mint, false, false), - meta(vault, true, false), - meta(share, true, false), - meta(supplier_liq, true, false), - meta(supplier_share, true, false), - meta(token_program(), false, false), - ]; - self.run(data, metas) - } +fn set_price(test: &mut Test, w: &Pdas, the_mint: Pubkey, mantissa: i128) { + test.send(SetPriceInstruction { + owner: OWNER, + lending_market: w.market, + mint: the_mint, + price_mantissa: mantissa, + exponent: EXP, + }) + .succeeds(); +} - fn redeem( - &mut self, - supplier_liq: Pubkey, - supplier_share: Pubkey, - shares: u64, - ) -> quasar_svm::ExecutionResult { - let mut data = vec![4u8]; - data.extend_from_slice(&shares.to_le_bytes()); - let metas = vec![ - meta(SUPPLIER, true, true), - meta(self.borrow_reserve, true, false), - meta(BORROW_MINT, false, false), - meta(self.borrow_vault, true, false), - meta(self.borrow_share_mint, true, false), - meta(supplier_liq, true, false), - meta(supplier_share, true, false), - meta(token_program(), false, false), - ]; - self.run(data, metas) - } +fn init_reserve(test: &mut Test, w: &Pdas, the_mint: Pubkey) { + // 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, 10% + // reserve factor, kink 80%, 2% / 20% / 150% APR curve. + test.send(InitReserveInstruction { + owner: OWNER, + lending_market: w.market, + liquidity_mint: the_mint, + loan_to_value_bps: 7_500, + liquidation_threshold_bps: 8_000, + liquidation_bonus_bps: 500, + close_factor_bps: 5_000, + reserve_factor_bps: 1_000, + optimal_utilization_bps: 8_000, + min_borrow_rate_bps: 200, + optimal_borrow_rate_bps: 2_000, + max_borrow_rate_bps: 15_000, + }) + .succeeds(); +} - fn init_obligation(&mut self) { - let metas = vec![ - meta(BORROWER, true, true), - meta(self.market, false, false), - meta(self.obligation, true, false), - meta(system_program(), false, false), - ]; - self.run(vec![5], metas).assert_success(); - } +fn setup_markets(test: &mut Test, w: &Pdas) { + test.send(InitLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, w, COLLATERAL_MINT, dollars(1)); + set_price(test, w, BORROW_MINT, dollars(1)); + init_reserve(test, w, COLLATERAL_MINT); + init_reserve(test, w, BORROW_MINT); +} - fn post_collateral(&mut self, shares: u64) -> quasar_svm::ExecutionResult { - let mut data = vec![6u8]; - data.extend_from_slice(&shares.to_le_bytes()); - let metas = vec![ - meta(BORROWER, true, true), - meta(self.market, false, false), - meta(self.obligation, true, false), - meta(self.collateral_reserve, false, false), - meta(self.collateral_share_mint, false, false), - meta(self.obligation_vault, true, false), - meta(BORROWER_COLLATERAL_SHARE, true, false), - meta(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false, false), - meta(token_program(), false, false), - meta(system_program(), false, false), - ]; - self.run(data, metas) - } +fn deposit_borrow_side(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { + test.send(DepositReserveLiquidityInstruction { + supplier: SUPPLIER, + reserve: w.borrow_reserve, + liquidity_mint: BORROW_MINT, + liquidity_vault: w.borrow_vault, + share_mint: w.borrow_share_mint, + supplier_liquidity: SUPPLIER_BORROW, + supplier_share: SUPPLIER_BORROW_SHARE, + amount, + }) +} - fn borrow(&mut self, amount: u64) -> quasar_svm::ExecutionResult { - let mut data = vec![8u8]; - data.extend_from_slice(&amount.to_le_bytes()); - let metas = vec![ - meta(BORROWER, true, true), - meta(self.market, false, false), - meta(self.obligation, true, false), - meta(self.collateral_reserve, true, false), - meta(self.collateral_price, false, false), - meta(self.borrow_reserve, true, false), - meta(self.borrow_price, false, false), - meta(BORROW_MINT, false, false), - meta(self.borrow_vault, true, false), - meta(BORROWER_BORROW, true, false), - meta(token_program(), false, false), - ]; - self.run(data, metas) - } +fn deposit_collateral_side(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { + test.send(DepositReserveLiquidityInstruction { + supplier: BORROWER, + reserve: w.collateral_reserve, + liquidity_mint: COLLATERAL_MINT, + liquidity_vault: w.collateral_vault, + share_mint: w.collateral_share_mint, + supplier_liquidity: BORROWER_COLLATERAL, + supplier_share: BORROWER_COLLATERAL_SHARE, + amount, + }) +} - fn repay(&mut self, amount: u64) -> quasar_svm::ExecutionResult { - let mut data = vec![9u8]; - data.extend_from_slice(&amount.to_le_bytes()); - let metas = vec![ - meta(BORROWER, true, true), - meta(self.obligation, true, false), - meta(self.borrow_reserve, true, false), - meta(BORROW_MINT, false, false), - meta(self.borrow_vault, true, false), - meta(BORROWER_BORROW, true, false), - meta(token_program(), false, false), - ]; - self.run(data, metas) - } +fn redeem(test: &mut Test, w: &Pdas, shares: u64) -> Outcome { + test.send(RedeemReserveCollateralInstruction { + supplier: SUPPLIER, + reserve: w.borrow_reserve, + liquidity_mint: BORROW_MINT, + liquidity_vault: w.borrow_vault, + share_mint: w.borrow_share_mint, + supplier_liquidity: SUPPLIER_BORROW, + supplier_share: SUPPLIER_BORROW_SHARE, + shares, + }) +} - fn liquidate(&mut self, amount: u64) -> quasar_svm::ExecutionResult { - let mut data = vec![10u8]; - data.extend_from_slice(&amount.to_le_bytes()); - let metas = vec![ - meta(LIQUIDATOR, true, true), - meta(self.obligation, true, false), - meta(self.market, false, false), - meta(self.collateral_reserve, true, false), - meta(self.collateral_price, false, false), - meta(self.collateral_share_mint, false, false), - meta(self.obligation_vault, true, false), - meta(LIQUIDATOR_COLLATERAL_SHARE, true, false), - meta(self.borrow_reserve, true, false), - meta(self.borrow_price, false, false), - meta(BORROW_MINT, false, false), - meta(self.borrow_vault, true, false), - meta(LIQUIDATOR_BORROW, true, false), - meta(token_program(), false, false), - ]; - self.run(data, metas) - } +fn borrow(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { + test.send(BorrowObligationLiquidityInstruction { + owner: BORROWER, + lending_market: w.market, + collateral_reserve: w.collateral_reserve, + collateral_price: w.collateral_price, + borrow_reserve: w.borrow_reserve, + borrow_price: w.borrow_price, + liquidity_mint: BORROW_MINT, + liquidity_vault: w.borrow_vault, + owner_liquidity: BORROWER_BORROW, + amount, + }) +} - /// Supplier funds the borrow reserve; borrower posts 1000 units of collateral. - fn bootstrap_position(&mut self) { - self.setup_markets(); - self.deposit( - SUPPLIER, self.borrow_reserve, BORROW_MINT, self.borrow_vault, - self.borrow_share_mint, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT, - ) - .assert_success(); - self.deposit( - BORROWER, self.collateral_reserve, COLLATERAL_MINT, self.collateral_vault, - self.collateral_share_mint, BORROWER_COLLATERAL, BORROWER_COLLATERAL_SHARE, 1_000 * UNIT, - ) - .assert_success(); - self.init_obligation(); - self.post_collateral(1_000 * UNIT).assert_success(); - } +fn repay(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { + test.send(RepayObligationLiquidityInstruction { + repayer: BORROWER, + obligation: w.obligation, + borrow_reserve: w.borrow_reserve, + liquidity_mint: BORROW_MINT, + liquidity_vault: w.borrow_vault, + repayer_liquidity: BORROWER_BORROW, + amount, + }) +} - /// Market owner collects accrued protocol fees from the borrow reserve into - /// `OWNER_BORROW`. The handler accrues interest itself, so no separate refresh. - fn collect_borrow_fees(&mut self) -> quasar_svm::ExecutionResult { - let metas = vec![ - meta(OWNER, true, true), - meta(self.market, false, false), - meta(self.borrow_reserve, true, false), - meta(BORROW_MINT, false, false), - meta(self.borrow_vault, true, false), - meta(OWNER_BORROW, true, false), - meta(token_program(), false, false), - ]; - self.run(vec![11u8], metas) - } +fn liquidate(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { + test.send(LiquidateObligationInstruction { + liquidator: LIQUIDATOR, + obligation: w.obligation, + lending_market: w.market, + collateral_reserve: w.collateral_reserve, + collateral_price: w.collateral_price, + share_mint: w.collateral_share_mint, + liquidator_collateral: LIQUIDATOR_COLLATERAL_SHARE, + borrow_reserve: w.borrow_reserve, + borrow_price: w.borrow_price, + liquidity_mint: BORROW_MINT, + liquidity_vault: w.borrow_vault, + liquidator_liquidity: LIQUIDATOR_BORROW, + amount, + }) } -#[test] -fn supply_mints_shares_one_to_one_and_redeem_returns_liquidity() { - let mut world = World::new(); - world.setup_markets(); +/// Supplier funds the borrow reserve; borrower posts 1000 units of collateral. +fn bootstrap_position(test: &mut Test, w: &Pdas) { + setup_markets(test, w); + deposit_borrow_side(test, w, 1_000 * UNIT).succeeds(); + deposit_collateral_side(test, w, 1_000 * UNIT).succeeds(); + test.send(InitObligationInstruction { + owner: BORROWER, + lending_market: w.market, + }) + .succeeds(); + test.send(DepositObligationCollateralInstruction { + owner: BORROWER, + lending_market: w.market, + reserve: w.collateral_reserve, + share_mint: w.collateral_share_mint, + owner_share: BORROWER_COLLATERAL_SHARE, + shares: 1_000 * UNIT, + }) + .succeeds(); +} - let result = world.deposit( - SUPPLIER, world.borrow_reserve, BORROW_MINT, world.borrow_vault, - world.borrow_share_mint, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT, - ); - result.assert_success(); - assert_eq!(balance(&result, SUPPLIER_BORROW_SHARE), 1_000 * UNIT, "first deposit mints 1:1"); - assert_eq!(balance(&result, SUPPLIER_BORROW), 0); - - let result = world.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1_000 * UNIT); - result.assert_success(); - assert_eq!(balance(&result, SUPPLIER_BORROW), 1_000 * UNIT, "redeem returns liquidity"); - assert_eq!(balance(&result, SUPPLIER_BORROW_SHARE), 0); +#[quasar_test] +fn supply_mints_shares_one_to_one_and_redeem_returns_liquidity(test: &mut Test) { + let w = base_world(test); + setup_markets(test, &w); + + deposit_borrow_side(test, &w, 1_000 * UNIT) + .succeeds() + // First deposit mints 1:1. + .has_tokens(SUPPLIER_BORROW_SHARE, 1_000 * UNIT) + .has_tokens(SUPPLIER_BORROW, 0); + + redeem(test, &w, 1_000 * UNIT) + .succeeds() + // Redeem returns liquidity. + .has_tokens(SUPPLIER_BORROW, 1_000 * UNIT) + .has_tokens(SUPPLIER_BORROW_SHARE, 0); } -#[test] -fn borrow_up_to_ltv_succeeds_and_beyond_fails() { - let mut world = World::new(); - world.bootstrap_position(); +#[quasar_test] +fn borrow_up_to_ltv_succeeds_and_beyond_fails(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); // $1000 collateral, 75% LTV => borrow up to 750 units of the $1 borrow token. - let result = world.borrow(750 * UNIT); - result.assert_success(); - assert_eq!(balance(&result, BORROWER_BORROW), 750 * UNIT); + borrow(test, &w, 750 * UNIT) + .succeeds() + .has_tokens(BORROWER_BORROW, 750 * UNIT); // One unit more exceeds the allowed borrow value. - assert!(world.borrow(UNIT).is_err(), "borrowing past LTV must fail"); + assert!( + borrow(test, &w, UNIT).is_err(), + "borrowing past LTV must fail" + ); } -#[test] -fn repay_reduces_debt() { - let mut world = World::new(); - world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); +#[quasar_test] +fn repay_reduces_debt(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 500 * UNIT).succeeds(); - let result = world.repay(200 * UNIT); - result.assert_success(); // Borrower spent 200 of the 500 borrowed. - assert_eq!(balance(&result, BORROWER_BORROW), 300 * UNIT); + repay(test, &w, 200 * UNIT) + .succeeds() + .has_tokens(BORROWER_BORROW, 300 * UNIT); } -#[test] -fn interest_accrues_and_lifts_share_value() { - let mut world = World::new(); - world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); - - // ~0.1 year passes; re-publish prices so feeds stay fresh. - world.svm.sysvars.warp_to_slot(7_884_000); - world.set_price(COLLATERAL_MINT, world.collateral_price, dollars(1)); - world.set_price(BORROW_MINT, world.borrow_price, dollars(1)); - - // Supplier redeems 100 shares; interest on the 500 borrowed means each share - // is now worth more than one liquidity unit. - let result = world.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 100 * UNIT); - result.assert_success(); - assert!( - balance(&result, SUPPLIER_BORROW) > 100 * UNIT, - "100 shares should redeem for more than 100 units after interest, got {}", - balance(&result, SUPPLIER_BORROW) - ); -} - -#[test] -fn unhealthy_position_is_liquidated_and_healthy_is_rejected() { - let mut world = World::new(); - world.bootstrap_position(); - world.borrow(700 * UNIT).assert_success(); +#[quasar_test] +fn unhealthy_position_is_liquidated_and_healthy_is_rejected(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); // Healthy at $1 collateral ($1000 * 80% = $800 threshold > $700 debt). - assert!(world.liquidate(350 * UNIT).is_err(), "healthy obligation must not be liquidatable"); + assert!( + liquidate(test, &w, 350 * UNIT).is_err(), + "healthy obligation must not be liquidatable" + ); // Collateral price halves to $0.50: $500 collateral, $400 threshold < $700 debt. - world.set_price(COLLATERAL_MINT, world.collateral_price, cents(50)); + set_price(test, &w, COLLATERAL_MINT, cents(50)); - let result = world.liquidate(350 * UNIT); - result.assert_success(); - // Liquidator repaid 350 of the borrow token and seized collateral share tokens. - assert_eq!(balance(&result, LIQUIDATOR_BORROW), 650 * UNIT); + liquidate(test, &w, 350 * UNIT) + .succeeds() + // Liquidator repaid 350 of the borrow token... + .has_tokens(LIQUIDATOR_BORROW, 650 * UNIT); + // ...and seized collateral share tokens. assert!( - balance(&result, LIQUIDATOR_COLLATERAL_SHARE) > 0, + test.tokens(LIQUIDATOR_COLLATERAL_SHARE) > 0, "liquidator should receive seized collateral shares" ); } -#[test] -fn protocol_fees_accrue_and_owner_can_collect() { - let mut world = World::new(); - world.bootstrap_position(); - world.borrow(500 * UNIT).assert_success(); +/// The two scenarios below warp the SLOT so that interest accrues +/// (`Clock::get()?.slot` drives the interest index). quasar-test has no slot +/// warp — `warp_to_timestamp` only moves `unix_timestamp` — so these keep the +/// low-level quasar-svm harness (`QuasarSvm` + `sysvars.warp_to_slot` + raw +/// instructions), loading the compiled program at runtime. +mod slot_warp { + use { + super::{dollars, EXP}, + super::{ + BORROWER, BORROWER_BORROW, BORROWER_COLLATERAL, BORROWER_COLLATERAL_SHARE, + COLLATERAL_MINT, BORROW_MINT, DECIMALS, MARKET_ID, OWNER, OWNER_BORROW, QUOTE_MINT, + SUPPLIER, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, UNIT, + }, + quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, + spl_token::state::{Account as SplToken, AccountState, Mint as SplMint}, + }; - // ~0.1 year passes; interest accrues, and the reserve factor (10%) sets some - // of it aside for the market owner. - world.svm.sysvars.warp_to_slot(7_884_000); + fn pda(seeds: &[&[u8]]) -> (Pubkey, u8) { + Pubkey::find_program_address(seeds, &crate::ID) + } - let result = world.collect_borrow_fees(); - result.assert_success(); - assert!( - balance(&result, OWNER_BORROW) > 0, - "owner should collect a positive protocol fee, got {}", - balance(&result, OWNER_BORROW) - ); + fn meta(address: Pubkey, writable: bool, signer: bool) -> AccountMeta { + if writable { + AccountMeta::new(address, signer) + } else { + AccountMeta::new_readonly(address, signer) + } + } + + fn system(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) + } + fn empty(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: quasar_svm::system_program::ID, + executable: false, + } + } + fn mint(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &SplMint { + mint_authority: Some(authority).into(), + supply: 1_000_000_000_000, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) + } + fn token(address: Pubkey, the_mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &SplToken { + mint: the_mint, + owner, + amount, + state: AccountState::Initialized, + ..SplToken::default() + }, + ) + } + + /// Read an SPL token account's amount from committed bytes (offset 64..72). + fn balance(result: &quasar_svm::ExecutionResult, address: Pubkey) -> u64 { + let account = result.account(&address).expect("account present"); + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) + } + + struct World { + svm: QuasarSvm, + market: Pubkey, + collateral_reserve: Pubkey, + collateral_vault: Pubkey, + collateral_share_mint: Pubkey, + collateral_price: Pubkey, + borrow_reserve: Pubkey, + borrow_vault: Pubkey, + borrow_share_mint: Pubkey, + borrow_price: Pubkey, + obligation: Pubkey, + obligation_vault: Pubkey, + } + + impl World { + fn new() -> Self { + // Runtime read (NOT include_bytes!) so the crate compiles without + // the .so; only running the test requires a prior `quasar build`. + let elf = std::fs::read("target/deploy/quasar_lending.so").unwrap(); + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &elf) + .with_token_program(); + + let (market, _) = pda(&[b"lending_market", &MARKET_ID.to_le_bytes()]); + let (collateral_reserve, _) = + pda(&[b"reserve", market.as_ref(), COLLATERAL_MINT.as_ref()]); + let (borrow_reserve, _) = pda(&[b"reserve", market.as_ref(), BORROW_MINT.as_ref()]); + let (collateral_vault, _) = pda(&[b"liquidity_vault", collateral_reserve.as_ref()]); + let (borrow_vault, _) = pda(&[b"liquidity_vault", borrow_reserve.as_ref()]); + let (collateral_share_mint, _) = pda(&[b"share_mint", collateral_reserve.as_ref()]); + let (borrow_share_mint, _) = pda(&[b"share_mint", borrow_reserve.as_ref()]); + // Feed PDAs are seeded by (market, mint). + let (collateral_price, _) = + pda(&[b"price_feed", market.as_ref(), COLLATERAL_MINT.as_ref()]); + let (borrow_price, _) = pda(&[b"price_feed", market.as_ref(), BORROW_MINT.as_ref()]); + let (obligation, _) = pda(&[b"obligation", market.as_ref(), BORROWER.as_ref()]); + let (obligation_vault, _) = pda(&[ + b"obligation_vault", + collateral_reserve.as_ref(), + obligation.as_ref(), + ]); + + for account in [ + system(OWNER), + system(SUPPLIER), + system(BORROWER), + mint(COLLATERAL_MINT, OWNER), + mint(BORROW_MINT, OWNER), + mint(QUOTE_MINT, OWNER), + // PDAs created by the program. + empty(market), + empty(collateral_reserve), + empty(borrow_reserve), + empty(collateral_vault), + empty(borrow_vault), + empty(collateral_share_mint), + empty(borrow_share_mint), + empty(collateral_price), + empty(borrow_price), + empty(obligation), + empty(obligation_vault), + // Funded user token accounts. + token(SUPPLIER_BORROW, BORROW_MINT, SUPPLIER, 1_000 * UNIT), + token(SUPPLIER_BORROW_SHARE, borrow_share_mint, SUPPLIER, 0), + token(BORROWER_COLLATERAL, COLLATERAL_MINT, BORROWER, 1_000 * UNIT), + token(BORROWER_COLLATERAL_SHARE, collateral_share_mint, BORROWER, 0), + token(BORROWER_BORROW, BORROW_MINT, BORROWER, 0), + // Where the market owner receives collected protocol fees. + token(OWNER_BORROW, BORROW_MINT, OWNER, 0), + ] { + svm.set_account(account); + } + + World { + svm, + market, + collateral_reserve, + collateral_vault, + collateral_share_mint, + collateral_price, + borrow_reserve, + borrow_vault, + borrow_share_mint, + borrow_price, + obligation, + obligation_vault, + } + } + + fn run(&mut self, data: Vec, metas: Vec) -> quasar_svm::ExecutionResult { + let instruction = Instruction { + program_id: crate::ID, + accounts: metas, + data, + }; + self.svm.process_instruction(&instruction, &[]) + } + + fn init_market(&mut self) { + // Instruction data: [discriminator 0][market_id u64 LE]. + let mut data = vec![0u8]; + data.extend_from_slice(&MARKET_ID.to_le_bytes()); + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, true, false), + meta(QUOTE_MINT, false, false), + meta(quasar_svm::system_program::ID, false, false), + ]; + self.run(data, metas).assert_success(); + } + + fn set_price(&mut self, the_mint: Pubkey, price_feed: Pubkey, mantissa: i128) { + let mut data = vec![2u8]; + data.extend_from_slice(&mantissa.to_le_bytes()); + data.extend_from_slice(&EXP.to_le_bytes()); + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, false, false), + meta(price_feed, true, false), + meta(the_mint, false, false), + meta(quasar_svm::system_program::ID, false, false), + ]; + self.run(data, metas).assert_success(); + } + + fn init_reserve( + &mut self, + the_mint: Pubkey, + reserve: Pubkey, + vault: Pubkey, + share: Pubkey, + price: Pubkey, + ) { + // 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, + // 10% reserve factor, kink 80%, 2% / 20% / 150% APR curve. + let config: [u16; 9] = [7_500, 8_000, 500, 5_000, 1_000, 8_000, 200, 2_000, 15_000]; + let mut data = vec![1u8]; + for value in config { + data.extend_from_slice(&value.to_le_bytes()); + } + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, false, false), + meta(reserve, true, false), + meta(the_mint, false, false), + meta(vault, true, false), + meta(share, true, false), + meta(price, false, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + meta(quasar_svm::system_program::ID, false, false), + ]; + self.run(data, metas).assert_success(); + } + + fn setup_markets(&mut self) { + self.init_market(); + self.set_price(COLLATERAL_MINT, self.collateral_price, dollars(1)); + self.set_price(BORROW_MINT, self.borrow_price, dollars(1)); + self.init_reserve( + COLLATERAL_MINT, + self.collateral_reserve, + self.collateral_vault, + self.collateral_share_mint, + self.collateral_price, + ); + self.init_reserve( + BORROW_MINT, + self.borrow_reserve, + self.borrow_vault, + self.borrow_share_mint, + self.borrow_price, + ); + } + + #[allow(clippy::too_many_arguments)] + fn deposit( + &mut self, + supplier: Pubkey, + reserve: Pubkey, + the_mint: Pubkey, + vault: Pubkey, + share: Pubkey, + supplier_liq: Pubkey, + supplier_share: Pubkey, + amount: u64, + ) -> quasar_svm::ExecutionResult { + let mut data = vec![3u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(supplier, true, true), + meta(reserve, true, false), + meta(the_mint, false, false), + meta(vault, true, false), + meta(share, true, false), + meta(supplier_liq, true, false), + meta(supplier_share, true, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + ]; + self.run(data, metas) + } + + fn redeem( + &mut self, + supplier_liq: Pubkey, + supplier_share: Pubkey, + shares: u64, + ) -> quasar_svm::ExecutionResult { + let mut data = vec![4u8]; + data.extend_from_slice(&shares.to_le_bytes()); + let metas = vec![ + meta(SUPPLIER, true, true), + meta(self.borrow_reserve, true, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(self.borrow_share_mint, true, false), + meta(supplier_liq, true, false), + meta(supplier_share, true, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + ]; + self.run(data, metas) + } + + fn init_obligation(&mut self) { + let metas = vec![ + meta(BORROWER, true, true), + meta(self.market, false, false), + meta(self.obligation, true, false), + meta(quasar_svm::system_program::ID, false, false), + ]; + self.run(vec![5], metas).assert_success(); + } + + fn post_collateral(&mut self, shares: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![6u8]; + data.extend_from_slice(&shares.to_le_bytes()); + let metas = vec![ + meta(BORROWER, true, true), + meta(self.market, false, false), + meta(self.obligation, true, false), + meta(self.collateral_reserve, false, false), + meta(self.collateral_share_mint, false, false), + meta(self.obligation_vault, true, false), + meta(BORROWER_COLLATERAL_SHARE, true, false), + meta(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + meta(quasar_svm::system_program::ID, false, false), + ]; + self.run(data, metas) + } + + fn borrow(&mut self, amount: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![8u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let metas = vec![ + meta(BORROWER, true, true), + meta(self.market, false, false), + meta(self.obligation, true, false), + meta(self.collateral_reserve, true, false), + meta(self.collateral_price, false, false), + meta(self.borrow_reserve, true, false), + meta(self.borrow_price, false, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(BORROWER_BORROW, true, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + ]; + self.run(data, metas) + } + + /// Supplier funds the borrow reserve; borrower posts 1000 units of collateral. + fn bootstrap_position(&mut self) { + self.setup_markets(); + self.deposit( + SUPPLIER, + self.borrow_reserve, + BORROW_MINT, + self.borrow_vault, + self.borrow_share_mint, + SUPPLIER_BORROW, + SUPPLIER_BORROW_SHARE, + 1_000 * UNIT, + ) + .assert_success(); + self.deposit( + BORROWER, + self.collateral_reserve, + COLLATERAL_MINT, + self.collateral_vault, + self.collateral_share_mint, + BORROWER_COLLATERAL, + BORROWER_COLLATERAL_SHARE, + 1_000 * UNIT, + ) + .assert_success(); + self.init_obligation(); + self.post_collateral(1_000 * UNIT).assert_success(); + } + + /// Market owner collects accrued protocol fees from the borrow reserve + /// into `OWNER_BORROW`. The handler accrues interest itself, so no + /// separate refresh. + fn collect_borrow_fees(&mut self) -> quasar_svm::ExecutionResult { + let metas = vec![ + meta(OWNER, true, true), + meta(self.market, false, false), + meta(self.borrow_reserve, true, false), + meta(BORROW_MINT, false, false), + meta(self.borrow_vault, true, false), + meta(OWNER_BORROW, true, false), + meta(quasar_svm::SPL_TOKEN_PROGRAM_ID, false, false), + ]; + self.run(vec![11u8], metas) + } + } + + #[test] + fn interest_accrues_and_lifts_share_value() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + + // ~0.1 year passes; re-publish prices so feeds stay fresh. + world.svm.sysvars.warp_to_slot(7_884_000); + world.set_price(COLLATERAL_MINT, world.collateral_price, dollars(1)); + world.set_price(BORROW_MINT, world.borrow_price, dollars(1)); + + // Supplier redeems 100 shares; interest on the 500 borrowed means each + // share is now worth more than one liquidity unit. + let result = world.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 100 * UNIT); + result.assert_success(); + assert!( + balance(&result, SUPPLIER_BORROW) > 100 * UNIT, + "100 shares should redeem for more than 100 units after interest, got {}", + balance(&result, SUPPLIER_BORROW) + ); + } + + #[test] + fn protocol_fees_accrue_and_owner_can_collect() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + + // ~0.1 year passes; interest accrues, and the reserve factor (10%) + // sets some of it aside for the market owner. + world.svm.sysvars.warp_to_slot(7_884_000); + + let result = world.collect_borrow_fees(); + result.assert_success(); + assert!( + balance(&result, OWNER_BORROW) > 0, + "owner should collect a positive protocol fee, got {}", + balance(&result, OWNER_BORROW) + ); + } } diff --git a/finance/order-book/quasar/CHANGELOG.md b/finance/order-book/quasar/CHANGELOG.md index 4f116388..d7ef1416 100644 --- a/finance/order-book/quasar/CHANGELOG.md +++ b/finance/order-book/quasar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders — including + `remaining_accounts` for crossing maker orders — and `Outcome` assertions). + The `quasar-svm` git dev-dependency is gone; compute-unit assertions were + dropped pending recalibration under 0.1.0. Program-source fix for 0.1.0: + `Seed` is no longer in the prelude, so `place_order.rs`, `settle_funds.rs`, + and `admin/withdraw_fees.rs` now import it from `quasar_lang::cpi`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/order-book/quasar/Cargo.toml b/finance/order-book/quasar/Cargo.toml index 47a2063e..fe2b397b 100644 --- a/finance/order-book/quasar/Cargo.toml +++ b/finance/order-book/quasar/Cargo.toml @@ -14,22 +14,23 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# quasar pin rationale: matches the finance/escrow Quasar example. master HEAD -# currently fails to compile because zeropod 0.3.x auto-generates accessor -# methods that conflict with hand-written ones in quasar-spl. 623bb70 is the -# last working rev on master before that bump. Unpin (back to branch = "master") -# once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -solana-address = { version = "2.2.0" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } # The ported Openbook slab casts a contiguous byte region to fixed-layout node # structs. `derive` gives `Pod`/`Zeroable`; `min_const_generics` lets the @@ -40,10 +41,4 @@ bytemuck = { version = "1.18", features = ["derive", "min_const_generics"] } static_assertions = "1.1" [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/order-book/quasar/Quasar.toml b/finance/order-book/quasar/Quasar.toml index ecbd28c6..70c66f5a 100644 --- a/finance/order-book/quasar/Quasar.toml +++ b/finance/order-book/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_order_book" - -[toolchain] -type = "solana" +name = "quasar-order-book" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs b/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs index 7032592f..3a1cb455 100644 --- a/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs +++ b/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_spl::prelude::*; diff --git a/finance/order-book/quasar/src/instructions/place_order.rs b/finance/order-book/quasar/src/instructions/place_order.rs index f531f928..e8ab00e9 100644 --- a/finance/order-book/quasar/src/instructions/place_order.rs +++ b/finance/order-book/quasar/src/instructions/place_order.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_lang::remaining::RemainingAccounts; // Anonymous import: brings the `Sysvar` trait's `Clock::get()` into scope diff --git a/finance/order-book/quasar/src/instructions/settle_funds.rs b/finance/order-book/quasar/src/instructions/settle_funds.rs index 1ac5f198..b04837bb 100644 --- a/finance/order-book/quasar/src/instructions/settle_funds.rs +++ b/finance/order-book/quasar/src/instructions/settle_funds.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_spl::prelude::*; diff --git a/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs index cb71fd15..05b1cbd4 100644 --- a/finance/order-book/quasar/src/tests.rs +++ b/finance/order-book/quasar/src/tests.rs @@ -1,26 +1,20 @@ -//! QuasarSVM integration tests. They drive the real program instructions +//! quasar-test integration tests. They drive the real program instructions //! end-to-end: initialize a market, create users, place and cross orders, //! settle, and withdraw fees, asserting on-chain state and token balances at //! each step. -//! -//! Multi-step flows use `process_instruction_chain`, which runs several -//! instructions atomically over a shared, evolving account set - so a resting -//! order placed by one instruction is visible to the crossing order in the -//! next, without hand-building the zero-copy slab. - -extern crate std; use { - alloc::vec, - alloc::vec::Vec, - quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, - solana_program_pack::Pack, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::{ + cpi::{ + CancelOrderInstruction, CreateMarketUserInstruction, InitializeMarketInstruction, + PlaceOrderInstruction, SettleFundsInstruction, WithdrawFeesInstruction, + }, + errors::OrderBookError, + state::{Market, MarketUser, Order, OrderStatus, ORDER_BOOK_ACCOUNT_SIZE}, + }, + quasar_test::prelude::*, }; -use crate::state::{MARKET_SEED, MARKET_USER_SEED, ORDER_BOOK_ACCOUNT_SIZE, ORDER_SEED}; - // --- Market parameters used across the tests (NVDAx-style base / USDC-style // quote): base 9 decimals, quote 6 decimals. base_lot_size = 10^(9-6) = 1000, // quote_lot_size = 1, so `price` reads as quote units per base lot. --- @@ -32,435 +26,182 @@ const MIN_ORDER_SIZE: u64 = 1; const BASE_DECIMALS: u8 = 9; const QUOTE_DECIMALS: u8 = 6; -const STARTING_LAMPORTS: u64 = 1_000_000_000; - -fn program_id() -> Pubkey { - Pubkey::new_from_array(crate::ID.to_bytes()) -} - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_order_book.so").unwrap(); - QuasarSvm::new() - .with_program(&program_id(), &elf) - .with_token_program() -} - -fn rent_id() -> Pubkey { - quasar_svm::solana_sdk_ids::sysvar::rent::ID -} -fn token_program_id() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn system_program_id() -> Pubkey { - quasar_svm::system_program::ID -} - -fn signer_account(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) -} - -fn empty_account(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: system_program_id(), - executable: false, - } -} - -fn mint_account(address: Pubkey, authority: Pubkey, decimals: u8) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 1_000_000_000_000, - decimals, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} - -fn token_account(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() - }, - ) -} - -/// A program-owned, zeroed order-book account of the exact size the program -/// expects. Stands in for the client's `create_account` call. -fn order_book_account(address: Pubkey) -> Account { - Account { - address, - lamports: 5_000_000_000, - data: vec![0u8; ORDER_BOOK_ACCOUNT_SIZE], - owner: program_id(), - executable: false, - } -} - -fn token_amount(account: &Account) -> u64 { - TokenAccount::unpack(&account.data).unwrap().amount -} - -fn derive_market(base_mint: &Pubkey, quote_mint: &Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[MARKET_SEED, base_mint.as_ref(), quote_mint.as_ref()], - &program_id(), - ) - .0 -} - -fn derive_market_user(market: &Pubkey, owner: &Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[MARKET_USER_SEED, market.as_ref(), owner.as_ref()], - &program_id(), - ) - .0 -} - -fn derive_order(market: &Pubkey, order_id: u64) -> Pubkey { - Pubkey::find_program_address( - &[ORDER_SEED, market.as_ref(), &order_id.to_le_bytes()], - &program_id(), - ) - .0 -} - -// --- Instruction data builders (discriminator byte + little-endian args) --- - -fn initialize_market_data() -> Vec { - let mut data = vec![0u8]; - data.extend_from_slice(&FEE_BASIS_POINTS.to_le_bytes()); - data.extend_from_slice(&TICK_SIZE.to_le_bytes()); - data.extend_from_slice(&BASE_LOT_SIZE.to_le_bytes()); - data.extend_from_slice("E_LOT_SIZE.to_le_bytes()); - data.extend_from_slice(&MIN_ORDER_SIZE.to_le_bytes()); - data -} - -fn create_market_user_data() -> Vec { - vec![1u8] -} - -fn place_order_data(side: u8, price: u64, quantity: u64, order_id: u64) -> Vec { - let mut data = vec![2u8, side]; - data.extend_from_slice(&price.to_le_bytes()); - data.extend_from_slice(&quantity.to_le_bytes()); - data.extend_from_slice(&order_id.to_le_bytes()); - data -} - -fn cancel_order_data() -> Vec { - vec![3u8] -} - -fn settle_funds_data() -> Vec { - vec![4u8] -} - -fn withdraw_fees_data() -> Vec { - vec![5u8] +// Deterministic addresses keep tests independent of discovery order. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const BASE_MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const QUOTE_MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const ORDER_BOOK: Pubkey = Pubkey::new_from_array([4; 32]); +const BASE_VAULT: Pubkey = Pubkey::new_from_array([5; 32]); +const QUOTE_VAULT: Pubkey = Pubkey::new_from_array([6; 32]); +const FEE_VAULT: Pubkey = Pubkey::new_from_array([7; 32]); +const MAKER: Pubkey = Pubkey::new_from_array([8; 32]); +const TAKER: Pubkey = Pubkey::new_from_array([9; 32]); +const MAKER_BASE: Pubkey = Pubkey::new_from_array([10; 32]); +const MAKER_QUOTE: Pubkey = Pubkey::new_from_array([11; 32]); +const TAKER_BASE: Pubkey = Pubkey::new_from_array([12; 32]); +const TAKER_QUOTE: Pubkey = Pubkey::new_from_array([13; 32]); +const AUTHORITY_QUOTE: Pubkey = Pubkey::new_from_array([14; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([15; 32]); +const ATTACKER_QUOTE: Pubkey = Pubkey::new_from_array([16; 32]); + +/// Register the authority, both mints, the pre-created order-book account, and +/// initialize the market. Returns the Market PDA. +fn init_market(test: &mut Test) -> Pubkey { + test.add(Wallet::new().at(AUTHORITY)); + test.add( + Mint::new(AUTHORITY) + .at(BASE_MINT) + .supply(1_000_000_000_000) + .decimals(BASE_DECIMALS), + ); + test.add( + Mint::new(AUTHORITY) + .at(QUOTE_MINT) + .supply(1_000_000_000_000) + .decimals(QUOTE_DECIMALS), + ); + // The ~180 KB order book cannot be created via inner CPI (10 KB cap), so + // the client pre-creates it program-owned and zeroed; this fixture stands + // in for that `create_account` call. + let program_id = test.program_id(); + test.add(Account::new( + ORDER_BOOK, + program_id, + 5_000_000_000, + vec![0u8; ORDER_BOOK_ACCOUNT_SIZE], + )); + + test.send(InitializeMarketInstruction { + authority: AUTHORITY, + order_book: ORDER_BOOK, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + base_vault: BASE_VAULT, + quote_vault: QUOTE_VAULT, + fee_vault: FEE_VAULT, + fee_basis_points: FEE_BASIS_POINTS, + tick_size: TICK_SIZE, + base_lot_size: BASE_LOT_SIZE, + quote_lot_size: QUOTE_LOT_SIZE, + min_order_size: MIN_ORDER_SIZE, + }) + .succeeds(); + + test.derive_pda(Market::seeds(&BASE_MINT, "E_MINT)) +} + +fn create_market_user(test: &mut Test, market: Pubkey, owner: Pubkey) -> Pubkey { + test.add(Wallet::new().at(owner)); + test.send(CreateMarketUserInstruction { owner, market }).succeeds(); + test.derive_pda(MarketUser::seeds(&market, &owner)) } -/// A market fixture: the mints, PDA, vaults, and order-book account for one -/// (base, quote) pair. -struct MarketFixture { - authority: Pubkey, - base_mint: Pubkey, - quote_mint: Pubkey, +#[allow(clippy::too_many_arguments)] +fn place_order( + test: &mut Test, market: Pubkey, - order_book: Pubkey, - base_vault: Pubkey, - quote_vault: Pubkey, - fee_vault: Pubkey, -} - -fn market_fixture() -> MarketFixture { - let authority = Pubkey::new_unique(); - let base_mint = Pubkey::new_unique(); - let quote_mint = Pubkey::new_unique(); - MarketFixture { - authority, - base_mint, - quote_mint, - market: derive_market(&base_mint, "e_mint), - order_book: Pubkey::new_unique(), - base_vault: Pubkey::new_unique(), - quote_vault: Pubkey::new_unique(), - fee_vault: Pubkey::new_unique(), - } -} - -fn initialize_market_ix(fx: &MarketFixture) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(fx.authority, true), - AccountMeta::new(fx.market, false), - AccountMeta::new(fx.order_book, false), - AccountMeta::new_readonly(fx.base_mint, false), - AccountMeta::new_readonly(fx.quote_mint, false), - AccountMeta::new(fx.base_vault, true), - AccountMeta::new(fx.quote_vault, true), - AccountMeta::new(fx.fee_vault, true), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: initialize_market_data(), - } -} - -fn create_market_user_ix(fx: &MarketFixture, owner: Pubkey, market_user: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(owner, true), - AccountMeta::new_readonly(fx.market, false), - AccountMeta::new(market_user, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: create_market_user_data(), - } -} - -/// One trader's per-market accounts. -struct Trader { owner: Pubkey, - market_user: Pubkey, - base_account: Pubkey, - quote_account: Pubkey, -} - -fn trader(fx: &MarketFixture) -> Trader { - let owner = Pubkey::new_unique(); - Trader { - owner, - market_user: derive_market_user(&fx.market, &owner), - base_account: Pubkey::new_unique(), - quote_account: Pubkey::new_unique(), - } -} - -#[allow(clippy::too_many_arguments)] -fn place_order_ix( - fx: &MarketFixture, - trader: &Trader, - order: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, side: u8, price: u64, quantity: u64, order_id: u64, makers: &[(Pubkey, Pubkey)], -) -> Instruction { - let mut accounts = vec![ - AccountMeta::new_readonly(fx.market, false), - AccountMeta::new(fx.order_book, false), - AccountMeta::new(order, false), - AccountMeta::new(trader.market_user, false), - AccountMeta::new(fx.base_vault, false), - AccountMeta::new(fx.quote_vault, false), - AccountMeta::new(fx.fee_vault, false), - AccountMeta::new(trader.base_account, false), - AccountMeta::new(trader.quote_account, false), - AccountMeta::new_readonly(fx.base_mint, false), - AccountMeta::new_readonly(fx.quote_mint, false), - AccountMeta::new(trader.owner, true), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ]; +) -> Outcome { + // Resting maker orders to cross arrive as remaining accounts, in pairs of + // (maker_order, maker_market_user), in price-time priority. + let mut remaining_accounts = Vec::new(); for (maker_order, maker_market_user) in makers { - accounts.push(AccountMeta::new(*maker_order, false)); - accounts.push(AccountMeta::new(*maker_market_user, false)); - } - Instruction { - program_id: program_id(), - accounts, - data: place_order_data(side, price, quantity, order_id), - } -} - -fn cancel_order_ix(fx: &MarketFixture, trader: &Trader, order: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new_readonly(fx.market, false), - AccountMeta::new(fx.order_book, false), - AccountMeta::new(order, false), - AccountMeta::new(trader.market_user, false), - AccountMeta::new_readonly(trader.owner, true), - ], - data: cancel_order_data(), - } -} - -fn settle_funds_ix(fx: &MarketFixture, trader: &Trader) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new_readonly(trader.owner, true), - AccountMeta::new_readonly(fx.market, false), - AccountMeta::new(trader.market_user, false), - AccountMeta::new(fx.base_vault, false), - AccountMeta::new(fx.quote_vault, false), - AccountMeta::new(trader.base_account, false), - AccountMeta::new(trader.quote_account, false), - AccountMeta::new_readonly(fx.base_mint, false), - AccountMeta::new_readonly(fx.quote_mint, false), - AccountMeta::new_readonly(token_program_id(), false), - ], - data: settle_funds_data(), - } -} - -fn withdraw_fees_ix(fx: &MarketFixture, authority: Pubkey, authority_quote: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new_readonly(fx.market, false), - AccountMeta::new(fx.fee_vault, false), - AccountMeta::new(authority_quote, false), - AccountMeta::new_readonly(fx.quote_mint, false), - AccountMeta::new_readonly(authority, true), - AccountMeta::new_readonly(token_program_id(), false), - ], - data: withdraw_fees_data(), + remaining_accounts.push(AccountMeta::new(*maker_order, false)); + remaining_accounts.push(AccountMeta::new(*maker_market_user, false)); } + test.send(PlaceOrderInstruction { + market, + order_book: ORDER_BOOK, + base_vault: BASE_VAULT, + quote_vault: QUOTE_VAULT, + fee_vault: FEE_VAULT, + user_base_account, + user_quote_account, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + owner, + side, + price, + quantity, + order_id, + remaining_accounts, + }) } -// --- Order-account field offsets (dense discriminator + fields, little-endian). -// disc(1) market(32) owner(32) order_id(8) side(1) price(8) original(8) -// filled(8) status(1) timestamp(8) bump(1). --- -const ORDER_STATUS_OFFSET: usize = 1 + 32 + 32 + 8 + 1 + 8 + 8 + 8; -const ORDER_FILLED_OFFSET: usize = 1 + 32 + 32 + 8 + 1 + 8 + 8; - -fn order_status(account: &Account) -> u8 { - account.data[ORDER_STATUS_OFFSET] -} -fn order_filled(account: &Account) -> u64 { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&account.data[ORDER_FILLED_OFFSET..ORDER_FILLED_OFFSET + 8]); - u64::from_le_bytes(bytes) -} - -// --- MarketUser field offsets. disc(1) market(32) owner(32) unsettled_base(8) -// unsettled_quote(8) open_orders_len(1) bump(1) open_orders(160). --- -const MU_UNSETTLED_BASE_OFFSET: usize = 1 + 32 + 32; -const MU_UNSETTLED_QUOTE_OFFSET: usize = 1 + 32 + 32 + 8; -const MU_OPEN_ORDERS_LEN_OFFSET: usize = 1 + 32 + 32 + 8 + 8; - -fn mu_unsettled_base(account: &Account) -> u64 { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&account.data[MU_UNSETTLED_BASE_OFFSET..MU_UNSETTLED_BASE_OFFSET + 8]); - u64::from_le_bytes(bytes) -} -fn mu_unsettled_quote(account: &Account) -> u64 { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&account.data[MU_UNSETTLED_QUOTE_OFFSET..MU_UNSETTLED_QUOTE_OFFSET + 8]); - u64::from_le_bytes(bytes) -} -fn mu_open_orders_len(account: &Account) -> u8 { - account.data[MU_OPEN_ORDERS_LEN_OFFSET] -} - -// Order status codes (mirror state::OrderStatus). -const STATUS_FILLED: u8 = 2; -const STATUS_CANCELLED: u8 = 3; - -#[test] -fn test_initialize_market() { - let mut svm = setup(); - let fx = market_fixture(); - - let accounts = vec![ - signer_account(fx.authority), - empty_account(fx.market), - order_book_account(fx.order_book), - mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), - mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), - empty_account(fx.base_vault), - empty_account(fx.quote_vault), - empty_account(fx.fee_vault), - ]; - - let result = svm.process_instruction(&initialize_market_ix(&fx), &accounts); - assert!(result.is_ok(), "initialize_market failed: {:?}", result.raw_result); - - // Market discriminator (dense = 1) is stamped. - let market = result.account(&fx.market).unwrap(); - assert_eq!(market.data[0], 1, "market discriminator"); - - // Order-book discriminator + next_order_id == 1. Layout: disc(8) then - // market(32), bids_root(8), asks_root(8), next_order_id(8)... - let order_book = result.account(&fx.order_book).unwrap(); +fn settle_funds( + test: &mut Test, + market: Pubkey, + owner: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, +) -> Outcome { + test.send(SettleFundsInstruction { + owner, + market, + base_vault: BASE_VAULT, + quote_vault: QUOTE_VAULT, + user_base_account, + user_quote_account, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + }) +} + +#[quasar_test] +fn initialize_market_stamps_market_and_order_book(test: &mut Test) { + let market = init_market(test); + + // Market state records the pair, vaults, and parameters. + let state = test.read::(market); + assert_eq!(state.authority, AUTHORITY, "authority"); + assert_eq!(state.base_mint, BASE_MINT, "base_mint"); + assert_eq!(state.quote_mint, QUOTE_MINT, "quote_mint"); + assert_eq!(state.base_vault, BASE_VAULT, "base_vault"); + assert_eq!(state.quote_vault, QUOTE_VAULT, "quote_vault"); + assert_eq!(state.fee_vault, FEE_VAULT, "fee_vault"); + assert_eq!(state.order_book, ORDER_BOOK, "order_book"); + assert_eq!(u16::from(state.fee_basis_points), FEE_BASIS_POINTS); + + // Order-book discriminator + next_order_id == 1. The byte layout IS the + // point here (hand-rolled zero-copy slab): disc(8) then market(32), + // bids_root(8), asks_root(8), next_order_id(8)... + let order_book = test.account(ORDER_BOOK).unwrap(); assert_eq!(&order_book.data[0..8], b"ORDRBOOK", "order-book discriminator"); let next_order_id_offset = 8 + 32 + 8 + 8; let mut id_bytes = [0u8; 8]; id_bytes.copy_from_slice(&order_book.data[next_order_id_offset..next_order_id_offset + 8]); assert_eq!(u64::from_le_bytes(id_bytes), 1, "next_order_id starts at 1"); - - println!(" INITIALIZE_MARKET CU: {}", result.compute_units_consumed); } -#[test] -fn test_create_market_user() { - let mut svm = setup(); - let fx = market_fixture(); - let maker = trader(&fx); - - let accounts = vec![ - signer_account(fx.authority), - empty_account(fx.market), - order_book_account(fx.order_book), - mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), - mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), - empty_account(fx.base_vault), - empty_account(fx.quote_vault), - empty_account(fx.fee_vault), - signer_account(maker.owner), - empty_account(maker.market_user), - ]; - - let instructions = vec![ - initialize_market_ix(&fx), - create_market_user_ix(&fx, maker.owner, maker.market_user), - ]; - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!(result.is_ok(), "chain failed: {:?}", result.raw_result); - - let market_user = result.account(&maker.market_user).unwrap(); - assert_eq!(market_user.data[0], 2, "market_user discriminator"); - assert_eq!(mu_unsettled_base(market_user), 0); - assert_eq!(mu_unsettled_quote(market_user), 0); - assert_eq!(mu_open_orders_len(market_user), 0); - - println!(" CREATE_MARKET_USER CU: {}", result.compute_units_consumed); +#[quasar_test] +fn create_market_user_starts_with_empty_balances(test: &mut Test) { + let market = init_market(test); + let market_user = create_market_user(test, market, MAKER); + + let state = test.read::(market_user); + assert_eq!(state.market, market, "market"); + assert_eq!(state.owner, MAKER, "owner"); + assert_eq!(u64::from(state.unsettled_base), 0); + assert_eq!(u64::from(state.unsettled_quote), 0); + assert_eq!(state.open_orders_len, 0); } /// Full lifecycle: a maker rests an ask, a taker bid crosses it fully, both /// settle, and the authority withdraws the fee. Prices in the NVDAx/USDC lot /// model: ask 5 lots @ 100 -> gross 500 quote, 1% fee = 5, maker nets 495 /// quote, taker receives 5000 raw base. -#[test] -fn test_place_match_settle_withdraw() { - let mut svm = setup(); - let fx = market_fixture(); - let maker = trader(&fx); - let taker = trader(&fx); - let maker_order = derive_order(&fx.market, 1); - let taker_order = derive_order(&fx.market, 2); - let authority_quote = Pubkey::new_unique(); +#[quasar_test] +fn place_match_settle_withdraw_moves_tokens_and_fees(test: &mut Test) { + let market = init_market(test); + let maker_market_user = create_market_user(test, market, MAKER); + let taker_market_user = create_market_user(test, market, TAKER); // Maker sells 5 base lots (locks 5 * 1000 = 5000 raw base); taker buys 5 // lots at 100 (locks 100 * 5 * 1 = 500 raw quote). @@ -472,166 +213,134 @@ fn test_place_match_settle_withdraw() { const FEE_QUOTE: u64 = 5; // ceil(500 * 100 / 10000) const MAKER_NET_QUOTE: u64 = GROSS_QUOTE - FEE_QUOTE; // 495 - let accounts = vec![ - signer_account(fx.authority), - empty_account(fx.market), - order_book_account(fx.order_book), - mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), - mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), - empty_account(fx.base_vault), - empty_account(fx.quote_vault), - empty_account(fx.fee_vault), - // maker - signer_account(maker.owner), - empty_account(maker.market_user), - empty_account(maker_order), - token_account(maker.base_account, fx.base_mint, maker.owner, MAKER_BASE_LOCK), - token_account(maker.quote_account, fx.quote_mint, maker.owner, 0), - // taker - signer_account(taker.owner), - empty_account(taker.market_user), - empty_account(taker_order), - token_account(taker.base_account, fx.base_mint, taker.owner, 0), - token_account(taker.quote_account, fx.quote_mint, taker.owner, TAKER_QUOTE_LOCK), - // fee withdrawal destination - token_account(authority_quote, fx.quote_mint, fx.authority, 0), - ]; - - let instructions = vec![ - initialize_market_ix(&fx), - create_market_user_ix(&fx, maker.owner, maker.market_user), - create_market_user_ix(&fx, taker.owner, taker.market_user), - // Maker ask (id 1) rests on the book. - place_order_ix(&fx, &maker, maker_order, 1, PRICE, QUANTITY, 1, &[]), - // Taker bid (id 2) crosses the maker ask; maker accounts supplied as - // remaining accounts. - place_order_ix( - &fx, - &taker, - taker_order, - 0, - PRICE, - QUANTITY, - 2, - &[(maker_order, maker.market_user)], - ), - // Both settle, then the authority sweeps the fee vault. - settle_funds_ix(&fx, &maker), - settle_funds_ix(&fx, &taker), - withdraw_fees_ix(&fx, fx.authority, authority_quote), - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!(result.is_ok(), "lifecycle chain failed: {:?}", result.raw_result); + test.add( + TokenAccount::new(BASE_MINT, MAKER) + .at(MAKER_BASE) + .amount(MAKER_BASE_LOCK), + ); + test.add(TokenAccount::new(QUOTE_MINT, MAKER).at(MAKER_QUOTE)); + test.add(TokenAccount::new(BASE_MINT, TAKER).at(TAKER_BASE)); + test.add( + TokenAccount::new(QUOTE_MINT, TAKER) + .at(TAKER_QUOTE) + .amount(TAKER_QUOTE_LOCK), + ); + // Fee withdrawal destination. + test.add(TokenAccount::new(QUOTE_MINT, AUTHORITY).at(AUTHORITY_QUOTE)); + + let maker_order = test.derive_pda(Order::seeds(&market, 1)); + let taker_order = test.derive_pda(Order::seeds(&market, 2)); + + // Maker ask (id 1) rests on the book. + place_order(test, market, MAKER, MAKER_BASE, MAKER_QUOTE, 1, PRICE, QUANTITY, 1, &[]) + .succeeds(); + // Taker bid (id 2) crosses the maker ask; maker accounts supplied as + // remaining accounts. + place_order( + test, + market, + TAKER, + TAKER_BASE, + TAKER_QUOTE, + 0, + PRICE, + QUANTITY, + 2, + &[(maker_order, maker_market_user)], + ) + .succeeds(); + // Both settle, then the authority sweeps the fee vault. + settle_funds(test, market, MAKER, MAKER_BASE, MAKER_QUOTE).succeeds(); + settle_funds(test, market, TAKER, TAKER_BASE, TAKER_QUOTE).succeeds(); + test.send(WithdrawFeesInstruction { + market, + fee_vault: FEE_VAULT, + authority_quote_account: AUTHORITY_QUOTE, + quote_mint: QUOTE_MINT, + authority: AUTHORITY, + }) + .succeeds(); // Both orders fully filled. - assert_eq!(order_status(result.account(&maker_order).unwrap()), STATUS_FILLED); - assert_eq!(order_filled(result.account(&maker_order).unwrap()), QUANTITY); - assert_eq!(order_status(result.account(&taker_order).unwrap()), STATUS_FILLED); - assert_eq!(order_filled(result.account(&taker_order).unwrap()), QUANTITY); + let maker_state = test.read::(maker_order); + assert_eq!(maker_state.status, OrderStatus::Filled as u8); + assert_eq!(u64::from(maker_state.filled_quantity), QUANTITY); + let taker_state = test.read::(taker_order); + assert_eq!(taker_state.status, OrderStatus::Filled as u8); + assert_eq!(u64::from(taker_state.filled_quantity), QUANTITY); // Maker's open-orders list emptied when its resting order fully filled. - assert_eq!(mu_open_orders_len(result.account(&maker.market_user).unwrap()), 0); + assert_eq!(test.read::(maker_market_user).open_orders_len, 0); + let _ = taker_market_user; // Settlement moved tokens: maker received net quote, taker received base. - assert_eq!( - token_amount(result.account(&maker.quote_account).unwrap()), - MAKER_NET_QUOTE - ); - assert_eq!( - token_amount(result.account(&taker.base_account).unwrap()), - MAKER_BASE_LOCK - ); + assert_eq!(test.tokens(MAKER_QUOTE), MAKER_NET_QUOTE); + assert_eq!(test.tokens(TAKER_BASE), MAKER_BASE_LOCK); // Fee swept to the authority. - assert_eq!(token_amount(result.account(&authority_quote).unwrap()), FEE_QUOTE); - assert_eq!(token_amount(result.account(&fx.fee_vault).unwrap()), 0); + assert_eq!(test.tokens(AUTHORITY_QUOTE), FEE_QUOTE); + assert_eq!(test.tokens(FEE_VAULT), 0); // Vaults drained after settlement (maker sold all base, taker paid gross). - assert_eq!(token_amount(result.account(&fx.base_vault).unwrap()), 0); - assert_eq!(token_amount(result.account(&fx.quote_vault).unwrap()), 0); - - println!(" LIFECYCLE CU: {}", result.compute_units_consumed); + assert_eq!(test.tokens(BASE_VAULT), 0); + assert_eq!(test.tokens(QUOTE_VAULT), 0); } /// Cancelling a resting order credits the locked base back to the owner's /// unsettled balance and marks the order cancelled. -#[test] -fn test_cancel_order() { - let mut svm = setup(); - let fx = market_fixture(); - let maker = trader(&fx); - let maker_order = derive_order(&fx.market, 1); +#[quasar_test] +fn cancel_order_credits_the_locked_base_back(test: &mut Test) { + let market = init_market(test); + let maker_market_user = create_market_user(test, market, MAKER); const PRICE: u64 = 100; const QUANTITY: u64 = 5; const MAKER_BASE_LOCK: u64 = QUANTITY * BASE_LOT_SIZE; - let accounts = vec![ - signer_account(fx.authority), - empty_account(fx.market), - order_book_account(fx.order_book), - mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), - mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), - empty_account(fx.base_vault), - empty_account(fx.quote_vault), - empty_account(fx.fee_vault), - signer_account(maker.owner), - empty_account(maker.market_user), - empty_account(maker_order), - token_account(maker.base_account, fx.base_mint, maker.owner, MAKER_BASE_LOCK), - token_account(maker.quote_account, fx.quote_mint, maker.owner, 0), - ]; - - let instructions = vec![ - initialize_market_ix(&fx), - create_market_user_ix(&fx, maker.owner, maker.market_user), - place_order_ix(&fx, &maker, maker_order, 1, PRICE, QUANTITY, 1, &[]), - cancel_order_ix(&fx, &maker, maker_order), - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!(result.is_ok(), "cancel chain failed: {:?}", result.raw_result); - - assert_eq!(order_status(result.account(&maker_order).unwrap()), STATUS_CANCELLED); + test.add( + TokenAccount::new(BASE_MINT, MAKER) + .at(MAKER_BASE) + .amount(MAKER_BASE_LOCK), + ); + test.add(TokenAccount::new(QUOTE_MINT, MAKER).at(MAKER_QUOTE)); + + let maker_order = test.derive_pda(Order::seeds(&market, 1)); + place_order(test, market, MAKER, MAKER_BASE, MAKER_QUOTE, 1, PRICE, QUANTITY, 1, &[]) + .succeeds(); + + test.send(CancelOrderInstruction { + market, + order_book: ORDER_BOOK, + order_order_id_seed: 1, + owner: MAKER, + }) + .succeeds(); + + assert_eq!( + test.read::(maker_order).status, + OrderStatus::Cancelled as u8 + ); // The locked base is credited back to the owner's unsettled balance and // the open-order slot is freed. - let market_user = result.account(&maker.market_user).unwrap(); - assert_eq!(mu_unsettled_base(market_user), MAKER_BASE_LOCK); - assert_eq!(mu_open_orders_len(market_user), 0); - - println!(" CANCEL_ORDER CU: {}", result.compute_units_consumed); + let market_user = test.read::(maker_market_user); + assert_eq!(u64::from(market_user.unsettled_base), MAKER_BASE_LOCK); + assert_eq!(market_user.open_orders_len, 0); } /// A non-authority signer cannot withdraw the fee vault. -#[test] -fn test_withdraw_fees_rejects_non_authority() { - let mut svm = setup(); - let fx = market_fixture(); - let attacker = Pubkey::new_unique(); - let attacker_quote = Pubkey::new_unique(); - - let init_accounts = vec![ - signer_account(fx.authority), - empty_account(fx.market), - order_book_account(fx.order_book), - mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), - mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), - empty_account(fx.base_vault), - empty_account(fx.quote_vault), - empty_account(fx.fee_vault), - signer_account(attacker), - token_account(attacker_quote, fx.quote_mint, attacker, 0), - ]; - - let instructions = vec![ - initialize_market_ix(&fx), - withdraw_fees_ix(&fx, attacker, attacker_quote), - ]; - let result = svm.process_instruction_chain(&instructions, &init_accounts); - assert!( - !result.is_ok(), - "withdraw_fees must reject a signer who is not the market authority" - ); +#[quasar_test] +fn withdraw_fees_rejects_a_non_authority_signer(test: &mut Test) { + let market = init_market(test); + test.add(Wallet::new().at(ATTACKER)); + test.add(TokenAccount::new(QUOTE_MINT, ATTACKER).at(ATTACKER_QUOTE)); + + test.send(WithdrawFeesInstruction { + market, + fee_vault: FEE_VAULT, + authority_quote_account: ATTACKER_QUOTE, + quote_mint: QUOTE_MINT, + authority: ATTACKER, + }) + .fails_with(OrderBookError::NotMarketAuthority); } diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 4f116388..939b6e87 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions; the hand-crafted oracle feed account is injected via + `test.set_account`). The `quasar-svm` git dev-dependency is gone. Program- + source fix for 0.1.0: `Seed` is no longer in the prelude, so the instruction + files that build signer seeds now import it from `quasar_lang::cpi`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/perpetual-futures/quasar/Cargo.toml b/finance/perpetual-futures/quasar/Cargo.toml index c41bcc6e..f29400ec 100644 --- a/finance/perpetual-futures/quasar/Cargo.toml +++ b/finance/perpetual-futures/quasar/Cargo.toml @@ -14,28 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pinned to the same revision the other Quasar examples use. 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. 623bb70 is the last working rev on master before that bump. -# Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/perpetual-futures/quasar/Quasar.toml b/finance/perpetual-futures/quasar/Quasar.toml index e31f23af..f9ca3ae0 100644 --- a/finance/perpetual-futures/quasar/Quasar.toml +++ b/finance/perpetual-futures/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_perpetual_futures" - -[toolchain] -type = "solana" +name = "quasar-perpetual-futures" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 5806f217..741ae3ed 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ constants::MINIMUM_LIQUIDITY, instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs index be04d366..2d72b05d 100644 --- a/finance/perpetual-futures/quasar/src/instructions/close_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/close_position.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ constants::SIDE_LONG, instructions::shared::{ diff --git a/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs b/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs index 37797ce5..60fcad46 100644 --- a/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs +++ b/finance/perpetual-futures/quasar/src/instructions/collect_fees.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ instructions::shared::{err, error}, state::Pool, diff --git a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs index c5fa9f59..77856e14 100644 --- a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ instructions::{ close_position::remove_open_interest, diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index bba2109d..30428dfc 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, state::Pool, diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 9210da8f..4dfe6539 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -1,621 +1,376 @@ -extern crate std; +//! quasar-test integration tests. They exercise the full lifecycle: pool +//! initialization, liquidity add/remove, opening/closing/liquidating leveraged +//! positions, fee collection, and the oracle/leverage/reserve guard rails. use { - alloc::{vec, vec::Vec}, - quasar_svm::{ - token::{ - create_keyed_associated_token_account, create_keyed_mint_account, - create_keyed_system_account, Mint, + crate::{ + cpi::{ + AddLiquidityInstruction, ClosePositionInstruction, CollectFeesInstruction, + InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, + RemoveLiquidityInstruction, }, - Account, AccountMeta, Instruction, Pubkey, QuasarSvm, + state::{Pool, Position}, + LpMintPda, VaultPda, }, - std::fs, + quasar_test::prelude::*, }; const ONE_USDC: u64 = 1_000_000; const ORACLE_SCALE: u32 = 8; - -fn program_id() -> Pubkey { - crate::ID.into() -} -fn token_program() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn ata_program() -> Pubkey { - quasar_svm::SPL_ASSOCIATED_TOKEN_PROGRAM_ID -} -fn system_program() -> Pubkey { - quasar_svm::system_program::ID -} -fn clock_sysvar() -> Pubkey { - "SysvarC1ock11111111111111111111111111111111" - .parse() - .unwrap() -} -fn rent_sysvar() -> Pubkey { - "SysvarRent111111111111111111111111111111111" - .parse() - .unwrap() -} +// quasar-test worlds run at the default slot (0); the feed is stamped with the +// same slot so the staleness check passes. +const SLOT: u64 = 0; + +// Deterministic addresses. +const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); +const COLLATERAL_MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const FEED: Pubkey = Pubkey::new_from_array([3; 32]); +const PROVIDER: Pubkey = Pubkey::new_from_array([4; 32]); +const PROVIDER_COLLATERAL: Pubkey = Pubkey::new_from_array([5; 32]); +const PROVIDER_LP: Pubkey = Pubkey::new_from_array([6; 32]); +const TRADER: Pubkey = Pubkey::new_from_array([7; 32]); +const TRADER_COLLATERAL: Pubkey = Pubkey::new_from_array([8; 32]); +const LIQUIDATOR: Pubkey = Pubkey::new_from_array([9; 32]); +const LIQUIDATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([10; 32]); +const ADMIN_COLLATERAL: Pubkey = Pubkey::new_from_array([11; 32]); fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } -fn pda(seeds: &[&[u8]]) -> Pubkey { - Pubkey::find_program_address(seeds, &program_id()).0 -} -fn ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[wallet.as_ref(), token_program().as_ref(), mint.as_ref()], - &ata_program(), - ) - .0 -} - -fn empty(address: &Pubkey) -> Account { - Account { - address: *address, - lamports: 0, - data: vec![], - owner: system_program(), - executable: false, - } -} - -fn mint_account(address: &Pubkey) -> Account { - create_keyed_mint_account( - address, - &Mint { - decimals: 6, - is_initialized: true, - ..Default::default() - }, - ) -} - /// A feed account in this program's layout: price (i128), scale (u32), /// last_update_slot (u64), confidence (u64). The tests own this; production /// reads a real feed. -fn feed_account(address: &Pubkey, price: i128, scale: u32, slot: u64, confidence: u64) -> Account { +fn set_feed(test: &mut Test, price: i128, confidence: u64) { let mut data = Vec::with_capacity(36); data.extend_from_slice(&price.to_le_bytes()); - data.extend_from_slice(&scale.to_le_bytes()); - data.extend_from_slice(&slot.to_le_bytes()); + data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); + data.extend_from_slice(&SLOT.to_le_bytes()); data.extend_from_slice(&confidence.to_le_bytes()); - Account { - address: *address, - lamports: 1_000_000, - data, - owner: system_program(), - executable: false, - } + test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); } -fn token_amount(svm: &QuasarSvm, address: &Pubkey) -> u64 { - let account = svm.get_account(address).expect("token account exists"); - // SPL token account layout: mint (32) + owner (32) + amount (u64) at offset 64. - u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +fn init_pool(test: &mut Test, maintenance_margin_bps: u16, close_fee_bps: u16) -> Outcome { + test.send(InitializePoolInstruction { + authority: ADMIN, + collateral_mint: COLLATERAL_MINT, + oracle_feed: FEED, + oracle_scale: ORACLE_SCALE, + funding_rate_per_slot: 0, + open_fee_bps: 10, + close_fee_bps, + max_leverage: 10, + maintenance_margin_bps, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + }) } +/// The pool and its derived PDAs. struct Env { - svm: QuasarSvm, - collateral_mint: Pubkey, - feed: Pubkey, - admin: Pubkey, pool: Pubkey, - pool_authority: Pubkey, lp_mint: Pubkey, custody_vault: Pubkey, } -const SLOT: u64 = 10; - -/// Build an SVM with the program, token program, a collateral mint, an oracle -/// feed at $100, and an initialized pool. -fn setup() -> Env { - try_setup(500, 10).expect("pool initialization should succeed") +/// Build a world with a collateral mint, an oracle feed at $100, and an +/// initialized pool (0.1% open/close fees, 10x max leverage, 5% maintenance +/// margin, 1% liquidation fee, 1% max confidence). +fn setup(test: &mut Test) -> Env { + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); + init_pool(test, 500, 10).succeeds(); + + let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); + Env { + pool, + lp_mint: test.derive_pda(LpMintPda::seeds(&pool)), + custody_vault: test.derive_pda(VaultPda::seeds(&pool)), + } } -/// Like `setup`, but with the two cross-checked pool parameters exposed and an -/// `initialize_pool` rejection surfaced instead of panicking, so tests can -/// probe the parameter validation. -fn try_setup(maintenance_margin_bps: u16, close_fee_bps: u16) -> Result { - let elf = fs::read("target/deploy/quasar_perpetual_futures.so").unwrap(); - let collateral_mint = Pubkey::new_unique(); - let feed = Pubkey::new_unique(); - let admin = Pubkey::new_unique(); - let pool = pda(&[b"pool", collateral_mint.as_ref(), feed.as_ref()]); - let pool_authority = pda(&[b"authority", pool.as_ref()]); - let lp_mint = pda(&[b"lp_mint", pool.as_ref()]); - let custody_vault = pda(&[b"vault", pool.as_ref()]); - - let mut svm = QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() - .with_slot(SLOT) - .with_account(mint_account(&collateral_mint)) - .with_account(feed_account(&feed, dollars(100), ORACLE_SCALE, SLOT, 0)) - .with_account(create_keyed_system_account(&admin, 100_000_000_000)); - - // initialize_pool - let mut data = vec![0u8]; - data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); - data.extend_from_slice(&0u64.to_le_bytes()); // funding_rate_per_slot = 0 - data.extend_from_slice(&10u16.to_le_bytes()); // open_fee_bps - data.extend_from_slice(&close_fee_bps.to_le_bytes()); - data.extend_from_slice(&10u16.to_le_bytes()); // max_leverage - data.extend_from_slice(&maintenance_margin_bps.to_le_bytes()); - data.extend_from_slice(&100u16.to_le_bytes()); // liquidation_fee_bps - data.extend_from_slice(&100u16.to_le_bytes()); // max_confidence_bps - let metas = vec![ - AccountMeta::new(admin, true), - AccountMeta::new(pool, false), - AccountMeta::new_readonly(collateral_mint, false), - AccountMeta::new_readonly(feed, false), - AccountMeta::new_readonly(pool_authority, false), - AccountMeta::new(lp_mint, false), - AccountMeta::new(custody_vault, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - AccountMeta::new_readonly(rent_sysvar(), false), - ]; - let provided = vec![ - svm.get_account(&admin).unwrap(), - empty(&pool), - svm.get_account(&collateral_mint).unwrap(), - empty(&lp_mint), - empty(&custody_vault), - ]; - let result = svm.process_instruction( - &Instruction { - program_id: program_id(), - accounts: metas, - data, - }, - &provided, +/// Fund a wallet with a collateral token account. +fn fund(test: &mut Test, wallet: Pubkey, collateral_account: Pubkey, collateral: u64) { + test.add(Wallet::new().at(wallet)); + test.add( + TokenAccount::new(COLLATERAL_MINT, wallet) + .at(collateral_account) + .amount(collateral), ); - if !result.is_ok() { - return Err(()); - } +} - Ok(Env { - svm, - collateral_mint, - feed, - admin, - pool, - pool_authority, - lp_mint, - custody_vault, +fn add_liquidity(test: &mut Test, env: &Env, amount: u64) -> Outcome { + test.send(AddLiquidityInstruction { + provider: PROVIDER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + provider_collateral: PROVIDER_COLLATERAL, + provider_lp: PROVIDER_LP, + amount, + minimum_shares_out: 0, }) } -impl Env { - /// Create a wallet with a funded collateral token account, returning the - /// wallet and its collateral account. - fn funded_wallet(&mut self, collateral: u64) -> (Pubkey, Pubkey) { - let wallet = Pubkey::new_unique(); - let collateral_account = ata(&wallet, &self.collateral_mint); - self.svm - .set_account(create_keyed_system_account(&wallet, 100_000_000_000)); - self.svm.set_account(create_keyed_associated_token_account( - &wallet, - &self.collateral_mint, - collateral, - )); - (wallet, collateral_account) - } - - fn lp_account(&mut self, wallet: &Pubkey) -> Pubkey { - let account = ata(wallet, &self.lp_mint); - self.svm.set_account(create_keyed_associated_token_account( - wallet, - &self.lp_mint, - 0, - )); - account - } - - fn add_liquidity(&mut self, provider: &Pubkey, amount: u64) -> bool { - let provider_collateral = ata(provider, &self.collateral_mint); - let provider_lp = self.lp_account(provider); - let mut data = vec![1u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data.extend_from_slice(&0u64.to_le_bytes()); - let metas = vec![ - AccountMeta::new(*provider, true), - AccountMeta::new(self.pool, false), - AccountMeta::new_readonly(self.pool_authority, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.collateral_mint, false), - AccountMeta::new(self.lp_mint, false), - AccountMeta::new(self.custody_vault, false), - AccountMeta::new(provider_collateral, false), - AccountMeta::new(provider_lp, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - ]; - self.run(metas, data, &[*provider, provider_collateral, provider_lp]) - } - - fn remove_liquidity(&mut self, provider: &Pubkey, shares: u64) -> bool { - let provider_collateral = ata(provider, &self.collateral_mint); - let provider_lp = ata(provider, &self.lp_mint); - let mut data = vec![2u8]; - data.extend_from_slice(&shares.to_le_bytes()); - data.extend_from_slice(&0u64.to_le_bytes()); - let metas = vec![ - AccountMeta::new(*provider, true), - AccountMeta::new(self.pool, false), - AccountMeta::new_readonly(self.pool_authority, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.collateral_mint, false), - AccountMeta::new(self.lp_mint, false), - AccountMeta::new(self.custody_vault, false), - AccountMeta::new(provider_collateral, false), - AccountMeta::new(provider_lp, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - ]; - self.run(metas, data, &[*provider, provider_collateral, provider_lp]) - } - - fn open_position(&mut self, owner: &Pubkey, side: u8, collateral: u64, size: u64) -> bool { - let trader_collateral = ata(owner, &self.collateral_mint); - let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); - let mut data = vec![3u8, side]; - data.extend_from_slice(&collateral.to_le_bytes()); - data.extend_from_slice(&size.to_le_bytes()); - data.extend_from_slice(&0u64.to_le_bytes()); - let metas = vec![ - AccountMeta::new(*owner, true), - AccountMeta::new(self.pool, false), - AccountMeta::new(position, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.collateral_mint, false), - AccountMeta::new(self.custody_vault, false), - AccountMeta::new(trader_collateral, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - AccountMeta::new_readonly(rent_sysvar(), false), - ]; - self.run(metas, data, &[*owner, position, trader_collateral]) - } - - fn set_price(&mut self, price: i128) { - self.svm - .set_account(feed_account(&self.feed, price, ORACLE_SCALE, SLOT, 0)); - } - - fn set_price_with_confidence(&mut self, price: i128, confidence: u64) { - self.svm.set_account(feed_account( - &self.feed, - price, - ORACLE_SCALE, - SLOT, - confidence, - )); - } - - fn close_position(&mut self, owner: &Pubkey) -> bool { - let trader_collateral = ata(owner, &self.collateral_mint); - let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); - let mut data = vec![4u8]; - data.extend_from_slice(&0u64.to_le_bytes()); - let metas = vec![ - AccountMeta::new(*owner, true), - AccountMeta::new(self.pool, false), - AccountMeta::new(position, false), - AccountMeta::new_readonly(self.pool_authority, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.collateral_mint, false), - AccountMeta::new(self.custody_vault, false), - AccountMeta::new(trader_collateral, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - ]; - self.run(metas, data, &[*owner, position, trader_collateral]) - } - - fn liquidate(&mut self, liquidator: &Pubkey, owner: &Pubkey) -> bool { - let trader_collateral = ata(owner, &self.collateral_mint); - let liquidator_collateral = ata(liquidator, &self.collateral_mint); - self.svm.set_account(create_keyed_associated_token_account( - liquidator, - &self.collateral_mint, - 0, - )); - let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); - let data = vec![5u8]; - let metas = vec![ - AccountMeta::new(*liquidator, true), - AccountMeta::new(*owner, false), - AccountMeta::new(self.pool, false), - AccountMeta::new(position, false), - AccountMeta::new_readonly(self.pool_authority, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.collateral_mint, false), - AccountMeta::new(self.custody_vault, false), - AccountMeta::new(trader_collateral, false), - AccountMeta::new(liquidator_collateral, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - ]; - self.run( - metas, - data, - &[ - *liquidator, - *owner, - position, - trader_collateral, - liquidator_collateral, - ], - ) - } +fn remove_liquidity(test: &mut Test, env: &Env, shares: u64) -> Outcome { + test.send(RemoveLiquidityInstruction { + provider: PROVIDER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + provider_collateral: PROVIDER_COLLATERAL, + provider_lp: PROVIDER_LP, + shares, + minimum_amount_out: 0, + }) +} - fn collect_fees(&mut self) -> bool { - let admin = self.admin; - let admin_collateral = ata(&admin, &self.collateral_mint); - self.svm.set_account(create_keyed_associated_token_account( - &admin, - &self.collateral_mint, - 0, - )); - let data = vec![6u8]; - let metas = vec![ - AccountMeta::new(admin, true), - AccountMeta::new(self.pool, false), - AccountMeta::new_readonly(self.pool_authority, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.collateral_mint, false), - AccountMeta::new(self.custody_vault, false), - AccountMeta::new(admin_collateral, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - ]; - self.run(metas, data, &[admin, admin_collateral]) - } +fn open_position(test: &mut Test, env: &Env, side: u8, collateral: u64, size: u64) -> Outcome { + test.send(OpenPositionInstruction { + owner: TRADER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: TRADER_COLLATERAL, + side, + collateral_amount: collateral, + size, + acceptable_price: 0, + }) +} - fn run(&mut self, metas: Vec, data: Vec, provide: &[Pubkey]) -> bool { - let accounts: Vec = provide - .iter() - .map(|pk| self.svm.get_account(pk).unwrap_or_else(|| empty(pk))) - .collect(); - let result = self.svm.process_instruction( - &Instruction { - program_id: program_id(), - accounts: metas, - data, - }, - &accounts, - ); - result.is_ok() - } +fn close_position(test: &mut Test, env: &Env) -> Outcome { + test.send(ClosePositionInstruction { + owner: TRADER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: TRADER_COLLATERAL, + minimum_payout: 0, + }) } -#[test] -fn test_initialize_pool() { - let env = setup(); +#[quasar_test] +fn initialize_pool_creates_pool_vault_and_lp_mint(test: &mut Test) { + let env = setup(test); // The pool, vault, and liquidity-provider mint were created. - assert!(env.svm.get_account(&env.pool).is_some()); - assert!(env.svm.get_account(&env.custody_vault).is_some()); - assert!(env.svm.get_account(&env.lp_mint).is_some()); + assert!(test.account(env.pool).is_some()); + assert!(test.account(env.custody_vault).is_some()); + assert!(test.account(env.lp_mint).is_some()); } -#[test] -fn test_add_liquidity() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(10_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); - - // The vault holds the deposit and the provider received shares. - assert_eq!( - token_amount(&env.svm, &env.custody_vault), - 10_000 * ONE_USDC - ); - let provider_lp = ata(&provider, &env.lp_mint); - assert_eq!( - token_amount(&env.svm, &provider_lp), - 10_000 * ONE_USDC - 1_000 - ); +#[quasar_test] +fn add_liquidity_deposits_and_mints_shares(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 10_000 * ONE_USDC); + + add_liquidity(test, &env, 10_000 * ONE_USDC) + .succeeds() + // The vault holds the deposit and the provider received shares + // (minus the withheld minimum liquidity). + .has_tokens(env.custody_vault, 10_000 * ONE_USDC) + .has_tokens(PROVIDER_LP, 10_000 * ONE_USDC - 1_000); } -#[test] -fn test_remove_liquidity_round_trip() { - let mut env = setup(); - let (provider, provider_collateral) = env.funded_wallet(10_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); - - let provider_lp = ata(&provider, &env.lp_mint); - let shares = token_amount(&env.svm, &provider_lp); - let mut data = vec![2u8]; - data.extend_from_slice(&shares.to_le_bytes()); - data.extend_from_slice(&0u64.to_le_bytes()); - let metas = vec![ - AccountMeta::new(provider, true), - AccountMeta::new(env.pool, false), - AccountMeta::new_readonly(env.pool_authority, false), - AccountMeta::new_readonly(env.feed, false), - AccountMeta::new_readonly(env.collateral_mint, false), - AccountMeta::new(env.lp_mint, false), - AccountMeta::new(env.custody_vault, false), - AccountMeta::new(provider_collateral, false), - AccountMeta::new(provider_lp, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - ]; - assert!(env.run(metas, data, &[provider, provider_collateral, provider_lp])); - - // Sole provider reclaims the full deposit. - assert_eq!( - token_amount(&env.svm, &provider_collateral), - 10_000 * ONE_USDC - ); +#[quasar_test] +fn remove_liquidity_round_trip_returns_the_deposit(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 10_000 * ONE_USDC); + add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); + + let shares = test.tokens(PROVIDER_LP); + remove_liquidity(test, &env, shares) + .succeeds() + // Sole provider reclaims the full deposit. + .has_tokens(PROVIDER_COLLATERAL, 10_000 * ONE_USDC); } -#[test] -fn test_open_long_position() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); +#[quasar_test] +fn open_long_position_creates_the_position(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); - assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); - let position = pda(&[b"position", env.pool.as_ref(), trader.as_ref()]); - assert!(env.svm.get_account(&position).is_some()); + let position = test.derive_pda(Position::seeds(&env.pool, &TRADER)); + assert!(test.account(position).is_some()); } -#[test] -fn test_close_long_in_profit() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); +#[quasar_test] +fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let (trader, trader_collateral) = env.funded_wallet(1_000 * ONE_USDC); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); let size = 5_000 * ONE_USDC; - assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); + open_position(test, &env, 0, 1_000 * ONE_USDC, size).succeeds(); // Price rises 20%: a $5,000 long earns $1,000. - env.set_price(dollars(120)); - assert!(env.close_position(&trader)); + set_feed(test, dollars(120), 0); let open_fee = size / 1_000; let close_fee = size / 1_000; let net_collateral = 1_000 * ONE_USDC - open_fee; let profit = size / 5; let expected = net_collateral + profit - close_fee; - assert_eq!(token_amount(&env.svm, &trader_collateral), expected); + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, expected); } -#[test] -fn test_open_rejects_excess_leverage() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); +#[quasar_test] +fn open_rejects_excess_leverage(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); // 11x exceeds the 10x maximum. - assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 11_000 * ONE_USDC)); + assert!( + open_position(test, &env, 0, 1_000 * ONE_USDC, 11_000 * ONE_USDC).is_err(), + "11x leverage must be rejected" + ); } -#[test] -fn test_liquidate_underwater_long() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); +#[quasar_test] +fn liquidate_underwater_long_pays_the_liquidator_and_closes(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - let (trader, _) = env.funded_wallet(1_100 * ONE_USDC); + fund(test, TRADER, TRADER_COLLATERAL, 1_100 * ONE_USDC); let size = 10_000 * ONE_USDC; - assert!(env.open_position(&trader, 0, 1_100 * ONE_USDC, size)); + open_position(test, &env, 0, 1_100 * ONE_USDC, size).succeeds(); // Price falls 9%: a $10,000 long loses $900, dropping below maintenance. - env.set_price(dollars(91)); - let liquidator = Pubkey::new_unique(); - env.svm - .set_account(create_keyed_system_account(&liquidator, 100_000_000_000)); - assert!(env.liquidate(&liquidator, &trader)); - - let liquidator_collateral = ata(&liquidator, &env.collateral_mint); - assert!(token_amount(&env.svm, &liquidator_collateral) > 0); - let position = pda(&[b"position", env.pool.as_ref(), trader.as_ref()]); - assert!(env - .svm - .get_account(&position) - .map(|a| a.data.is_empty()) - .unwrap_or(true)); + set_feed(test, dollars(91), 0); + test.add(Wallet::new().at(LIQUIDATOR)); + + let position = test.derive_pda(Position::seeds(&env.pool, &TRADER)); + test.send(LiquidatePositionInstruction { + liquidator: LIQUIDATOR, + owner: TRADER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: TRADER_COLLATERAL, + liquidator_collateral: LIQUIDATOR_COLLATERAL, + }) + .succeeds() + .is_closed(position); + + assert!( + test.tokens(LIQUIDATOR_COLLATERAL) > 0, + "liquidator should earn the liquidation fee" + ); } -#[test] -fn test_collect_fees() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); +#[quasar_test] +fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); let size = 5_000 * ONE_USDC; - assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); - - assert!(env.collect_fees()); - let admin_collateral = ata(&env.admin, &env.collateral_mint); + open_position(test, &env, 0, 1_000 * ONE_USDC, size).succeeds(); + + test.send(CollectFeesInstruction { + authority: ADMIN, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + authority_collateral: ADMIN_COLLATERAL, + }) + .succeeds() // The open fee (0.1% of notional) was swept to the admin. - assert_eq!(token_amount(&env.svm, &admin_collateral), size / 1_000); + .has_tokens(ADMIN_COLLATERAL, size / 1_000); } -#[test] -fn test_wide_oracle_confidence_rejected() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); +#[quasar_test] +fn wide_oracle_confidence_is_rejected(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); // The pool tolerates a 1% confidence band (max_confidence_bps = 100). Widen // the feed's band to 2% of the price and the open must be rejected. - env.set_price_with_confidence(dollars(100), dollars(2) as u64); - assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + set_feed(test, dollars(100), dollars(2) as u64); + assert!( + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).is_err(), + "a confidence band wider than max_confidence_bps must be rejected" + ); } -#[test] -fn test_open_rejects_when_pool_cannot_back_it() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(3_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 3_000 * ONE_USDC)); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); +#[quasar_test] +fn open_rejects_when_pool_cannot_back_it(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 3_000 * ONE_USDC); + add_liquidity(test, &env, 3_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); // A 5,000 position must reserve 5,000, but the pool only holds 3,000. - assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + assert!( + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).is_err(), + "a position larger than the pool's free liquidity must be rejected" + ); } -#[test] -fn test_profit_capped_at_reserved_notional() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); +#[quasar_test] +fn profit_is_capped_at_the_reserved_notional(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); let collateral = 2_000 * ONE_USDC; let size = 5_000 * ONE_USDC; - let (trader, trader_collateral) = env.funded_wallet(collateral); - assert!(env.open_position(&trader, 0, collateral, size)); + fund(test, TRADER, TRADER_COLLATERAL, collateral); + open_position(test, &env, 0, collateral, size).succeeds(); // Price triples: uncapped profit would be 2x the notional, but recoverable // profit is capped at the reserved notional (`size`). - env.set_price(dollars(300)); - assert!(env.close_position(&trader)); + set_feed(test, dollars(300), 0); let open_fee = size / 1_000; let close_fee = size / 1_000; let net_collateral = collateral - open_fee; let expected = net_collateral + size - close_fee; - assert_eq!(token_amount(&env.svm, &trader_collateral), expected); + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, expected); } -#[test] -fn test_remove_liquidity_blocked_by_reserved() { - let mut env = setup(); - let (provider, _) = env.funded_wallet(10_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); - assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); +#[quasar_test] +fn remove_liquidity_is_blocked_by_reserved_notional(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 10_000 * ONE_USDC); + add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); // 5,000 of the 10,000 liquidity is reserved: pulling everything fails, but // withdrawing within the free half succeeds. - let provider_lp = ata(&provider, &env.lp_mint); - let shares = token_amount(&env.svm, &provider_lp); - assert!(!env.remove_liquidity(&provider, shares)); - assert!(env.remove_liquidity(&provider, shares / 2)); + let shares = test.tokens(PROVIDER_LP); + assert!( + remove_liquidity(test, &env, shares).is_err(), + "withdrawing reserved liquidity must fail" + ); + remove_liquidity(test, &env, shares / 2).succeeds(); } -#[test] -fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { +#[quasar_test] +fn initialize_pool_rejects_close_fee_at_or_above_maintenance_margin(test: &mut Test) { // A pool whose close fee reached the maintenance margin could strand a // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. - assert!(try_setup(500, 600).is_err()); + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); + assert!( + init_pool(test, 500, 600).is_err(), + "close_fee_bps >= maintenance_margin_bps must be rejected" + ); } diff --git a/finance/prop-amm/quasar/CHANGELOG.md b/finance/prop-amm/quasar/CHANGELOG.md index b0e2fc9c..c866b606 100644 --- a/finance/prop-amm/quasar/CHANGELOG.md +++ b/finance/prop-amm/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). quasar-test has no `with_slot`, so the tests pin the current + slot by writing the Clock sysvar ACCOUNT via `test.set_account` (the SVM + fills its sysvar cache from provided accounts first), which keeps the + stale-price scenario expressible. The `quasar-svm` git dev-dependency is + gone. Program-source fix for 0.1.0: `Seed` is no longer in the prelude, so + `swap.rs` and `withdraw_inventory.rs` now import it from `quasar_lang::cpi`. + ## 2026-07-11 (later) Retuned the walkthrough trade to 5 NVDAx (825.825 USDC at the ask, diff --git a/finance/prop-amm/quasar/Cargo.toml b/finance/prop-amm/quasar/Cargo.toml index 49f0acd6..35311d86 100644 --- a/finance/prop-amm/quasar/Cargo.toml +++ b/finance/prop-amm/quasar/Cargo.toml @@ -14,29 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pinned to the same revision the other Quasar examples use. 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. 623bb70 is the last working rev on master before that bump. -# Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump — the same environment the other Quasar examples' CI runs built in. -# Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/prop-amm/quasar/Quasar.toml b/finance/prop-amm/quasar/Quasar.toml index 6143b52e..304ae2ad 100644 --- a/finance/prop-amm/quasar/Quasar.toml +++ b/finance/prop-amm/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_prop_amm" - -[toolchain] -type = "solana" +name = "quasar-prop-amm" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/prop-amm/quasar/src/instructions/swap.rs b/finance/prop-amm/quasar/src/instructions/swap.rs index 91b2711c..aabf53da 100644 --- a/finance/prop-amm/quasar/src/instructions/swap.rs +++ b/finance/prop-amm/quasar/src/instructions/swap.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ constants::{DIRECTION_BUY_BASE, DIRECTION_SELL_BASE}, instructions::shared::{self, err, error}, diff --git a/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs b/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs index 8a1b112b..038307a9 100644 --- a/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs +++ b/finance/prop-amm/quasar/src/instructions/withdraw_inventory.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ instructions::shared::{err, error}, state::Market, diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index 6ff613d3..802f10e4 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -1,15 +1,17 @@ -extern crate std; +//! quasar-test integration tests: initialize a market, stock inventory, swap +//! against the oracle-anchored quote, and exercise every guard rail (operator +//! gating, slippage, staleness, confidence, pause, inventory limits). use { - alloc::{vec, vec::Vec}, - quasar_svm::{ - token::{ - create_keyed_associated_token_account, create_keyed_mint_account, - create_keyed_system_account, Mint, + crate::{ + cpi::{ + DepositInventoryInstruction, InitializeMarketInstruction, SetQuoteInstruction, + SwapInstruction, WithdrawInventoryInstruction, }, - Account, AccountMeta, Instruction, Pubkey, QuasarSvm, + state::Market, + BaseVaultPda, QuoteVaultPda, }, - std::fs, + quasar_test::prelude::*, }; // Both tokens have 6 decimals: base is NVDAx (tokenized NVIDIA stock), quote @@ -25,561 +27,425 @@ const DIRECTION_SELL_BASE: u8 = 1; // A fixed current slot well above the staleness bound, so tests can write // feed accounts that are fresh (slot = SLOT) or stale (slot older than the -// 150-slot bound) without warping the SVM clock. +// 150-slot bound). quasar-test has no slot control, so the Clock sysvar +// ACCOUNT is overridden directly — the SVM fills its sysvar cache from +// provided accounts before falling back to defaults. const SLOT: u64 = 1_000; -fn program_id() -> Pubkey { - crate::ID.into() -} -fn token_program() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn ata_program() -> Pubkey { - quasar_svm::SPL_ASSOCIATED_TOKEN_PROGRAM_ID -} -fn system_program() -> Pubkey { - quasar_svm::system_program::ID -} -fn clock_sysvar() -> Pubkey { - "SysvarC1ock11111111111111111111111111111111" - .parse() - .unwrap() -} -fn rent_sysvar() -> Pubkey { - "SysvarRent111111111111111111111111111111111" - .parse() - .unwrap() -} +// Deterministic addresses. +const OPERATOR: Pubkey = Pubkey::new_from_array([1; 32]); +const BASE_MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const QUOTE_MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const FEED: Pubkey = Pubkey::new_from_array([4; 32]); +const OPERATOR_BASE: Pubkey = Pubkey::new_from_array([5; 32]); +const OPERATOR_QUOTE: Pubkey = Pubkey::new_from_array([6; 32]); +const TRADER: Pubkey = Pubkey::new_from_array([7; 32]); +const TRADER_BASE: Pubkey = Pubkey::new_from_array([8; 32]); +const TRADER_QUOTE: Pubkey = Pubkey::new_from_array([9; 32]); +const MALLORY: Pubkey = Pubkey::new_from_array([10; 32]); +const MALLORY_BASE: Pubkey = Pubkey::new_from_array([11; 32]); +const MALLORY_QUOTE: Pubkey = Pubkey::new_from_array([12; 32]); fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } -fn pda(seeds: &[&[u8]]) -> Pubkey { - Pubkey::find_program_address(seeds, &program_id()).0 -} -fn ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[wallet.as_ref(), token_program().as_ref(), mint.as_ref()], - &ata_program(), - ) - .0 -} - -fn empty(address: &Pubkey) -> Account { - Account { - address: *address, - lamports: 0, - data: vec![], - owner: system_program(), - executable: false, - } -} - -fn mint_account(address: &Pubkey) -> Account { - create_keyed_mint_account( - address, - &Mint { - decimals: 6, - is_initialized: true, - ..Default::default() - }, - ) +/// Pin the Clock sysvar account at `SLOT`. Clock's bincode layout is the raw +/// little-endian fields: slot, epoch_start_timestamp, epoch, +/// leader_schedule_epoch, unix_timestamp. +fn set_clock(test: &mut Test) { + let mut data = Vec::with_capacity(40); + data.extend_from_slice(&SLOT.to_le_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); + let clock_id: Pubkey = "SysvarC1ock11111111111111111111111111111111" + .parse() + .unwrap(); + let sysvar_owner: Pubkey = "Sysvar1111111111111111111111111111111111111" + .parse() + .unwrap(); + test.set_account(Account::new(clock_id, sysvar_owner, 1_169_280, data)); } /// A feed account in this program's layout: price (i128), scale (u32), /// last_update_slot (u64), confidence (u64). The tests own this; production /// reads a real feed. -fn feed_account(address: &Pubkey, price: i128, scale: u32, slot: u64, confidence: u64) -> Account { +fn set_feed_at_slot(test: &mut Test, price: i128, slot: u64, confidence: u64) { let mut data = Vec::with_capacity(36); data.extend_from_slice(&price.to_le_bytes()); - data.extend_from_slice(&scale.to_le_bytes()); + data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); data.extend_from_slice(&slot.to_le_bytes()); data.extend_from_slice(&confidence.to_le_bytes()); - Account { - address: *address, - lamports: 1_000_000, - data, - owner: system_program(), - executable: false, - } + test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); +} + +fn set_feed(test: &mut Test, price: i128, confidence: u64) { + set_feed_at_slot(test, price, SLOT, confidence); } -fn token_amount(svm: &QuasarSvm, address: &Pubkey) -> u64 { - let account = svm.get_account(address).expect("token account exists"); - // SPL token account layout: mint (32) + owner (32) + amount (u64) at offset 64. - u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +/// Write the feed with an update slot older than the 150-slot staleness bound +/// (the Clock sysvar sits at `SLOT`). +fn make_price_stale(test: &mut Test) { + set_feed_at_slot(test, dollars(165), SLOT - 151, 0); +} + +fn init_market(test: &mut Test, spread_bps: u16) -> Outcome { + test.send(InitializeMarketInstruction { + operator: OPERATOR, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + oracle_feed: FEED, + oracle_scale: ORACLE_SCALE, + spread_bps, + max_confidence_bps: MAX_CONFIDENCE_BPS, + }) } struct Env { - svm: QuasarSvm, - base_mint: Pubkey, - quote_mint: Pubkey, - feed: Pubkey, - operator: Pubkey, market: Pubkey, - market_authority: Pubkey, base_vault: Pubkey, quote_vault: Pubkey, } -/// Build an SVM with the program, both mints, an oracle feed at $165, an -/// initialized market at a 10 bps spread, and 1,000 NVDAx + 200,000 USDC of -/// operator inventory deposited. -fn setup() -> Env { - let mut env = try_setup(SPREAD_BPS).expect("market initialization should succeed"); - assert!(env.deposit_inventory_as_operator(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN)); +/// Mints, feed at $165, clock at `SLOT`, funded operator inventory accounts, +/// and an initialized (but unstocked) market at `spread_bps`. +fn base_world(test: &mut Test, spread_bps: u16) -> (Env, Outcome) { + test.add(Wallet::new().at(OPERATOR)); + test.add(Mint::new(OPERATOR).at(BASE_MINT).decimals(6)); + test.add(Mint::new(OPERATOR).at(QUOTE_MINT).decimals(6)); + set_feed(test, dollars(165), 0); + set_clock(test); + test.add( + TokenAccount::new(BASE_MINT, OPERATOR) + .at(OPERATOR_BASE) + .amount(10_000 * ONE_TOKEN), + ); + test.add( + TokenAccount::new(QUOTE_MINT, OPERATOR) + .at(OPERATOR_QUOTE) + .amount(10_000_000 * ONE_TOKEN), + ); + let outcome = init_market(test, spread_bps); + let market = test.derive_pda(Market::seeds(&BASE_MINT, "E_MINT)); + ( + Env { + market, + base_vault: test.derive_pda(BaseVaultPda::seeds(&market)), + quote_vault: test.derive_pda(QuoteVaultPda::seeds(&market)), + }, + outcome, + ) +} + +fn deposit_inventory(test: &mut Test, env: &Env, signer: Pubkey, signer_base: Pubkey, signer_quote: Pubkey, base: u64, quote: u64) -> Outcome { + test.send(DepositInventoryInstruction { + operator: signer, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + base_vault: env.base_vault, + quote_vault: env.quote_vault, + operator_base: signer_base, + operator_quote: signer_quote, + base_amount: base, + quote_amount: quote, + }) +} + +/// Market with a 10 bps spread and 1,000 NVDAx + 200,000 USDC of operator +/// inventory deposited. +fn setup(test: &mut Test) -> Env { + let (env, outcome) = base_world(test, SPREAD_BPS); + outcome.succeeds(); + deposit_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN).succeeds(); env } -/// Like `setup`, but without the inventory deposit and with the spread exposed -/// so tests can probe the parameter validation. -fn try_setup(spread_bps: u16) -> Result { - let elf = fs::read("target/deploy/quasar_prop_amm.so").unwrap(); - let base_mint = Pubkey::new_unique(); - let quote_mint = Pubkey::new_unique(); - let feed = Pubkey::new_unique(); - let operator = Pubkey::new_unique(); - let market = pda(&[b"market", base_mint.as_ref(), quote_mint.as_ref()]); - let market_authority = pda(&[b"authority", market.as_ref()]); - let base_vault = pda(&[b"base_vault", market.as_ref()]); - let quote_vault = pda(&[b"quote_vault", market.as_ref()]); - - let mut svm = QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() - .with_slot(SLOT) - .with_account(mint_account(&base_mint)) - .with_account(mint_account("e_mint)) - .with_account(feed_account(&feed, dollars(165), ORACLE_SCALE, SLOT, 0)) - .with_account(create_keyed_system_account(&operator, 100_000_000_000)); - - // The operator's own inventory accounts, funded. - svm.set_account(create_keyed_associated_token_account( - &operator, - &base_mint, - 10_000 * ONE_TOKEN, - )); - svm.set_account(create_keyed_associated_token_account( - &operator, - "e_mint, - 10_000_000 * ONE_TOKEN, - )); - - // initialize_market - let mut data = vec![0u8]; - data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); - data.extend_from_slice(&spread_bps.to_le_bytes()); - data.extend_from_slice(&MAX_CONFIDENCE_BPS.to_le_bytes()); - let metas = vec![ - AccountMeta::new(operator, true), - AccountMeta::new(market, false), - AccountMeta::new_readonly(base_mint, false), - AccountMeta::new_readonly(quote_mint, false), - AccountMeta::new_readonly(feed, false), - AccountMeta::new_readonly(market_authority, false), - AccountMeta::new(base_vault, false), - AccountMeta::new(quote_vault, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(system_program(), false), - AccountMeta::new_readonly(rent_sysvar(), false), - ]; - let provided = vec![ - svm.get_account(&operator).unwrap(), - empty(&market), - svm.get_account(&base_mint).unwrap(), - svm.get_account("e_mint).unwrap(), - empty(&base_vault), - empty("e_vault), - ]; - let result = svm.process_instruction( - &Instruction { - program_id: program_id(), - accounts: metas, - data, - }, - &provided, - ); - if !result.is_ok() { - return Err(()); - } - - Ok(Env { - svm, - base_mint, - quote_mint, - feed, - operator, - market, - market_authority, - base_vault, - quote_vault, +fn fund_trader(test: &mut Test, wallet: Pubkey, base_account: Pubkey, quote_account: Pubkey, base: u64, quote: u64) { + test.add(Wallet::new().at(wallet)); + test.add(TokenAccount::new(BASE_MINT, wallet).at(base_account).amount(base)); + test.add(TokenAccount::new(QUOTE_MINT, wallet).at(quote_account).amount(quote)); +} + +fn set_quote(test: &mut Test, signer: Pubkey, spread_bps: u16, paused: u8) -> Outcome { + test.send(SetQuoteInstruction { + operator: signer, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + spread_bps, + paused, + }) +} + +fn swap(test: &mut Test, env: &Env, trader: Pubkey, trader_base: Pubkey, trader_quote: Pubkey, direction: u8, amount_in: u64, minimum_amount_out: u64) -> Outcome { + test.send(SwapInstruction { + trader, + oracle_feed: FEED, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + base_vault: env.base_vault, + quote_vault: env.quote_vault, + trader_base, + trader_quote, + direction, + amount_in, + minimum_amount_out, }) } -impl Env { - /// Create a wallet with funded base and quote token accounts. - fn funded_wallet(&mut self, base: u64, quote: u64) -> Pubkey { - let wallet = Pubkey::new_unique(); - self.svm - .set_account(create_keyed_system_account(&wallet, 100_000_000_000)); - self.svm.set_account(create_keyed_associated_token_account( - &wallet, - &self.base_mint, - base, - )); - self.svm.set_account(create_keyed_associated_token_account( - &wallet, - &self.quote_mint, - quote, - )); - wallet - } - - fn set_price(&mut self, price: i128) { - self.svm - .set_account(feed_account(&self.feed, price, ORACLE_SCALE, SLOT, 0)); - } - - fn set_price_with_confidence(&mut self, price: i128, confidence: u64) { - self.svm.set_account(feed_account( - &self.feed, - price, - ORACLE_SCALE, - SLOT, - confidence, - )); - } - - /// Write the feed with an update slot older than the 150-slot staleness - /// bound (the SVM clock sits at `SLOT`). - fn make_price_stale(&mut self) { - self.svm.set_account(feed_account( - &self.feed, - dollars(165), - ORACLE_SCALE, - SLOT - 151, - 0, - )); - } - - fn move_inventory(&mut self, signer: &Pubkey, deposit: bool, base: u64, quote: u64) -> bool { - let signer_base = ata(signer, &self.base_mint); - let signer_quote = ata(signer, &self.quote_mint); - let mut data = vec![if deposit { 1u8 } else { 2u8 }]; - data.extend_from_slice(&base.to_le_bytes()); - data.extend_from_slice("e.to_le_bytes()); - let mut metas = vec![AccountMeta::new(*signer, true)]; - metas.push(AccountMeta::new_readonly(self.market, false)); - if !deposit { - metas.push(AccountMeta::new_readonly(self.market_authority, false)); - } - metas.extend([ - AccountMeta::new_readonly(self.base_mint, false), - AccountMeta::new_readonly(self.quote_mint, false), - AccountMeta::new(self.base_vault, false), - AccountMeta::new(self.quote_vault, false), - AccountMeta::new(signer_base, false), - AccountMeta::new(signer_quote, false), - AccountMeta::new_readonly(token_program(), false), - ]); - self.run(metas, data, &[*signer, signer_base, signer_quote]) - } - - fn deposit_inventory_as_operator(&mut self, base: u64, quote: u64) -> bool { - let operator = self.operator; - self.move_inventory(&operator, true, base, quote) - } - - fn withdraw_inventory_as_operator(&mut self, base: u64, quote: u64) -> bool { - let operator = self.operator; - self.move_inventory(&operator, false, base, quote) - } - - fn set_quote(&mut self, signer: &Pubkey, spread_bps: u16, paused: u8) -> bool { - let mut data = vec![3u8]; - data.extend_from_slice(&spread_bps.to_le_bytes()); - data.push(paused); - let metas = vec![ - AccountMeta::new_readonly(*signer, true), - AccountMeta::new(self.market, false), - AccountMeta::new_readonly(self.base_mint, false), - AccountMeta::new_readonly(self.quote_mint, false), - ]; - self.run(metas, data, &[*signer]) - } - - fn swap( - &mut self, - trader: &Pubkey, - direction: u8, - amount_in: u64, - minimum_amount_out: u64, - ) -> bool { - let trader_base = ata(trader, &self.base_mint); - let trader_quote = ata(trader, &self.quote_mint); - let mut data = vec![4u8, direction]; - data.extend_from_slice(&amount_in.to_le_bytes()); - data.extend_from_slice(&minimum_amount_out.to_le_bytes()); - let metas = vec![ - AccountMeta::new(*trader, true), - AccountMeta::new_readonly(self.market, false), - AccountMeta::new_readonly(self.market_authority, false), - AccountMeta::new_readonly(self.feed, false), - AccountMeta::new_readonly(self.base_mint, false), - AccountMeta::new_readonly(self.quote_mint, false), - AccountMeta::new(self.base_vault, false), - AccountMeta::new(self.quote_vault, false), - AccountMeta::new(trader_base, false), - AccountMeta::new(trader_quote, false), - AccountMeta::new_readonly(token_program(), false), - AccountMeta::new_readonly(clock_sysvar(), false), - ]; - self.run(metas, data, &[*trader, trader_base, trader_quote]) - } - - fn run(&mut self, metas: Vec, data: Vec, provide: &[Pubkey]) -> bool { - let accounts: Vec = provide - .iter() - .map(|pk| self.svm.get_account(pk).unwrap_or_else(|| empty(pk))) - .collect(); - let result = self.svm.process_instruction( - &Instruction { - program_id: program_id(), - accounts: metas, - data, - }, - &accounts, - ); - result.is_ok() - } -} - -#[test] -fn test_initialize_market() { - let env = setup(); +fn withdraw_inventory(test: &mut Test, env: &Env, signer: Pubkey, signer_base: Pubkey, signer_quote: Pubkey, base: u64, quote: u64) -> Outcome { + test.send(WithdrawInventoryInstruction { + operator: signer, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + base_vault: env.base_vault, + quote_vault: env.quote_vault, + operator_base: signer_base, + operator_quote: signer_quote, + base_amount: base, + quote_amount: quote, + }) +} + +#[quasar_test] +fn initialize_market_creates_market_and_stocked_vaults(test: &mut Test) { + let env = setup(test); // The market and both vaults were created, and the inventory landed. - assert!(env.svm.get_account(&env.market).is_some()); - assert_eq!(token_amount(&env.svm, &env.base_vault), 1_000 * ONE_TOKEN); - assert_eq!( - token_amount(&env.svm, &env.quote_vault), - 200_000 * ONE_TOKEN - ); + assert!(test.account(env.market).is_some()); + assert_eq!(test.tokens(env.base_vault), 1_000 * ONE_TOKEN); + assert_eq!(test.tokens(env.quote_vault), 200_000 * ONE_TOKEN); } /// Alice buys 5 NVDAx. At $165 with a 10 bps spread the ask is $165.165, so /// 5 NVDAx costs exactly 825.825 USDC. -#[test] -fn test_swap_buys_base_at_the_ask() { - let mut env = setup(); +#[quasar_test] +fn swap_buys_base_at_the_ask(test: &mut Test) { + let env = setup(test); let quote_in = 825_825_000; - let alice = env.funded_wallet(0, quote_in); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); - assert!(env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN)); - - assert_eq!( - token_amount(&env.svm, &ata(&alice, &env.base_mint)), - 5 * ONE_TOKEN - ); - assert_eq!(token_amount(&env.svm, &ata(&alice, &env.quote_mint)), 0); - // Conservation: the vaults moved by exactly the two legs of the fill. - assert_eq!(token_amount(&env.svm, &env.base_vault), 995 * ONE_TOKEN); - assert_eq!( - token_amount(&env.svm, &env.quote_vault), - 200_000 * ONE_TOKEN + quote_in - ); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN) + .succeeds() + .has_tokens(TRADER_BASE, 5 * ONE_TOKEN) + .has_tokens(TRADER_QUOTE, 0) + // Conservation: the vaults moved by exactly the two legs of the fill. + .has_tokens(env.base_vault, 995 * ONE_TOKEN) + .has_tokens(env.quote_vault, 200_000 * ONE_TOKEN + quote_in); } /// Bob sells 5 NVDAx. At $165 with a 10 bps spread the bid is $164.835, so /// he receives exactly 824.175 USDC. -#[test] -fn test_swap_sells_base_at_the_bid() { - let mut env = setup(); - let bob = env.funded_wallet(5 * ONE_TOKEN, 0); - - assert!(env.swap(&bob, DIRECTION_SELL_BASE, 5 * ONE_TOKEN, 824_175_000)); +#[quasar_test] +fn swap_sells_base_at_the_bid(test: &mut Test) { + let env = setup(test); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 5 * ONE_TOKEN, 0); - assert_eq!(token_amount(&env.svm, &ata(&bob, &env.base_mint)), 0); - assert_eq!( - token_amount(&env.svm, &ata(&bob, &env.quote_mint)), - 824_175_000 - ); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_SELL_BASE, 5 * ONE_TOKEN, 824_175_000) + .succeeds() + .has_tokens(TRADER_BASE, 0) + .has_tokens(TRADER_QUOTE, 824_175_000); } /// A buy immediately followed by a sell of the same 5 NVDAx costs exactly /// the round-trip spread: 1.65 USDC, all of which stays in the inventory. -#[test] -fn test_round_trip_costs_exactly_the_spread() { - let mut env = setup(); +#[quasar_test] +fn round_trip_costs_exactly_the_spread(test: &mut Test) { + let env = setup(test); let quote_in = 825_825_000; - let carol = env.funded_wallet(0, quote_in); - - assert!(env.swap(&carol, DIRECTION_BUY_BASE, quote_in, 0)); - assert!(env.swap(&carol, DIRECTION_SELL_BASE, 5 * ONE_TOKEN, 0)); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); - assert_eq!(token_amount(&env.svm, &ata(&carol, &env.base_mint)), 0); - assert_eq!( - token_amount(&env.svm, &ata(&carol, &env.quote_mint)), - quote_in - 1_650_000 - ); - assert_eq!( - token_amount(&env.svm, &env.quote_vault), - 200_000 * ONE_TOKEN + 1_650_000 - ); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, 0).succeeds(); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_SELL_BASE, 5 * ONE_TOKEN, 0) + .succeeds() + .has_tokens(TRADER_BASE, 0) + .has_tokens(TRADER_QUOTE, quote_in - 1_650_000) + .has_tokens(env.quote_vault, 200_000 * ONE_TOKEN + 1_650_000); } /// When the oracle reprices, the quote follows instantly. At $170 the ask is /// $170.17, so 5 NVDAx costs exactly 850.85 USDC. -#[test] -fn test_quote_follows_the_oracle() { - let mut env = setup(); - env.set_price(dollars(170)); +#[quasar_test] +fn quote_follows_the_oracle(test: &mut Test) { + let env = setup(test); + set_feed(test, dollars(170), 0); let quote_in = 850_850_000; - let alice = env.funded_wallet(0, quote_in); - assert!(env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN)); - assert_eq!( - token_amount(&env.svm, &ata(&alice, &env.base_mint)), - 5 * ONE_TOKEN - ); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN) + .succeeds() + .has_tokens(TRADER_BASE, 5 * ONE_TOKEN); } /// The operator re-quotes to a 50 bps spread; the next fill prices at /// $165.825, so 5 NVDAx costs exactly 829.125 USDC. -#[test] -fn test_set_quote_changes_the_spread() { - let mut env = setup(); - let operator = env.operator; - assert!(env.set_quote(&operator, 50, 0)); +#[quasar_test] +fn set_quote_changes_the_spread(test: &mut Test) { + let env = setup(test); + set_quote(test, OPERATOR, 50, 0).succeeds(); let quote_in = 829_125_000; - let alice = env.funded_wallet(0, quote_in); - assert!(env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN)); - assert_eq!( - token_amount(&env.svm, &ata(&alice, &env.base_mint)), - 5 * ONE_TOKEN - ); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN) + .succeeds() + .has_tokens(TRADER_BASE, 5 * ONE_TOKEN); } /// The operator can withdraw every token in both vaults at any time — its /// capital, its exit. Afterwards swaps fail rather than misprice. -#[test] -fn test_operator_can_withdraw_everything_and_swaps_then_fail() { - let mut env = setup(); - assert!(env.withdraw_inventory_as_operator(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN)); - - assert_eq!(token_amount(&env.svm, &env.base_vault), 0); - assert_eq!(token_amount(&env.svm, &env.quote_vault), 0); - - let alice = env.funded_wallet(0, 825_825_000); - assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 825_825_000, 0)); +#[quasar_test] +fn operator_can_withdraw_everything_and_swaps_then_fail(test: &mut Test) { + let env = setup(test); + withdraw_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .succeeds() + .has_tokens(env.base_vault, 0) + .has_tokens(env.quote_vault, 0); + + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 0).is_err(), + "a swap against an empty inventory must fail" + ); } -#[test] -fn test_withdraw_more_than_inventory_fails() { - let mut env = setup(); - assert!(!env.withdraw_inventory_as_operator(1_001 * ONE_TOKEN, 0)); +#[quasar_test] +fn withdraw_more_than_inventory_fails(test: &mut Test) { + let env = setup(test); + assert!( + withdraw_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 1_001 * ONE_TOKEN, 0) + .is_err(), + "withdrawing more than the vault holds must fail" + ); } -#[test] -fn test_deposit_rejects_non_operator() { - let mut env = setup(); - let mallory = env.funded_wallet(ONE_TOKEN, ONE_TOKEN); - assert!(!env.move_inventory(&mallory, true, ONE_TOKEN, 0)); +#[quasar_test] +fn deposit_rejects_non_operator(test: &mut Test) { + let env = setup(test); + fund_trader(test, MALLORY, MALLORY_BASE, MALLORY_QUOTE, ONE_TOKEN, ONE_TOKEN); + assert!( + deposit_inventory(test, &env, MALLORY, MALLORY_BASE, MALLORY_QUOTE, ONE_TOKEN, 0).is_err(), + "deposit_inventory must reject a non-operator signer" + ); } -#[test] -fn test_withdraw_rejects_non_operator() { - let mut env = setup(); - let mallory = env.funded_wallet(0, 0); - assert!(!env.move_inventory(&mallory, false, ONE_TOKEN, 0)); +#[quasar_test] +fn withdraw_rejects_non_operator(test: &mut Test) { + let env = setup(test); + fund_trader(test, MALLORY, MALLORY_BASE, MALLORY_QUOTE, 0, 0); + assert!( + withdraw_inventory(test, &env, MALLORY, MALLORY_BASE, MALLORY_QUOTE, ONE_TOKEN, 0).is_err(), + "withdraw_inventory must reject a non-operator signer" + ); } -#[test] -fn test_set_quote_rejects_non_operator() { - let mut env = setup(); - let mallory = env.funded_wallet(0, 0); - assert!(!env.set_quote(&mallory, 500, 1)); +#[quasar_test] +fn set_quote_rejects_non_operator(test: &mut Test) { + setup(test); + test.add(Wallet::new().at(MALLORY)); + assert!( + set_quote(test, MALLORY, 500, 1).is_err(), + "set_quote must reject a non-operator signer" + ); } /// A fill below the caller's minimum is rejected, not filled worse. -#[test] -fn test_swap_rejects_slippage() { - let mut env = setup(); +#[quasar_test] +fn swap_rejects_slippage(test: &mut Test) { + let env = setup(test); let quote_in = 825_825_000; - let alice = env.funded_wallet(0, quote_in); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); // The fill would be exactly 5 NVDAx; demand one minor unit more. - assert!(!env.swap(&alice, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN + 1)); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, 5 * ONE_TOKEN + 1) + .is_err(), + "a fill below minimum_amount_out must be rejected" + ); } /// An oracle price older than the staleness bound cannot be traded against: /// a lagging quote is a free option for arbitrageurs. -#[test] -fn test_swap_rejects_stale_price() { - let mut env = setup(); - env.make_price_stale(); - let alice = env.funded_wallet(0, 825_825_000); - assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 825_825_000, 0)); +#[quasar_test] +fn swap_rejects_stale_price(test: &mut Test) { + let env = setup(test); + make_price_stale(test); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 0).is_err(), + "a stale oracle price must be rejected" + ); } /// A price the oracle itself is unsure about is rejected: the confidence band /// (about 1.2% here) exceeds the market's 1% limit. -#[test] -fn test_swap_rejects_wide_confidence() { - let mut env = setup(); - env.set_price_with_confidence(dollars(165), 200_000_000); - let alice = env.funded_wallet(0, 825_825_000); - assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 825_825_000, 0)); +#[quasar_test] +fn swap_rejects_wide_confidence(test: &mut Test) { + let env = setup(test); + set_feed(test, dollars(165), 200_000_000); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 0).is_err(), + "a confidence band wider than max_confidence_bps must be rejected" + ); } /// While the operator has pulled its quotes, nobody can swap; unpausing /// restores the exact same quote. -#[test] -fn test_swap_rejects_when_paused() { - let mut env = setup(); - let operator = env.operator; - assert!(env.set_quote(&operator, SPREAD_BPS, 1)); - - let alice = env.funded_wallet(0, 825_825_000); - assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 825_825_000, 0)); +#[quasar_test] +fn swap_rejects_when_paused(test: &mut Test) { + let env = setup(test); + set_quote(test, OPERATOR, SPREAD_BPS, 1).succeeds(); + + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 0).is_err(), + "a paused market must reject swaps" + ); - assert!(env.set_quote(&operator, SPREAD_BPS, 0)); - assert!(env.swap(&alice, DIRECTION_BUY_BASE, 825_825_000, 5 * ONE_TOKEN)); + set_quote(test, OPERATOR, SPREAD_BPS, 0).succeeds(); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 5 * ONE_TOKEN) + .succeeds(); } -#[test] -fn test_swap_rejects_zero_amount() { - let mut env = setup(); - let alice = env.funded_wallet(0, ONE_TOKEN); - assert!(!env.swap(&alice, DIRECTION_BUY_BASE, 0, 0)); +#[quasar_test] +fn swap_rejects_zero_amount(test: &mut Test) { + let env = setup(test); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, ONE_TOKEN); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 0, 0).is_err(), + "a zero-amount swap must be rejected" + ); } /// A buy bigger than the base inventory is rejected whole — a prop AMM never /// partially fills, and never prices what it cannot deliver. -#[test] -fn test_swap_rejects_insufficient_inventory() { - let mut env = setup(); +#[quasar_test] +fn swap_rejects_insufficient_inventory(test: &mut Test) { + let env = setup(test); // 1,100 NVDAx at $165.165 ≈ 181,681.50 USDC — affordable for the trader, // but the vault only holds 1,000 NVDAx. let quote_in = 181_681_500_000; - let whale = env.funded_wallet(0, quote_in); - assert!(!env.swap(&whale, DIRECTION_BUY_BASE, quote_in, 0)); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, 0).is_err(), + "a swap larger than the inventory must be rejected" + ); } -#[test] -fn test_initialize_market_rejects_zero_spread() { - assert!(try_setup(0).is_err()); +#[quasar_test] +fn initialize_market_rejects_zero_spread(test: &mut Test) { + let (_env, outcome) = base_world(test, 0); + assert!(outcome.is_err(), "a zero spread must be rejected"); } -#[test] -fn test_initialize_market_rejects_full_spread() { - assert!(try_setup(10_000).is_err()); +#[quasar_test] +fn initialize_market_rejects_full_spread(test: &mut Test) { + let (_env, outcome) = base_world(test, 10_000); + assert!(outcome.is_err(), "a 100% spread must be rejected"); } -#[test] -fn test_set_quote_rejects_invalid_spread() { - let mut env = setup(); - let operator = env.operator; - assert!(!env.set_quote(&operator, 0, 0)); - assert!(!env.set_quote(&operator, 10_000, 0)); +#[quasar_test] +fn set_quote_rejects_invalid_spread(test: &mut Test) { + setup(test); + assert!(set_quote(test, OPERATOR, 0, 0).is_err()); + assert!(set_quote(test, OPERATOR, 10_000, 0).is_err()); } diff --git a/finance/token-fundraiser/quasar/CHANGELOG.md b/finance/token-fundraiser/quasar/CHANGELOG.md index 4f116388..5f376860 100644 --- a/finance/token-fundraiser/quasar/CHANGELOG.md +++ b/finance/token-fundraiser/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the QuasarSVM harness + generated-client path + dev-dependency to `quasar-test` (`#[quasar_test]` fixtures, `crate::cpi` + instruction builders, `Outcome` assertions, `test.warp_to_timestamp` for the + deadline scenarios). The `quasar-svm` git dev-dependency and the + `quasar-token-fundraiser-client` path dev-dependency are gone. Program-source + fix for 0.1.0: `Seed` is no longer in the prelude, so + `check_contributions.rs` and `refund.rs` now import it from + `quasar_lang::cpi`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/token-fundraiser/quasar/Cargo.toml b/finance/token-fundraiser/quasar/Cargo.toml index 7fbb680e..f54e5d2b 100644 --- a/finance/token-fundraiser/quasar/Cargo.toml +++ b/finance/token-fundraiser/quasar/Cargo.toml @@ -13,32 +13,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } 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" } -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/token-fundraiser/quasar/Quasar.toml b/finance/token-fundraiser/quasar/Quasar.toml index 6f34b8e2..0847761d 100644 --- a/finance/token-fundraiser/quasar/Quasar.toml +++ b/finance/token-fundraiser/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_fundraiser" - -[toolchain] -type = "solana" +name = "quasar-token-fundraiser" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs b/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs index 1272cc0e..a9ebfe43 100644 --- a/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs +++ b/finance/token-fundraiser/quasar/src/instructions/check_contributions.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use { crate::{error::FundraiserError, state::Fundraiser}, quasar_lang::prelude::*, diff --git a/finance/token-fundraiser/quasar/src/instructions/refund.rs b/finance/token-fundraiser/quasar/src/instructions/refund.rs index b8223936..29ec78f0 100644 --- a/finance/token-fundraiser/quasar/src/instructions/refund.rs +++ b/finance/token-fundraiser/quasar/src/instructions/refund.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use { crate::{ error::FundraiserError, diff --git a/finance/token-fundraiser/quasar/src/tests.rs b/finance/token-fundraiser/quasar/src/tests.rs index 2fcc8f91..176b7512 100644 --- a/finance/token-fundraiser/quasar/src/tests.rs +++ b/finance/token-fundraiser/quasar/src/tests.rs @@ -1,15 +1,18 @@ -extern crate std; +//! quasar-test integration tests: create a fundraiser, contribute inside the +//! window, refund after a failed raise, and pay the maker after a successful +//! one — plus the deadline, target, and account-binding guard rails. + use { - 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, + crate::{ + cpi::{ + CheckContributionsInstruction, ContributeInstruction, InitializeInstruction, + RefundInstruction, + }, + error::FundraiserError, + state::{Contributor, Fundraiser, SECONDS_PER_DAY}, }, - solana_program_pack::Pack, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::{vec, vec::Vec}, + quasar_lang::error::QuasarError, + quasar_test::prelude::*, }; /// Fundraising target in minor units of the raised token. @@ -26,536 +29,292 @@ 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(); - let mut svm = QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program(); - svm.warp_to_timestamp(START_TIME); - svm -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -fn mint(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 1_000_000_000, - decimals: 9, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} +// Deterministic addresses. +const MAKER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const VAULT: Pubkey = Pubkey::new_from_array([3; 32]); +const CONTRIBUTOR: Pubkey = Pubkey::new_from_array([4; 32]); +const CONTRIBUTOR_TA: Pubkey = Pubkey::new_from_array([5; 32]); +const MAKER_TA: Pubkey = Pubkey::new_from_array([6; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([7; 32]); +const ATTACKER_TA: Pubkey = Pubkey::new_from_array([8; 32]); +const DECOY_VAULT: Pubkey = Pubkey::new_from_array([9; 32]); -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 -} - -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, -} - -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, -} - -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], - } -} - -struct Cursor<'a> { - data: &'a [u8], - offset: usize, -} - -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 framework_error(error: QuasarError) -> ProgramError { + ProgramError::Custom(error as u32) } -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, - } +/// Register the maker, the mint, and warp to the fixed start time. +fn base_world(test: &mut Test) { + test.add(Wallet::new().at(MAKER)); + test.add( + Mint::new(MAKER) + .at(MINT) + .supply(1_000_000_000) + .decimals(9), + ); + test.warp_to_timestamp(START_TIME); } -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, +fn initialize(test: &mut Test, amount_to_raise: u64, duration: u16) -> Outcome { + test.send(InitializeInstruction { + maker: MAKER, + mint_to_raise: MINT, + vault: VAULT, 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 -} - -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), + }) +} + +/// A world with an initialized fundraiser and a funded contributor. +fn initialized_world(test: &mut Test) -> Pubkey { + base_world(test); + initialize(test, TARGET_AMOUNT, DURATION_DAYS).succeeds(); + test.add(Wallet::new().at(CONTRIBUTOR)); + test.add( + TokenAccount::new(MINT, CONTRIBUTOR) + .at(CONTRIBUTOR_TA) + .amount(CONTRIBUTOR_STARTING_BALANCE), ); - result.assert_success(); + test.derive_pda(Fundraiser::seeds(&MAKER)) } -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, - mint_to_raise: fixture.mint, - token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, - system_program: quasar_svm::system_program::ID, +fn contribute(test: &mut Test, amount: u64) -> Outcome { + test.send(ContributeInstruction { + contributor: CONTRIBUTOR, + maker: MAKER, + contributor_ta: CONTRIBUTOR_TA, + vault: VAULT, + mint_to_raise: MINT, amount, - } - .into() -} - -/// 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, - ), - ] -} - -/// 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 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, - mint_to_raise: fixture.mint, - token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, - } - .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_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); + }) +} + +fn refund(test: &mut Test) -> Outcome { + test.send(RefundInstruction { + contributor: CONTRIBUTOR, + maker: MAKER, + contributor_ta: CONTRIBUTOR_TA, + vault: VAULT, + mint_to_raise: MINT, + }) +} + +fn check_contributions(test: &mut Test) -> Outcome { + test.send(CheckContributionsInstruction { + maker: MAKER, + vault: VAULT, + maker_ta: MAKER_TA, + mint_to_raise: MINT, + }) +} + +#[quasar_test] +fn initialize_records_state_and_clock_time(test: &mut Test) { + base_world(test); + initialize(test, TARGET_AMOUNT, DURATION_DAYS) + .succeeds() + .has_tokens(VAULT, 0); + + let (fundraiser, expected_bump) = test.derive_pda_with_bump(Fundraiser::seeds(&MAKER)); + let state = test.read::(fundraiser); + assert_eq!(state.maker, MAKER); + assert_eq!(state.mint_to_raise, MINT); + assert_eq!(state.vault, VAULT); + assert_eq!(u64::from(state.amount_to_raise), TARGET_AMOUNT); + assert_eq!(u64::from(state.current_amount), 0); + assert_eq!(i64::from(state.time_started), START_TIME); + assert_eq!(u16::from(state.duration), DURATION_DAYS); assert_eq!(state.bump, expected_bump); - - assert_eq!(token_balance(&svm, &fixture.vault), 0); } -#[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)); +#[quasar_test] +fn initialize_rejects_zero_amount(test: &mut Test) { + base_world(test); + initialize(test, 0, DURATION_DAYS).fails_with(FundraiserError::InvalidAmount); } -#[test] -fn test_initialize_rejects_zero_duration() { - let mut svm = setup(); - let fixture = fixture(); - - let result = svm.process_instruction( - &initialize_instruction(&fixture, TARGET_AMOUNT, 0), - &initialize_accounts(&fixture), - ); - result.assert_error(fundraiser_error( - QuasarTokenFundraiserError::InvalidDuration, - )); +#[quasar_test] +fn initialize_rejects_zero_duration(test: &mut Test) { + base_world(test); + initialize(test, TARGET_AMOUNT, 0).fails_with(FundraiserError::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); +#[quasar_test] +fn contribute_creates_contributor_account_and_moves_tokens(test: &mut Test) { + let fundraiser = initialized_world(test); - assert_eq!(token_balance(&svm, &fixture.vault), PARTIAL_CONTRIBUTION); - assert_eq!( - token_balance(&svm, &fixture.contributor_ta), - CONTRIBUTOR_STARTING_BALANCE - PARTIAL_CONTRIBUTION - ); + contribute(test, PARTIAL_CONTRIBUTION) + .succeeds() + .has_tokens(VAULT, PARTIAL_CONTRIBUTION) + .has_tokens( + 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 fundraiser_state = test.read::(fundraiser); + assert_eq!(u64::from(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); + let (contributor_account, expected_bump) = + test.derive_pda_with_bump(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + let contributor_state = test.read::(contributor_account); + assert_eq!(u64::from(contributor_state.amount), PARTIAL_CONTRIBUTION); assert_eq!(contributor_state.bump, expected_bump); } -#[test] -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(); +#[quasar_test] +fn contribute_accumulates_across_calls(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); + // Second contribution reuses the contributor account created by the first. 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); -} - -#[test] -fn test_contribute_rejected_after_deadline() { - let mut svm = setup(); - let fixture = fixture(); - initialize_fundraiser(&mut svm, &fixture); - - svm.warp_to_timestamp(DEADLINE); + contribute(test, PARTIAL_CONTRIBUTION) + .succeeds() + .has_tokens(VAULT, expected_total); - let result = svm.process_instruction( - &contribute_instruction(&fixture, PARTIAL_CONTRIBUTION), - &first_contribution_accounts(&fixture), + let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + assert_eq!( + u64::from(test.read::(contributor_account).amount), + expected_total + ); + assert_eq!( + u64::from(test.read::(fundraiser).current_amount), + expected_total ); - result.assert_error(fundraiser_error( - QuasarTokenFundraiserError::FundraiserEnded, - )); } -#[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); +#[quasar_test] +fn contribute_rejected_after_deadline(test: &mut Test) { + initialized_world(test); + test.warp_to_timestamp(DEADLINE); + contribute(test, PARTIAL_CONTRIBUTION).fails_with(FundraiserError::FundraiserEnded); +} - contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); - assert_eq!(token_balance(&svm, &fixture.vault), PARTIAL_CONTRIBUTION); +#[quasar_test] +fn contribute_allowed_just_before_deadline(test: &mut Test) { + initialized_world(test); + test.warp_to_timestamp(DEADLINE - 1); + contribute(test, PARTIAL_CONTRIBUTION) + .succeeds() + .has_tokens(VAULT, PARTIAL_CONTRIBUTION); } -#[test] -fn test_contribute_rejects_vault_not_bound_to_fundraiser() { - let mut svm = setup(); - let fixture = fixture(); - initialize_fundraiser(&mut svm, &fixture); +#[quasar_test] +fn contribute_rejects_vault_not_bound_to_fundraiser(test: &mut Test) { + let fundraiser = initialized_world(test); // 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)); - - let mut instruction = contribute_instruction(&fixture, PARTIAL_CONTRIBUTION); - // Account index 5 is the vault (see ContributeInstruction ordering). - instruction.accounts[5].pubkey = decoy_vault; + test.add(TokenAccount::new(MINT, fundraiser).at(DECOY_VAULT)); + + let mut instruction: Instruction = ContributeInstruction { + contributor: CONTRIBUTOR, + maker: MAKER, + contributor_ta: CONTRIBUTOR_TA, + vault: DECOY_VAULT, + mint_to_raise: MINT, + amount: PARTIAL_CONTRIBUTION, + } + .into(); + // Account index 5 is the vault (accounts-struct field order); the builder + // already put the decoy there, this documents the tampered position. + instruction.accounts[5].pubkey = DECOY_VAULT; - let result = svm.process_instruction(&instruction, &accounts); - result.assert_error(framework_error(QuasarError::HasOneMismatch)); + test.send(instruction) + .fails(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); +#[quasar_test] +fn refund_returns_tokens_after_failed_fundraiser(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - let result = svm.process_instruction(&refund_instruction(&fixture), &[]); - result.assert_success(); + test.warp_to_timestamp(DEADLINE); - 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); + let contributor_account = test.derive_pda(Contributor::seeds(&fundraiser, &CONTRIBUTOR)); + refund(test) + .succeeds() + .has_tokens(VAULT, 0) + .has_tokens(CONTRIBUTOR_TA, CONTRIBUTOR_STARTING_BALANCE) + // The contributor account was closed and its rent returned. + .is_closed(contributor_account); - // 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"); + assert_eq!(u64::from(test.read::(fundraiser).current_amount), 0); } -#[test] -fn test_refund_rejected_before_deadline() { - let mut svm = setup(); - let fixture = fixture(); - initialize_fundraiser(&mut svm, &fixture); - contribute(&mut svm, &fixture, PARTIAL_CONTRIBUTION); - - svm.warp_to_timestamp(DEADLINE - 1); +#[quasar_test] +fn refund_rejected_before_deadline(test: &mut Test) { + initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - let result = svm.process_instruction(&refund_instruction(&fixture), &[]); - result.assert_error(fundraiser_error( - QuasarTokenFundraiserError::FundraiserNotEnded, - )); + test.warp_to_timestamp(DEADLINE - 1); + refund(test).fails_with(FundraiserError::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); +#[quasar_test] +fn refund_rejected_when_target_met(test: &mut Test) { + initialized_world(test); + contribute(test, TARGET_AMOUNT).succeeds(); - svm.warp_to_timestamp(DEADLINE); - - let result = svm.process_instruction(&refund_instruction(&fixture), &[]); - result.assert_error(fundraiser_error(QuasarTokenFundraiserError::TargetMet)); + test.warp_to_timestamp(DEADLINE); + refund(test).fails_with(FundraiserError::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); +#[quasar_test] +fn refund_rejects_another_contributors_account(test: &mut Test) { + initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - svm.warp_to_timestamp(DEADLINE); + test.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(attacker), - token(attacker_ta, fixture.mint, attacker, 0), - ], - ); + test.add(Wallet::new().at(ATTACKER)); + test.add(TokenAccount::new(MINT, ATTACKER).at(ATTACKER_TA)); + + let mut instruction: Instruction = RefundInstruction { + contributor: CONTRIBUTOR, + maker: MAKER, + contributor_ta: ATTACKER_TA, + vault: VAULT, + mint_to_raise: MINT, + } + .into(); + // Account indices follow the accounts-struct field order: + // 0 contributor (signer), 3 contributor_account, 4 contributor_ta. The + // builder derived contributor_account for the VICTIM; swap only the + // signer to the attacker. + instruction.accounts[0].pubkey = ATTACKER; + // The contributor_account PDA check derives ["contributor", fundraiser, // attacker], which does not match the victim's record. - result.assert_error(framework_error(QuasarError::InvalidPda)); + test.send(instruction) + .fails(framework_error(QuasarError::InvalidPda)); // The vault still holds the victim's contribution. - assert_eq!(token_balance(&svm, &fixture.vault), PARTIAL_CONTRIBUTION); + assert_eq!(test.tokens(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, - mint_to_raise: fixture.mint, - token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, - } - .into(); +#[quasar_test] +fn check_contributions_pays_maker_when_target_met(test: &mut Test) { + let fundraiser = initialized_world(test); + contribute(test, TARGET_AMOUNT).succeeds(); - let result = - svm.process_instruction(&instruction, &[token(maker_ta, fixture.mint, fixture.maker, 0)]); - result.assert_success(); + test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); - 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); + check_contributions(test) + .succeeds() + .has_tokens(MAKER_TA, TARGET_AMOUNT) + // The vault and fundraiser accounts were closed. + .is_closed(VAULT) + .is_closed(fundraiser); } -#[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, - mint_to_raise: fixture.mint, - token_program: quasar_svm::SPL_TOKEN_PROGRAM_ID, - } - .into(); +#[quasar_test] +fn check_contributions_rejected_below_target(test: &mut Test) { + initialized_world(test); + contribute(test, PARTIAL_CONTRIBUTION).succeeds(); - let result = - svm.process_instruction(&instruction, &[token(maker_ta, fixture.mint, fixture.maker, 0)]); - result.assert_error(fundraiser_error(QuasarTokenFundraiserError::TargetNotMet)); + test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); + check_contributions(test).fails_with(FundraiserError::TargetNotMet); } diff --git a/finance/token-swap/quasar/CHANGELOG.md b/finance/token-swap/quasar/CHANGELOG.md index 4f116388..8fab21fa 100644 --- a/finance/token-swap/quasar/CHANGELOG.md +++ b/finance/token-swap/quasar/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The AMM-math helpers (`mul_div`, `expected_swap_output`) and + every scenario — deposits with the ratio-clamp regression pair, withdrawals, + swaps, and all slippage guards — carry over verbatim. The `quasar-svm` git + dev-dependency is gone; compute-unit assertions were dropped pending + recalibration under 0.1.0. Program-source fix for 0.1.0: `Seed` is no longer + in the prelude, so the instruction files that build signer seeds now import + it from `quasar_lang::cpi`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/token-swap/quasar/Cargo.toml b/finance/token-swap/quasar/Cargo.toml index 830b5ecf..ee56fadf 100644 --- a/finance/token-swap/quasar/Cargo.toml +++ b/finance/token-swap/quasar/Cargo.toml @@ -14,30 +14,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pin both quasar deps to branch = "master" so trait impls resolve consistently. -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/token-swap/quasar/Quasar.toml b/finance/token-swap/quasar/Quasar.toml index f378bd69..4951e3c9 100644 --- a/finance/token-swap/quasar/Quasar.toml +++ b/finance/token-swap/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_swap" - -[toolchain] -type = "solana" +name = "quasar-token-swap" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] 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 1f167a30..a7ac0782 100644 --- a/finance/token-swap/quasar/src/instructions/claim_admin_fees.rs +++ b/finance/token-swap/quasar/src/instructions/claim_admin_fees.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ error::AmmError, state::{Config, PoolConfig, PoolConfigInner}, diff --git a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs index 618e8697..f218249d 100644 --- a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ error::AmmError, state::{Config, PoolConfig}, diff --git a/finance/token-swap/quasar/src/instructions/swap_tokens.rs b/finance/token-swap/quasar/src/instructions/swap_tokens.rs index b70d8865..2804fb28 100644 --- a/finance/token-swap/quasar/src/instructions/swap_tokens.rs +++ b/finance/token-swap/quasar/src/instructions/swap_tokens.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ error::AmmError, state::{Config, PoolConfig, PoolConfigInner}, diff --git a/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs b/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs index 5777d691..4c34e15a 100644 --- a/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs +++ b/finance/token-swap/quasar/src/instructions/withdraw_liquidity.rs @@ -1,4 +1,5 @@ use { + quasar_lang::cpi::Seed, crate::{ error::AmmError, state::{Config, PoolConfig}, diff --git a/finance/token-swap/quasar/src/tests.rs b/finance/token-swap/quasar/src/tests.rs index 182926d7..317f1fe4 100644 --- a/finance/token-swap/quasar/src/tests.rs +++ b/finance/token-swap/quasar/src/tests.rs @@ -1,20 +1,20 @@ -extern crate std; +//! quasar-test integration tests for the constant-product AMM: config and +//! pool creation, deposits (including the ratio-clamp regression tests), +//! withdrawals, swaps, admin-fee claims, and every slippage guard rail. + use { - crate::error::AmmError, - alloc::vec, - quasar_svm::{ - token::{create_keyed_associated_token_account, create_keyed_mint_account, Mint}, - Account, Instruction, ProgramError, Pubkey, QuasarSvm, SPL_TOKEN_PROGRAM_ID, + crate::{ + cpi::{ + ClaimAdminFeesInstruction, CreateConfigInstruction, CreatePoolInstruction, + DepositLiquidityInstruction, SwapTokensInstruction, WithdrawLiquidityInstruction, + }, + error::AmmError, + state::Config, + ConfigPda, LiquidityMintPda, PoolPda, }, - std::println, + quasar_test::prelude::*, }; -/// 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 { @@ -28,590 +28,272 @@ fn mul_div(amount: u64, numerator: u64, denominator: u64) -> u64 { .expect("mul_div: result exceeds u64") } -// ── SVM setup ──────────────────────────────────────────────────────────────── - -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_swap.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -// ── Account factories ───────────────────────────────────────────────────────── - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) -} - -fn empty(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: quasar_svm::system_program::ID, - executable: false, - } -} - -/// Pre-initialised SPL mint with no authority and no supply. -fn test_mint(addr: Pubkey, decimals: u8) -> Account { - create_keyed_mint_account( - &addr, - &Mint { - is_initialized: true, - decimals, - ..Mint::default() - }, - ) -} - -/// Depositor's pre-funded ATA (address derived from wallet + mint). -fn funded_ata(wallet: Pubkey, mint: Pubkey, amount: u64) -> Account { - create_keyed_associated_token_account(&wallet, &mint, amount) -} - -/// 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()) -} - -// ── PDA helpers ─────────────────────────────────────────────────────────────── - -fn config_pda() -> Pubkey { - Pubkey::find_program_address(&[b"config"], &crate::ID.into()).0 -} - -fn pool_pda(config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[b"", config.as_ref(), mint_a.as_ref(), mint_b.as_ref()], - &crate::ID.into(), - ) - .0 -} - -fn pool_authority_pda(config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[b"authority", config.as_ref(), mint_a.as_ref(), mint_b.as_ref()], - &crate::ID.into(), - ) - .0 -} - -fn lp_mint_pda(config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[b"liquidity", config.as_ref(), mint_a.as_ref(), mint_b.as_ref()], - &crate::ID.into(), - ) - .0 +/// 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) } -// ── Instruction data builders ───────────────────────────────────────────────── - -fn build_create_config_data(fee: u16, admin_share_bps: u16) -> Vec { - let mut data = vec![0u8]; // discriminator = 0 - data.extend_from_slice(&fee.to_le_bytes()); - data.extend_from_slice(&admin_share_bps.to_le_bytes()); - data -} +/// Trading fee passed to `create_config`, in basis points. +const POOL_FEE_BPS: u64 = 30; +/// Admin's share of the trading fee, in basis points. +const ADMIN_SHARE_BPS: u16 = 1_667; + +// Deterministic addresses. +const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); +const PAYER: Pubkey = Pubkey::new_from_array([2; 32]); +const MINT_A: Pubkey = Pubkey::new_from_array([3; 32]); +const MINT_B: Pubkey = Pubkey::new_from_array([4; 32]); +const POOL_A: Pubkey = Pubkey::new_from_array([5; 32]); +const POOL_B: Pubkey = Pubkey::new_from_array([6; 32]); +// The pool-seeding depositor. +const SEEDER: Pubkey = Pubkey::new_from_array([7; 32]); +const SEEDER_TOKEN_A: Pubkey = Pubkey::new_from_array([8; 32]); +const SEEDER_TOKEN_B: Pubkey = Pubkey::new_from_array([9; 32]); +const SEEDER_LP: Pubkey = Pubkey::new_from_array([10; 32]); +// A second, independent depositor. +const DEPOSITOR: Pubkey = Pubkey::new_from_array([11; 32]); +const DEPOSITOR_TOKEN_A: Pubkey = Pubkey::new_from_array([12; 32]); +const DEPOSITOR_TOKEN_B: Pubkey = Pubkey::new_from_array([13; 32]); +const DEPOSITOR_LP: Pubkey = Pubkey::new_from_array([14; 32]); +// A trader and the withdraw/claim destinations. +const TRADER: Pubkey = Pubkey::new_from_array([15; 32]); +const TRADER_TOKEN_A: Pubkey = Pubkey::new_from_array([16; 32]); +const TRADER_TOKEN_B: Pubkey = Pubkey::new_from_array([17; 32]); +const RECV_A: Pubkey = Pubkey::new_from_array([18; 32]); +const RECV_B: Pubkey = Pubkey::new_from_array([19; 32]); +const ADMIN_TOKEN_A: Pubkey = Pubkey::new_from_array([20; 32]); +const ADMIN_TOKEN_B: Pubkey = Pubkey::new_from_array([21; 32]); +const BAD_ACTOR: Pubkey = Pubkey::new_from_array([22; 32]); +const BAD_TOKEN_A: Pubkey = Pubkey::new_from_array([23; 32]); +const BAD_TOKEN_B: Pubkey = Pubkey::new_from_array([24; 32]); -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 +struct PoolEnv { + config: Pubkey, + pool_config: Pubkey, + lp_mint: Pubkey, } -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 +fn create_config(test: &mut Test, fee: u16, admin_share_bps: u16) -> Outcome { + test.add(Wallet::new().at(PAYER)); + test.send(CreateConfigInstruction { + admin: ADMIN, + payer: PAYER, + fee, + admin_share_bps, + }) } -fn build_swap_data(input_is_token_a: bool, input_amount: u64, min_output: u64) -> Vec { - let mut data = vec![4u8]; // discriminator = 4 - data.push(input_is_token_a as u8); - data.extend_from_slice(&input_amount.to_le_bytes()); - data.extend_from_slice(&min_output.to_le_bytes()); - data -} +/// Creates config + two mints + pool. +fn setup_pool(test: &mut Test) -> PoolEnv { + create_config(test, POOL_FEE_BPS as u16, ADMIN_SHARE_BPS).succeeds(); -// ── Instruction builders ────────────────────────────────────────────────────── - -fn ix_create_config(config: Pubkey, admin: Pubkey, payer: Pubkey, fee: u16, admin_share: u16) -> Instruction { - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(config.into(), false), - solana_instruction::AccountMeta::new_readonly(admin.into(), false), - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), - ], - data: build_create_config_data(fee, admin_share), + // Pre-populate mint accounts (no onchain minting needed for tests). + test.add(Mint::new(PAYER).at(MINT_A).decimals(6)); + test.add(Mint::new(PAYER).at(MINT_B).decimals(6)); + + // create_pool: the pool_config, pool authority, and LP-mint PDAs are + // derived by the builder; pool_a/pool_b are non-PDA token accounts the + // program creates at the given addresses. + test.send(CreatePoolInstruction { + mint_a: MINT_A, + mint_b: MINT_B, + pool_a: POOL_A, + pool_b: POOL_B, + payer: PAYER, + }) + .succeeds(); + + let config = test.derive_pda(ConfigPda::seeds()); + PoolEnv { + config, + pool_config: test.derive_pda(PoolPda::seeds(&config, &MINT_A, &MINT_B)), + lp_mint: test.derive_pda(LiquidityMintPda::seeds(&config, &MINT_A, &MINT_B)), } } -fn ix_create_pool( - config: Pubkey, - pool_config: Pubkey, - pool_authority: Pubkey, - lp_mint: Pubkey, - mint_a: Pubkey, - mint_b: Pubkey, - pool_a: Pubkey, - pool_b: Pubkey, - payer: Pubkey, -) -> Instruction { - let rent_id = quasar_svm::solana_sdk_ids::sysvar::rent::ID; - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(config.into(), false), - solana_instruction::AccountMeta::new(pool_config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), - solana_instruction::AccountMeta::new(lp_mint.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), - // pool_a and pool_b are non-PDA token accounts created via - // system::create_account CPI, which requires the `to` account to - // be a signer in the parent transaction (signers=[]). - solana_instruction::AccountMeta::new(pool_a.into(), true), - solana_instruction::AccountMeta::new(pool_b.into(), true), - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), - solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), - solana_instruction::AccountMeta::new_readonly(rent_id.into(), false), - ], - data: vec![1u8], // discriminator = 1 - } +/// Fund a depositor wallet with token A/B accounts holding the given amounts. +fn fund(test: &mut Test, wallet: Pubkey, token_a: Pubkey, token_b: Pubkey, amount_a: u64, amount_b: u64) { + test.add(Wallet::new().at(wallet)); + test.add(TokenAccount::new(MINT_A, wallet).at(token_a).amount(amount_a)); + test.add(TokenAccount::new(MINT_B, wallet).at(token_b).amount(amount_b)); } -fn ix_deposit( - config: Pubkey, - pool_config: Pubkey, - pool_authority: Pubkey, +#[allow(clippy::too_many_arguments)] +fn deposit( + test: &mut Test, depositor: Pubkey, - lp_mint: Pubkey, - mint_a: Pubkey, - mint_b: Pubkey, - pool_a: Pubkey, - pool_b: Pubkey, - lp_token: Pubkey, token_a: Pubkey, token_b: Pubkey, - payer: Pubkey, + lp_token: Pubkey, amount_a: u64, amount_b: u64, minimum_lp_tokens_out: u64, -) -> Instruction { - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), - solana_instruction::AccountMeta::new_readonly(depositor.into(), true), - solana_instruction::AccountMeta::new(lp_mint.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), - solana_instruction::AccountMeta::new(pool_a.into(), false), - solana_instruction::AccountMeta::new(pool_b.into(), false), - // lp_token is a non-PDA account created via system::create_account - // CPI; the `to` account must be a signer in the parent instruction. - solana_instruction::AccountMeta::new(lp_token.into(), true), - solana_instruction::AccountMeta::new(token_a.into(), false), - solana_instruction::AccountMeta::new(token_b.into(), false), - solana_instruction::AccountMeta::new(payer.into(), true), - 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, minimum_lp_tokens_out), - } -} - -fn ix_withdraw( - config: Pubkey, - pool_config: Pubkey, - pool_authority: Pubkey, - depositor: Pubkey, - lp_mint: Pubkey, - mint_a: Pubkey, - mint_b: Pubkey, - pool_a: Pubkey, - pool_b: Pubkey, - lp_token: Pubkey, - token_a: Pubkey, - token_b: Pubkey, - payer: Pubkey, - amount: u64, - minimum_token_a_out: u64, - minimum_token_b_out: u64, -) -> Instruction { - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), - solana_instruction::AccountMeta::new_readonly(depositor.into(), true), - solana_instruction::AccountMeta::new(lp_mint.into(), false), - solana_instruction::AccountMeta::new(mint_a.into(), false), - solana_instruction::AccountMeta::new(mint_b.into(), false), - solana_instruction::AccountMeta::new(pool_a.into(), false), - solana_instruction::AccountMeta::new(pool_b.into(), false), - solana_instruction::AccountMeta::new(lp_token.into(), false), - // token_a and token_b are non-PDA accounts created via - // system::create_account CPI; must be signers in parent. - solana_instruction::AccountMeta::new(token_a.into(), true), - solana_instruction::AccountMeta::new(token_b.into(), true), - solana_instruction::AccountMeta::new(payer.into(), true), - 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, minimum_token_a_out, minimum_token_b_out), - } -} - -fn ix_swap( - config: Pubkey, - pool_config: Pubkey, - pool_authority: Pubkey, +) -> Outcome { + test.send(DepositLiquidityInstruction { + depositor, + mint_a: MINT_A, + mint_b: MINT_B, + pool_a: POOL_A, + pool_b: POOL_B, + liquidity_provider_token: lp_token, + token_a, + token_b, + payer: PAYER, + amount_a, + amount_b, + minimum_lp_tokens_out, + }) +} + +/// Fund the seeding depositor and deposit `amount_a` / `amount_b`, with no LP +/// floor (pool-setup helper, not a slippage test). Returns the LP balance. +fn seed_pool(test: &mut Test, amount_a: u64, amount_b: u64) -> u64 { + fund(test, SEEDER, SEEDER_TOKEN_A, SEEDER_TOKEN_B, amount_a, amount_b); + deposit(test, SEEDER, SEEDER_TOKEN_A, SEEDER_TOKEN_B, SEEDER_LP, amount_a, amount_b, 0) + .succeeds(); + test.tokens(SEEDER_LP) +} + +fn swap( + test: &mut Test, trader: Pubkey, - mint_a: Pubkey, - mint_b: Pubkey, - pool_a: Pubkey, - pool_b: Pubkey, token_a: Pubkey, token_b: Pubkey, - payer: Pubkey, input_is_token_a: bool, input_amount: u64, - min_output: u64, -) -> Instruction { - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(config.into(), false), - solana_instruction::AccountMeta::new(pool_config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), - solana_instruction::AccountMeta::new_readonly(trader.into(), true), - solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), - solana_instruction::AccountMeta::new(pool_a.into(), false), - solana_instruction::AccountMeta::new(pool_b.into(), false), - // Both token accounts have init(idempotent); the output one is a - // non-PDA created via system CPI and needs to be a signer. - // Marking both is harmless since the SVM doesn't verify signatures. - solana_instruction::AccountMeta::new(token_a.into(), true), - solana_instruction::AccountMeta::new(token_b.into(), true), - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), - solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), - ], - data: build_swap_data(input_is_token_a, input_amount, min_output), - } -} - -fn ix_claim_fees( - config: Pubkey, - pool_config: Pubkey, - pool_authority: Pubkey, - mint_a: Pubkey, - mint_b: Pubkey, - pool_a: Pubkey, - pool_b: Pubkey, - admin: Pubkey, - admin_token_a: Pubkey, - admin_token_b: Pubkey, -) -> Instruction { - Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new_readonly(config.into(), false), - solana_instruction::AccountMeta::new(pool_config.into(), false), - solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), - solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), - solana_instruction::AccountMeta::new(pool_a.into(), false), - solana_instruction::AccountMeta::new(pool_b.into(), false), - solana_instruction::AccountMeta::new_readonly(admin.into(), true), - solana_instruction::AccountMeta::new(admin_token_a.into(), false), - solana_instruction::AccountMeta::new(admin_token_b.into(), false), - solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), - ], - data: vec![5u8], // discriminator = 5 - } -} - -// ── Shared pool environment ─────────────────────────────────────────────────── - -struct PoolEnv { - svm: QuasarSvm, - admin: Pubkey, - payer: Pubkey, - config: Pubkey, - mint_a: Pubkey, - mint_b: Pubkey, - pool_config: Pubkey, - pool_authority: Pubkey, - lp_mint: Pubkey, - pool_a: Pubkey, - pool_b: Pubkey, -} - -/// Creates config + two mints + pool and commits everything to the SVM. -fn setup_pool() -> PoolEnv { - let mut svm = setup(); - let payer = Pubkey::new_unique(); - let admin = Pubkey::new_unique(); - - // create_config - let config = config_pda(); - let r = svm.process_instruction( - &ix_create_config(config, admin, payer, 30, 1_667), - &[empty(config), empty(admin), signer(payer)], - ); - assert!(r.is_ok(), "setup_pool/create_config: {:?}", r.raw_result); - - // 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)); - svm.set_account(test_mint(mint_b, 6)); - - // Derive pool PDAs. - let pool_config = pool_pda(config, mint_a, mint_b); - let pool_authority = pool_authority_pda(config, mint_a, mint_b); - let lp_mint = lp_mint_pda(config, mint_a, mint_b); - // Pool token-A and token-B reserves live at arbitrary unique addresses. - let pool_a = Pubkey::new_unique(); - let pool_b = Pubkey::new_unique(); - - // 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( - &ix_create_pool( - config, pool_config, pool_authority, lp_mint, - mint_a, mint_b, pool_a, pool_b, payer, - ), - &[ - empty(pool_config), - empty(pool_authority), - empty(lp_mint), - signer(pool_a), // non-PDA: needs signer status for create_account CPI - signer(pool_b), - signer(payer), - ], - ); - assert!(r.is_ok(), "setup_pool/create_pool: {:?}", r.raw_result); - - PoolEnv { svm, admin, payer, config, mint_a, mint_b, pool_config, pool_authority, lp_mint, pool_a, pool_b } -} - -/// Deposits `amount_a` / `amount_b` for a fresh depositor. Returns the -/// depositor's LP-token account address. -fn do_deposit(env: &mut PoolEnv, amount_a: u64, amount_b: u64) -> (Pubkey, Pubkey) { - let depositor = Pubkey::new_unique(); - - // Pre-fund the depositor's token accounts and commit them to the SVM so - // they're in the "merged" set and get committed after the instruction. - let ta = funded_ata(depositor, env.mint_a, amount_a); - let tb = funded_ata(depositor, env.mint_b, amount_b); - let token_a = ta.address; - let token_b = tb.address; - env.svm.set_account(ta); - env.svm.set_account(tb); - - // 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(); - - 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, - // Pool-setup helper, not a slippage test: no LP floor. - amount_a, amount_b, 0, - ), - &[signer(lp_token), signer(depositor)], - ); - assert!(r.is_ok(), "do_deposit: {:?}", r.raw_result); - - (depositor, lp_token) -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Tests - create_config (existing) -// ═══════════════════════════════════════════════════════════════════════════════ - -#[test] -fn test_create_config() { - let mut svm = setup(); - let payer = Pubkey::new_unique(); - let admin = Pubkey::new_unique(); - let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - let data = build_create_config_data(30, 1667); - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(config_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(admin.into(), false), - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), - ], - data, - }; - let result = svm.process_instruction( - &instruction, - &[empty(config_pda), signer(admin), signer(payer)], - ); - assert!(result.is_ok(), "create_config failed: {:?}", result.raw_result); - println!(" CREATE CONFIG CU: {}", result.compute_units_consumed); -} - -#[test] -fn test_create_config_invalid_fee() { - let mut svm = setup(); - let payer = Pubkey::new_unique(); - let admin = Pubkey::new_unique(); - let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - let data = build_create_config_data(10000, 1667); // fee >= 10_000 → invalid - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(config_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(admin.into(), false), - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), - ], - data, - }; - let result = svm.process_instruction( - &instruction, - &[empty(config_pda), signer(admin), signer(payer)], + min_output_amount: u64, +) -> Outcome { + test.send(SwapTokensInstruction { + trader, + mint_a: MINT_A, + mint_b: MINT_B, + pool_a: POOL_A, + pool_b: POOL_B, + token_a, + token_b, + payer: PAYER, + input_is_token_a, + input_amount, + min_output_amount, + }) +} + +fn claim_fees(test: &mut Test, admin: Pubkey, admin_token_a: Pubkey, admin_token_b: Pubkey) -> Outcome { + test.send(ClaimAdminFeesInstruction { + mint_a: MINT_A, + mint_b: MINT_B, + pool_a: POOL_A, + pool_b: POOL_B, + admin, + admin_token_a, + admin_token_b, + }) +} + +// ─── create_config ─────────────────────────────────────────────────────────── + +#[quasar_test] +fn create_config_records_admin_and_fees(test: &mut Test) { + create_config(test, 30, 1_667).succeeds(); + + let config = test.derive_pda(ConfigPda::seeds()); + let state = test.read::(config); + assert_eq!(state.admin, ADMIN); + assert_eq!(u16::from(state.fee), 30); + assert_eq!(u16::from(state.admin_share_bps), 1_667); +} + +#[quasar_test] +fn create_config_rejects_invalid_fee(test: &mut Test) { + // fee >= 10_000 → invalid. + let outcome = create_config(test, 10_000, 1_667); + assert!( + outcome.is_err(), + "create_config should have failed with invalid fee" ); - assert!(!result.is_ok(), "create_config should have failed with invalid fee"); - println!(" CREATE CONFIG (invalid fee) correctly rejected"); } -#[test] -fn test_create_config_invalid_admin_share() { - let mut svm = setup(); - let payer = Pubkey::new_unique(); - let admin = Pubkey::new_unique(); - let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - let data = build_create_config_data(30, 10000); // admin_share_bps >= 10_000 → invalid - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(config_pda.into(), false), - solana_instruction::AccountMeta::new_readonly(admin.into(), false), - solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), - ], - data, - }; - let result = svm.process_instruction( - &instruction, - &[empty(config_pda), signer(admin), signer(payer)], +#[quasar_test] +fn create_config_rejects_invalid_admin_share(test: &mut Test) { + // admin_share_bps >= 10_000 → invalid. + let outcome = create_config(test, 30, 10_000); + assert!( + outcome.is_err(), + "create_config should have failed with admin_share_bps >= 10000" ); - assert!(!result.is_ok(), "create_config should have failed with admin_share_bps >= 10000"); - println!(" CREATE CONFIG (invalid admin share) correctly rejected"); } -// ═══════════════════════════════════════════════════════════════════════════════ -// Tests - create_pool -// ═══════════════════════════════════════════════════════════════════════════════ +// ─── create_pool ───────────────────────────────────────────────────────────── -#[test] -fn test_create_pool() { - let env = setup_pool(); +#[quasar_test] +fn create_pool_creates_pool_config_and_lp_mint(test: &mut Test) { + let env = setup_pool(test); // The pool_config PDA must now exist and be owned by our program. - let pc = env.svm.get_account(&env.pool_config).expect("pool_config missing after create_pool"); - assert_eq!(pc.owner, env.svm.get_account(&env.pool_config).unwrap().owner); + let pc = test.account(env.pool_config).expect("pool_config missing after create_pool"); + assert_eq!(pc.owner, test.program_id()); // LP mint PDA must be a valid SPL mint (82 bytes, owned by token program). - let lp = env.svm.get_account(&env.lp_mint).expect("lp_mint missing"); + let lp = test.account(env.lp_mint).expect("lp_mint missing"); assert_eq!(lp.data.len(), 82, "LP mint should be 82 bytes"); - println!(" CREATE POOL: pool_config={}, lp_mint={}", env.pool_config, env.lp_mint); } -// ═══════════════════════════════════════════════════════════════════════════════ -// Tests - deposit_liquidity -// ═══════════════════════════════════════════════════════════════════════════════ +// ─── deposit_liquidity ─────────────────────────────────────────────────────── -#[test] -fn test_deposit_liquidity_initial() { - let mut env = setup_pool(); +#[quasar_test] +fn deposit_liquidity_initial(test: &mut Test) { + setup_pool(test); let amount_a = 1_000_000u64; let amount_b = 4_000_000u64; + let lp_balance = seed_pool(test, amount_a, amount_b); - let (_depositor, lp_token) = do_deposit(&mut env, amount_a, amount_b); - - // LP token account must exist with a non-zero balance. - let lp_acct = env.svm.get_account(&lp_token).expect("lp_token missing after deposit"); - let lp_balance = token_amount(&lp_acct); + // LP token account must exist with a non-zero balance, and the pool + // reserves must have received the tokens. assert!(lp_balance > 0, "expected LP tokens, got 0"); - - // Pool reserves must have received the tokens. - let pa = env.svm.get_account(&env.pool_a).expect("pool_a missing"); - let pb = env.svm.get_account(&env.pool_b).expect("pool_b missing"); - assert_eq!(token_amount(&pa), amount_a); - assert_eq!(token_amount(&pb), amount_b); - - println!(" DEPOSIT: LP minted={}, pool_a={}, pool_b={}", lp_balance, amount_a, amount_b); + assert_eq!(test.tokens(POOL_A), amount_a); + assert_eq!(test.tokens(POOL_B), amount_b); } -#[test] -fn test_deposit_liquidity_subsequent_proportional() { - let mut env = setup_pool(); +#[quasar_test] +fn deposit_liquidity_subsequent_proportional(test: &mut Test) { + setup_pool(test); // Initial deposit: 1:4 ratio. - let (_, lp1) = do_deposit(&mut env, 1_000_000, 4_000_000); - let lp1_bal = token_amount(&env.svm.get_account(&lp1).unwrap()); + let lp1_bal = seed_pool(test, 1_000_000, 4_000_000); // Second depositor with the same 1:4 ratio gets proportional LP tokens. - let (_, lp2) = do_deposit(&mut env, 500_000, 2_000_000); - let lp2_bal = token_amount(&env.svm.get_account(&lp2).unwrap()); + fund(test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, 500_000, 2_000_000); + deposit( + test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, DEPOSITOR_LP, + 500_000, 2_000_000, 0, + ) + .succeeds(); + let lp2_bal = test.tokens(DEPOSITOR_LP); // Half the first deposit → should get roughly half the LP tokens. - // Allow ±1 for integer rounding. assert!( lp2_bal > 0 && lp2_bal <= lp1_bal, "second depositor LP={} should be > 0 and <= first LP={}", - lp2_bal, lp1_bal + lp2_bal, + lp1_bal ); - println!(" SECOND DEPOSIT: lp1={}, lp2={}", lp1_bal, lp2_bal); } -#[test] -fn test_deposit_insufficient_funds_rejected() { - let mut env = setup_pool(); +#[quasar_test] +fn deposit_insufficient_funds_rejected(test: &mut Test) { + setup_pool(test); - let depositor = Pubkey::new_unique(); // Fund with only 100 of each but request 1_000_000. - let ta = funded_ata(depositor, env.mint_a, 100); - let tb = funded_ata(depositor, env.mint_b, 100); - 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 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, - 1_000_000, 1_000_000, 0, - ), - &[empty(lp_token), signer(depositor)], - ); - r.assert_error(amm_error(AmmError::InsufficientBalance)); - println!(" DEPOSIT insufficient funds correctly rejected"); + fund(test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, 100, 100); + deposit( + test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, DEPOSITOR_LP, + 1_000_000, 1_000_000, 0, + ) + .fails_with(AmmError::InsufficientBalance); } /// Regression test for the ratio-clamp direction bug: with reserves at @@ -619,175 +301,129 @@ fn test_deposit_insufficient_funds_rejected() { /// 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(); +#[quasar_test] +fn deposit_clamps_down_never_up(test: &mut Test) { + setup_pool(test); // 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()); + let lp_supply = seed_pool(test, pool_seed_a, pool_seed_b); // 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(); + fund(test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, stated_a, stated_b); 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); - + deposit( + test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, DEPOSITOR_LP, + stated_a, stated_b, expected_lp, + ) + .succeeds() // 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 - ); + .has_tokens(DEPOSITOR_TOKEN_A, 0) + .has_tokens(DEPOSITOR_TOKEN_B, stated_b - expected_b_pulled) + .has_tokens(POOL_A, pool_seed_a + stated_a) + .has_tokens(POOL_B, pool_seed_b + expected_b_pulled) + // LP mint must be proportional. + .has_tokens(DEPOSITOR_LP, expected_lp); } -/// Mirror of `test_deposit_clamps_down_never_up` with the reserves reversed +/// Mirror of `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(); +#[quasar_test] +fn deposit_clamps_down_other_side(test: &mut Test) { + setup_pool(test); // 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 lp_supply = seed_pool(test, pool_seed_a, pool_seed_b); - 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(); + fund(test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, stated_a, stated_b); // 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 - ); + deposit( + test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, DEPOSITOR_LP, + stated_a, stated_b, expected_lp, + ) + .succeeds() + .has_tokens(DEPOSITOR_TOKEN_A, stated_a - expected_a_pulled) + .has_tokens(DEPOSITOR_TOKEN_B, 0) + .has_tokens(POOL_A, pool_seed_a + expected_a_pulled) + .has_tokens(POOL_B, pool_seed_b + stated_b) + // LP mint must be proportional. + .has_tokens(DEPOSITOR_LP, expected_lp); } -#[test] -fn test_deposit_slippage_rejected() { - let mut env = setup_pool(); +#[quasar_test] +fn deposit_slippage_rejected(test: &mut Test) { + setup_pool(test); 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 lp_supply = seed_pool(test, pool_seed_a, pool_seed_b); - 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(); + fund(test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, stated_a, stated_b); // 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)); + deposit( + test, DEPOSITOR, DEPOSITOR_TOKEN_A, DEPOSITOR_TOKEN_B, DEPOSITOR_LP, + stated_a, stated_b, exact_lp + 1, + ) + .fails_with(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"); + assert_eq!(test.tokens(DEPOSITOR_TOKEN_A), stated_a, "token A must be untouched after revert"); + assert_eq!(test.tokens(DEPOSITOR_TOKEN_B), stated_b, "token B must be untouched after revert"); + assert_eq!(test.tokens(POOL_A), pool_seed_a, "pool_a must be untouched after revert"); + assert_eq!(test.tokens(POOL_B), pool_seed_b, "pool_b must be untouched after revert"); } -// ═══════════════════════════════════════════════════════════════════════════════ -// Tests - withdraw_liquidity -// ═══════════════════════════════════════════════════════════════════════════════ +// ─── withdraw_liquidity ────────────────────────────────────────────────────── -#[test] -fn test_withdraw_liquidity() { - let mut env = setup_pool(); +#[allow(clippy::too_many_arguments)] +fn withdraw( + test: &mut Test, + depositor: Pubkey, + lp_token: Pubkey, + recv_a: Pubkey, + recv_b: Pubkey, + amount: u64, + minimum_token_a_out: u64, + minimum_token_b_out: u64, +) -> Outcome { + test.send(WithdrawLiquidityInstruction { + depositor, + mint_a: MINT_A, + mint_b: MINT_B, + pool_a: POOL_A, + pool_b: POOL_B, + liquidity_provider_token: lp_token, + token_a: recv_a, + token_b: recv_b, + payer: PAYER, + amount, + minimum_token_a_out, + minimum_token_b_out, + }) +} + +#[quasar_test] +fn withdraw_liquidity_pays_the_proportional_share(test: &mut Test) { + setup_pool(test); let amount_a = 2_000_000u64; let amount_b = 2_000_000u64; - - let (depositor, lp_token) = do_deposit(&mut env, amount_a, amount_b); - let lp_balance = token_amount(&env.svm.get_account(&lp_token).unwrap()); + let lp_balance = seed_pool(test, amount_a, amount_b); assert!(lp_balance > 0); // Withdraw half the LP tokens. @@ -802,49 +438,25 @@ fn test_withdraw_liquidity() { 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(); - - 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, - // 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 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_eq!(token_amount(&ra), expected_a, "token A withdrawal mismatch"); - assert_eq!(token_amount(&rb), expected_b, "token B withdrawal mismatch"); - + // Output token accounts are created by init(idempotent). Pass the exact + // expected amounts as the slippage floors: the pool hasn't moved since + // the quote, so the floors must be met. + withdraw( + test, SEEDER, SEEDER_LP, RECV_A, RECV_B, + withdraw_amount, expected_a, expected_b, + ) + .succeeds() + // The depositor received exactly the proportional share. + .has_tokens(RECV_A, expected_a) + .has_tokens(RECV_B, expected_b) // 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={}", - withdraw_amount, token_amount(&ra), token_amount(&rb) - ); + .has_tokens(SEEDER_LP, lp_balance - withdraw_amount); } -#[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()); +#[quasar_test] +fn withdraw_slippage_rejected(test: &mut Test) { + setup_pool(test); + let lp_balance = seed_pool(test, 2_000_000, 2_000_000); let withdraw_amount = lp_balance / 2; let divisor = lp_balance @@ -852,279 +464,131 @@ fn test_withdraw_slippage_rejected() { .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)); + withdraw( + test, SEEDER, SEEDER_LP, RECV_A, RECV_B, + withdraw_amount, expected_a + 1, 0, + ) + .fails_with(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 -// ═══════════════════════════════════════════════════════════════════════════════ - -/// 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) + assert_eq!(test.tokens(POOL_A), 2_000_000, "pool_a must be untouched after revert"); + assert_eq!(test.tokens(POOL_B), 2_000_000, "pool_b must be untouched after revert"); + assert_eq!(test.tokens(SEEDER_LP), lp_balance, "LP balance must be untouched after revert"); } -/// Trading fee passed to `create_config` in `setup_pool`, in basis points. -const POOL_FEE_BPS: u64 = 30; +// ─── swap_tokens ───────────────────────────────────────────────────────────── -#[test] -fn test_swap_a_to_b_conserves_balances() { - let mut env = setup_pool(); +#[quasar_test] +fn swap_a_to_b_conserves_balances(test: &mut Test) { + setup_pool(test); // Seed the pool with liquidity first. let (pool_seed_a, pool_seed_b) = (10_000_000u64, 10_000_000u64); - do_deposit(&mut env, pool_seed_a, pool_seed_b); + seed_pool(test, pool_seed_a, pool_seed_b); - // Trader swaps 100_000 token A for token B. - let trader = Pubkey::new_unique(); + // Trader swaps 100_000 token A for token B (the output account is created + // by init(idempotent)). 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); + test.add(Wallet::new().at(TRADER)); + test.add(TokenAccount::new(MINT_A, TRADER).at(TRADER_TOKEN_A).amount(trader_funding)); 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, 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); - - // 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_conserves_balances() { - let mut env = setup_pool(); + // floor = exact quote; the pool hasn't moved. + swap(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, true, input, expected_output) + .succeeds() + // Conservation: the trader pays exactly `input` and receives exactly + // what the pool sent; nothing is minted or lost in transit. + .has_tokens(TRADER_TOKEN_A, trader_funding - input) + .has_tokens(TRADER_TOKEN_B, expected_output) + .has_tokens(POOL_A, pool_seed_a + input) + .has_tokens(POOL_B, pool_seed_b - expected_output); +} + +#[quasar_test] +fn swap_b_to_a_conserves_balances(test: &mut Test) { + setup_pool(test); let (pool_seed_a, pool_seed_b) = (10_000_000u64, 10_000_000u64); - do_deposit(&mut env, pool_seed_a, pool_seed_b); + seed_pool(test, pool_seed_a, pool_seed_b); - let trader = Pubkey::new_unique(); 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); + test.add(Wallet::new().at(TRADER)); + test.add(TokenAccount::new(MINT_B, TRADER).at(TRADER_TOKEN_B).amount(trader_funding)); 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, 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 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); + // input_is_token_a = false. + swap(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, false, input, expected_output) + .succeeds() + .has_tokens(TRADER_TOKEN_B, trader_funding - input) + .has_tokens(TRADER_TOKEN_A, expected_output) + .has_tokens(POOL_B, pool_seed_b + input) + .has_tokens(POOL_A, pool_seed_a - expected_output); } -#[test] -fn test_swap_slippage_rejected() { - let mut env = setup_pool(); - do_deposit(&mut env, 10_000_000, 10_000_000); +#[quasar_test] +fn swap_slippage_rejected(test: &mut Test) { + setup_pool(test); + seed_pool(test, 10_000_000, 10_000_000); - let trader = Pubkey::new_unique(); - let ta = funded_ata(trader, env.mint_a, 1_000_000); - let token_a = ta.address; - let token_b_out = Pubkey::new_unique(); - env.svm.set_account(ta); + test.add(Wallet::new().at(TRADER)); + test.add(TokenAccount::new(MINT_A, TRADER).at(TRADER_TOKEN_A).amount(1_000_000)); // 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, input, quote + 1, - ), - &[empty(token_b_out), signer(trader)], - ); - r.assert_error(amm_error(AmmError::SlippageExceeded)); + swap(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, true, input, quote + 1) + .fails_with(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"); + assert_eq!(test.tokens(TRADER_TOKEN_A), 1_000_000, "trader balance must be untouched after revert"); + assert_eq!(test.tokens(POOL_A), 10_000_000, "pool_a must be untouched after revert"); + assert_eq!(test.tokens(POOL_B), 10_000_000, "pool_b must be untouched after revert"); } -// ═══════════════════════════════════════════════════════════════════════════════ -// Tests - claim_admin_fees -// ═══════════════════════════════════════════════════════════════════════════════ +// ─── claim_admin_fees ──────────────────────────────────────────────────────── -#[test] -fn test_claim_admin_fees() { - let mut env = setup_pool(); +#[quasar_test] +fn claim_admin_fees_pays_the_admin(test: &mut Test) { + setup_pool(test); // Seed pool and do a swap so fees accumulate. - do_deposit(&mut env, 10_000_000, 10_000_000); - - let trader = Pubkey::new_unique(); - let ta = funded_ata(trader, env.mint_a, 1_000_000); - let token_a_in = ta.address; - let token_b_out = Pubkey::new_unique(); - env.svm.set_account(ta); - - 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_in, token_b_out, env.payer, - true, 500_000, 1, - ), - &[signer(token_b_out), signer(trader)], - ); - assert!(r.is_ok(), "swap before claim: {:?}", r.raw_result); + seed_pool(test, 10_000_000, 10_000_000); + test.add(Wallet::new().at(TRADER)); + test.add(TokenAccount::new(MINT_A, TRADER).at(TRADER_TOKEN_A).amount(1_000_000)); + swap(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, true, 500_000, 1).succeeds(); // Admin claims accumulated fees. - let admin_ta = funded_ata(env.admin, env.mint_a, 0); - let admin_tb = funded_ata(env.admin, env.mint_b, 0); - let (ata_a, ata_b) = (admin_ta.address, admin_tb.address); - env.svm.set_account(admin_ta); - env.svm.set_account(admin_tb); - - let r = env.svm.process_instruction( - &ix_claim_fees( - env.config, env.pool_config, env.pool_authority, - env.mint_a, env.mint_b, env.pool_a, env.pool_b, - env.admin, ata_a, ata_b, - ), - &[signer(env.admin)], - ); - assert!(r.is_ok(), "claim_admin_fees failed: {:?}", r.raw_result); + test.add(Wallet::new().at(ADMIN)); + test.add(TokenAccount::new(MINT_A, ADMIN).at(ADMIN_TOKEN_A)); + test.add(TokenAccount::new(MINT_B, ADMIN).at(ADMIN_TOKEN_B)); + + claim_fees(test, ADMIN, ADMIN_TOKEN_A, ADMIN_TOKEN_B).succeeds(); - // After claim, admin_token_a should have received some fees (A was the input side). - let admin_a = env.svm.get_account(&ata_a).expect("admin_ta missing after claim"); + // After the claim, admin_token_a should have received some fees (A was + // the input side). assert!( - token_amount(&admin_a) > 0, + test.tokens(ADMIN_TOKEN_A) > 0, "admin should have received token-A fees" ); - println!(" CLAIM FEES: admin_a_fees={}", token_amount(&admin_a)); } -#[test] -fn test_claim_admin_fees_unauthorized() { - let mut env = setup_pool(); - do_deposit(&mut env, 10_000_000, 10_000_000); +#[quasar_test] +fn claim_admin_fees_rejects_non_admin(test: &mut Test) { + setup_pool(test); + seed_pool(test, 10_000_000, 10_000_000); // Swap to accumulate some fees. - let trader = Pubkey::new_unique(); - let ta = funded_ata(trader, env.mint_a, 1_000_000); - let token_a_in = ta.address; - let token_b_out = Pubkey::new_unique(); - env.svm.set_account(ta); - 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_in, token_b_out, env.payer, - true, 100_000, 1, - ), - &[signer(token_b_out), signer(trader)], - ) - .expect("swap before unauthorized claim test"); + test.add(Wallet::new().at(TRADER)); + test.add(TokenAccount::new(MINT_A, TRADER).at(TRADER_TOKEN_A).amount(1_000_000)); + swap(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, true, 100_000, 1).succeeds(); // Impersonator tries to claim with a wrong signer. - let bad_actor = Pubkey::new_unique(); - let fake_ta = funded_ata(bad_actor, env.mint_a, 0); - let fake_tb = funded_ata(bad_actor, env.mint_b, 0); - let (fta, ftb) = (fake_ta.address, fake_tb.address); - env.svm.set_account(fake_ta); - env.svm.set_account(fake_tb); - - let r = env.svm.process_instruction( - &ix_claim_fees( - env.config, env.pool_config, env.pool_authority, - env.mint_a, env.mint_b, env.pool_a, env.pool_b, - bad_actor, fta, ftb, - ), - &[signer(bad_actor)], - ); - assert!(!r.is_ok(), "unauthorized claim_admin_fees should fail"); - println!(" CLAIM FEES unauthorized correctly rejected"); + test.add(Wallet::new().at(BAD_ACTOR)); + test.add(TokenAccount::new(MINT_A, BAD_ACTOR).at(BAD_TOKEN_A)); + test.add(TokenAccount::new(MINT_B, BAD_ACTOR).at(BAD_TOKEN_B)); + + let outcome = claim_fees(test, BAD_ACTOR, BAD_TOKEN_A, BAD_TOKEN_B); + assert!(outcome.is_err(), "unauthorized claim_admin_fees should fail"); } diff --git a/finance/vault-strategy/quasar/CHANGELOG.md b/finance/vault-strategy/quasar/CHANGELOG.md index 44cb79ce..fbe53e45 100644 --- a/finance/vault-strategy/quasar/CHANGELOG.md +++ b/finance/vault-strategy/quasar/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [2026-07-22] + +### Changed + +- Migrated both programs (`vault-strategy` and `mock-swap-router`) to Quasar + 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml rewritten to the + 0.1.0 schema, `idl-build` feature and `lib` crate-type added, and tests + rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders — including + `remaining_accounts` for the per-asset deposit accounts — and `Outcome` + assertions). The two-program deposit test loads the sibling router's + compiled `.so` at runtime via + `test.add(Program::new(ROUTER_ID, &std::fs::read("../mock-swap-router/target/deploy/quasar_mock_swap_router.so")...))`, + so `quasar build` must run in `mock-swap-router` before the vault-strategy + tests execute. The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. Program-source + fix for 0.1.0 in both programs: `Seed` is no longer in the prelude, so the + instruction files that build signer seeds now import it from + `quasar_lang::cpi`. + ## 2026-07-20 - **`WhitelistEntry` renamed `ApprovedAsset`** (and `whitelist_asset` renamed `approve_asset`, PDA seed `"whitelist"` renamed `"approved_asset"`), naming the account after what it is: one curator-approved asset bound to its official price feed. The unused `AssetNotWhitelisted` error is removed; approval is checked by the `ApprovedAsset` account's existence. Doc comments and README now state that the `Registry` account is the curator record at the root of the approved set, not the list itself. diff --git a/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml b/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml index 0a4dc6d0..2a5c367f 100644 --- a/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml +++ b/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml @@ -13,26 +13,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pinned to match the finance/escrow Quasar example (623bb70 is the last rev -# before a zeropod 0.3 bump that breaks quasar-spl). Unpin once upstream fixes it. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -solana-address = { version = "2.2.0" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml b/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml index d1f7ddb1..cb1bdcea 100644 --- a/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml +++ b/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_mock_swap_router" - -[toolchain] -type = "solana" +name = "quasar-mock-swap-router" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs index 4d66e2eb..d50c4991 100644 --- a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_spl::prelude::*; diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs index f3108122..9d911894 100644 --- a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_spl::prelude::*; diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs b/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs index ad74d82e..b0c1748d 100644 --- a/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs +++ b/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs @@ -1,248 +1,72 @@ -//! QuasarSVM integration tests for the mock swap router: initialize it, set a -//! rate (which also creates the USDC treasury), then swap USDC for an asset and -//! back, asserting the minted/burned amounts and treasury flows. - -extern crate std; +//! quasar-test integration tests for the mock swap router: initialize it, set +//! a rate (which also creates the USDC treasury), then swap USDC for an asset, +//! asserting the minted amounts and treasury flows. use { - alloc::vec, - quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, - solana_program_pack::Pack, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::{ + cpi::{InitializeRouterInstruction, SetRateInstruction, SwapUsdcForAssetInstruction}, + state::{AssetRate, RouterAuthorityPda, TreasuryPda}, + }, + quasar_test::prelude::*, }; -use crate::state::{ASSET_RATE_SEED, ROUTER_AUTHORITY_SEED, ROUTER_CONFIG_SEED}; - const DECIMALS: u8 = 6; const RATE: u64 = 250; // 250 USDC base units per asset base unit -const STARTING_LAMPORTS: u64 = 1_000_000_000; - -fn program_id() -> Pubkey { - Pubkey::new_from_array(crate::ID.to_bytes()) -} -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_mock_swap_router.so").unwrap(); - QuasarSvm::new() - .with_program(&program_id(), &elf) - .with_token_program() -} -fn rent_id() -> Pubkey { - quasar_svm::solana_sdk_ids::sysvar::rent::ID -} -fn token_program_id() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn system_program_id() -> Pubkey { - quasar_svm::system_program::ID -} - -fn signer_account(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) -} -fn empty_account(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: system_program_id(), - executable: false, - } -} -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: DECIMALS, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} -fn token_account(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_amount(account: &Account) -> u64 { - TokenAccount::unpack(&account.data).unwrap().amount -} - -fn config_pda() -> Pubkey { - Pubkey::find_program_address(&[ROUTER_CONFIG_SEED], &program_id()).0 -} -fn authority_pda() -> Pubkey { - Pubkey::find_program_address(&[ROUTER_AUTHORITY_SEED], &program_id()).0 -} -fn treasury_pda() -> Pubkey { - Pubkey::find_program_address(&[b"treasury"], &program_id()).0 -} -fn rate_pda(mint: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[ASSET_RATE_SEED, mint.as_ref()], &program_id()).0 -} -fn init_router_ix(authority: Pubkey, usdc_mint: Pubkey, config: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(authority, true), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new(config, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: vec![0u8], - } -} - -#[allow(clippy::too_many_arguments)] -fn set_rate_ix( - authority: Pubkey, - config: Pubkey, - asset_mint: Pubkey, - usdc_mint: Pubkey, - rate: Pubkey, - router_authority: Pubkey, - treasury: Pubkey, - usdc_per_token: u64, -) -> Instruction { - let mut data = vec![1u8]; - data.extend_from_slice(&usdc_per_token.to_le_bytes()); - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(authority, true), - AccountMeta::new_readonly(config, false), - AccountMeta::new_readonly(asset_mint, false), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new(rate, false), - AccountMeta::new_readonly(router_authority, false), - AccountMeta::new(treasury, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data, - } -} +// Deterministic addresses. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const USDC_MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const ASSET_MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const CALLER_USDC: Pubkey = Pubkey::new_from_array([4; 32]); +const CALLER_ASSET: Pubkey = Pubkey::new_from_array([5; 32]); -#[allow(clippy::too_many_arguments)] -fn swap_usdc_for_asset_ix( - caller: Pubkey, - config: Pubkey, - rate: Pubkey, - usdc_mint: Pubkey, - asset_mint: Pubkey, - caller_usdc: Pubkey, - caller_asset: Pubkey, - treasury: Pubkey, - router_authority: Pubkey, - usdc_amount_in: u64, - minimum_asset_out: u64, -) -> Instruction { - let mut data = vec![2u8]; - data.extend_from_slice(&usdc_amount_in.to_le_bytes()); - data.extend_from_slice(&minimum_asset_out.to_le_bytes()); - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new_readonly(caller, true), - AccountMeta::new_readonly(config, false), - AccountMeta::new_readonly(rate, false), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new(asset_mint, false), - AccountMeta::new(caller_usdc, false), - AccountMeta::new(caller_asset, false), - AccountMeta::new(treasury, false), - AccountMeta::new_readonly(router_authority, false), - AccountMeta::new_readonly(token_program_id(), false), - ], - data, - } -} - -#[test] -fn test_initialize_and_swap_usdc_for_asset() { - let mut svm = setup(); - - let authority = Pubkey::new_unique(); - let usdc_mint = Pubkey::new_unique(); - let asset_mint = Pubkey::new_unique(); - let config = config_pda(); - let router_authority = authority_pda(); - let treasury = treasury_pda(); - let rate = rate_pda(&asset_mint); - let caller_usdc = Pubkey::new_unique(); - let caller_asset = Pubkey::new_unique(); +#[quasar_test] +fn initialize_and_swap_usdc_for_asset(test: &mut Test) { + let router_authority = test.derive_pda(RouterAuthorityPda::seeds()); + let treasury = test.derive_pda(TreasuryPda::seeds()); + let rate = test.derive_pda(AssetRate::seeds(&ASSET_MINT)); const USDC_IN: u64 = 1_000; const ASSET_OUT: u64 = USDC_IN / RATE; // 4 - let accounts = vec![ - signer_account(authority), - mint_account(usdc_mint, authority), - // The asset mint's authority is the router-authority PDA, so the router - // can mint it. - mint_account(asset_mint, router_authority), - empty_account(config), - empty_account(rate), - empty_account(router_authority), - empty_account(treasury), - token_account(caller_usdc, usdc_mint, authority, USDC_IN), - token_account(caller_asset, asset_mint, authority, 0), - ]; - - let instructions = vec![ - init_router_ix(authority, usdc_mint, config), - set_rate_ix( - authority, - config, - asset_mint, - usdc_mint, - rate, - router_authority, - treasury, - RATE, - ), - swap_usdc_for_asset_ix( - authority, - config, - rate, - usdc_mint, - asset_mint, - caller_usdc, - caller_asset, - treasury, - router_authority, - USDC_IN, - ASSET_OUT, - ), - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!( - result.is_ok(), - "router chain failed: {:?}", - result.raw_result + test.add(Wallet::new().at(AUTHORITY)); + test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(DECIMALS)); + // The asset mint's authority is the router-authority PDA, so the router + // can mint it. + test.add(Mint::new(router_authority).at(ASSET_MINT).decimals(DECIMALS)); + test.add( + TokenAccount::new(USDC_MINT, AUTHORITY) + .at(CALLER_USDC) + .amount(USDC_IN), ); - - // Caller paid all USDC and received the minted asset; treasury holds the USDC. - assert_eq!(token_amount(result.account(&caller_usdc).unwrap()), 0); - assert_eq!( - token_amount(result.account(&caller_asset).unwrap()), - ASSET_OUT - ); - assert_eq!(token_amount(result.account(&treasury).unwrap()), USDC_IN); - - println!(" ROUTER SWAP CU: {}", result.compute_units_consumed); + test.add(TokenAccount::new(ASSET_MINT, AUTHORITY).at(CALLER_ASSET)); + + test.send(InitializeRouterInstruction { + authority: AUTHORITY, + usdc_mint: USDC_MINT, + }) + .succeeds(); + test.send(SetRateInstruction { + authority: AUTHORITY, + asset_mint: ASSET_MINT, + usdc_mint: USDC_MINT, + usdc_per_token: RATE, + }) + .succeeds(); + test.send(SwapUsdcForAssetInstruction { + caller: AUTHORITY, + asset_rate: rate, + usdc_mint: USDC_MINT, + asset_mint: ASSET_MINT, + caller_usdc_account: CALLER_USDC, + caller_asset_account: CALLER_ASSET, + usdc_amount_in: USDC_IN, + minimum_asset_out: ASSET_OUT, + }) + .succeeds() + // Caller paid all USDC and received the minted asset; the treasury holds + // the USDC. + .has_tokens(CALLER_USDC, 0) + .has_tokens(CALLER_ASSET, ASSET_OUT) + .has_tokens(treasury, USDC_IN); } diff --git a/finance/vault-strategy/quasar/vault-strategy/Cargo.toml b/finance/vault-strategy/quasar/vault-strategy/Cargo.toml index 963f97f1..121d27be 100644 --- a/finance/vault-strategy/quasar/vault-strategy/Cargo.toml +++ b/finance/vault-strategy/quasar/vault-strategy/Cargo.toml @@ -13,26 +13,24 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# Pinned to match the finance/escrow Quasar example (623bb70 is the last rev -# before a zeropod 0.3 bump that breaks quasar-spl). Unpin once upstream fixes it. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -solana-address = { version = "2.2.0" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/vault-strategy/quasar/vault-strategy/Quasar.toml b/finance/vault-strategy/quasar/vault-strategy/Quasar.toml index 0e8f5282..b432a9bb 100644 --- a/finance/vault-strategy/quasar/vault-strategy/Quasar.toml +++ b/finance/vault-strategy/quasar/vault-strategy/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_vault_strategy" - -[toolchain] -type = "solana" +name = "quasar-vault-strategy" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs index 70e11b36..4e9be1fe 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs index e988e199..b0380ef2 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_lang::remaining::RemainingAccounts; use quasar_lang::sysvars::Sysvar as _; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs index caeea6f2..735870fd 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs index d09f0309..b09dc9cd 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs @@ -1,3 +1,4 @@ +use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; use quasar_lang::remaining::RemainingAccounts; use quasar_spl::prelude::*; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs index e40da13d..fe8882c5 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs @@ -1,138 +1,49 @@ -//! QuasarSVM integration tests. `test_strategy_setup` drives the manager-side +//! quasar-test integration tests. `strategy_setup` drives the manager-side //! setup (registry, approve asset, strategy, add asset) and asserts state. -//! `test_deposit` is a two-program test: it loads the mock swap router too, -//! wires up rates and a Pyth-shaped price feed, and deposits, checking that the +//! `deposit` is a two-program test: it loads the mock swap router too, wires +//! up rates and a Pyth-shaped price feed, and deposits, checking that the //! deposit is priced 1:1 on the first deposit and deployed into the basket //! through the router CPI. -extern crate std; - use { - alloc::vec, - alloc::vec::Vec, - quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, - solana_program_pack::Pack, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, + crate::{ + cpi::{ + AddAssetInstruction, ApproveAssetInstruction, DepositInstruction, + InitializeRegistryInstruction, InitializeStrategyInstruction, + }, + state::{ + AssetConfig, AssetVaultPda, Registry, ShareMintPda, Strategy, UsdcVaultPda, + }, + }, + quasar_test::prelude::*, }; -use crate::state::{APPROVED_ASSET_SEED, ASSET_CONFIG_SEED, REGISTRY_SEED, STRATEGY_SEED}; - const DECIMALS: u8 = 6; const FEE_BPS: u16 = 100; const MAX_SLIPPAGE_BPS: u16 = 100; -const STARTING_LAMPORTS: u64 = 1_000_000_000; // Router program (loaded for the deposit test). const ROUTER_ID_STR: &str = "SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm"; const RATE: u64 = 250; // USDC base units per asset base unit const NOW: i64 = 1_000; // fixed clock for the deposit test +const STRATEGY_INDEX: u64 = 0; + +// Deterministic addresses. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const MANAGER: Pubkey = Pubkey::new_from_array([2; 32]); +const DEPOSITOR: Pubkey = Pubkey::new_from_array([3; 32]); +const USDC_MINT: Pubkey = Pubkey::new_from_array([4; 32]); +const ASSET_MINT: Pubkey = Pubkey::new_from_array([5; 32]); +const PRICE_FEED: Pubkey = Pubkey::new_from_array([6; 32]); +const DEPOSITOR_USDC: Pubkey = Pubkey::new_from_array([7; 32]); +const DEPOSITOR_SHARE: Pubkey = Pubkey::new_from_array([8; 32]); +const FEED_OWNER: Pubkey = Pubkey::new_from_array([9; 32]); -fn program_id() -> Pubkey { - Pubkey::new_from_array(crate::ID.to_bytes()) -} fn router_id() -> Pubkey { ROUTER_ID_STR.parse().unwrap() } -fn rent_id() -> Pubkey { - quasar_svm::solana_sdk_ids::sysvar::rent::ID -} -fn token_program_id() -> Pubkey { - quasar_svm::SPL_TOKEN_PROGRAM_ID -} -fn system_program_id() -> Pubkey { - quasar_svm::system_program::ID -} - -fn signer_account(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) -} -fn empty_account(address: Pubkey) -> Account { - Account { - address, - lamports: 0, - data: vec![], - owner: system_program_id(), - executable: false, - } -} -fn mint_account(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: DECIMALS, - is_initialized: true, - freeze_authority: None.into(), - }, - ) -} -fn token_account(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_amount(account: &Account) -> u64 { - TokenAccount::unpack(&account.data).unwrap().amount -} - -// A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, `publish_time` -// (i64) at offset 93. The program reads only those two fields. -fn pyth_feed_account(address: Pubkey, price: i64, publish_time: i64) -> Account { - let mut data = vec![0u8; 200]; - data[73..81].copy_from_slice(&price.to_le_bytes()); - data[93..101].copy_from_slice(&publish_time.to_le_bytes()); - Account { - address, - lamports: 1_000_000, - data, - owner: Pubkey::new_unique(), - executable: false, - } -} -fn registry_pda(authority: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[REGISTRY_SEED, authority.as_ref()], &program_id()).0 -} -fn approved_asset_pda(registry: &Pubkey, mint: &Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[APPROVED_ASSET_SEED, registry.as_ref(), mint.as_ref()], - &program_id(), - ) - .0 -} -fn strategy_pda(index: u64) -> Pubkey { - Pubkey::find_program_address(&[STRATEGY_SEED, &index.to_le_bytes()], &program_id()).0 -} -fn share_mint_pda(strategy: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[b"share_mint", strategy.as_ref()], &program_id()).0 -} -fn usdc_vault_pda(strategy: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[b"usdc_vault", strategy.as_ref()], &program_id()).0 -} -fn asset_config_pda(strategy: &Pubkey, index: u8) -> Pubkey { - Pubkey::find_program_address( - &[ASSET_CONFIG_SEED, strategy.as_ref(), &[index]], - &program_id(), - ) - .0 -} -fn asset_vault_pda(strategy: &Pubkey, index: u8) -> Pubkey { - Pubkey::find_program_address( - &[b"asset_vault", strategy.as_ref(), &[index]], - &program_id(), - ) - .0 -} -// Router PDAs. +// Router PDAs (owned by the router program, so derived manually). fn router_config_pda() -> Pubkey { Pubkey::find_program_address(&[b"router_config"], &router_id()).0 } @@ -146,425 +57,191 @@ fn router_rate_pda(mint: &Pubkey) -> Pubkey { Pubkey::find_program_address(&[b"rate", mint.as_ref()], &router_id()).0 } -// --- vault-strategy instruction data --- -fn init_registry_data() -> Vec { - vec![0u8] -} -fn approve_asset_data(price_feed: &Pubkey) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(price_feed.as_ref()); - data -} -fn init_strategy_data(index: u64, swap_router: &Pubkey) -> Vec { - let mut data = vec![2u8]; - data.extend_from_slice(&index.to_le_bytes()); - data.extend_from_slice(&FEE_BPS.to_le_bytes()); - data.extend_from_slice(&MAX_SLIPPAGE_BPS.to_le_bytes()); - data.extend_from_slice(swap_router.as_ref()); - data -} -fn add_asset_data(weight_bps: u16) -> Vec { - let mut data = vec![3u8]; - data.extend_from_slice(&weight_bps.to_le_bytes()); - data -} -fn deposit_data(usdc_amount: u64, minimum_shares: u64) -> Vec { - let mut data = vec![5u8]; - data.extend_from_slice(&usdc_amount.to_le_bytes()); - data.extend_from_slice(&minimum_shares.to_le_bytes()); - data -} - -// --- router instruction data --- -fn router_init_data() -> Vec { - vec![0u8] -} -fn router_set_rate_data(usdc_per_token: u64) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&usdc_per_token.to_le_bytes()); - data -} - -fn init_registry_ix(authority: Pubkey, registry: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(authority, true), - AccountMeta::new(registry, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: init_registry_data(), - } -} - -fn approve_asset_ix( - authority: Pubkey, - registry: Pubkey, - asset_mint: Pubkey, - approved_asset: Pubkey, - price_feed: Pubkey, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(authority, true), - AccountMeta::new_readonly(registry, false), - AccountMeta::new_readonly(asset_mint, false), - AccountMeta::new(approved_asset, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: approve_asset_data(&price_feed), - } -} - -#[allow(clippy::too_many_arguments)] -fn init_strategy_ix( - manager: Pubkey, - usdc_mint: Pubkey, - registry: Pubkey, - strategy: Pubkey, - share_mint: Pubkey, - vault_usdc: Pubkey, - index: u64, - swap_router: Pubkey, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(manager, true), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new_readonly(registry, false), - AccountMeta::new(strategy, false), - AccountMeta::new(share_mint, false), - AccountMeta::new(vault_usdc, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: init_strategy_data(index, &swap_router), - } +// A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, +// `publish_time` (i64) at offset 93. The program reads only those two fields. +fn add_pyth_feed(test: &mut Test, price: i64, publish_time: i64) { + let mut data = vec![0u8; 200]; + data[73..81].copy_from_slice(&price.to_le_bytes()); + data[93..101].copy_from_slice(&publish_time.to_le_bytes()); + test.set_account(Account::new(PRICE_FEED, FEED_OWNER, 1_000_000, data)); } -#[allow(clippy::too_many_arguments)] -fn add_asset_ix( - manager: Pubkey, +/// The strategy-side PDAs the assertions read. +struct Pdas { strategy: Pubkey, - registry: Pubkey, - asset_mint: Pubkey, - approved_asset: Pubkey, asset_config: Pubkey, vault_asset: Pubkey, - weight_bps: u16, -) -> Instruction { - Instruction { - program_id: program_id(), - accounts: vec![ - AccountMeta::new(manager, true), - AccountMeta::new(strategy, false), - AccountMeta::new_readonly(registry, false), - AccountMeta::new_readonly(asset_mint, false), - AccountMeta::new_readonly(approved_asset, false), - AccountMeta::new(asset_config, false), - AccountMeta::new(vault_asset, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ], - data: add_asset_data(weight_bps), - } + vault_usdc: Pubkey, + share_mint: Pubkey, } -// Strategy account offsets (1-byte discriminator, tight packing). -const STRATEGY_ASSET_COUNT_OFFSET: usize = 1 + 8 + 32 + 32 + 32 + 32 + 32 + 2 + 2 + 8 + 8; -const STRATEGY_TOTAL_WEIGHT_OFFSET: usize = STRATEGY_ASSET_COUNT_OFFSET + 1; -// AssetConfig weight_bps offset. -const ASSET_CONFIG_WEIGHT_OFFSET: usize = 1 + 32 + 1 + 32 + 32 + 32; - -fn read_u16(data: &[u8], offset: usize) -> u16 { - u16::from_le_bytes([data[offset], data[offset + 1]]) +fn pdas(test: &Test) -> Pdas { + let strategy = test.derive_pda(Strategy::seeds(STRATEGY_INDEX)); + Pdas { + strategy, + asset_config: test.derive_pda(AssetConfig::seeds(&strategy, 0)), + vault_asset: test.derive_pda(AssetVaultPda::seeds(&strategy, 0)), + vault_usdc: test.derive_pda(UsdcVaultPda::seeds(&strategy)), + share_mint: test.derive_pda(ShareMintPda::seeds(&strategy)), + } } -#[test] -fn test_strategy_setup() { - let mut svm = QuasarSvm::new() - .with_program( - &program_id(), - &std::fs::read("target/deploy/quasar_vault_strategy.so").unwrap(), - ) - .with_token_program(); - - let authority = Pubkey::new_unique(); - let manager = Pubkey::new_unique(); - let usdc_mint = Pubkey::new_unique(); - let asset_mint = Pubkey::new_unique(); - let price_feed = Pubkey::new_unique(); - let swap_router = router_id(); - - let registry = registry_pda(&authority); - let approved_asset = approved_asset_pda(®istry, &asset_mint); - let index = 0u64; - let strategy = strategy_pda(index); - let share_mint = share_mint_pda(&strategy); - let vault_usdc = usdc_vault_pda(&strategy); - let asset_config = asset_config_pda(&strategy, 0); - let vault_asset = asset_vault_pda(&strategy, 0); - - let accounts = vec![ - signer_account(authority), - signer_account(manager), - mint_account(usdc_mint, authority), - mint_account(asset_mint, authority), - empty_account(registry), - empty_account(approved_asset), - empty_account(strategy), - empty_account(share_mint), - empty_account(vault_usdc), - empty_account(asset_config), - empty_account(vault_asset), - ]; - - let instructions = vec![ - init_registry_ix(authority, registry), - approve_asset_ix(authority, registry, asset_mint, approved_asset, price_feed), - init_strategy_ix( - manager, - usdc_mint, - registry, - strategy, - share_mint, - vault_usdc, - index, - swap_router, - ), - add_asset_ix( - manager, - strategy, - registry, - asset_mint, - approved_asset, - asset_config, - vault_asset, - 10_000, - ), - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!( - result.is_ok(), - "setup chain failed: {:?}", - result.raw_result +/// Registry + approved asset + strategy + one basket asset at 100% weight. +fn setup_strategy(test: &mut Test, asset_mint_authority: Pubkey) { + test.add(Wallet::new().at(AUTHORITY)); + test.add(Wallet::new().at(MANAGER)); + test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(DECIMALS)); + test.add( + Mint::new(asset_mint_authority) + .at(ASSET_MINT) + .decimals(DECIMALS), ); - let strategy_data = &result.account(&strategy).unwrap().data; - assert_eq!(strategy_data[0], 3, "strategy discriminator"); - assert_eq!(strategy_data[STRATEGY_ASSET_COUNT_OFFSET], 1, "asset_count"); - assert_eq!( - read_u16(strategy_data, STRATEGY_TOTAL_WEIGHT_OFFSET), - 10_000, - "total_weight_bps" - ); - - let asset_config_data = &result.account(&asset_config).unwrap().data; - assert_eq!(asset_config_data[0], 4, "asset_config discriminator"); - assert_eq!( - read_u16(asset_config_data, ASSET_CONFIG_WEIGHT_OFFSET), - 10_000, - "weight_bps" - ); - - println!(" STRATEGY SETUP CU: {}", result.compute_units_consumed); + let registry = test.derive_pda(Registry::seeds(&AUTHORITY)); + + test.send(InitializeRegistryInstruction { authority: AUTHORITY }).succeeds(); + test.send(ApproveAssetInstruction { + authority: AUTHORITY, + asset_mint: ASSET_MINT, + price_feed: PRICE_FEED, + }) + .succeeds(); + test.send(InitializeStrategyInstruction { + manager: MANAGER, + usdc_mint: USDC_MINT, + registry, + index: STRATEGY_INDEX, + fee_bps: FEE_BPS, + max_slippage_bps: MAX_SLIPPAGE_BPS, + swap_router: router_id(), + }) + .succeeds(); + test.send(AddAssetInstruction { + manager: MANAGER, + strategy_index_seed: STRATEGY_INDEX, + registry, + asset_mint: ASSET_MINT, + strategy_asset_count_seed: 0, + weight_bps: 10_000, + }) + .succeeds(); +} + +#[quasar_test] +fn strategy_setup_records_the_basket(test: &mut Test) { + setup_strategy(test, AUTHORITY); + let w = pdas(test); + + let strategy = test.read::(w.strategy); + assert_eq!(strategy.asset_count, 1, "asset_count"); + assert_eq!(u16::from(strategy.total_weight_bps), 10_000, "total_weight_bps"); + + let asset_config = test.read::(w.asset_config); + assert_eq!(u16::from(asset_config.weight_bps), 10_000, "weight_bps"); + assert_eq!(asset_config.mint, ASSET_MINT, "asset mint"); + assert_eq!(asset_config.price_feed, PRICE_FEED, "price feed copied from registry"); } /// Two-program deposit: set up the router + a single-asset strategy, then -/// deposit USDC. The first deposit mints shares 1:1 and deploys the whole amount -/// into the asset through the router CPI. -#[test] -fn test_deposit() { - let mut svm = QuasarSvm::new() - .with_program( - &program_id(), - &std::fs::read("target/deploy/quasar_vault_strategy.so").unwrap(), - ) - .with_program( - &router_id(), - &std::fs::read("../mock-swap-router/target/deploy/quasar_mock_swap_router.so").unwrap(), - ) - .with_token_program(); - svm.warp_to_timestamp(NOW); - - let authority = Pubkey::new_unique(); - let manager = Pubkey::new_unique(); - let depositor = Pubkey::new_unique(); - let usdc_mint = Pubkey::new_unique(); - let asset_mint = Pubkey::new_unique(); - let price_feed = Pubkey::new_unique(); - - let registry = registry_pda(&authority); - let approved_asset = approved_asset_pda(®istry, &asset_mint); - let index = 0u64; - let strategy = strategy_pda(index); - let share_mint = share_mint_pda(&strategy); - let vault_usdc = usdc_vault_pda(&strategy); - let asset_config = asset_config_pda(&strategy, 0); - let vault_asset = asset_vault_pda(&strategy, 0); +/// deposit USDC. The first deposit mints shares 1:1 and deploys the whole +/// amount into the asset through the router CPI. +#[quasar_test] +fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { + // Runtime read (NOT include_bytes!): quasar-test auto-loads only this + // program's .so; the sibling router program is added explicitly. + let router_elf = + std::fs::read("../mock-swap-router/target/deploy/quasar_mock_swap_router.so").unwrap(); + test.add(Program::new(router_id(), &router_elf)); + test.warp_to_timestamp(NOW); - let r_config = router_config_pda(); let r_authority = router_authority_pda(); - let r_treasury = router_treasury_pda(); - let r_rate = router_rate_pda(&asset_mint); + // The asset mint is minted by the router authority. + setup_strategy(test, r_authority); + let w = pdas(test); - let depositor_usdc = Pubkey::new_unique(); - let depositor_share = Pubkey::new_unique(); + test.add(Wallet::new().at(DEPOSITOR)); // Asset priced 250 USDC/token: Pyth price = 250 * 10^8 so // asset_value = amount * price / 10^8 gives 250 USDC per token base unit. let pyth_price: i64 = 250 * 100_000_000; + add_pyth_feed(test, pyth_price, NOW); const DEPOSIT: u64 = 1_000; const ASSET_OUT: u64 = DEPOSIT / RATE; // 4 - let accounts = vec![ - signer_account(authority), - signer_account(manager), - signer_account(depositor), - // The asset mint is minted by the router authority. - mint_account(usdc_mint, authority), - mint_account(asset_mint, r_authority), - // Router accounts. - empty_account(r_config), - empty_account(r_authority), - empty_account(r_treasury), - empty_account(r_rate), - // Vault-strategy accounts. - empty_account(registry), - empty_account(approved_asset), - empty_account(strategy), - empty_account(share_mint), - empty_account(vault_usdc), - empty_account(asset_config), - empty_account(vault_asset), - // Depositor token accounts (share account created up front). - token_account(depositor_usdc, usdc_mint, depositor, DEPOSIT), - token_account(depositor_share, share_mint, depositor, 0), - // Pyth feed. - pyth_feed_account(price_feed, pyth_price, NOW), - ]; + // Depositor token accounts (share account created up front). + test.add( + TokenAccount::new(USDC_MINT, DEPOSITOR) + .at(DEPOSITOR_USDC) + .amount(DEPOSIT), + ); + test.add(TokenAccount::new(w.share_mint, DEPOSITOR).at(DEPOSITOR_SHARE)); - // Router account order for set_rate: - // authority, router_config, asset_mint, usdc_mint, asset_rate, - // router_authority, router_usdc_treasury, rent, token_program, system_program - let router_set_rate = Instruction { + // Initialize the router and set the asset's rate (hand-built: the router's + // builders live in the sibling crate). + let rent_id: Pubkey = "SysvarRent111111111111111111111111111111111".parse().unwrap(); + test.send(Instruction { program_id: router_id(), accounts: vec![ - AccountMeta::new(authority, true), - AccountMeta::new_readonly(r_config, false), - AccountMeta::new_readonly(asset_mint, false), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new(r_rate, false), - AccountMeta::new_readonly(r_authority, false), - AccountMeta::new(r_treasury, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), + AccountMeta::new(AUTHORITY, true), + AccountMeta::new_readonly(USDC_MINT, false), + AccountMeta::new(router_config_pda(), false), + AccountMeta::new_readonly(rent_id, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(system_program::ID, false), ], - data: router_set_rate_data(RATE), - }; - let router_init = Instruction { + data: vec![0u8], + }) + .succeeds(); + let mut set_rate_data = vec![1u8]; + set_rate_data.extend_from_slice(&RATE.to_le_bytes()); + test.send(Instruction { program_id: router_id(), accounts: vec![ - AccountMeta::new(authority, true), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new(r_config, false), - AccountMeta::new_readonly(rent_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), + AccountMeta::new(AUTHORITY, true), + AccountMeta::new_readonly(router_config_pda(), false), + AccountMeta::new_readonly(ASSET_MINT, false), + AccountMeta::new_readonly(USDC_MINT, false), + AccountMeta::new(router_rate_pda(&ASSET_MINT), false), + AccountMeta::new_readonly(r_authority, false), + AccountMeta::new(router_treasury_pda(), false), + AccountMeta::new_readonly(rent_id, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(system_program::ID, false), ], - data: router_init_data(), - }; - - // Deposit: declared accounts then remaining (asset_config, vault_asset, - // asset_mint, asset_rate, price_feed). - let mut deposit_accounts = vec![ - AccountMeta::new(depositor, true), - AccountMeta::new(strategy, false), - AccountMeta::new(share_mint, false), - AccountMeta::new_readonly(usdc_mint, false), - AccountMeta::new(depositor_usdc, false), - AccountMeta::new(depositor_share, false), - AccountMeta::new(vault_usdc, false), - AccountMeta::new(r_config, false), - AccountMeta::new(r_treasury, false), - AccountMeta::new_readonly(r_authority, false), - AccountMeta::new_readonly(router_id(), false), - AccountMeta::new_readonly(token_program_id(), false), - AccountMeta::new_readonly(system_program_id(), false), - ]; - for meta in [ - AccountMeta::new_readonly(asset_config, false), - AccountMeta::new(vault_asset, false), - AccountMeta::new(asset_mint, false), - AccountMeta::new_readonly(r_rate, false), - AccountMeta::new_readonly(price_feed, false), - ] { - deposit_accounts.push(meta); - } - let deposit_ix = Instruction { - program_id: program_id(), - accounts: deposit_accounts, - data: deposit_data(DEPOSIT, DEPOSIT), - }; - - let instructions = vec![ - router_init, - router_set_rate, - init_registry_ix(authority, registry), - approve_asset_ix(authority, registry, asset_mint, approved_asset, price_feed), - init_strategy_ix( - manager, - usdc_mint, - registry, - strategy, - share_mint, - vault_usdc, - index, - router_id(), - ), - add_asset_ix( - manager, - strategy, - registry, - asset_mint, - approved_asset, - asset_config, - vault_asset, - 10_000, - ), - deposit_ix, - ]; - - let result = svm.process_instruction_chain(&instructions, &accounts); - assert!( - result.is_ok(), - "deposit chain failed: {:?}", - result.raw_result - ); - + data: set_rate_data, + }) + .succeeds(); + + // Deposit: declared accounts, then remaining accounts per basket asset + // (asset_config, vault_asset, asset_mint, asset_rate, price_feed). + test.send(DepositInstruction { + depositor: DEPOSITOR, + strategy_index_seed: STRATEGY_INDEX, + usdc_mint: USDC_MINT, + depositor_usdc_account: DEPOSITOR_USDC, + depositor_share_account: DEPOSITOR_SHARE, + router_config: router_config_pda(), + router_usdc_treasury: router_treasury_pda(), + router_authority: r_authority, + swap_router_program: router_id(), + usdc_amount: DEPOSIT, + minimum_shares: DEPOSIT, + remaining_accounts: vec![ + AccountMeta::new_readonly(w.asset_config, false), + AccountMeta::new(w.vault_asset, false), + AccountMeta::new(ASSET_MINT, false), + AccountMeta::new_readonly(router_rate_pda(&ASSET_MINT), false), + AccountMeta::new_readonly(PRICE_FEED, false), + ], + }) + .succeeds() // First deposit mints shares 1:1 with USDC. - assert_eq!( - token_amount(result.account(&depositor_share).unwrap()), - DEPOSIT - ); + .has_tokens(DEPOSITOR_SHARE, DEPOSIT) // The deposit was deployed into the asset via the router. - assert_eq!( - token_amount(result.account(&vault_asset).unwrap()), - ASSET_OUT - ); - assert_eq!(token_amount(result.account(&depositor_usdc).unwrap()), 0); - assert_eq!(token_amount(result.account(&r_treasury).unwrap()), DEPOSIT); + .has_tokens(w.vault_asset, ASSET_OUT) + .has_tokens(DEPOSITOR_USDC, 0) + .has_tokens(router_treasury_pda(), DEPOSIT) // All USDC was swapped out of the vault into the asset. - assert_eq!(token_amount(result.account(&vault_usdc).unwrap()), 0); - - println!(" DEPOSIT CU: {}", result.compute_units_consumed); + .has_tokens(w.vault_usdc, 0); } From 36397cd8c448248d2908a595894152188b6fecf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:32:51 +0000 Subject: [PATCH 10/14] CHANGELOG: record the Quasar 0.1.0 migration Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 657630cc..0351f59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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-07-22] - Quasar 0.1.0 + +### Changed + +- Migrated 50 of the 53 Quasar examples to the Quasar `0.1.0-release` line, pinned by rev (`be60fca`) because crates.io still hosts `0.0.0` placeholders for `quasar-lang`/`quasar-cli`. Per project: `quasar-lang`/`quasar-spl` repinned (the four previously floating examples — `basics/pyth` and the three `compression` examples — are now pinned too); `Quasar.toml` rewritten to the 0.1.0 schema (`[testing] command`, `[clients] targets`; the old `[toolchain]`/`testing.language`/`testing.rust`/`clients.languages` keys are hard errors in 0.1.0); the `idl-build` feature and `"lib"` crate-type added for the new IDL build; and tests fully rewritten from the direct QuasarSVM harness (`QuasarSvm::new().with_program(...)`, `include_bytes!`, `assert_success`) to the new `quasar-test` fixture harness (`#[quasar_test]`, `Wallet`/`Mint`/`TokenAccount` fixtures, `crate::cpi` instruction builders, `Outcome` assertions). The standalone `quasar-svm` git dev-dependency is gone — `quasar-test` pulls the published `quasar-svm 0.1.0` from crates.io — and generated-client path dev-dependencies were dropped in favor of `crate::cpi` (a path dev-dependency to a not-yet-generated crate now breaks the required `cargo generate-lockfile`). +- `quasar.yml` CI installs the 0.1.0 CLI (`--rev be60fca`), runs `cargo generate-lockfile` before `quasar build` (the 0.1.0 IDL step runs `cargo metadata --locked`), and builds the three unmigrated examples in a separate `legacy-metadata-examples` job with the pre-0.1.0 CLI. +- Compute-unit assertions were dropped from the migrated tests pending recalibration under 0.1.0 (correct values are unknowable until the suite first runs on the new line). + +### Known limitations + +- `tokens/token-minter`, `tokens/nft-minter`, and `tokens/nft-operations` stay on the pre-0.1.0 pins (quasar `623bb70` / quasar-svm `cb7565d`): they depend on `quasar-metadata`, which was removed upstream before 0.1.0 with no replacement. They are listed in `.github/.ghaignore` and built by the legacy CI job. + ## [2026-07-11] - Discoverability and FAQ pass ### Fixed From 70ac7441040ea2f1056fe7abd827f9d6873fde7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:39:49 +0000 Subject: [PATCH 11/14] tokens: vendor quasar-metadata for the 0.1.0 line; drop legacy CI job Upstream removed the quasar-metadata crate before the 0.1.0 release with no replacement, which had left token-minter/nft-minter/nft-operations frozen on pre-0.1.0 pins behind a separate legacy CI job. This vendors the crate instead: tokens/quasar-metadata is a copy of metadata/ from blueshift-gg/quasar rev 623bb70f (the last revision shipping it, MIT licensed), adapted to compile against the be60fca pin: - AccountInit::init impls take the trait's new R: RentAccess parameter - based_try_find_program_address renamed to try_find_program_address - Seed imported from quasar_lang::cpi (left the prelude) - CpiDynamic::set_data_len became unsafe; both call sites wrapped with SAFETY comments (the preceding raw-pointer writes initialize exactly data[0..offset]) Provenance and the local diff are documented in the crate's README and CHANGELOG. With the three metadata examples migrating onto this crate, the legacy-metadata-examples CI job and the .ghaignore entries are gone: the whole matrix builds with the one 0.1.0 CLI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- .github/.ghaignore | 6 - .github/workflows/quasar.yml | 55 - CHANGELOG.md | 11 + tokens/quasar-metadata/CHANGELOG.md | 24 + tokens/quasar-metadata/Cargo.toml | 29 + tokens/quasar-metadata/LICENSE-MIT | 21 + tokens/quasar-metadata/README.md | 22 + tokens/quasar-metadata/src/account_init.rs | 152 +++ .../src/accounts/master_edition.rs | 221 ++++ .../quasar-metadata/src/accounts/metadata.rs | 227 ++++ tokens/quasar-metadata/src/accounts/mod.rs | 18 + .../quasar-metadata/src/accounts/prelude.rs | 1 + tokens/quasar-metadata/src/codec.rs | 208 ++++ tokens/quasar-metadata/src/constants.rs | 13 + tokens/quasar-metadata/src/init.rs | 183 +++ .../src/instructions/approve_collection.rs | 38 + .../quasar-metadata/src/instructions/burn.rs | 77 ++ .../src/instructions/create_master_edition.rs | 66 ++ .../src/instructions/create_metadata.rs | 104 ++ .../src/instructions/freeze_thaw.rs | 53 + .../src/instructions/mint_edition.rs | 73 ++ .../quasar-metadata/src/instructions/mod.rs | 1049 +++++++++++++++++ .../src/instructions/remove_creator.rs | 23 + .../src/instructions/revoke_collection.rs | 35 + .../instructions/set_and_verify_collection.rs | 79 ++ .../src/instructions/set_collection_size.rs | 67 ++ .../src/instructions/set_token_standard.rs | 25 + .../src/instructions/sign_metadata.rs | 23 + .../src/instructions/unverify_collection.rs | 68 ++ .../src/instructions/update_metadata.rs | 135 +++ .../src/instructions/update_primary_sale.rs | 25 + .../src/instructions/utilize.rs | 39 + .../src/instructions/verify_collection.rs | 71 ++ tokens/quasar-metadata/src/lib.rs | 32 + tokens/quasar-metadata/src/pda.rs | 80 ++ tokens/quasar-metadata/src/prelude.rs | 15 + tokens/quasar-metadata/src/program.rs | 12 + tokens/quasar-metadata/src/state.rs | 189 +++ tokens/quasar-metadata/src/validate.rs | 119 ++ 39 files changed, 3627 insertions(+), 61 deletions(-) create mode 100644 tokens/quasar-metadata/CHANGELOG.md create mode 100644 tokens/quasar-metadata/Cargo.toml create mode 100644 tokens/quasar-metadata/LICENSE-MIT create mode 100644 tokens/quasar-metadata/README.md create mode 100644 tokens/quasar-metadata/src/account_init.rs create mode 100644 tokens/quasar-metadata/src/accounts/master_edition.rs create mode 100644 tokens/quasar-metadata/src/accounts/metadata.rs create mode 100644 tokens/quasar-metadata/src/accounts/mod.rs create mode 100644 tokens/quasar-metadata/src/accounts/prelude.rs create mode 100644 tokens/quasar-metadata/src/codec.rs create mode 100644 tokens/quasar-metadata/src/constants.rs create mode 100644 tokens/quasar-metadata/src/init.rs create mode 100644 tokens/quasar-metadata/src/instructions/approve_collection.rs create mode 100644 tokens/quasar-metadata/src/instructions/burn.rs create mode 100644 tokens/quasar-metadata/src/instructions/create_master_edition.rs create mode 100644 tokens/quasar-metadata/src/instructions/create_metadata.rs create mode 100644 tokens/quasar-metadata/src/instructions/freeze_thaw.rs create mode 100644 tokens/quasar-metadata/src/instructions/mint_edition.rs create mode 100644 tokens/quasar-metadata/src/instructions/mod.rs create mode 100644 tokens/quasar-metadata/src/instructions/remove_creator.rs create mode 100644 tokens/quasar-metadata/src/instructions/revoke_collection.rs create mode 100644 tokens/quasar-metadata/src/instructions/set_and_verify_collection.rs create mode 100644 tokens/quasar-metadata/src/instructions/set_collection_size.rs create mode 100644 tokens/quasar-metadata/src/instructions/set_token_standard.rs create mode 100644 tokens/quasar-metadata/src/instructions/sign_metadata.rs create mode 100644 tokens/quasar-metadata/src/instructions/unverify_collection.rs create mode 100644 tokens/quasar-metadata/src/instructions/update_metadata.rs create mode 100644 tokens/quasar-metadata/src/instructions/update_primary_sale.rs create mode 100644 tokens/quasar-metadata/src/instructions/utilize.rs create mode 100644 tokens/quasar-metadata/src/instructions/verify_collection.rs create mode 100644 tokens/quasar-metadata/src/lib.rs create mode 100644 tokens/quasar-metadata/src/pda.rs create mode 100644 tokens/quasar-metadata/src/prelude.rs create mode 100644 tokens/quasar-metadata/src/program.rs create mode 100644 tokens/quasar-metadata/src/state.rs create mode 100644 tokens/quasar-metadata/src/validate.rs diff --git a/.github/.ghaignore b/.github/.ghaignore index b51ff6fb..e69de29b 100644 --- a/.github/.ghaignore +++ b/.github/.ghaignore @@ -1,6 +0,0 @@ -# Not migrated to Quasar 0.1.0: quasar-metadata was removed upstream before -# the 0.1.0 release with no replacement. These build in the -# legacy-metadata-examples job in quasar.yml with the pre-0.1.0 CLI. -tokens/token-minter/quasar -tokens/nft-minter/quasar -tokens/nft-operations/quasar diff --git a/.github/workflows/quasar.yml b/.github/workflows/quasar.yml index bb572aa6..55d20477 100644 --- a/.github/workflows/quasar.yml +++ b/.github/workflows/quasar.yml @@ -31,7 +31,6 @@ jobs: changed_projects: ${{ steps.analyze.outputs.changed_projects }} total_projects: ${{ steps.analyze.outputs.total_projects }} matrix: ${{ steps.matrix.outputs.matrix }} - legacy: ${{ steps.changes.outputs.legacy }} steps: - uses: actions/checkout@v5 with: @@ -46,11 +45,6 @@ jobs: - added|modified: '**/quasar/**' workflow: - added|modified: '.github/workflows/quasar.yml' - legacy: - - added|modified: 'tokens/token-minter/quasar/**' - - added|modified: 'tokens/nft-minter/quasar/**' - - added|modified: 'tokens/nft-operations/quasar/**' - - added|modified: '.github/workflows/quasar.yml' - name: Analyze Changes id: analyze run: | @@ -296,55 +290,6 @@ jobs: if: always() run: sccache --show-stats - legacy-metadata-examples: - # tokens/{token-minter,nft-minter,nft-operations} are NOT migrated to - # Quasar 0.1.0: they depend on quasar-metadata, which was removed - # upstream before the 0.1.0 release with no replacement. They stay on - # the last working pre-0.1.0 revs (quasar 623bb70 / quasar-svm cb7565d, - # listed in .github/.ghaignore so the main matrix skips them) and build - # with the older CLI, which still parses their pre-0.1.0 Quasar.toml - # schema. Migrate them and remove this job once upstream ships a - # metadata story for 0.1.x. - needs: changes - if: github.event_name == 'push' || needs.changes.outputs.legacy == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 - - name: Configure sccache - run: | - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - - name: Cache Cargo registry and git - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: cargo-legacy-${{ runner.os }}-${{ hashFiles('tokens/token-minter/quasar/Cargo.toml', 'tokens/nft-minter/quasar/Cargo.toml', 'tokens/nft-operations/quasar/Cargo.toml') }} - restore-keys: | - cargo-legacy-${{ runner.os }}- - - name: Setup Solana Stable - uses: heyAyushh/setup-solana@v5.9 - with: - # Pin an exact version rather than a tag like "stable", which setup-solana resolves via the GitHub API (can fail with 429). - solana-cli-version: 3.1.14 - - name: Install Quasar CLI (pre-0.1.0) - # Rev 3d6fb0d8: the HEAD the original Quasar migration was written - # against (immediately after PRs #195 + #196). The next merged PR - # (#198 "idl-redesign-clean") regressed `quasar build` for - # flat-layout projects, and the 0.1.0 line removed quasar-metadata - # entirely, so these examples need this exact older CLI. - run: cargo install --git https://github.com/blueshift-gg/quasar --rev 3d6fb0d8 quasar-cli --locked - - name: Build and test legacy metadata examples - run: | - for project in tokens/token-minter/quasar tokens/nft-minter/quasar tokens/nft-operations/quasar; do - echo "::group::$project" - ( cd "$project" && quasar build && cargo test ) - echo "::endgroup::" - done - summary: needs: [changes, build-and-test] if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 0351f59d..5fda7adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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-07-23] - Metadata examples on Quasar 0.1.0 (vendored quasar-metadata) + +### Added + +- `tokens/quasar-metadata`: a vendored copy of the `quasar-metadata` crate from blueshift-gg/quasar rev `623bb70f` (the last revision that shipped it), adapted to compile against the 0.1.0 `quasar-lang` API (`RentAccess` type parameter on `AccountInit::init`, `try_find_program_address` rename, `Seed` import from `quasar_lang::cpi`, `unsafe` `set_data_len`). Upstream removed the crate before 0.1.0 with no replacement; vendoring it lets the Metaplex-metadata examples ride the same release pin as everything else. Provenance and local changes are documented in the crate's README and CHANGELOG. + +### Changed + +- `tokens/token-minter`, `tokens/nft-minter`, and `tokens/nft-operations` now migrate to the 0.1.0-release pin (`be60fca`) like every other example, depending on the vendored crate via `quasar-metadata = { path = "../quasar-metadata" }`. This supersedes the previous day's "not migrated" limitation: all 53 Quasar examples are now on 0.1.0. +- `quasar.yml` drops the `legacy-metadata-examples` job and `.github/.ghaignore` is empty again — the whole matrix builds with the one 0.1.0 CLI. + ## [2026-07-22] - Quasar 0.1.0 ### Changed diff --git a/tokens/quasar-metadata/CHANGELOG.md b/tokens/quasar-metadata/CHANGELOG.md new file mode 100644 index 00000000..05c06ab6 --- /dev/null +++ b/tokens/quasar-metadata/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## [2026-07-23] + +### Added + +- Vendored from [blueshift-gg/quasar](https://github.com/blueshift-gg/quasar) + rev `623bb70f` (the last revision shipping the `metadata/` crate), so the + three Metaplex-metadata examples can build against the Quasar + `0.1.0-release` pin. See README.md for the full rationale. + +### Changed (adaptations to the 0.1.0 `quasar-lang` API) + +- `Cargo.toml` rewritten as a standalone crate: `quasar-lang` moves from the + upstream workspace path dep to the git pin `rev = "be60fca"`; workspace + lints/package values inlined. +- `AccountInit::init` impls gained the trait's new `R: RentAccess` type + parameter (`InitCtx<'a, R>`). +- `quasar_lang::pda::based_try_find_program_address` was renamed upstream; + both PDA helpers now call `try_find_program_address`. +- `Seed` left the prelude; `init.rs` imports `quasar_lang::cpi::Seed`. +- `CpiDynamic::set_data_len` became `unsafe`; both call sites (create/update + metadata) wrap it with a SAFETY comment — the preceding raw-pointer writes + initialize exactly `data[0..offset]`. diff --git a/tokens/quasar-metadata/Cargo.toml b/tokens/quasar-metadata/Cargo.toml new file mode 100644 index 00000000..da20eb6d --- /dev/null +++ b/tokens/quasar-metadata/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "quasar-metadata" +description = "Metaplex Token Metadata integration for the Quasar Solana framework (vendored)" +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/quicknode/solana-program-examples" +authors = ["Leonardo Donatacci ", "Dean Little "] + +# Standalone workspace - not part of the root program-examples workspace, +# same as every Quasar example project. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(kani)', + 'cfg(kani_ra)', +] + +[features] +debug = [] + +[dependencies] +# Pinned to the same 0.1.0-release rev as every Quasar example in this +# repository. See README.md for why this crate is vendored here. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +solana-address = { version = ">=2.2, <2.6", features = ["copy"] } diff --git a/tokens/quasar-metadata/LICENSE-MIT b/tokens/quasar-metadata/LICENSE-MIT new file mode 100644 index 00000000..c20cb3fd --- /dev/null +++ b/tokens/quasar-metadata/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Blueshift + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tokens/quasar-metadata/README.md b/tokens/quasar-metadata/README.md new file mode 100644 index 00000000..e155318b --- /dev/null +++ b/tokens/quasar-metadata/README.md @@ -0,0 +1,22 @@ +# quasar-metadata (vendored) + +Metaplex Token Metadata integration for the Quasar Solana framework. + +This crate is a vendored copy of the `metadata/` crate from +[blueshift-gg/quasar](https://github.com/blueshift-gg/quasar) at rev +`623bb70f` — the last revision that shipped it. Upstream removed +`quasar-metadata` before the 0.1.0 release with no replacement, but three +examples in this repository (`tokens/token-minter`, `tokens/nft-minter`, +`tokens/nft-operations`) demonstrate Metaplex metadata flows and need it. +Vendoring the crate lets those examples build and test against the same +Quasar `0.1.0-release` pin (`be60fca`) as every other example instead of +staying frozen on a pre-0.1.0 toolchain. + +Local changes against the upstream copy are limited to what the 0.1.0 +`quasar-lang` API requires (documented in CHANGELOG.md alongside this file) +plus this standalone Cargo.toml. Delete this crate and switch the three +examples back to the upstream dependency if Quasar ships a metadata story +for 0.1.x. + +Licensed under MIT (LICENSE-MIT), matching the upstream repository at the +vendored revision. diff --git a/tokens/quasar-metadata/src/account_init.rs b/tokens/quasar-metadata/src/account_init.rs new file mode 100644 index 00000000..7c9459a4 --- /dev/null +++ b/tokens/quasar-metadata/src/account_init.rs @@ -0,0 +1,152 @@ +//! AccountInit implementations for metadata account types. +//! +//! Defines init params enums and CPI dispatch for `create_metadata_accounts_v3` +//! and `create_master_edition_v3`. The derive calls these via `Op::apply` when +//! a field has `#[account(init)]` with a metadata/master_edition behavior. + +use { + crate::state::{MasterEditionAccount, MetadataAccount}, + quasar_lang::prelude::*, +}; + +// --------------------------------------------------------------------------- +// MetadataInitParams +// --------------------------------------------------------------------------- + +/// Init params for metadata account creation via CPI. +/// +/// The derive constructs `Default` (Unset) and the metadata behavior fills +/// the `Create` variant via `AccountBehavior::set_init_param`. +#[derive(Default)] +pub enum MetadataInitParams<'a> { + /// No behavior has filled init params yet. + #[default] + Unset, + /// Create metadata via `create_metadata_accounts_v3` CPI. + Create { + program: &'a AccountView, + mint: &'a AccountView, + mint_authority: &'a AccountView, + update_authority: &'a AccountView, + system_program: &'a AccountView, + rent: &'a AccountView, + name: &'a str, + symbol: &'a str, + uri: &'a str, + seller_fee_basis_points: u16, + is_mutable: bool, + }, +} + +impl quasar_lang::account_init::AccountInit for MetadataAccount { + type InitParams<'a> = MetadataInitParams<'a>; + const DEFAULT_INIT_PARAMS_VALID: bool = false; + + #[inline(always)] + fn init<'a, R: quasar_lang::ops::RentAccess>( + ctx: quasar_lang::account_init::InitCtx<'a, R>, + params: &Self::InitParams<'a>, + ) -> quasar_lang::__solana_program_error::ProgramResult { + match params { + MetadataInitParams::Unset => Err(ProgramError::InvalidArgument), + MetadataInitParams::Create { + program, + mint, + mint_authority, + update_authority, + system_program, + rent, + name, + symbol, + uri, + seller_fee_basis_points, + is_mutable, + } => { + crate::validate::validate_metadata_program(program)?; + crate::instructions::create_metadata::create_metadata_accounts_v3( + program, + ctx.target, + mint, + mint_authority, + ctx.payer, + update_authority, + system_program, + rent, + *name, + *symbol, + *uri, + *seller_fee_basis_points, + *is_mutable, + true, // update_authority_is_signer + )? + .invoke() + } + } + } +} + +// --------------------------------------------------------------------------- +// MasterEditionInitParams +// --------------------------------------------------------------------------- + +/// Init params for master edition account creation via CPI. +#[derive(Default)] +pub enum MasterEditionInitParams<'a> { + /// No behavior has filled init params yet. + #[default] + Unset, + /// Create master edition via `create_master_edition_v3` CPI. + Create { + program: &'a AccountView, + mint: &'a AccountView, + update_authority: &'a AccountView, + mint_authority: &'a AccountView, + metadata: &'a AccountView, + token_program: &'a AccountView, + system_program: &'a AccountView, + rent: &'a AccountView, + max_supply: Option, + }, +} + +impl quasar_lang::account_init::AccountInit for MasterEditionAccount { + type InitParams<'a> = MasterEditionInitParams<'a>; + const DEFAULT_INIT_PARAMS_VALID: bool = false; + + #[inline(always)] + fn init<'a, R: quasar_lang::ops::RentAccess>( + ctx: quasar_lang::account_init::InitCtx<'a, R>, + params: &Self::InitParams<'a>, + ) -> quasar_lang::__solana_program_error::ProgramResult { + match params { + MasterEditionInitParams::Unset => Err(ProgramError::InvalidArgument), + MasterEditionInitParams::Create { + program, + mint, + update_authority, + mint_authority, + metadata, + token_program, + system_program, + rent, + max_supply, + } => { + crate::validate::validate_metadata_program(program)?; + crate::instructions::create_master_edition::create_master_edition_v3( + program, + ctx.target, + mint, + update_authority, + mint_authority, + ctx.payer, + metadata, + token_program, + system_program, + rent, + *max_supply, + ) + .invoke() + } + } + } +} diff --git a/tokens/quasar-metadata/src/accounts/master_edition.rs b/tokens/quasar-metadata/src/accounts/master_edition.rs new file mode 100644 index 00000000..ae2586d4 --- /dev/null +++ b/tokens/quasar-metadata/src/accounts/master_edition.rs @@ -0,0 +1,221 @@ +//! Master edition account behavior module. +//! +//! Provides check and init behavior for master edition account fields. +//! +//! ```rust,ignore +//! use quasar_metadata::accounts::master_edition; +//! #[account(master_edition(program = mp, mint = mint, ...))] +//! pub master_edition: Account, +//! ``` +//! +//! # Field ordering +//! +//! The `metadata` field must be declared before `master_edition` in the struct. +//! The `create_master_edition_v3` CPI requires the metadata account, and the +//! derive processes init in declaration order. + +use quasar_lang::prelude::*; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +/// Max supply specification for the master edition behavior arg. +pub enum MaxSupplyArg { + /// Not specified — defaults to `Some(0)` (unique 1/1 NFT). + Unset, + /// Unlimited editions (max_supply = None). + Unlimited, + /// Limited to N editions (max_supply = Some(N)). + Limited(u64), +} + +pub struct Args<'a> { + pub program: &'a AccountView, + pub mint: &'a AccountView, + pub update_authority: Option<&'a AccountView>, + pub mint_authority: Option<&'a AccountView>, + pub metadata: Option<&'a AccountView>, + pub token_program: Option<&'a AccountView>, + pub system_program: Option<&'a AccountView>, + pub rent: Option<&'a AccountView>, + pub max_supply: MaxSupplyArg, +} + +pub struct ArgsBuilder<'a> { + program: Option<&'a AccountView>, + mint: Option<&'a AccountView>, + update_authority: Option<&'a AccountView>, + mint_authority: Option<&'a AccountView>, + metadata: Option<&'a AccountView>, + token_program: Option<&'a AccountView>, + system_program: Option<&'a AccountView>, + rent: Option<&'a AccountView>, + max_supply: MaxSupplyArg, +} + +impl<'a> Args<'a> { + pub fn builder() -> ArgsBuilder<'a> { + ArgsBuilder { + program: None, + mint: None, + update_authority: None, + mint_authority: None, + metadata: None, + token_program: None, + system_program: None, + rent: None, + max_supply: MaxSupplyArg::Unset, + } + } +} + +impl<'a> ArgsBuilder<'a> { + #[inline(always)] + pub fn program(mut self, v: &'a AccountView) -> Self { + self.program = Some(v); + self + } + + #[inline(always)] + pub fn mint(mut self, v: &'a AccountView) -> Self { + self.mint = Some(v); + self + } + + #[inline(always)] + pub fn update_authority(mut self, v: &'a AccountView) -> Self { + self.update_authority = Some(v); + self + } + + #[inline(always)] + pub fn mint_authority(mut self, v: &'a AccountView) -> Self { + self.mint_authority = Some(v); + self + } + + #[inline(always)] + pub fn metadata(mut self, v: &'a AccountView) -> Self { + self.metadata = Some(v); + self + } + + #[inline(always)] + pub fn token_program(mut self, v: &'a AccountView) -> Self { + self.token_program = Some(v); + self + } + + #[inline(always)] + pub fn system_program(mut self, v: &'a AccountView) -> Self { + self.system_program = Some(v); + self + } + + #[inline(always)] + pub fn rent(mut self, v: &'a AccountView) -> Self { + self.rent = Some(v); + self + } + + #[inline(always)] + pub fn max_supply(mut self, v: Option) -> Self { + self.max_supply = match v { + None => MaxSupplyArg::Unlimited, + Some(n) => MaxSupplyArg::Limited(n), + }; + self + } + + /// Build args for the check phase. Only `program` and `mint` are required. + #[inline(always)] + pub fn build_check(self) -> Result, ProgramError> { + Ok(Args { + program: self.program.ok_or(ProgramError::InvalidArgument)?, + mint: self.mint.ok_or(ProgramError::InvalidArgument)?, + update_authority: self.update_authority, + mint_authority: self.mint_authority, + metadata: self.metadata, + token_program: self.token_program, + system_program: self.system_program, + rent: self.rent, + max_supply: self.max_supply, + }) + } + + /// Build args for the init phase. All CPI-relevant fields required. + #[inline(always)] + pub fn build_init(self) -> Result, ProgramError> { + Ok(Args { + program: self.program.ok_or(ProgramError::InvalidArgument)?, + mint: self.mint.ok_or(ProgramError::InvalidArgument)?, + update_authority: Some(self.update_authority.ok_or(ProgramError::InvalidArgument)?), + mint_authority: Some(self.mint_authority.ok_or(ProgramError::InvalidArgument)?), + metadata: Some(self.metadata.ok_or(ProgramError::InvalidArgument)?), + token_program: Some(self.token_program.ok_or(ProgramError::InvalidArgument)?), + system_program: Some(self.system_program.ok_or(ProgramError::InvalidArgument)?), + rent: Some(self.rent.ok_or(ProgramError::InvalidArgument)?), + max_supply: self.max_supply, + }) + } + + #[inline(always)] + pub fn build_exit(self) -> Result, ProgramError> { + self.build_check() + } +} + +// --------------------------------------------------------------------------- +// Behavior +// --------------------------------------------------------------------------- + +pub struct Behavior; + +impl AccountBehavior> for Behavior { + type Args<'a> = Args<'a>; + const SETS_INIT_PARAMS: bool = true; + const VALIDATES_ACCOUNT_DATA: bool = true; + + #[inline(always)] + fn set_init_param<'a>( + params: &mut as AccountInit>::InitParams<'a>, + args: &Args<'a>, + ) -> Result<(), ProgramError> { + *params = crate::MasterEditionInitParams::Create { + program: args.program, + mint: args.mint, + update_authority: args.update_authority.ok_or(ProgramError::InvalidArgument)?, + mint_authority: args.mint_authority.ok_or(ProgramError::InvalidArgument)?, + metadata: args.metadata.ok_or(ProgramError::InvalidArgument)?, + token_program: args.token_program.ok_or(ProgramError::InvalidArgument)?, + system_program: args.system_program.ok_or(ProgramError::InvalidArgument)?, + rent: args.rent.ok_or(ProgramError::InvalidArgument)?, + max_supply: match &args.max_supply { + MaxSupplyArg::Unset => Some(0), + MaxSupplyArg::Unlimited => None, + MaxSupplyArg::Limited(n) => Some(*n), + }, + }; + Ok(()) + } + + #[inline(always)] + fn check<'a>( + account: &Account, + args: &Args<'a>, + ) -> Result<(), ProgramError> { + // Validate the metadata program address. + crate::validate::validate_metadata_program(args.program)?; + crate::validate::validate_master_edition_account( + account.to_account_view(), + Some(args.mint.address()), + )?; + // Validate metadata account when provided (owner, key, mint, and PDA). + if let Some(metadata) = args.metadata { + crate::validate::validate_metadata_account(metadata, args.mint.address(), None)?; + crate::pda::verify_metadata_address(metadata.address(), args.mint.address())?; + } + Ok(()) + } +} diff --git a/tokens/quasar-metadata/src/accounts/metadata.rs b/tokens/quasar-metadata/src/accounts/metadata.rs new file mode 100644 index 00000000..f5e9706f --- /dev/null +++ b/tokens/quasar-metadata/src/accounts/metadata.rs @@ -0,0 +1,227 @@ +//! Metadata account behavior module. +//! +//! Provides check and init behavior for metadata account fields. +//! +//! ```rust,ignore +//! use quasar_metadata::accounts::metadata; +//! #[account(metadata(program = mp, mint = mint, mint_authority = auth, ...))] +//! pub metadata: Account, +//! ``` +//! +//! # Field ordering +//! +//! When using `#[account(init, metadata(...))]`, the metadata field must appear +//! before any `master_edition` field in the struct — the derive processes init +//! in declaration order and `create_master_edition_v3` requires the metadata +//! account to exist. + +use quasar_lang::prelude::*; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +pub struct Args<'a> { + pub program: &'a AccountView, + pub mint: &'a AccountView, + pub mint_authority: Option<&'a AccountView>, + pub update_authority: Option<&'a AccountView>, + pub system_program: Option<&'a AccountView>, + pub rent: Option<&'a AccountView>, + pub name: Option<&'a str>, + pub symbol: Option<&'a str>, + pub uri: Option<&'a str>, + pub seller_fee_basis_points: Option, + pub is_mutable: Option, +} + +pub struct ArgsBuilder<'a> { + program: Option<&'a AccountView>, + mint: Option<&'a AccountView>, + mint_authority: Option<&'a AccountView>, + update_authority: Option<&'a AccountView>, + system_program: Option<&'a AccountView>, + rent: Option<&'a AccountView>, + name: Option<&'a str>, + symbol: Option<&'a str>, + uri: Option<&'a str>, + seller_fee_basis_points: Option, + is_mutable: Option, +} + +impl<'a> Args<'a> { + pub fn builder() -> ArgsBuilder<'a> { + ArgsBuilder { + program: None, + mint: None, + mint_authority: None, + update_authority: None, + system_program: None, + rent: None, + name: None, + symbol: None, + uri: None, + seller_fee_basis_points: None, + is_mutable: None, + } + } +} + +impl<'a> ArgsBuilder<'a> { + #[inline(always)] + pub fn program(mut self, v: &'a AccountView) -> Self { + self.program = Some(v); + self + } + + #[inline(always)] + pub fn mint(mut self, v: &'a AccountView) -> Self { + self.mint = Some(v); + self + } + + #[inline(always)] + pub fn mint_authority(mut self, v: &'a AccountView) -> Self { + self.mint_authority = Some(v); + self + } + + #[inline(always)] + pub fn update_authority(mut self, v: &'a AccountView) -> Self { + self.update_authority = Some(v); + self + } + + #[inline(always)] + pub fn system_program(mut self, v: &'a AccountView) -> Self { + self.system_program = Some(v); + self + } + + #[inline(always)] + pub fn rent(mut self, v: &'a AccountView) -> Self { + self.rent = Some(v); + self + } + + #[inline(always)] + pub fn name(mut self, v: &'a str) -> Self { + self.name = Some(v); + self + } + + #[inline(always)] + pub fn symbol(mut self, v: &'a str) -> Self { + self.symbol = Some(v); + self + } + + #[inline(always)] + pub fn uri(mut self, v: &'a str) -> Self { + self.uri = Some(v); + self + } + + #[inline(always)] + pub fn seller_fee_basis_points(mut self, v: u16) -> Self { + self.seller_fee_basis_points = Some(v); + self + } + + #[inline(always)] + pub fn is_mutable(mut self, v: bool) -> Self { + self.is_mutable = Some(v); + self + } + + /// Build args for the check phase. Only `program` and `mint` are required. + #[inline(always)] + pub fn build_check(self) -> Result, ProgramError> { + Ok(Args { + program: self.program.ok_or(ProgramError::InvalidArgument)?, + mint: self.mint.ok_or(ProgramError::InvalidArgument)?, + mint_authority: self.mint_authority, + update_authority: self.update_authority, + system_program: self.system_program, + rent: self.rent, + name: self.name, + symbol: self.symbol, + uri: self.uri, + seller_fee_basis_points: self.seller_fee_basis_points, + is_mutable: self.is_mutable, + }) + } + + /// Build args for the init phase. All CPI-relevant fields required. + #[inline(always)] + pub fn build_init(self) -> Result, ProgramError> { + Ok(Args { + program: self.program.ok_or(ProgramError::InvalidArgument)?, + mint: self.mint.ok_or(ProgramError::InvalidArgument)?, + mint_authority: Some(self.mint_authority.ok_or(ProgramError::InvalidArgument)?), + update_authority: Some(self.update_authority.ok_or(ProgramError::InvalidArgument)?), + system_program: Some(self.system_program.ok_or(ProgramError::InvalidArgument)?), + rent: Some(self.rent.ok_or(ProgramError::InvalidArgument)?), + name: Some(self.name.ok_or(ProgramError::InvalidArgument)?), + symbol: Some(self.symbol.ok_or(ProgramError::InvalidArgument)?), + uri: Some(self.uri.ok_or(ProgramError::InvalidArgument)?), + seller_fee_basis_points: Some(self.seller_fee_basis_points.unwrap_or(0)), + is_mutable: Some(self.is_mutable.unwrap_or(true)), + }) + } + + #[inline(always)] + pub fn build_exit(self) -> Result, ProgramError> { + self.build_check() + } +} + +// --------------------------------------------------------------------------- +// Behavior +// --------------------------------------------------------------------------- + +pub struct Behavior; + +impl AccountBehavior> for Behavior { + type Args<'a> = Args<'a>; + const SETS_INIT_PARAMS: bool = true; + const VALIDATES_ACCOUNT_DATA: bool = true; + + #[inline(always)] + fn set_init_param<'a>( + params: &mut as AccountInit>::InitParams<'a>, + args: &Args<'a>, + ) -> Result<(), ProgramError> { + *params = crate::MetadataInitParams::Create { + program: args.program, + mint: args.mint, + mint_authority: args.mint_authority.ok_or(ProgramError::InvalidArgument)?, + update_authority: args.update_authority.ok_or(ProgramError::InvalidArgument)?, + system_program: args.system_program.ok_or(ProgramError::InvalidArgument)?, + rent: args.rent.ok_or(ProgramError::InvalidArgument)?, + name: args.name.ok_or(ProgramError::InvalidArgument)?, + symbol: args.symbol.ok_or(ProgramError::InvalidArgument)?, + uri: args.uri.ok_or(ProgramError::InvalidArgument)?, + seller_fee_basis_points: args.seller_fee_basis_points.unwrap_or(0), + is_mutable: args.is_mutable.unwrap_or(true), + }; + Ok(()) + } + + #[inline(always)] + fn check<'a>( + account: &Account, + args: &Args<'a>, + ) -> Result<(), ProgramError> { + // Validate the metadata program address. + crate::validate::validate_metadata_program(args.program)?; + let view = account.to_account_view(); + crate::validate::validate_metadata_account( + view, + args.mint.address(), + args.update_authority.map(|ua| ua.address()), + )?; + crate::pda::verify_metadata_address(view.address(), args.mint.address())?; + Ok(()) + } +} diff --git a/tokens/quasar-metadata/src/accounts/mod.rs b/tokens/quasar-metadata/src/accounts/mod.rs new file mode 100644 index 00000000..75d44e44 --- /dev/null +++ b/tokens/quasar-metadata/src/accounts/mod.rs @@ -0,0 +1,18 @@ +//! Metadata behavior modules for `#[derive(Accounts)]`. +//! +//! Each module exports `Args`, `Args::builder()`, and `Behavior`, implementing +//! `AccountBehavior` for supported account wrapper types. +//! +//! # Adding a new behavior +//! +//! 1. Create `accounts/foo.rs` +//! 2. Define `Args` struct + `ArgsBuilder` with `build_init()`, +//! `build_check()`, `build_exit()` methods +//! 3. Define `Behavior` unit struct +//! 4. Implement `AccountBehavior` for each supported wrapper type +//! 5. Export from `accounts/mod.rs`, `accounts/prelude.rs`, and `prelude.rs` +//! 6. Add compile-pass and compile-fail tests in `metadata/tests/` + +pub mod master_edition; +pub mod metadata; +pub mod prelude; diff --git a/tokens/quasar-metadata/src/accounts/prelude.rs b/tokens/quasar-metadata/src/accounts/prelude.rs new file mode 100644 index 00000000..fbfd450c --- /dev/null +++ b/tokens/quasar-metadata/src/accounts/prelude.rs @@ -0,0 +1 @@ +pub use super::{master_edition, metadata}; diff --git a/tokens/quasar-metadata/src/codec.rs b/tokens/quasar-metadata/src/codec.rs new file mode 100644 index 00000000..00316fdf --- /dev/null +++ b/tokens/quasar-metadata/src/codec.rs @@ -0,0 +1,208 @@ +//! Borsh-compatible serialization primitives used by metadata CPI builders. + +pub trait CpiEncode { + fn encoded_len(&self) -> usize; + + /// # Safety + /// + /// Caller must ensure the target range is valid for writes. + unsafe fn write_to(&self, ptr: *mut u8, offset: usize) -> usize; +} + +pub trait BorshCpiEncode: CpiEncode<4> {} + +impl> BorshCpiEncode for T {} + +#[inline(always)] +unsafe fn write_prefix(ptr: *mut u8, offset: usize, value: u32) { + const { + assert!(PREFIX_BYTES == 1 || PREFIX_BYTES == 2 || PREFIX_BYTES == 4); + } + match PREFIX_BYTES { + 1 => *ptr.add(offset) = value as u8, + 2 => { + let le = (value as u16).to_le_bytes(); + core::ptr::copy_nonoverlapping(le.as_ptr(), ptr.add(offset), 2); + } + 4 => { + let le = value.to_le_bytes(); + core::ptr::copy_nonoverlapping(le.as_ptr(), ptr.add(offset), 4); + } + _ => core::hint::unreachable_unchecked(), + } +} + +impl CpiEncode for &str { + #[inline(always)] + fn encoded_len(&self) -> usize { + const { + assert!(T == 1 || T == 2 || T == 4); + } + T + self.len() + } + + #[inline(always)] + unsafe fn write_to(&self, ptr: *mut u8, offset: usize) -> usize { + write_prefix::(ptr, offset, self.len() as u32); + core::ptr::copy_nonoverlapping(self.as_ptr(), ptr.add(offset + T), self.len()); + offset + T + self.len() + } +} + +impl CpiEncode for &[u8] { + #[inline(always)] + fn encoded_len(&self) -> usize { + const { + assert!(T == 1 || T == 2 || T == 4); + } + T + self.len() + } + + #[inline(always)] + unsafe fn write_to(&self, ptr: *mut u8, offset: usize) -> usize { + write_prefix::(ptr, offset, self.len() as u32); + core::ptr::copy_nonoverlapping(self.as_ptr(), ptr.add(offset + T), self.len()); + offset + T + self.len() + } +} + +// --------------------------------------------------------------------------- +// Kani model-checking proof harnesses +// --------------------------------------------------------------------------- + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// Prove write_prefix::<1> writes a byte that decodes to the original + /// value (truncated to u8). + #[kani::proof] + fn write_prefix_u8_roundtrip() { + let value: u32 = kani::any(); + kani::assume(value <= u8::MAX as u32); + let mut buf = [0u8; 4]; + unsafe { write_prefix::<1>(buf.as_mut_ptr(), 0, value) }; + assert!(buf[0] as u32 == value); + } + + /// Prove write_prefix::<2> writes LE bytes that decode to the original + /// value (truncated to u16). + #[kani::proof] + fn write_prefix_u16_roundtrip() { + let value: u32 = kani::any(); + kani::assume(value <= u16::MAX as u32); + let mut buf = [0u8; 4]; + unsafe { write_prefix::<2>(buf.as_mut_ptr(), 0, value) }; + let decoded = u16::from_le_bytes([buf[0], buf[1]]) as u32; + assert!(decoded == value); + } + + /// Prove write_prefix::<4> writes LE bytes that decode to the original + /// value. + #[kani::proof] + fn write_prefix_u32_roundtrip() { + let value: u32 = kani::any(); + let mut buf = [0u8; 4]; + unsafe { write_prefix::<4>(buf.as_mut_ptr(), 0, value) }; + let decoded = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + assert!(decoded == value); + } + + /// Prove write_prefix at a nonzero offset writes to the correct + /// location and doesn't clobber earlier bytes. + #[kani::proof] + fn write_prefix_offset_correctness() { + let value: u32 = kani::any(); + let sentinel: u8 = kani::any(); + let mut buf = [sentinel; 8]; + unsafe { write_prefix::<4>(buf.as_mut_ptr(), 2, value) }; + // Bytes before offset are untouched. + assert!(buf[0] == sentinel); + assert!(buf[1] == sentinel); + // Written bytes decode correctly. + let decoded = u32::from_le_bytes([buf[2], buf[3], buf[4], buf[5]]); + assert!(decoded == value); + } + + /// Prove `CpiEncode<4>::write_to` for `&str` writes prefix + data within + /// `offset + encoded_len()`, and doesn't clobber bytes before offset. + #[kani::proof] + #[kani::unwind(10)] + fn str_write_to_bounds_and_roundtrip() { + // Use a small fixed string to keep CBMC tractable. + let len: usize = kani::any(); + kani::assume(len <= 8); + + let data = [0x41u8; 8]; // "AAAAAAAA" + let s = unsafe { core::str::from_utf8_unchecked(&data[..len]) }; + + let mut buf = [0xFFu8; 16]; + let offset: usize = kani::any(); + kani::assume(offset <= 4); + kani::assume(offset + 4 + len <= 16); + + let new_offset = unsafe { <&str as CpiEncode<4>>::write_to(&s, buf.as_mut_ptr(), offset) }; + + // new_offset == offset + 4 + len + assert!(new_offset == offset + 4 + len); + + // Prefix decodes to string length. + let prefix = u32::from_le_bytes([ + buf[offset], + buf[offset + 1], + buf[offset + 2], + buf[offset + 3], + ]); + assert!(prefix == len as u32); + + // Data bytes match. + let mut i = 0; + while i < len { + assert!(buf[offset + 4 + i] == 0x41); + i += 1; + } + } + + /// Prove `CpiEncode<4>::write_to` for `&[u8]` writes correctly. + #[kani::proof] + #[kani::unwind(10)] + fn bytes_write_to_bounds_and_roundtrip() { + let len: usize = kani::any(); + kani::assume(len <= 8); + + let data = [0xBBu8; 8]; + let slice = &data[..len]; + + let mut buf = [0xFFu8; 16]; + let offset: usize = kani::any(); + kani::assume(offset <= 4); + kani::assume(offset + 4 + len <= 16); + + let new_offset = + unsafe { <&[u8] as CpiEncode<4>>::write_to(&slice, buf.as_mut_ptr(), offset) }; + + assert!(new_offset == offset + 4 + len); + + let prefix = u32::from_le_bytes([ + buf[offset], + buf[offset + 1], + buf[offset + 2], + buf[offset + 3], + ]); + assert!(prefix == len as u32); + } + + /// Prove `encoded_len` for `&[u8]` returns PREFIX + content length. + #[kani::proof] + fn encoded_len_matches_written() { + let len: usize = kani::any(); + kani::assume(len <= 8); + + let data = [0u8; 8]; + let slice: &[u8] = &data[..len]; + + // encoded_len must equal what write_to actually advances. + let el = <&[u8] as CpiEncode<4>>::encoded_len(&slice); + assert!(el == 4 + len); + } +} diff --git a/tokens/quasar-metadata/src/constants.rs b/tokens/quasar-metadata/src/constants.rs new file mode 100644 index 00000000..ecfeb6b7 --- /dev/null +++ b/tokens/quasar-metadata/src/constants.rs @@ -0,0 +1,13 @@ +use solana_address::Address; + +pub(crate) const METADATA_PROGRAM_BYTES: [u8; 32] = [ + 11, 112, 101, 177, 227, 209, 124, 69, 56, 157, 82, 127, 107, 4, 195, 205, 88, 184, 108, 115, + 26, 160, 253, 181, 73, 182, 209, 188, 3, 248, 41, 70, +]; + +/// Metaplex Token Metadata program address +/// (`metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s`). +#[cfg(any(target_os = "solana", target_arch = "bpf"))] +pub static METADATA_PROGRAM_ID: Address = Address::new_from_array(METADATA_PROGRAM_BYTES); +#[cfg(not(any(target_os = "solana", target_arch = "bpf")))] +pub const METADATA_PROGRAM_ID: Address = Address::new_from_array(METADATA_PROGRAM_BYTES); diff --git a/tokens/quasar-metadata/src/init.rs b/tokens/quasar-metadata/src/init.rs new file mode 100644 index 00000000..fbcbefc5 --- /dev/null +++ b/tokens/quasar-metadata/src/init.rs @@ -0,0 +1,183 @@ +use { + super::instructions::MetadataCpi, crate::codec::BorshCpiEncode, quasar_lang::cpi::Seed, + quasar_lang::prelude::*, +}; + +/// Extension trait for metadata account initialization. +/// +/// Invokes `create_metadata_accounts_v3` via CPI. The Metaplex program +/// derives and allocates the metadata PDA internally — no +/// `SystemProgram::create_account` needed from the caller. +/// +/// ```ignore +/// self.metadata.init( +/// &self.metadata_program, +/// &self.mint, +/// &self.mint_authority, +/// &self.payer, +/// &self.update_authority, +/// &self.system_program, +/// "My Token", +/// "TKN", +/// "https://example.com/meta.json", +/// 0, // seller_fee_basis_points +/// true, // is_mutable +/// )?; +/// ``` +pub trait InitMetadata: AsAccountView + Sized { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn init( + &self, + metadata_program: &impl MetadataCpi, + mint: &impl AsAccountView, + mint_authority: &impl AsAccountView, + payer: &impl AsAccountView, + update_authority: &impl AsAccountView, + system_program: &Program, + rent: &impl AsAccountView, + name: impl BorshCpiEncode, + symbol: impl BorshCpiEncode, + uri: impl BorshCpiEncode, + seller_fee_basis_points: u16, + is_mutable: bool, + ) -> Result<(), ProgramError> { + metadata_program + .create_metadata_accounts_v3( + self, + mint, + mint_authority, + payer, + update_authority, + system_program, + rent, + name, + symbol, + uri, + seller_fee_basis_points, + is_mutable, + true, // update_authority_is_signer + )? + .invoke() + } + + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn init_signed( + &self, + metadata_program: &impl MetadataCpi, + mint: &impl AsAccountView, + mint_authority: &impl AsAccountView, + payer: &impl AsAccountView, + update_authority: &impl AsAccountView, + system_program: &Program, + rent: &impl AsAccountView, + name: impl BorshCpiEncode, + symbol: impl BorshCpiEncode, + uri: impl BorshCpiEncode, + seller_fee_basis_points: u16, + is_mutable: bool, + seeds: &[Seed], + ) -> Result<(), ProgramError> { + metadata_program + .create_metadata_accounts_v3( + self, + mint, + mint_authority, + payer, + update_authority, + system_program, + rent, + name, + symbol, + uri, + seller_fee_basis_points, + is_mutable, + true, + )? + .invoke_signed(seeds) + } +} + +/// Extension trait for master edition account initialization. +/// +/// Invokes `create_master_edition_v3` via CPI. The Metaplex program +/// derives and allocates the master edition PDA internally. +/// +/// ```ignore +/// self.master_edition.init( +/// &self.metadata_program, +/// &self.mint, +/// &self.update_authority, +/// &self.mint_authority, +/// &self.payer, +/// &self.metadata, +/// &self.token_program, +/// &self.system_program, +/// Some(0), // max_supply: 0 = unique 1/1 NFT +/// )?; +/// ``` +pub trait InitMasterEdition: AsAccountView + Sized { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn init( + &self, + metadata_program: &impl MetadataCpi, + mint: &impl AsAccountView, + update_authority: &impl AsAccountView, + mint_authority: &impl AsAccountView, + payer: &impl AsAccountView, + metadata: &impl AsAccountView, + token_program: &impl AsAccountView, + system_program: &Program, + rent: &impl AsAccountView, + max_supply: Option, + ) -> Result<(), ProgramError> { + metadata_program + .create_master_edition_v3( + self, + mint, + update_authority, + mint_authority, + payer, + metadata, + token_program, + system_program, + rent, + max_supply, + ) + .invoke() + } + + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn init_signed( + &self, + metadata_program: &impl MetadataCpi, + mint: &impl AsAccountView, + update_authority: &impl AsAccountView, + mint_authority: &impl AsAccountView, + payer: &impl AsAccountView, + metadata: &impl AsAccountView, + token_program: &impl AsAccountView, + system_program: &Program, + rent: &impl AsAccountView, + max_supply: Option, + seeds: &[Seed], + ) -> Result<(), ProgramError> { + metadata_program + .create_master_edition_v3( + self, + mint, + update_authority, + mint_authority, + payer, + metadata, + token_program, + system_program, + rent, + max_supply, + ) + .invoke_signed(seeds) + } +} diff --git a/tokens/quasar-metadata/src/instructions/approve_collection.rs b/tokens/quasar-metadata/src/instructions/approve_collection.rs new file mode 100644 index 00000000..b15559b4 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/approve_collection.rs @@ -0,0 +1,38 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const APPROVE_COLLECTION_AUTHORITY: u8 = 23; + +#[inline(always)] +pub fn approve_collection_authority<'a>( + program: &'a AccountView, + collection_authority_record: &'a AccountView, + new_collection_authority: &'a AccountView, + update_authority: &'a AccountView, + payer: &'a AccountView, + metadata: &'a AccountView, + mint: &'a AccountView, +) -> CpiCall<'a, 6, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(collection_authority_record.address()), + InstructionAccount::readonly(new_collection_authority.address()), + InstructionAccount::readonly_signer(update_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(metadata.address()), + InstructionAccount::readonly(mint.address()), + ], + [ + collection_authority_record, + new_collection_authority, + update_authority, + payer, + metadata, + mint, + ], + [APPROVE_COLLECTION_AUTHORITY], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/burn.rs b/tokens/quasar-metadata/src/instructions/burn.rs new file mode 100644 index 00000000..e0a30d70 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/burn.rs @@ -0,0 +1,77 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const BURN_NFT: u8 = 29; +const BURN_EDITION_NFT: u8 = 37; + +#[inline(always)] +pub fn burn_nft<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + owner: &'a AccountView, + mint: &'a AccountView, + token: &'a AccountView, + edition: &'a AccountView, + spl_token: &'a AccountView, +) -> CpiCall<'a, 6, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::writable_signer(owner.address()), + InstructionAccount::writable(mint.address()), + InstructionAccount::writable(token.address()), + InstructionAccount::writable(edition.address()), + InstructionAccount::readonly(spl_token.address()), + ], + [metadata, owner, mint, token, edition, spl_token], + [BURN_NFT], + ) +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn burn_edition_nft<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + owner: &'a AccountView, + print_edition_mint: &'a AccountView, + master_edition_mint: &'a AccountView, + print_edition_token: &'a AccountView, + master_edition_token: &'a AccountView, + master_edition: &'a AccountView, + print_edition: &'a AccountView, + edition_marker: &'a AccountView, + spl_token: &'a AccountView, +) -> CpiCall<'a, 10, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::writable_signer(owner.address()), + InstructionAccount::writable(print_edition_mint.address()), + InstructionAccount::readonly(master_edition_mint.address()), + InstructionAccount::writable(print_edition_token.address()), + InstructionAccount::writable(master_edition_token.address()), + InstructionAccount::writable(master_edition.address()), + InstructionAccount::writable(print_edition.address()), + InstructionAccount::writable(edition_marker.address()), + InstructionAccount::readonly(spl_token.address()), + ], + [ + metadata, + owner, + print_edition_mint, + master_edition_mint, + print_edition_token, + master_edition_token, + master_edition, + print_edition, + edition_marker, + spl_token, + ], + [BURN_EDITION_NFT], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/create_master_edition.rs b/tokens/quasar-metadata/src/instructions/create_master_edition.rs new file mode 100644 index 00000000..768b9a30 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/create_master_edition.rs @@ -0,0 +1,66 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const CREATE_MASTER_EDITION_V3: u8 = 17; + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn create_master_edition_v3<'a>( + program: &'a AccountView, + edition: &'a AccountView, + mint: &'a AccountView, + update_authority: &'a AccountView, + mint_authority: &'a AccountView, + payer: &'a AccountView, + metadata: &'a AccountView, + token_program: &'a AccountView, + system_program: &'a AccountView, + rent: &'a AccountView, + max_supply: Option, +) -> CpiCall<'a, 9, 10> { + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 10]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, CREATE_MASTER_EDITION_V3); + match max_supply { + Some(v) => { + core::ptr::write(ptr.add(1), 1u8); + core::ptr::copy_nonoverlapping(v.to_le_bytes().as_ptr(), ptr.add(2), 8); + } + None => { + core::ptr::write(ptr.add(1), 0u8); + core::ptr::write_bytes(ptr.add(2), 0, 8); + } + } + buf.assume_init() + }; + + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(edition.address()), + InstructionAccount::writable(mint.address()), + InstructionAccount::readonly_signer(update_authority.address()), + InstructionAccount::readonly_signer(mint_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly(token_program.address()), + InstructionAccount::readonly(system_program.address()), + InstructionAccount::readonly(rent.address()), + ], + [ + edition, + mint, + update_authority, + mint_authority, + payer, + metadata, + token_program, + system_program, + rent, + ], + data, + ) +} diff --git a/tokens/quasar-metadata/src/instructions/create_metadata.rs b/tokens/quasar-metadata/src/instructions/create_metadata.rs new file mode 100644 index 00000000..bc69910b --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/create_metadata.rs @@ -0,0 +1,104 @@ +use { + crate::codec::BorshCpiEncode, + quasar_lang::{cpi::CpiDynamic, prelude::*}, +}; + +const CREATE_METADATA_ACCOUNTS_V3: u8 = 33; + +#[cold] +#[inline(never)] +fn metadata_field_too_long() -> ProgramError { + ProgramError::InvalidInstructionData +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn create_metadata_accounts_v3<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + mint: &'a AccountView, + mint_authority: &'a AccountView, + payer: &'a AccountView, + update_authority: &'a AccountView, + system_program: &'a AccountView, + rent: &'a AccountView, + name: impl BorshCpiEncode, + symbol: impl BorshCpiEncode, + uri: impl BorshCpiEncode, + seller_fee_basis_points: u16, + is_mutable: bool, + update_authority_is_signer: bool, +) -> Result, ProgramError> { + let name_len = name.encoded_len() - 4; + let symbol_len = symbol.encoded_len() - 4; + let uri_len = uri.encoded_len() - 4; + if name_len > super::MAX_NAME_LEN + || symbol_len > super::MAX_SYMBOL_LEN + || uri_len > super::MAX_URI_LEN + { + return Err(metadata_field_too_long()); + } + + let mut cpi = CpiDynamic::<7, 512>::new(program.address()); + + // Push accounts. + cpi.push_account(metadata, false, true)?; + cpi.push_account(mint, false, false)?; + cpi.push_account(mint_authority, true, false)?; + cpi.push_account(payer, true, true)?; + cpi.push_account(update_authority, update_authority_is_signer, false)?; + cpi.push_account(system_program, false, false)?; + cpi.push_account(rent, false, false)?; + + // Borsh-serialize: discriminator + DataV2 + is_mutable + collection_details + // DataV2 = name(String) + symbol(String) + uri(String) + seller_fee(u16) + + // creators(Option) + collection(Option) + uses(Option) + let mut offset = 0; + + // SAFETY: Writing serialized instruction data into the uninitialized buffer. + // All bytes [0..offset] are written before set_data_len() is called. + unsafe { + let ptr = cpi.data_mut() as *mut u8; + + // Discriminator + core::ptr::write(ptr, CREATE_METADATA_ACCOUNTS_V3); + offset += 1; + + // DataV2.name, symbol, uri (Borsh strings: u32 LE length + bytes) + offset = name.write_to(ptr, offset); + offset = symbol.write_to(ptr, offset); + offset = uri.write_to(ptr, offset); + + // DataV2.seller_fee_basis_points + core::ptr::copy_nonoverlapping( + seller_fee_basis_points.to_le_bytes().as_ptr(), + ptr.add(offset), + 2, + ); + offset += 2; + + // DataV2.creators: Option> = None + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + + // DataV2.collection: Option = None + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + + // DataV2.uses: Option = None + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + + // is_mutable + core::ptr::write(ptr.add(offset), is_mutable as u8); + offset += 1; + + // collection_details: Option = None + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + } + + // SAFETY: the writes above initialized exactly data[0..offset]. + unsafe { cpi.set_data_len(offset)? }; + Ok(cpi) +} diff --git a/tokens/quasar-metadata/src/instructions/freeze_thaw.rs b/tokens/quasar-metadata/src/instructions/freeze_thaw.rs new file mode 100644 index 00000000..2f4cb590 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/freeze_thaw.rs @@ -0,0 +1,53 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const FREEZE_DELEGATED_ACCOUNT: u8 = 26; +const THAW_DELEGATED_ACCOUNT: u8 = 27; + +#[inline(always)] +pub fn freeze_delegated_account<'a>( + program: &'a AccountView, + delegate: &'a AccountView, + token_account: &'a AccountView, + edition: &'a AccountView, + mint: &'a AccountView, + token_program: &'a AccountView, +) -> CpiCall<'a, 5, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::readonly_signer(delegate.address()), + InstructionAccount::writable(token_account.address()), + InstructionAccount::readonly(edition.address()), + InstructionAccount::readonly(mint.address()), + InstructionAccount::readonly(token_program.address()), + ], + [delegate, token_account, edition, mint, token_program], + [FREEZE_DELEGATED_ACCOUNT], + ) +} + +#[inline(always)] +pub fn thaw_delegated_account<'a>( + program: &'a AccountView, + delegate: &'a AccountView, + token_account: &'a AccountView, + edition: &'a AccountView, + mint: &'a AccountView, + token_program: &'a AccountView, +) -> CpiCall<'a, 5, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::readonly_signer(delegate.address()), + InstructionAccount::writable(token_account.address()), + InstructionAccount::readonly(edition.address()), + InstructionAccount::readonly(mint.address()), + InstructionAccount::readonly(token_program.address()), + ], + [delegate, token_account, edition, mint, token_program], + [THAW_DELEGATED_ACCOUNT], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/mint_edition.rs b/tokens/quasar-metadata/src/instructions/mint_edition.rs new file mode 100644 index 00000000..5bd56520 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/mint_edition.rs @@ -0,0 +1,73 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const MINT_NEW_EDITION_FROM_MASTER_EDITION_VIA_TOKEN: u8 = 11; + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn mint_new_edition_from_master_edition_via_token<'a>( + program: &'a AccountView, + new_metadata: &'a AccountView, + new_edition: &'a AccountView, + master_edition: &'a AccountView, + new_mint: &'a AccountView, + edition_mark_pda: &'a AccountView, + new_mint_authority: &'a AccountView, + payer: &'a AccountView, + token_account_owner: &'a AccountView, + token_account: &'a AccountView, + new_metadata_update_authority: &'a AccountView, + metadata: &'a AccountView, + token_program: &'a AccountView, + system_program: &'a AccountView, + rent: &'a AccountView, + edition: u64, +) -> CpiCall<'a, 14, 9> { + // SAFETY: All 9 bytes are written before assume_init. + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, MINT_NEW_EDITION_FROM_MASTER_EDITION_VIA_TOKEN); + core::ptr::copy_nonoverlapping(edition.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(new_metadata.address()), + InstructionAccount::writable(new_edition.address()), + InstructionAccount::writable(master_edition.address()), + InstructionAccount::writable(new_mint.address()), + InstructionAccount::writable(edition_mark_pda.address()), + InstructionAccount::readonly_signer(new_mint_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly_signer(token_account_owner.address()), + InstructionAccount::readonly(token_account.address()), + InstructionAccount::readonly(new_metadata_update_authority.address()), + InstructionAccount::readonly(metadata.address()), + InstructionAccount::readonly(token_program.address()), + InstructionAccount::readonly(system_program.address()), + InstructionAccount::readonly(rent.address()), + ], + [ + new_metadata, + new_edition, + master_edition, + new_mint, + edition_mark_pda, + new_mint_authority, + payer, + token_account_owner, + token_account, + new_metadata_update_authority, + metadata, + token_program, + system_program, + rent, + ], + data, + ) +} diff --git a/tokens/quasar-metadata/src/instructions/mod.rs b/tokens/quasar-metadata/src/instructions/mod.rs new file mode 100644 index 00000000..42c04ec1 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/mod.rs @@ -0,0 +1,1049 @@ +mod approve_collection; +mod burn; +pub(crate) mod create_master_edition; +pub(crate) mod create_metadata; +mod freeze_thaw; +mod mint_edition; +mod remove_creator; +mod revoke_collection; +mod set_and_verify_collection; +mod set_collection_size; +mod set_token_standard; +mod sign_metadata; +mod unverify_collection; +mod update_metadata; +mod update_primary_sale; +mod utilize; +mod verify_collection; + +use { + crate::codec::BorshCpiEncode, + quasar_lang::{ + cpi::{CpiCall, CpiDynamic}, + prelude::*, + }, +}; + +// Metaplex-enforced maximum field lengths. +const MAX_NAME_LEN: usize = 32; +const MAX_SYMBOL_LEN: usize = 10; +const MAX_URI_LEN: usize = 200; + +/// Trait for types that can execute Metaplex Token Metadata CPI calls. +/// +/// Implemented by [`crate::MetadataProgram`]. +pub trait MetadataCpi: AsAccountView { + // ----------------------------------------------------------------------- + // Variable-length instructions (CpiDynamic) + // ----------------------------------------------------------------------- + + /// Create a metadata account for an SPL Token mint. + /// + /// Accounts (7): metadata, mint, mint_authority, payer, update_authority, + /// system_program, rent. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn create_metadata_accounts_v3<'a>( + &'a self, + 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: impl BorshCpiEncode, + symbol: impl BorshCpiEncode, + uri: impl BorshCpiEncode, + seller_fee_basis_points: u16, + is_mutable: bool, + update_authority_is_signer: bool, + ) -> Result, ProgramError> { + create_metadata::create_metadata_accounts_v3( + self.to_account_view(), + metadata.to_account_view(), + mint.to_account_view(), + mint_authority.to_account_view(), + payer.to_account_view(), + update_authority.to_account_view(), + system_program.to_account_view(), + rent.to_account_view(), + name, + symbol, + uri, + seller_fee_basis_points, + is_mutable, + update_authority_is_signer, + ) + } + + /// Update a metadata account. + /// + /// Accounts (2): metadata, update_authority. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn update_metadata_accounts_v2<'a>( + &'a self, + metadata: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + new_update_authority: Option<&Address>, + name: Option<&[u8]>, + symbol: Option<&[u8]>, + uri: Option<&[u8]>, + seller_fee_basis_points: Option, + primary_sale_happened: Option, + is_mutable: Option, + ) -> Result, ProgramError> { + update_metadata::update_metadata_accounts_v2( + self.to_account_view(), + metadata.to_account_view(), + update_authority.to_account_view(), + new_update_authority, + name, + symbol, + uri, + seller_fee_basis_points, + primary_sale_happened, + is_mutable, + ) + } + + // ----------------------------------------------------------------------- + // Fixed-length instructions (CpiCall) + // ----------------------------------------------------------------------- + + /// Create a master edition account, making the mint a verified NFT. + /// + /// Accounts (9): edition, mint, update_authority, mint_authority, payer, + /// metadata, token_program, system_program, rent. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn create_master_edition_v3<'a>( + &'a self, + edition: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + mint_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + token_program: &'a impl AsAccountView, + system_program: &'a impl AsAccountView, + rent: &'a impl AsAccountView, + max_supply: Option, + ) -> CpiCall<'a, 9, 10> { + create_master_edition::create_master_edition_v3( + self.to_account_view(), + edition.to_account_view(), + mint.to_account_view(), + update_authority.to_account_view(), + mint_authority.to_account_view(), + payer.to_account_view(), + metadata.to_account_view(), + token_program.to_account_view(), + system_program.to_account_view(), + rent.to_account_view(), + max_supply, + ) + } + + /// Mint a new edition from a master edition via a token holder. + /// + /// Accounts (14): new_metadata, new_edition, master_edition, new_mint, + /// edition_mark_pda, new_mint_authority, payer, token_account_owner, + /// token_account, new_metadata_update_authority, metadata, token_program, + /// system_program, rent. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn mint_new_edition_from_master_edition_via_token<'a>( + &'a self, + new_metadata: &'a impl AsAccountView, + new_edition: &'a impl AsAccountView, + master_edition: &'a impl AsAccountView, + new_mint: &'a impl AsAccountView, + edition_mark_pda: &'a impl AsAccountView, + new_mint_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + token_account_owner: &'a impl AsAccountView, + token_account: &'a impl AsAccountView, + new_metadata_update_authority: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + token_program: &'a impl AsAccountView, + system_program: &'a impl AsAccountView, + rent: &'a impl AsAccountView, + edition: u64, + ) -> CpiCall<'a, 14, 9> { + mint_edition::mint_new_edition_from_master_edition_via_token( + self.to_account_view(), + new_metadata.to_account_view(), + new_edition.to_account_view(), + master_edition.to_account_view(), + new_mint.to_account_view(), + edition_mark_pda.to_account_view(), + new_mint_authority.to_account_view(), + payer.to_account_view(), + token_account_owner.to_account_view(), + token_account.to_account_view(), + new_metadata_update_authority.to_account_view(), + metadata.to_account_view(), + token_program.to_account_view(), + system_program.to_account_view(), + rent.to_account_view(), + edition, + ) + } + + /// Sign metadata as a creator. + /// + /// Accounts (2): creator, metadata. + #[inline(always)] + fn sign_metadata<'a>( + &'a self, + creator: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + ) -> CpiCall<'a, 2, 1> { + sign_metadata::sign_metadata( + self.to_account_view(), + creator.to_account_view(), + metadata.to_account_view(), + ) + } + + /// Remove creator verification from metadata. + /// + /// Accounts (2): creator, metadata. + #[inline(always)] + fn remove_creator_verification<'a>( + &'a self, + creator: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + ) -> CpiCall<'a, 2, 1> { + remove_creator::remove_creator_verification( + self.to_account_view(), + creator.to_account_view(), + metadata.to_account_view(), + ) + } + + /// Update primary sale happened flag via token holder. + /// + /// Accounts (3): metadata, owner, token. + #[inline(always)] + fn update_primary_sale_happened_via_token<'a>( + &'a self, + metadata: &'a impl AsAccountView, + owner: &'a impl AsAccountView, + token: &'a impl AsAccountView, + ) -> CpiCall<'a, 3, 1> { + update_primary_sale::update_primary_sale_happened_via_token( + self.to_account_view(), + metadata.to_account_view(), + owner.to_account_view(), + token.to_account_view(), + ) + } + + /// Verify a collection item. + /// + /// Accounts (6): metadata, collection_authority, payer, collection_mint, + /// collection_metadata, collection_master_edition. + #[inline(always)] + fn verify_collection<'a>( + &'a self, + metadata: &'a impl AsAccountView, + collection_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + collection_mint: &'a impl AsAccountView, + collection_metadata: &'a impl AsAccountView, + collection_master_edition: &'a impl AsAccountView, + ) -> CpiCall<'a, 6, 1> { + verify_collection::verify_collection( + self.to_account_view(), + metadata.to_account_view(), + collection_authority.to_account_view(), + payer.to_account_view(), + collection_mint.to_account_view(), + collection_metadata.to_account_view(), + collection_master_edition.to_account_view(), + ) + } + + /// Verify a sized collection item. + /// + /// Accounts (6): metadata, collection_authority, payer, collection_mint, + /// collection_metadata, collection_master_edition. + #[inline(always)] + fn verify_sized_collection_item<'a>( + &'a self, + metadata: &'a impl AsAccountView, + collection_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + collection_mint: &'a impl AsAccountView, + collection_metadata: &'a impl AsAccountView, + collection_master_edition: &'a impl AsAccountView, + ) -> CpiCall<'a, 6, 1> { + verify_collection::verify_sized_collection_item( + self.to_account_view(), + metadata.to_account_view(), + collection_authority.to_account_view(), + payer.to_account_view(), + collection_mint.to_account_view(), + collection_metadata.to_account_view(), + collection_master_edition.to_account_view(), + ) + } + + /// Unverify a collection item. + /// + /// Accounts (5): metadata, collection_authority, collection_mint, + /// collection_metadata, collection_master_edition. + #[inline(always)] + fn unverify_collection<'a>( + &'a self, + metadata: &'a impl AsAccountView, + collection_authority: &'a impl AsAccountView, + collection_mint: &'a impl AsAccountView, + collection_metadata: &'a impl AsAccountView, + collection_master_edition: &'a impl AsAccountView, + ) -> CpiCall<'a, 5, 1> { + unverify_collection::unverify_collection( + self.to_account_view(), + metadata.to_account_view(), + collection_authority.to_account_view(), + collection_mint.to_account_view(), + collection_metadata.to_account_view(), + collection_master_edition.to_account_view(), + ) + } + + /// Unverify a sized collection item. + /// + /// Accounts (6): metadata, collection_authority, payer, collection_mint, + /// collection_metadata, collection_master_edition. + #[inline(always)] + fn unverify_sized_collection_item<'a>( + &'a self, + metadata: &'a impl AsAccountView, + collection_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + collection_mint: &'a impl AsAccountView, + collection_metadata: &'a impl AsAccountView, + collection_master_edition: &'a impl AsAccountView, + ) -> CpiCall<'a, 6, 1> { + unverify_collection::unverify_sized_collection_item( + self.to_account_view(), + metadata.to_account_view(), + collection_authority.to_account_view(), + payer.to_account_view(), + collection_mint.to_account_view(), + collection_metadata.to_account_view(), + collection_master_edition.to_account_view(), + ) + } + + /// Set and verify a collection item. + /// + /// Accounts (7): metadata, collection_authority, payer, update_authority, + /// collection_mint, collection_metadata, collection_master_edition. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn set_and_verify_collection<'a>( + &'a self, + metadata: &'a impl AsAccountView, + collection_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + collection_mint: &'a impl AsAccountView, + collection_metadata: &'a impl AsAccountView, + collection_master_edition: &'a impl AsAccountView, + ) -> CpiCall<'a, 7, 1> { + set_and_verify_collection::set_and_verify_collection( + self.to_account_view(), + metadata.to_account_view(), + collection_authority.to_account_view(), + payer.to_account_view(), + update_authority.to_account_view(), + collection_mint.to_account_view(), + collection_metadata.to_account_view(), + collection_master_edition.to_account_view(), + ) + } + + /// Set and verify a sized collection item. + /// + /// Accounts (7): metadata, collection_authority, payer, update_authority, + /// collection_mint, collection_metadata, collection_master_edition. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn set_and_verify_sized_collection_item<'a>( + &'a self, + metadata: &'a impl AsAccountView, + collection_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + collection_mint: &'a impl AsAccountView, + collection_metadata: &'a impl AsAccountView, + collection_master_edition: &'a impl AsAccountView, + ) -> CpiCall<'a, 7, 1> { + set_and_verify_collection::set_and_verify_sized_collection_item( + self.to_account_view(), + metadata.to_account_view(), + collection_authority.to_account_view(), + payer.to_account_view(), + update_authority.to_account_view(), + collection_mint.to_account_view(), + collection_metadata.to_account_view(), + collection_master_edition.to_account_view(), + ) + } + + /// Approve a collection authority. + /// + /// Accounts (6): collection_authority_record, new_collection_authority, + /// update_authority, payer, metadata, mint. + #[inline(always)] + fn approve_collection_authority<'a>( + &'a self, + collection_authority_record: &'a impl AsAccountView, + new_collection_authority: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + payer: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + ) -> CpiCall<'a, 6, 1> { + approve_collection::approve_collection_authority( + self.to_account_view(), + collection_authority_record.to_account_view(), + new_collection_authority.to_account_view(), + update_authority.to_account_view(), + payer.to_account_view(), + metadata.to_account_view(), + mint.to_account_view(), + ) + } + + /// Revoke a collection authority. + /// + /// Accounts (5): collection_authority_record, delegate_authority, + /// revoke_authority, metadata, mint. + #[inline(always)] + fn revoke_collection_authority<'a>( + &'a self, + collection_authority_record: &'a impl AsAccountView, + delegate_authority: &'a impl AsAccountView, + revoke_authority: &'a impl AsAccountView, + metadata: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + ) -> CpiCall<'a, 5, 1> { + revoke_collection::revoke_collection_authority( + self.to_account_view(), + collection_authority_record.to_account_view(), + delegate_authority.to_account_view(), + revoke_authority.to_account_view(), + metadata.to_account_view(), + mint.to_account_view(), + ) + } + + /// Freeze a delegated token account. + /// + /// Accounts (5): delegate, token_account, edition, mint, token_program. + #[inline(always)] + fn freeze_delegated_account<'a>( + &'a self, + delegate: &'a impl AsAccountView, + token_account: &'a impl AsAccountView, + edition: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + token_program: &'a impl AsAccountView, + ) -> CpiCall<'a, 5, 1> { + freeze_thaw::freeze_delegated_account( + self.to_account_view(), + delegate.to_account_view(), + token_account.to_account_view(), + edition.to_account_view(), + mint.to_account_view(), + token_program.to_account_view(), + ) + } + + /// Thaw a delegated token account. + /// + /// Accounts (5): delegate, token_account, edition, mint, token_program. + #[inline(always)] + fn thaw_delegated_account<'a>( + &'a self, + delegate: &'a impl AsAccountView, + token_account: &'a impl AsAccountView, + edition: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + token_program: &'a impl AsAccountView, + ) -> CpiCall<'a, 5, 1> { + freeze_thaw::thaw_delegated_account( + self.to_account_view(), + delegate.to_account_view(), + token_account.to_account_view(), + edition.to_account_view(), + mint.to_account_view(), + token_program.to_account_view(), + ) + } + + /// Burn an NFT (metadata, edition, token, mint). + /// + /// Accounts (6): metadata, owner, mint, token, edition, spl_token. + #[inline(always)] + fn burn_nft<'a>( + &'a self, + metadata: &'a impl AsAccountView, + owner: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + token: &'a impl AsAccountView, + edition: &'a impl AsAccountView, + spl_token: &'a impl AsAccountView, + ) -> CpiCall<'a, 6, 1> { + burn::burn_nft( + self.to_account_view(), + metadata.to_account_view(), + owner.to_account_view(), + mint.to_account_view(), + token.to_account_view(), + edition.to_account_view(), + spl_token.to_account_view(), + ) + } + + /// Burn an edition NFT. + /// + /// Accounts (10): metadata, owner, print_edition_mint, master_edition_mint, + /// print_edition_token, master_edition_token, master_edition, + /// print_edition, edition_marker, spl_token. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn burn_edition_nft<'a>( + &'a self, + metadata: &'a impl AsAccountView, + owner: &'a impl AsAccountView, + print_edition_mint: &'a impl AsAccountView, + master_edition_mint: &'a impl AsAccountView, + print_edition_token: &'a impl AsAccountView, + master_edition_token: &'a impl AsAccountView, + master_edition: &'a impl AsAccountView, + print_edition: &'a impl AsAccountView, + edition_marker: &'a impl AsAccountView, + spl_token: &'a impl AsAccountView, + ) -> CpiCall<'a, 10, 1> { + burn::burn_edition_nft( + self.to_account_view(), + metadata.to_account_view(), + owner.to_account_view(), + print_edition_mint.to_account_view(), + master_edition_mint.to_account_view(), + print_edition_token.to_account_view(), + master_edition_token.to_account_view(), + master_edition.to_account_view(), + print_edition.to_account_view(), + edition_marker.to_account_view(), + spl_token.to_account_view(), + ) + } + + /// Set the collection size on a collection metadata. + /// + /// Accounts (3): metadata, update_authority, mint. + #[inline(always)] + fn set_collection_size<'a>( + &'a self, + metadata: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + size: u64, + ) -> CpiCall<'a, 3, 9> { + set_collection_size::set_collection_size( + self.to_account_view(), + metadata.to_account_view(), + update_authority.to_account_view(), + mint.to_account_view(), + size, + ) + } + + /// Set collection size via Bubblegum program. + /// + /// Accounts (4): metadata, update_authority, mint, bubblegum_signer. + #[inline(always)] + fn bubblegum_set_collection_size<'a>( + &'a self, + metadata: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + bubblegum_signer: &'a impl AsAccountView, + size: u64, + ) -> CpiCall<'a, 4, 9> { + set_collection_size::bubblegum_set_collection_size( + self.to_account_view(), + metadata.to_account_view(), + update_authority.to_account_view(), + mint.to_account_view(), + bubblegum_signer.to_account_view(), + size, + ) + } + + /// Set the token standard on a metadata account. + /// + /// Accounts (3): metadata, update_authority, mint. + #[inline(always)] + fn set_token_standard<'a>( + &'a self, + metadata: &'a impl AsAccountView, + update_authority: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + ) -> CpiCall<'a, 3, 1> { + set_token_standard::set_token_standard( + self.to_account_view(), + metadata.to_account_view(), + update_authority.to_account_view(), + mint.to_account_view(), + ) + } + + /// Use/utilize an NFT. + /// + /// Accounts (5): metadata, token_account, mint, use_authority, owner. + #[inline(always)] + fn utilize<'a>( + &'a self, + metadata: &'a impl AsAccountView, + token_account: &'a impl AsAccountView, + mint: &'a impl AsAccountView, + use_authority: &'a impl AsAccountView, + owner: &'a impl AsAccountView, + number_of_uses: u64, + ) -> CpiCall<'a, 5, 9> { + utilize::utilize( + self.to_account_view(), + metadata.to_account_view(), + token_account.to_account_view(), + mint.to_account_view(), + use_authority.to_account_view(), + owner.to_account_view(), + number_of_uses, + ) + } +} + +impl MetadataCpi for crate::MetadataProgram {} + +impl MetadataCpi for AccountView {} + +// --------------------------------------------------------------------------- +// Kani proof harnesses for Metaplex metadata instruction data layout +// --------------------------------------------------------------------------- +// +// Each harness replicates the unsafe `MaybeUninit` + pointer-write pattern used +// by the corresponding instruction builder and asserts: +// 1. The discriminator byte is correct. +// 2. Payload fields are written at the expected offsets in little-endian. +// 3. All bytes of the buffer are initialised before `assume_init`. +// +// Because the harnesses use `kani::any()` for payload values, Kani explores +// *every* possible input, giving us a full proof — not just example-based +// tests. +// --------------------------------------------------------------------------- + +#[cfg(kani)] +mod kani_proofs { + + // -- create_master_edition_v3 with Some(max_supply) (disc=17, 10-byte buf) -- + + /// Prove that the `create_master_edition_v3` instruction data layout is + /// correct when `max_supply` is `Some(v)` for all possible `v` values. + #[kani::proof] + fn create_master_edition_v3_some_layout() { + let max_supply: u64 = kani::any(); + + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 10]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, 17u8); + // Some variant: option tag = 1 + core::ptr::write(ptr.add(1), 1u8); + core::ptr::copy_nonoverlapping(max_supply.to_le_bytes().as_ptr(), ptr.add(2), 8); + buf.assume_init() + }; + + // Discriminator at offset 0 + assert!(data[0] == 17u8); + // Option tag at offset 1 + assert!(data[1] == 1u8); + // max_supply at offset 2..10 (little-endian) + let le = max_supply.to_le_bytes(); + assert!(data[2] == le[0]); + assert!(data[3] == le[1]); + assert!(data[4] == le[2]); + assert!(data[5] == le[3]); + assert!(data[6] == le[4]); + assert!(data[7] == le[5]); + assert!(data[8] == le[6]); + assert!(data[9] == le[7]); + } + + // -- create_master_edition_v3 with None (disc=17, 10-byte buf) ------------ + + /// Prove that the `create_master_edition_v3` instruction data layout is + /// correct when `max_supply` is `None` (option tag 0, eight zero bytes). + #[kani::proof] + fn create_master_edition_v3_none_layout() { + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 10]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, 17u8); + // None variant: option tag = 0 + core::ptr::write(ptr.add(1), 0u8); + core::ptr::write_bytes(ptr.add(2), 0, 8); + buf.assume_init() + }; + + // Discriminator at offset 0 + assert!(data[0] == 17u8); + // Option tag at offset 1 + assert!(data[1] == 0u8); + // Remaining 8 bytes must be zero + assert!(data[2] == 0u8); + assert!(data[3] == 0u8); + assert!(data[4] == 0u8); + assert!(data[5] == 0u8); + assert!(data[6] == 0u8); + assert!(data[7] == 0u8); + assert!(data[8] == 0u8); + assert!(data[9] == 0u8); + } + + // -- mint_new_edition_from_master_edition_via_token (disc=11, 9-byte buf) - + + /// Prove that the `mint_new_edition_from_master_edition_via_token` + /// instruction data layout is correct for all possible `edition` values. + #[kani::proof] + fn mint_edition_instruction_layout() { + let edition: u64 = kani::any(); + + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, 11u8); + core::ptr::copy_nonoverlapping(edition.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + // Discriminator at offset 0 + assert!(data[0] == 11u8); + // edition at offset 1..9 (little-endian) + let le = edition.to_le_bytes(); + assert!(data[1] == le[0]); + assert!(data[2] == le[1]); + assert!(data[3] == le[2]); + assert!(data[4] == le[3]); + assert!(data[5] == le[4]); + assert!(data[6] == le[5]); + assert!(data[7] == le[6]); + assert!(data[8] == le[7]); + } + + // -- set_collection_size (disc=34, 9-byte buf) ---------------------------- + + /// Prove that the `set_collection_size` instruction data layout is correct + /// for all possible `size` values. + #[kani::proof] + fn set_collection_size_instruction_layout() { + let size: u64 = kani::any(); + + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, 34u8); + core::ptr::copy_nonoverlapping(size.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + // Discriminator at offset 0 + assert!(data[0] == 34u8); + // size at offset 1..9 (little-endian) + let le = size.to_le_bytes(); + assert!(data[1] == le[0]); + assert!(data[2] == le[1]); + assert!(data[3] == le[2]); + assert!(data[4] == le[3]); + assert!(data[5] == le[4]); + assert!(data[6] == le[5]); + assert!(data[7] == le[6]); + assert!(data[8] == le[7]); + } + + // -- bubblegum_set_collection_size (disc=36, 9-byte buf) ------------------ + + /// Prove that the `bubblegum_set_collection_size` instruction data layout + /// is correct for all possible `size` values. + #[kani::proof] + fn bubblegum_set_collection_size_instruction_layout() { + let size: u64 = kani::any(); + + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, 36u8); + core::ptr::copy_nonoverlapping(size.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + // Discriminator at offset 0 + assert!(data[0] == 36u8); + // size at offset 1..9 (little-endian) + let le = size.to_le_bytes(); + assert!(data[1] == le[0]); + assert!(data[2] == le[1]); + assert!(data[3] == le[2]); + assert!(data[4] == le[3]); + assert!(data[5] == le[4]); + assert!(data[6] == le[5]); + assert!(data[7] == le[6]); + assert!(data[8] == le[7]); + } + + // -- utilize (disc=19, 9-byte buf) ---------------------------------------- + + /// Prove that the `utilize` instruction data layout is correct for all + /// possible `number_of_uses` values. + #[kani::proof] + fn utilize_instruction_layout() { + let number_of_uses: u64 = kani::any(); + + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, 19u8); + core::ptr::copy_nonoverlapping(number_of_uses.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + // Discriminator at offset 0 + assert!(data[0] == 19u8); + // number_of_uses at offset 1..9 (little-endian) + let le = number_of_uses.to_le_bytes(); + assert!(data[1] == le[0]); + assert!(data[2] == le[1]); + assert!(data[3] == le[2]); + assert!(data[4] == le[3]); + assert!(data[5] == le[4]); + assert!(data[6] == le[5]); + assert!(data[7] == le[6]); + assert!(data[8] == le[7]); + } + + // ----------------------------------------------------------------------- + // Dynamic-offset instruction builders — buffer overflow proofs + // ----------------------------------------------------------------------- + // + // These builders use variable-length Borsh strings, so the final offset + // depends on runtime field lengths. We prove that every valid combination + // of field lengths keeps the total offset within the 512-byte buffer. + + // -- create_metadata_accounts_v3 (disc=33, 512-byte CpiDynamic buf) ------ + + /// Prove that `create_metadata_accounts_v3` offset arithmetic stays within + /// the 512-byte buffer for all valid field lengths. + /// + /// Layout: + /// + /// ```text + /// [0] discriminator (33) 1 + /// [1..] name: Borsh string (4-byte u32 LE len + bytes) 4 + name_len + /// symbol: Borsh string 4 + symbol_len + /// uri: Borsh string 4 + uri_len + /// seller_fee_basis_points (u16 LE) 2 + /// creators Option None tag 1 + /// collection Option None tag 1 + /// uses Option None tag 1 + /// is_mutable (u8) 1 + /// collection_details Option None tag 1 + /// + /// Total = 1 + (4+name_len) + (4+symbol_len) + (4+uri_len) + 2 + 3 + 1 + 1 + /// = 20 + name_len + symbol_len + uri_len + /// + /// Max = 20 + 32 + 10 + 200 = 262 ≤ 512. + /// ``` + #[kani::proof] + fn create_metadata_v3_offset_within_buffer() { + const BUF_CAP: usize = 512; + const MAX_NAME: usize = 32; + const MAX_SYMBOL: usize = 10; + const MAX_URI: usize = 200; + + let name_len: usize = kani::any(); + let symbol_len: usize = kani::any(); + let uri_len: usize = kani::any(); + + kani::assume(name_len <= MAX_NAME); + kani::assume(symbol_len <= MAX_SYMBOL); + kani::assume(uri_len <= MAX_URI); + + // Mirror the offset arithmetic from create_metadata.rs + let mut offset: usize = 0; + + // Discriminator + offset += 1; + + // name: Borsh string (u32 LE prefix + bytes) + offset += 4 + name_len; + + // symbol: Borsh string + offset += 4 + symbol_len; + + // uri: Borsh string + offset += 4 + uri_len; + + // seller_fee_basis_points (u16) + offset += 2; + + // creators: Option> = None + offset += 1; + + // collection: Option = None + offset += 1; + + // uses: Option = None + offset += 1; + + // is_mutable (u8) + offset += 1; + + // collection_details: Option = None + offset += 1; + + assert!(offset <= BUF_CAP); + + // Verify the closed-form matches the step-by-step accumulation + let expected = 20 + name_len + symbol_len + uri_len; + assert!(offset == expected); + } + + // -- update_metadata_accounts_v2 (disc=15, 512-byte CpiDynamic buf) ------ + + /// Prove that `update_metadata_accounts_v2` offset arithmetic stays within + /// the 512-byte buffer in the worst case: all `Option` fields are `Some` + /// with maximum-length strings. + /// + /// Layout (all-Some branch): + /// discriminator 1 + /// Option Some tag 1 + /// name: Borsh string (4 + name_len) + /// symbol: Borsh string (4 + symbol_len) + /// uri: Borsh string (4 + uri_len) + /// seller_fee_basis_points (u16) 2 + /// creators None tag 1 + /// collection None tag 1 + /// uses None tag 1 + /// new_update_authority Some tag + Pubkey 1 + 32 + /// primary_sale_happened Some tag + bool 1 + 1 + /// is_mutable Some tag + bool 1 + 1 + /// + /// Total = 1 + 1 + (4+n) + (4+s) + (4+u) + 2 + 3 + 33 + 2 + 2 + /// = 56 + n + s + u + /// Max = 56 + 32 + 10 + 200 = 298 ≤ 512. + #[kani::proof] + fn update_metadata_v2_all_some_offset_within_buffer() { + const BUF_CAP: usize = 512; + const MAX_NAME: usize = 32; + const MAX_SYMBOL: usize = 10; + const MAX_URI: usize = 200; + + let name_len: usize = kani::any(); + let symbol_len: usize = kani::any(); + let uri_len: usize = kani::any(); + + kani::assume(name_len <= MAX_NAME); + kani::assume(symbol_len <= MAX_SYMBOL); + kani::assume(uri_len <= MAX_URI); + + // Mirror the offset arithmetic from update_metadata.rs (all-Some branch) + let mut offset: usize = 0; + + // Discriminator + offset += 1; + + // Option: Some tag + offset += 1; + + // name: Borsh string (u32 LE prefix + bytes) + offset += 4 + name_len; + + // symbol: Borsh string + offset += 4 + symbol_len; + + // uri: Borsh string + offset += 4 + uri_len; + + // seller_fee_basis_points (u16) + offset += 2; + + // creators: None + offset += 1; + + // collection: None + offset += 1; + + // uses: None + offset += 1; + + // new_update_authority: Some(Pubkey) — tag + 32 bytes + offset += 1 + 32; + + // primary_sale_happened: Some(bool) — tag + 1 byte + offset += 1 + 1; + + // is_mutable: Some(bool) — tag + 1 byte + offset += 1 + 1; + + assert!(offset <= BUF_CAP); + + // Verify the closed-form matches + let expected = 56 + name_len + symbol_len + uri_len; + assert!(offset == expected); + } + + /// Prove that `update_metadata_accounts_v2` offset arithmetic is correct + /// in the minimum case: all `Option` fields are `None`. + /// + /// Layout (all-None branch): + /// discriminator 1 + /// Option None tag 1 + /// new_update_authority None tag 1 + /// primary_sale_happened None tag 1 + /// is_mutable None tag 1 + /// + /// Total = 5 ≤ 512. + #[kani::proof] + fn update_metadata_v2_all_none_offset_within_buffer() { + const BUF_CAP: usize = 512; + + // Mirror the offset arithmetic from update_metadata.rs (all-None branch) + let mut offset: usize = 0; + + // Discriminator + offset += 1; + + // Option: None tag + offset += 1; + + // new_update_authority: None tag + offset += 1; + + // primary_sale_happened: None tag + offset += 1; + + // is_mutable: None tag + offset += 1; + + assert!(offset <= BUF_CAP); + assert!(offset == 5); + } +} diff --git a/tokens/quasar-metadata/src/instructions/remove_creator.rs b/tokens/quasar-metadata/src/instructions/remove_creator.rs new file mode 100644 index 00000000..58875ed8 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/remove_creator.rs @@ -0,0 +1,23 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const REMOVE_CREATOR_VERIFICATION: u8 = 28; + +#[inline(always)] +pub fn remove_creator_verification<'a>( + program: &'a AccountView, + creator: &'a AccountView, + metadata: &'a AccountView, +) -> CpiCall<'a, 2, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::readonly_signer(creator.address()), + InstructionAccount::writable(metadata.address()), + ], + [creator, metadata], + [REMOVE_CREATOR_VERIFICATION], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/revoke_collection.rs b/tokens/quasar-metadata/src/instructions/revoke_collection.rs new file mode 100644 index 00000000..4028d16d --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/revoke_collection.rs @@ -0,0 +1,35 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const REVOKE_COLLECTION_AUTHORITY: u8 = 24; + +#[inline(always)] +pub fn revoke_collection_authority<'a>( + program: &'a AccountView, + collection_authority_record: &'a AccountView, + delegate_authority: &'a AccountView, + revoke_authority: &'a AccountView, + metadata: &'a AccountView, + mint: &'a AccountView, +) -> CpiCall<'a, 5, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(collection_authority_record.address()), + InstructionAccount::readonly(delegate_authority.address()), + InstructionAccount::readonly_signer(revoke_authority.address()), + InstructionAccount::readonly(metadata.address()), + InstructionAccount::readonly(mint.address()), + ], + [ + collection_authority_record, + delegate_authority, + revoke_authority, + metadata, + mint, + ], + [REVOKE_COLLECTION_AUTHORITY], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/set_and_verify_collection.rs b/tokens/quasar-metadata/src/instructions/set_and_verify_collection.rs new file mode 100644 index 00000000..68579da3 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/set_and_verify_collection.rs @@ -0,0 +1,79 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const SET_AND_VERIFY_COLLECTION: u8 = 25; +const SET_AND_VERIFY_SIZED_COLLECTION_ITEM: u8 = 32; + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn set_and_verify_collection<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + collection_authority: &'a AccountView, + payer: &'a AccountView, + update_authority: &'a AccountView, + collection_mint: &'a AccountView, + collection_metadata: &'a AccountView, + collection_master_edition: &'a AccountView, +) -> CpiCall<'a, 7, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(update_authority.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::readonly(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + payer, + update_authority, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [SET_AND_VERIFY_COLLECTION], + ) +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn set_and_verify_sized_collection_item<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + collection_authority: &'a AccountView, + payer: &'a AccountView, + update_authority: &'a AccountView, + collection_mint: &'a AccountView, + collection_metadata: &'a AccountView, + collection_master_edition: &'a AccountView, +) -> CpiCall<'a, 7, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(update_authority.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::readonly(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + payer, + update_authority, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [SET_AND_VERIFY_SIZED_COLLECTION_ITEM], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/set_collection_size.rs b/tokens/quasar-metadata/src/instructions/set_collection_size.rs new file mode 100644 index 00000000..e9b005b2 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/set_collection_size.rs @@ -0,0 +1,67 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const SET_COLLECTION_SIZE: u8 = 34; +const BUBBLEGUM_SET_COLLECTION_SIZE: u8 = 36; + +#[inline(always)] +pub fn set_collection_size<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + update_authority: &'a AccountView, + mint: &'a AccountView, + size: u64, +) -> CpiCall<'a, 3, 9> { + // SAFETY: All 9 bytes are written before assume_init. + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, SET_COLLECTION_SIZE); + core::ptr::copy_nonoverlapping(size.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(update_authority.address()), + InstructionAccount::readonly(mint.address()), + ], + [metadata, update_authority, mint], + data, + ) +} + +#[inline(always)] +pub fn bubblegum_set_collection_size<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + update_authority: &'a AccountView, + mint: &'a AccountView, + bubblegum_signer: &'a AccountView, + size: u64, +) -> CpiCall<'a, 4, 9> { + // SAFETY: All 9 bytes are written before assume_init. + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, BUBBLEGUM_SET_COLLECTION_SIZE); + core::ptr::copy_nonoverlapping(size.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(update_authority.address()), + InstructionAccount::readonly(mint.address()), + InstructionAccount::readonly_signer(bubblegum_signer.address()), + ], + [metadata, update_authority, mint, bubblegum_signer], + data, + ) +} diff --git a/tokens/quasar-metadata/src/instructions/set_token_standard.rs b/tokens/quasar-metadata/src/instructions/set_token_standard.rs new file mode 100644 index 00000000..f08fb9a0 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/set_token_standard.rs @@ -0,0 +1,25 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const SET_TOKEN_STANDARD: u8 = 35; + +#[inline(always)] +pub fn set_token_standard<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + update_authority: &'a AccountView, + mint: &'a AccountView, +) -> CpiCall<'a, 3, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(update_authority.address()), + InstructionAccount::readonly(mint.address()), + ], + [metadata, update_authority, mint], + [SET_TOKEN_STANDARD], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/sign_metadata.rs b/tokens/quasar-metadata/src/instructions/sign_metadata.rs new file mode 100644 index 00000000..feb5df9b --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/sign_metadata.rs @@ -0,0 +1,23 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const SIGN_METADATA: u8 = 7; + +#[inline(always)] +pub fn sign_metadata<'a>( + program: &'a AccountView, + creator: &'a AccountView, + metadata: &'a AccountView, +) -> CpiCall<'a, 2, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::readonly_signer(creator.address()), + InstructionAccount::writable(metadata.address()), + ], + [creator, metadata], + [SIGN_METADATA], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/unverify_collection.rs b/tokens/quasar-metadata/src/instructions/unverify_collection.rs new file mode 100644 index 00000000..56d79d6a --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/unverify_collection.rs @@ -0,0 +1,68 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const UNVERIFY_COLLECTION: u8 = 22; +const UNVERIFY_SIZED_COLLECTION_ITEM: u8 = 31; + +#[inline(always)] +pub fn unverify_collection<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + collection_authority: &'a AccountView, + collection_mint: &'a AccountView, + collection_metadata: &'a AccountView, + collection_master_edition: &'a AccountView, +) -> CpiCall<'a, 5, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::readonly(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [UNVERIFY_COLLECTION], + ) +} + +#[inline(always)] +pub fn unverify_sized_collection_item<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + collection_authority: &'a AccountView, + payer: &'a AccountView, + collection_mint: &'a AccountView, + collection_metadata: &'a AccountView, + collection_master_edition: &'a AccountView, +) -> CpiCall<'a, 6, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::readonly(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + payer, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [UNVERIFY_SIZED_COLLECTION_ITEM], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/update_metadata.rs b/tokens/quasar-metadata/src/instructions/update_metadata.rs new file mode 100644 index 00000000..9e8b9cc0 --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/update_metadata.rs @@ -0,0 +1,135 @@ +use { + crate::codec::CpiEncode, + quasar_lang::{cpi::CpiDynamic, prelude::*}, +}; + +const UPDATE_METADATA_ACCOUNTS_V2: u8 = 15; + +#[cold] +#[inline(never)] +fn metadata_field_too_long() -> ProgramError { + ProgramError::InvalidInstructionData +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn update_metadata_accounts_v2<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + update_authority: &'a AccountView, + new_update_authority: Option<&Address>, + name: Option<&[u8]>, + symbol: Option<&[u8]>, + uri: Option<&[u8]>, + seller_fee_basis_points: Option, + primary_sale_happened: Option, + is_mutable: Option, +) -> Result, ProgramError> { + if let Some(n) = name { + if n.len() > super::MAX_NAME_LEN { + return Err(metadata_field_too_long()); + } + } + if let Some(s) = symbol { + if s.len() > super::MAX_SYMBOL_LEN { + return Err(metadata_field_too_long()); + } + } + if let Some(u) = uri { + if u.len() > super::MAX_URI_LEN { + return Err(metadata_field_too_long()); + } + } + + let mut cpi = CpiDynamic::<2, 512>::new(program.address()); + + // Push accounts. + cpi.push_account(metadata, false, true)?; + cpi.push_account(update_authority, true, false)?; + + let mut offset = 0; + + // SAFETY: Writing serialized instruction data into the uninitialized buffer. + // All bytes [0..offset] are written before set_data_len() is called. + unsafe { + let ptr = cpi.data_mut() as *mut u8; + + core::ptr::write(ptr, UPDATE_METADATA_ACCOUNTS_V2); + offset += 1; + + // Option + match (name, symbol, uri) { + (Some(n), Some(s), Some(u)) => { + core::ptr::write(ptr.add(offset), 1u8); // Some + offset += 1; + + offset = CpiEncode::<4>::write_to(&n, ptr, offset); + offset = CpiEncode::<4>::write_to(&s, ptr, offset); + offset = CpiEncode::<4>::write_to(&u, ptr, offset); + + // seller_fee_basis_points + let fee = seller_fee_basis_points.unwrap_or(0); + core::ptr::copy_nonoverlapping(fee.to_le_bytes().as_ptr(), ptr.add(offset), 2); + offset += 2; + + // creators: None, collection: None, uses: None + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + } + _ => { + core::ptr::write(ptr.add(offset), 0u8); // None + offset += 1; + } + } + + // new_update_authority: Option + match new_update_authority { + Some(addr) => { + core::ptr::write(ptr.add(offset), 1u8); + offset += 1; + core::ptr::copy_nonoverlapping(addr.as_ref().as_ptr(), ptr.add(offset), 32); + offset += 32; + } + None => { + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + } + } + + // primary_sale_happened: Option + match primary_sale_happened { + Some(v) => { + core::ptr::write(ptr.add(offset), 1u8); + offset += 1; + core::ptr::write(ptr.add(offset), v as u8); + offset += 1; + } + None => { + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + } + } + + // is_mutable: Option + match is_mutable { + Some(v) => { + core::ptr::write(ptr.add(offset), 1u8); + offset += 1; + core::ptr::write(ptr.add(offset), v as u8); + offset += 1; + } + None => { + core::ptr::write(ptr.add(offset), 0u8); + offset += 1; + } + } + } + + // SAFETY: the writes above initialized exactly data[0..offset]. + unsafe { cpi.set_data_len(offset)? }; + Ok(cpi) +} diff --git a/tokens/quasar-metadata/src/instructions/update_primary_sale.rs b/tokens/quasar-metadata/src/instructions/update_primary_sale.rs new file mode 100644 index 00000000..687d9efb --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/update_primary_sale.rs @@ -0,0 +1,25 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const UPDATE_PRIMARY_SALE_HAPPENED_VIA_TOKEN: u8 = 4; + +#[inline(always)] +pub fn update_primary_sale_happened_via_token<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + owner: &'a AccountView, + token: &'a AccountView, +) -> CpiCall<'a, 3, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(owner.address()), + InstructionAccount::readonly(token.address()), + ], + [metadata, owner, token], + [UPDATE_PRIMARY_SALE_HAPPENED_VIA_TOKEN], + ) +} diff --git a/tokens/quasar-metadata/src/instructions/utilize.rs b/tokens/quasar-metadata/src/instructions/utilize.rs new file mode 100644 index 00000000..845b920f --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/utilize.rs @@ -0,0 +1,39 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const UTILIZE: u8 = 19; + +#[inline(always)] +pub fn utilize<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + token_account: &'a AccountView, + mint: &'a AccountView, + use_authority: &'a AccountView, + owner: &'a AccountView, + number_of_uses: u64, +) -> CpiCall<'a, 5, 9> { + // SAFETY: All 9 bytes are written before assume_init. + let data = unsafe { + let mut buf = core::mem::MaybeUninit::<[u8; 9]>::uninit(); + let ptr = buf.as_mut_ptr() as *mut u8; + core::ptr::write(ptr, UTILIZE); + core::ptr::copy_nonoverlapping(number_of_uses.to_le_bytes().as_ptr(), ptr.add(1), 8); + buf.assume_init() + }; + + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::writable(token_account.address()), + InstructionAccount::writable(mint.address()), + InstructionAccount::readonly_signer(use_authority.address()), + InstructionAccount::readonly(owner.address()), + ], + [metadata, token_account, mint, use_authority, owner], + data, + ) +} diff --git a/tokens/quasar-metadata/src/instructions/verify_collection.rs b/tokens/quasar-metadata/src/instructions/verify_collection.rs new file mode 100644 index 00000000..ee75756a --- /dev/null +++ b/tokens/quasar-metadata/src/instructions/verify_collection.rs @@ -0,0 +1,71 @@ +use quasar_lang::{ + cpi::{CpiCall, InstructionAccount}, + prelude::*, +}; + +const VERIFY_COLLECTION: u8 = 18; +const VERIFY_SIZED_COLLECTION_ITEM: u8 = 30; + +#[inline(always)] +pub fn verify_collection<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + collection_authority: &'a AccountView, + payer: &'a AccountView, + collection_mint: &'a AccountView, + collection_metadata: &'a AccountView, + collection_master_edition: &'a AccountView, +) -> CpiCall<'a, 6, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::readonly(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + payer, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [VERIFY_COLLECTION], + ) +} + +#[inline(always)] +pub fn verify_sized_collection_item<'a>( + program: &'a AccountView, + metadata: &'a AccountView, + collection_authority: &'a AccountView, + payer: &'a AccountView, + collection_mint: &'a AccountView, + collection_metadata: &'a AccountView, + collection_master_edition: &'a AccountView, +) -> CpiCall<'a, 6, 1> { + CpiCall::new( + program.address(), + [ + InstructionAccount::writable(metadata.address()), + InstructionAccount::readonly_signer(collection_authority.address()), + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::readonly(collection_mint.address()), + InstructionAccount::readonly(collection_metadata.address()), + InstructionAccount::readonly(collection_master_edition.address()), + ], + [ + metadata, + collection_authority, + payer, + collection_mint, + collection_metadata, + collection_master_edition, + ], + [VERIFY_SIZED_COLLECTION_ITEM], + ) +} diff --git a/tokens/quasar-metadata/src/lib.rs b/tokens/quasar-metadata/src/lib.rs new file mode 100644 index 00000000..4cf6d0c3 --- /dev/null +++ b/tokens/quasar-metadata/src/lib.rs @@ -0,0 +1,32 @@ +//! Metaplex Token Metadata program integration. +//! +//! Provides zero-copy account types ([`MetadataAccount`], +//! [`MasterEditionAccount`]), CPI methods ([`MetadataCpi`]), and initialization +//! helpers ([`InitMetadata`], [`InitMasterEdition`]) for the Metaplex Token +//! Metadata program. + +#![no_std] + +mod account_init; +pub mod accounts; +mod codec; +mod constants; +mod init; +pub mod instructions; +pub mod pda; +pub mod prelude; +mod program; +mod state; +pub mod validate; + +pub use { + account_init::{MasterEditionInitParams, MetadataInitParams}, + constants::METADATA_PROGRAM_ID, + init::{InitMasterEdition, InitMetadata}, + instructions::MetadataCpi, + program::MetadataProgram, + state::{ + MasterEditionAccount, MasterEditionPrefix, MasterEditionPrefixZc, MetadataAccount, + MetadataPrefix, MetadataPrefixZc, + }, +}; diff --git a/tokens/quasar-metadata/src/pda.rs b/tokens/quasar-metadata/src/pda.rs new file mode 100644 index 00000000..e1d4d188 --- /dev/null +++ b/tokens/quasar-metadata/src/pda.rs @@ -0,0 +1,80 @@ +//! PDA derivation and verification helpers for Metaplex Token Metadata. +//! +//! Canonical seeds: +//! - Metadata: `["metadata", metadata_program_id, mint]` +//! - Master Edition: `["metadata", metadata_program_id, mint, "edition"]` + +use { + crate::constants::{METADATA_PROGRAM_BYTES, METADATA_PROGRAM_ID}, + quasar_lang::__solana_program_error::ProgramError, + solana_address::Address, +}; + +const METADATA_SEED: &[u8] = b"metadata"; +const EDITION_SEED: &[u8] = b"edition"; + +/// Derive the metadata PDA address from a mint. +/// +/// **Cost:** ~544 CU (SHA-256 loop). Prefer [`verify_metadata_address`] when +/// the address is already known. +#[inline(always)] +pub fn metadata_address(mint: &Address) -> (Address, u8) { + quasar_lang::pda::try_find_program_address( + &[METADATA_SEED, &METADATA_PROGRAM_BYTES, mint.as_ref()], + &METADATA_PROGRAM_ID, + ) + .unwrap() +} + +/// Derive the master edition PDA address from a mint. +/// +/// **Cost:** ~544 CU. Prefer [`verify_master_edition_address`] when known. +#[inline(always)] +pub fn master_edition_address(mint: &Address) -> (Address, u8) { + quasar_lang::pda::try_find_program_address( + &[ + METADATA_SEED, + &METADATA_PROGRAM_BYTES, + mint.as_ref(), + EDITION_SEED, + ], + &METADATA_PROGRAM_ID, + ) + .unwrap() +} + +/// Verify a metadata address matches the expected PDA for a mint. +/// +/// **Cost:** ~90 CU per attempt (single SHA-256 + compare). +#[inline(always)] +pub fn verify_metadata_address(address: &Address, mint: &Address) -> Result<(), ProgramError> { + quasar_lang::pda::find_bump_for_address( + &[METADATA_SEED, &METADATA_PROGRAM_BYTES, mint.as_ref()], + &METADATA_PROGRAM_ID, + address, + ) + .map(|_| ()) + .map_err(|_| ProgramError::InvalidSeeds) +} + +/// Verify a master edition address matches the expected PDA for a mint. +/// +/// **Cost:** ~90 CU per attempt. +#[inline(always)] +pub fn verify_master_edition_address( + address: &Address, + mint: &Address, +) -> Result<(), ProgramError> { + quasar_lang::pda::find_bump_for_address( + &[ + METADATA_SEED, + &METADATA_PROGRAM_BYTES, + mint.as_ref(), + EDITION_SEED, + ], + &METADATA_PROGRAM_ID, + address, + ) + .map(|_| ()) + .map_err(|_| ProgramError::InvalidSeeds) +} diff --git a/tokens/quasar-metadata/src/prelude.rs b/tokens/quasar-metadata/src/prelude.rs new file mode 100644 index 00000000..cbc1d408 --- /dev/null +++ b/tokens/quasar-metadata/src/prelude.rs @@ -0,0 +1,15 @@ +//! Convenience re-exports for metadata programs. +//! +//! ```rust,ignore +//! use quasar_lang::prelude::*; +//! use quasar_metadata::prelude::*; +//! ``` + +pub use crate::{ + accounts::{master_edition, metadata}, + init::{InitMasterEdition, InitMetadata}, + instructions::MetadataCpi, + pda::*, + MasterEditionAccount, MasterEditionPrefix, MasterEditionPrefixZc, MetadataAccount, + MetadataPrefix, MetadataPrefixZc, MetadataProgram, +}; diff --git a/tokens/quasar-metadata/src/program.rs b/tokens/quasar-metadata/src/program.rs new file mode 100644 index 00000000..f8581b71 --- /dev/null +++ b/tokens/quasar-metadata/src/program.rs @@ -0,0 +1,12 @@ +use { + crate::constants::METADATA_PROGRAM_BYTES, + quasar_lang::{prelude::*, traits::Id}, +}; + +quasar_lang::define_account!(pub struct MetadataProgram => [checks::Executable, checks::Address]); + +impl Id for MetadataProgram { + const ID: Address = Address::new_from_array(METADATA_PROGRAM_BYTES); +} + +impl crate::instructions::MetadataCpi for Program {} diff --git a/tokens/quasar-metadata/src/state.rs b/tokens/quasar-metadata/src/state.rs new file mode 100644 index 00000000..c72dbd0b --- /dev/null +++ b/tokens/quasar-metadata/src/state.rs @@ -0,0 +1,189 @@ +use { + crate::constants::METADATA_PROGRAM_ID, + quasar_lang::{__zeropod as zeropod, prelude::*}, + solana_address::Address, +}; + +// Re-export zeropod so #[derive(ZeroPod)] expansion resolves `zeropod::*` +// paths. + +/// Metaplex Key enum discriminant for MetadataV1 accounts. +pub(crate) const KEY_METADATA_V1: u8 = 4; +/// Metaplex Key enum discriminant for MasterEditionV2 accounts. +pub(crate) const KEY_MASTER_EDITION_V2: u8 = 6; + +// --------------------------------------------------------------------------- +// MetadataPrefix — ZeroPod schema for the fixed 65-byte header +// --------------------------------------------------------------------------- + +/// Zero-copy layout for the fixed-size prefix of Metaplex Metadata accounts. +/// +/// The first 65 bytes of a Metadata account have a stable layout: +/// - `key` (1 byte): Metaplex account type discriminant (`Key::MetadataV1 = 4`) +/// - `update_authority` (32 bytes): pubkey authorized to update this metadata +/// - `mint` (32 bytes): the SPL Token mint this metadata describes +/// +/// Fields after the prefix (name, symbol, uri, creators, etc.) are +/// variable-length Borsh-serialized data and require offset walking to access. +#[derive(quasar_lang::__zeropod::ZeroPod)] +pub struct MetadataPrefix { + pub key: u8, + pub update_authority: Address, + pub mint: Address, +} + +const _: () = assert!(core::mem::size_of::() == 65); +const _: () = assert!(core::mem::align_of::() == 1); +const _: () = assert!(core::mem::offset_of!(MetadataPrefixZc, key) == 0); +const _: () = assert!(core::mem::offset_of!(MetadataPrefixZc, update_authority) == 1); +const _: () = assert!(core::mem::offset_of!(MetadataPrefixZc, mint) == 33); + +// --------------------------------------------------------------------------- +// MasterEditionPrefix — ZeroPod schema for the fixed 18-byte header +// --------------------------------------------------------------------------- + +/// Zero-copy layout for the fixed-size prefix of Metaplex MasterEdition +/// accounts. +/// +/// - `key` (1 byte): Metaplex account type discriminant (`Key::MasterEditionV2 +/// = 6`) +/// - `supply` (8 bytes, u64 LE): number of editions printed +/// - `max_supply` (9 bytes): Borsh `Option` — 1-byte tag + 8-byte value +#[derive(quasar_lang::__zeropod::ZeroPod)] +pub struct MasterEditionPrefix { + pub key: u8, + pub supply: u64, + pub max_supply: zeropod::pod::PodOption, +} + +const _: () = assert!(core::mem::size_of::() == 18); +const _: () = assert!(core::mem::align_of::() == 1); +const _: () = assert!(core::mem::offset_of!(MasterEditionPrefixZc, key) == 0); +const _: () = assert!(core::mem::offset_of!(MasterEditionPrefixZc, supply) == 1); +const _: () = assert!(core::mem::offset_of!(MasterEditionPrefixZc, max_supply) == 9); + +/// Semantic accessors for MasterEditionPrefixZc. +impl MasterEditionPrefixZc { + #[inline(always)] + pub fn max_supply_tag_valid(&self) -> bool { + self.max_supply.raw_tag() <= 1 + } + + #[inline(always)] + pub fn supply_value(&self) -> u64 { + self.supply.get() + } + + #[inline(always)] + pub fn max_supply_value(&self) -> Option { + self.max_supply.get_ref().map(|v| v.get()) + } +} + +// --------------------------------------------------------------------------- +// MetadataAccount — schema form define_account! +// --------------------------------------------------------------------------- + +quasar_lang::define_account!( + /// Metaplex Metadata account — validates owner is Metadata program. + /// + /// Derefs to [`MetadataPrefixZc`] for zero-copy access to the fixed-size + /// header (update_authority, mint). Variable-length fields (name, symbol, + /// uri, creators) require Borsh deserialization and are not exposed here. + /// + /// Checks: owner == Metadata program, data_len >= 65, key byte == 4, + /// ZeroPod validation. + pub struct MetadataAccount => [checks::Owner, checks::Discriminator, checks::DataLen, checks::ZeroPod]: MetadataPrefix +); + +impl Owner for MetadataAccount { + const OWNER: Address = METADATA_PROGRAM_ID; +} + +impl quasar_lang::traits::Discriminator for MetadataAccount { + const DISCRIMINATOR: &'static [u8] = &[KEY_METADATA_V1]; +} + +// --------------------------------------------------------------------------- +// MasterEditionAccount — schema form define_account! +// --------------------------------------------------------------------------- + +quasar_lang::define_account!( + /// Metaplex MasterEdition account — validates owner is Metadata program. + /// + /// Derefs to [`MasterEditionPrefixZc`] for zero-copy access to supply and + /// max_supply fields. + /// + /// Checks: owner == Metadata program, data_len >= 18, key byte == 6, + /// ZeroPod validation. + pub struct MasterEditionAccount => [checks::Owner, checks::Discriminator, checks::DataLen, checks::ZeroPod]: MasterEditionPrefix +); + +impl Owner for MasterEditionAccount { + const OWNER: Address = METADATA_PROGRAM_ID; +} + +impl quasar_lang::traits::Discriminator for MasterEditionAccount { + const DISCRIMINATOR: &'static [u8] = &[KEY_MASTER_EDITION_V2]; +} + +// --------------------------------------------------------------------------- +// Kani model-checking proof harnesses +// --------------------------------------------------------------------------- + +#[cfg(kani)] +mod kani_proofs { + use super::*; + + // --- MetadataPrefixZc --- + + /// Prove MetadataPrefixZc is exactly 65 bytes (matches ZeroPodFixed::SIZE). + #[kani::proof] + fn metadata_prefix_zc_size_65() { + assert!(core::mem::size_of::() == 65); + assert!(::SIZE == 65); + } + + /// Prove MetadataPrefixZc has alignment 1 (safe for pointer cast from + /// arbitrary account data). + #[kani::proof] + fn metadata_prefix_zc_align_one() { + assert!(core::mem::align_of::() == 1); + } + + /// Prove: for any `data_len >= ZeroPodFixed::SIZE`, the data covers + /// the full Zc struct — verifies the DataLen check is sufficient for + /// the pointer cast in Deref. + #[kani::proof] + fn metadata_prefix_data_len_guard_sufficient() { + let data_len: usize = kani::any(); + kani::assume(data_len >= ::SIZE); + assert!(data_len >= core::mem::size_of::()); + } + + // --- MasterEditionPrefixZc --- + + /// Prove MasterEditionPrefixZc is exactly 18 bytes. + #[kani::proof] + fn master_edition_prefix_zc_size_18() { + assert!(core::mem::size_of::() == 18); + assert!(::SIZE == 18); + } + + /// Prove MasterEditionPrefixZc has alignment 1. + #[kani::proof] + fn master_edition_prefix_zc_align_one() { + assert!(core::mem::align_of::() == 1); + } + + /// Prove: for any `data_len >= ZeroPodFixed::SIZE`, the data covers + /// the full Zc struct. + #[kani::proof] + fn master_edition_prefix_data_len_guard_sufficient() { + let data_len: usize = kani::any(); + kani::assume( + data_len >= ::SIZE, + ); + assert!(data_len >= core::mem::size_of::()); + } +} diff --git a/tokens/quasar-metadata/src/validate.rs b/tokens/quasar-metadata/src/validate.rs new file mode 100644 index 00000000..0bc0cb2c --- /dev/null +++ b/tokens/quasar-metadata/src/validate.rs @@ -0,0 +1,119 @@ +//! Account validation helpers. +//! +//! Single source of truth for validating metadata accounts, master editions, +//! and the Metadata program. Every error path uses `unlikely()` hints for +//! expected-success paths. +//! +//! These functions perform full validation (owner, data_len, key byte, fields). +//! The behavior module's `check()` skips base checks already done by +//! `AccountLoad::check` and calls PDA/field checks directly. + +use { + crate::state::{ + MasterEditionPrefixZc, MetadataPrefixZc, KEY_MASTER_EDITION_V2, KEY_METADATA_V1, + }, + quasar_lang::{prelude::*, utils::hint::unlikely}, +}; + +/// Validate a metadata account (standalone, full checks). +/// +/// Performs owner, data length, key discriminant, and field validation. +/// `mint` is mandatory (always known from PDA derivation or behavior args). +/// `update_authority` is optional — omit to skip that check. +#[inline(always)] +pub fn validate_metadata_account( + view: &AccountView, + mint: &Address, + update_authority: Option<&Address>, +) -> Result<(), ProgramError> { + if unlikely(!quasar_lang::keys_eq( + view.owner(), + &crate::METADATA_PROGRAM_ID, + )) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_metadata_account: wrong program owner"); + return Err(ProgramError::IllegalOwner); + } + if unlikely(view.data_len() < core::mem::size_of::()) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_metadata_account: data too small"); + return Err(ProgramError::InvalidAccountData); + } + // SAFETY: owner + data_len checked. MetadataPrefixZc is #[repr(C)] align 1. + let prefix = unsafe { &*(view.data_ptr() as *const MetadataPrefixZc) }; + if unlikely(prefix.key() != KEY_METADATA_V1) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_metadata_account: wrong key discriminant"); + return Err(ProgramError::InvalidAccountData); + } + if unlikely(!quasar_lang::keys_eq(prefix.mint(), mint)) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_metadata_account: mint mismatch"); + return Err(ProgramError::InvalidAccountData); + } + if let Some(expected_ua) = update_authority { + if unlikely(!quasar_lang::keys_eq( + prefix.update_authority(), + expected_ua, + )) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_metadata_account: update_authority mismatch"); + return Err(ProgramError::InvalidAccountData); + } + } + Ok(()) +} + +/// Validate a master edition account (standalone, full checks). +/// +/// Performs owner, data length, key discriminant, and optional PDA validation. +#[inline(always)] +pub fn validate_master_edition_account( + view: &AccountView, + mint: Option<&Address>, +) -> Result<(), ProgramError> { + if unlikely(!quasar_lang::keys_eq( + view.owner(), + &crate::METADATA_PROGRAM_ID, + )) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_master_edition: wrong program owner"); + return Err(ProgramError::IllegalOwner); + } + if unlikely(view.data_len() < core::mem::size_of::()) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_master_edition: data too small"); + return Err(ProgramError::InvalidAccountData); + } + // SAFETY: owner + data_len checked. MasterEditionPrefixZc is #[repr(C)] align + // 1. + let prefix = unsafe { &*(view.data_ptr() as *const MasterEditionPrefixZc) }; + if unlikely(prefix.key() != KEY_MASTER_EDITION_V2) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_master_edition: wrong key discriminant"); + return Err(ProgramError::InvalidAccountData); + } + if unlikely(!prefix.max_supply_tag_valid()) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("validate_master_edition: invalid max_supply tag"); + return Err(ProgramError::InvalidAccountData); + } + if let Some(mint_addr) = mint { + crate::pda::verify_master_edition_address(view.address(), mint_addr)?; + } + Ok(()) +} + +/// Validate that an `AccountView` is the Metadata program. +#[inline(always)] +pub fn validate_metadata_program(view: &AccountView) -> Result<(), ProgramError> { + if unlikely(!quasar_lang::keys_eq( + view.address(), + &crate::METADATA_PROGRAM_ID, + )) { + #[cfg(feature = "debug")] + quasar_lang::prelude::log("Invalid metadata program"); + return Err(ProgramError::IncorrectProgramId); + } + Ok(()) +} From bd36b41f42a05b785a52eef66892e44868c24dc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:49:07 +0000 Subject: [PATCH 12/14] tokens: migrate metadata trio to Quasar 0.1.0 via vendored quasar-metadata token-minter, nft-minter, and nft-operations join the 0.1.0-release pin (be60fca) like every other example, depending on the vendored tokens/quasar-metadata crate as a path dependency. Standard migration otherwise: 0.1.0 Quasar.toml schema, idl-build feature, quasar-test suites with every scenario preserved (nft-operations still drives the full create-collection/mint/verify lifecycle against the real mpl_token_metadata fixture, loaded at runtime). The only program-source break was the recurring Seed-left-the-prelude import in nft-operations' three instruction files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- tokens/nft-minter/quasar/CHANGELOG.md | 21 +- tokens/nft-minter/quasar/Cargo.toml | 39 +- tokens/nft-minter/quasar/Quasar.toml | 19 +- tokens/nft-minter/quasar/src/tests.rs | 98 ++--- tokens/nft-operations/quasar/CHANGELOG.md | 24 +- tokens/nft-operations/quasar/Cargo.toml | 39 +- tokens/nft-operations/quasar/Quasar.toml | 19 +- .../src/instructions/create_collection.rs | 2 +- .../quasar/src/instructions/mint_nft.rs | 2 +- .../src/instructions/verify_collection.rs | 2 +- tokens/nft-operations/quasar/src/tests.rs | 342 +++++------------- tokens/token-minter/quasar/CHANGELOG.md | 21 +- tokens/token-minter/quasar/Cargo.toml | 41 +-- tokens/token-minter/quasar/Quasar.toml | 19 +- tokens/token-minter/quasar/src/tests.rs | 124 ++----- 15 files changed, 236 insertions(+), 576 deletions(-) diff --git a/tokens/nft-minter/quasar/CHANGELOG.md b/tokens/nft-minter/quasar/CHANGELOG.md index 2eefa60e..c54bcde7 100644 --- a/tokens/nft-minter/quasar/CHANGELOG.md +++ b/tokens/nft-minter/quasar/CHANGELOG.md @@ -1,16 +1,23 @@ # Changelog -## [2026-07-22] +## [2026-07-23] ### Changed -- Deliberately NOT migrated to Quasar 0.1.0: this example depends on - `quasar-metadata`, which was removed upstream before the 0.1.0 release with - no replacement. It stays on the pre-0.1.0 pins (quasar `623bb70` / - quasar-svm `cb7565d`) and builds in the `legacy-metadata-examples` CI job - with the older quasar CLI. Migrate once upstream ships a metadata story for - 0.1.x. +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `Outcome` assertions). The `quasar-svm` git + dev-dependency is gone; compute-unit assertions were dropped pending + recalibration under 0.1.0. +- `quasar-metadata` now resolves to the vendored copy at + `tokens/quasar-metadata` (path dependency): upstream removed the crate + before the 0.1.0 release with no replacement. See + `tokens/quasar-metadata/README.md`. +- Tests keep the previous suite's scope: the quasar-test harness ships no + Metaplex Token Metadata program, so they verify the program loads and that + the SPL Token mint_to step works, not the full mint_nft metadata flow. ## 2026-07-07 diff --git a/tokens/nft-minter/quasar/Cargo.toml b/tokens/nft-minter/quasar/Cargo.toml index ff7bcbba..a4cc9e9e 100644 --- a/tokens/nft-minter/quasar/Cargo.toml +++ b/tokens/nft-minter/quasar/Cargo.toml @@ -1,11 +1,3 @@ -# NOT migrated to Quasar 0.1.0: this example depends on quasar-metadata, -# which was removed from the quasar repo before the 0.1.0 release (no -# replacement shipped). It stays pinned to the last working pre-0.1.0 revs -# (quasar 623bb70 / quasar-svm cb7565d) and is built in CI by the separate -# legacy-metadata-examples job in .github/workflows/quasar.yml, which -# installs the older quasar CLI (rev 3d6fb0d8) that still parses this -# project's pre-0.1.0 Quasar.toml. Migrate once upstream ships a metadata -# story for 0.1.x. [package] name = "quasar-nft-minter" version = "0.1.0" @@ -22,32 +14,27 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# All quasar deps share branch = "master" so trait impls resolve consistently. -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -# Metaplex metadata moved out of quasar-spl into a dedicated crate (PR #196). -quasar-metadata = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +# Vendored: upstream removed the quasar-metadata crate before the 0.1.0 +# release with no replacement. See tokens/quasar-metadata/README.md. +quasar-metadata = { path = "../../quasar-metadata" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/nft-minter/quasar/Quasar.toml b/tokens/nft-minter/quasar/Quasar.toml index 2ed322d4..05464b77 100644 --- a/tokens/nft-minter/quasar/Quasar.toml +++ b/tokens/nft-minter/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_nft_minter" - -[toolchain] -type = "solana" +name = "quasar-nft-minter" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/nft-minter/quasar/src/tests.rs b/tokens/nft-minter/quasar/src/tests.rs index 629030e0..30561cbb 100644 --- a/tokens/nft-minter/quasar/src/tests.rs +++ b/tokens/nft-minter/quasar/src/tests.rs @@ -1,93 +1,47 @@ extern crate std; -use { - alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, - spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, - std::println, -}; +use {quasar_test::prelude::*, std::vec}; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_nft_minter.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 5_000_000_000) -} - -fn nft_mint(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 0, - is_initialized: true, - freeze_authority: Some(authority).into(), - }, - ) -} +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([3; 32]); -fn token_account(address: Pubkey, mint_address: Pubkey, owner: Pubkey, amount: u64) -> Account { - quasar_svm::token::create_keyed_token_account( - &address, - &TokenAccount { - mint: mint_address, - owner, - amount, - state: AccountState::Initialized, - ..TokenAccount::default() - }, - ) -} - -// Note: The mint_nft instruction requires the Metaplex Token Metadata program +// Note: the mint_nft instruction requires the Metaplex Token Metadata program // deployed in the SVM for the create_metadata and create_master_edition CPIs. -// The quasar-svm harness does not currently include it, so we verify the +// The quasar-test harness does not currently include it, so we verify the // program builds and can at least mint a token (the first CPI step). // Full integration testing requires a devnet/localnet deploy with Metaplex. -#[test] -fn test_program_builds() { - let _svm = setup(); - println!(" NFT minter program loaded successfully"); +#[quasar_test] +fn nft_minter_program_loads(test: &mut Test) { + // The #[quasar_test] harness loads the compiled program ELF; reaching + // this point means the program deploys into the SVM. + let _ = test; } -#[test] -fn test_mint_to_token_account() { +#[quasar_test] +fn spl_mint_to_deposits_one_token(test: &mut Test) { // Test that the SPL Token mint_to CPI works independently. - let mut svm = setup(); - - let payer = Pubkey::new_unique(); - let mint_address = Pubkey::new_unique(); - let token_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; + test.add(Wallet::new().at(PAYER)); + test.add(Mint::new(PAYER).at(MINT).decimals(0)); + test.add(TokenAccount::new(MINT, PAYER).at(TOKEN_ACCOUNT)); - // Build a raw mint_to CPI instruction to verify the token setup works. + // Build a raw mint_to instruction to verify the token setup works. let mut data = vec![7u8]; // SPL Token MintTo instruction data.extend_from_slice(&1u64.to_le_bytes()); let instruction = Instruction { - program_id: token_program, + program_id: SPL_TOKEN_PROGRAM_ID, accounts: vec![ - solana_instruction::AccountMeta::new(mint_address.into(), false), - solana_instruction::AccountMeta::new(token_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(payer.into(), true), + AccountMeta::new(MINT, false), + AccountMeta::new(TOKEN_ACCOUNT, false), + AccountMeta::new_readonly(PAYER, true), ], data, }; - let result = svm.process_instruction( - &instruction, - &[ - nft_mint(mint_address, payer), - token_account(token_addr, mint_address, payer, 0), - signer(payer), - ], - ); - - assert!(result.is_ok(), "SPL Token mint_to failed: {:?}", result.raw_result); - println!(" MINT TO CU: {}", result.compute_units_consumed); + test.send(instruction) + .succeeds() + .has_tokens(TOKEN_ACCOUNT, 1) + .has_supply(MINT, 1); } diff --git a/tokens/nft-operations/quasar/CHANGELOG.md b/tokens/nft-operations/quasar/CHANGELOG.md index 2eefa60e..2b22056c 100644 --- a/tokens/nft-operations/quasar/CHANGELOG.md +++ b/tokens/nft-operations/quasar/CHANGELOG.md @@ -1,16 +1,26 @@ # Changelog -## [2026-07-22] +## [2026-07-23] ### Changed -- Deliberately NOT migrated to Quasar 0.1.0: this example depends on - `quasar-metadata`, which was removed upstream before the 0.1.0 release with - no replacement. It stays on the pre-0.1.0 pins (quasar `623bb70` / - quasar-svm `cb7565d`) and builds in the `legacy-metadata-examples` CI job - with the older quasar CLI. Migrate once upstream ships a metadata story for - 0.1.x. +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. +- `quasar-metadata` now resolves to the vendored copy at + `tokens/quasar-metadata` (path dependency): upstream removed the crate + before the 0.1.0 release with no replacement. See + `tokens/quasar-metadata/README.md`. +- Instruction handlers import `Seed` from `quasar_lang::cpi` — 0.1.0 removed + it from the prelude. +- Tests keep loading the Metaplex Token Metadata fixture shared with the + Anchor twin (`../anchor/tests/fixtures/mpl_token_metadata.so`) and still + cover the full collection lifecycle: create_collection, mint_nft, + verify_collection. ## 2026-07-07 diff --git a/tokens/nft-operations/quasar/Cargo.toml b/tokens/nft-operations/quasar/Cargo.toml index 0ebdbbbf..9ee4eed1 100644 --- a/tokens/nft-operations/quasar/Cargo.toml +++ b/tokens/nft-operations/quasar/Cargo.toml @@ -1,11 +1,3 @@ -# NOT migrated to Quasar 0.1.0: this example depends on quasar-metadata, -# which was removed from the quasar repo before the 0.1.0 release (no -# replacement shipped). It stays pinned to the last working pre-0.1.0 revs -# (quasar 623bb70 / quasar-svm cb7565d) and is built in CI by the separate -# legacy-metadata-examples job in .github/workflows/quasar.yml, which -# installs the older quasar CLI (rev 3d6fb0d8) that still parses this -# project's pre-0.1.0 Quasar.toml. Migrate once upstream ships a metadata -# story for 0.1.x. [package] name = "quasar-nft-operations" version = "0.1.0" @@ -22,32 +14,27 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# All quasar deps share branch = "master" so trait impls resolve consistently. -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -# Metaplex metadata moved out of quasar-spl into a dedicated crate (PR #196). -quasar-metadata = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +# Vendored: upstream removed the quasar-metadata crate before the 0.1.0 +# release with no replacement. See tokens/quasar-metadata/README.md. +quasar-metadata = { path = "../../quasar-metadata" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/nft-operations/quasar/Quasar.toml b/tokens/nft-operations/quasar/Quasar.toml index b0f534bb..b5e12957 100644 --- a/tokens/nft-operations/quasar/Quasar.toml +++ b/tokens/nft-operations/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_nft_operations" - -[toolchain] -type = "solana" +name = "quasar-nft-operations" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/nft-operations/quasar/src/instructions/create_collection.rs b/tokens/nft-operations/quasar/src/instructions/create_collection.rs index ac7c4b1c..0827e8ad 100644 --- a/tokens/nft-operations/quasar/src/instructions/create_collection.rs +++ b/tokens/nft-operations/quasar/src/instructions/create_collection.rs @@ -1,6 +1,6 @@ use { crate::MintAuthorityPda, - quasar_lang::prelude::*, + quasar_lang::{cpi::Seed, prelude::*}, quasar_metadata::prelude::*, quasar_spl::prelude::*, }; diff --git a/tokens/nft-operations/quasar/src/instructions/mint_nft.rs b/tokens/nft-operations/quasar/src/instructions/mint_nft.rs index 71c392c6..4eed5d8d 100644 --- a/tokens/nft-operations/quasar/src/instructions/mint_nft.rs +++ b/tokens/nft-operations/quasar/src/instructions/mint_nft.rs @@ -1,6 +1,6 @@ use { crate::MintAuthorityPda, - quasar_lang::prelude::*, + quasar_lang::{cpi::Seed, prelude::*}, quasar_metadata::prelude::*, quasar_spl::prelude::*, }; diff --git a/tokens/nft-operations/quasar/src/instructions/verify_collection.rs b/tokens/nft-operations/quasar/src/instructions/verify_collection.rs index 3d8683e7..cbb6f1ba 100644 --- a/tokens/nft-operations/quasar/src/instructions/verify_collection.rs +++ b/tokens/nft-operations/quasar/src/instructions/verify_collection.rs @@ -1,7 +1,7 @@ use { crate::MintAuthorityPda, quasar_lang::{ - cpi::{CpiCall, InstructionAccount}, + cpi::{CpiCall, InstructionAccount, Seed}, prelude::*, }, quasar_metadata::prelude::*, diff --git a/tokens/nft-operations/quasar/src/tests.rs b/tokens/nft-operations/quasar/src/tests.rs index 21e1d252..4f8e3bfa 100644 --- a/tokens/nft-operations/quasar/src/tests.rs +++ b/tokens/nft-operations/quasar/src/tests.rs @@ -1,4 +1,4 @@ -//! QuasarSVM integration tests, ported from the Anchor twin's LiteSVM suite. +//! quasar-test 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 @@ -7,55 +7,26 @@ extern crate std; use { - quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, - solana_program_pack::Pack, - spl_token_interface::state::Account as TokenAccount, - std::{vec, vec::Vec}, + crate::cpi::{CreateCollectionInstruction, MintNftInstruction, VerifyCollectionInstruction}, + quasar_test::prelude::*, }; 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; +// Deterministic addresses keep tests independent of discovery order. +const PAYER: Pubkey = Pubkey::new_from_array([1; 32]); +const COLLECTION_MINT: Pubkey = Pubkey::new_from_array([2; 32]); +const COLLECTION_DESTINATION: Pubkey = Pubkey::new_from_array([3; 32]); +const NFT_MINT: Pubkey = Pubkey::new_from_array([4; 32]); +const NFT_DESTINATION: Pubkey = Pubkey::new_from_array([5; 32]); -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 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(&program_id(), &program_elf) - .with_program(&METADATA_PROGRAM_ID, &metadata_elf) - .with_token_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 -} +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 derive_metadata_pda(mint: &Pubkey) -> Pubkey { let (pda, _) = Pubkey::find_program_address( @@ -78,22 +49,6 @@ fn derive_edition_pda(mint: &Pubkey) -> Pubkey { 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. @@ -103,227 +58,110 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { .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" - ); +/// Register the payer and the Metaplex Token Metadata program fixture +/// (the fixture binary is shared with the Anchor twin's LiteSVM suite). +fn base_world(test: &mut Test) { + test.add(Wallet::new().at(PAYER)); + let metadata_elf = std::fs::read("../anchor/tests/fixtures/mpl_token_metadata.so").unwrap(); + test.add(Program::new(METADATA_PROGRAM_ID, &metadata_elf)); +} + +fn create_collection(test: &mut Test) -> Outcome { + test.send(CreateCollectionInstruction { + user: PAYER, + mint: COLLECTION_MINT, + metadata: derive_metadata_pda(&COLLECTION_MINT), + master_edition: derive_edition_pda(&COLLECTION_MINT), + destination: COLLECTION_DESTINATION, + name: COLLECTION_NAME.into(), + symbol: COLLECTION_SYMBOL.into(), + uri: COLLECTION_URI.into(), + }) +} + +fn mint_nft(test: &mut Test) -> Outcome { + test.send(MintNftInstruction { + owner: PAYER, + mint: NFT_MINT, + destination: NFT_DESTINATION, + metadata: derive_metadata_pda(&NFT_MINT), + master_edition: derive_edition_pda(&NFT_MINT), + collection_mint: COLLECTION_MINT, + name: NFT_NAME.into(), + symbol: NFT_SYMBOL.into(), + uri: NFT_URI.into(), + }) +} + +#[quasar_test] +fn create_collection_mints_the_collection_nft(test: &mut Test) { + base_world(test); + + create_collection(test) + .succeeds() + // The collection mint exists and 1 token was minted to the destination. + .has_supply(COLLECTION_MINT, 1) + .has_tokens(COLLECTION_DESTINATION, 1); // The metadata account carries the caller-supplied name, and the master // edition exists. - let metadata_account = result.account(&collection.metadata).unwrap(); + let metadata_account = test + .account(derive_metadata_pda(&COLLECTION_MINT)) + .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()); + assert!(!test + .account(derive_edition_pda(&COLLECTION_MINT)) + .unwrap() + .data + .is_empty()); } -#[test] -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(); +#[quasar_test] +fn mint_nft_references_the_collection_unverified(test: &mut Test) { + base_world(test); + create_collection(test).succeeds(); // 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(); + mint_nft(test).succeeds().has_tokens(NFT_DESTINATION, 1); - assert_eq!( - token_amount(&result.account(&nft.destination).unwrap()), - 1, - "Should hold 1 NFT" - ); - let nft_metadata_account = result.account(&nft.metadata).unwrap(); + let nft_metadata_account = test.account(derive_metadata_pda(&NFT_MINT)).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()), + 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(); +#[quasar_test] +fn verify_collection_updates_the_nft_metadata(test: &mut Test) { + base_world(test); + create_collection(test).succeeds(); + mint_nft(test).succeeds(); - 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 unverified_metadata = test.account(derive_metadata_pda(&NFT_MINT)).unwrap().data; - 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(); + test.send(VerifyCollectionInstruction { + authority: PAYER, + metadata: derive_metadata_pda(&NFT_MINT), + collection_mint: COLLECTION_MINT, + collection_metadata: derive_metadata_pda(&COLLECTION_MINT), + collection_master_edition: derive_edition_pda(&COLLECTION_MINT), + }) + .succeeds(); // 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(); + let verified_metadata = test.account(derive_metadata_pda(&NFT_MINT)).unwrap().data; assert!( - contains_bytes(&verified_metadata, collection.mint.as_ref()), + contains_bytes(&verified_metadata, COLLECTION_MINT.as_ref()), "Metadata should still reference the collection mint" ); assert_ne!( diff --git a/tokens/token-minter/quasar/CHANGELOG.md b/tokens/token-minter/quasar/CHANGELOG.md index 2eefa60e..a177d5db 100644 --- a/tokens/token-minter/quasar/CHANGELOG.md +++ b/tokens/token-minter/quasar/CHANGELOG.md @@ -1,16 +1,23 @@ # Changelog -## [2026-07-22] +## [2026-07-23] ### Changed -- Deliberately NOT migrated to Quasar 0.1.0: this example depends on - `quasar-metadata`, which was removed upstream before the 0.1.0 release with - no replacement. It stays on the pre-0.1.0 pins (quasar `623bb70` / - quasar-svm `cb7565d`) and builds in the `legacy-metadata-examples` CI job - with the older quasar CLI. Migrate once upstream ships a metadata story for - 0.1.x. +- Migrated to Quasar 0.1.0 (`0.1.0-release` branch, rev `be60fca`): Quasar.toml + rewritten to the 0.1.0 schema, `idl-build` feature and `lib` crate-type added, + and tests rewritten from the direct QuasarSVM harness to `quasar-test` + (`#[quasar_test]` fixtures, `crate::cpi` instruction builders, `Outcome` + assertions). The `quasar-svm` git dev-dependency is gone; compute-unit + assertions were dropped pending recalibration under 0.1.0. +- `quasar-metadata` now resolves to the vendored copy at + `tokens/quasar-metadata` (path dependency): upstream removed the crate + before the 0.1.0 release with no replacement. See + `tokens/quasar-metadata/README.md`. +- Tests still exercise `mint_token` only: the quasar-test harness ships no + Metaplex Token Metadata program, so `create_token` (metadata CPI) remains + untestable in-SVM, matching the previous suite. ## 2026-07-07 diff --git a/tokens/token-minter/quasar/Cargo.toml b/tokens/token-minter/quasar/Cargo.toml index 70ec4d74..f776e195 100644 --- a/tokens/token-minter/quasar/Cargo.toml +++ b/tokens/token-minter/quasar/Cargo.toml @@ -1,11 +1,3 @@ -# NOT migrated to Quasar 0.1.0: this example depends on quasar-metadata, -# which was removed from the quasar repo before the 0.1.0 release (no -# replacement shipped). It stays pinned to the last working pre-0.1.0 revs -# (quasar 623bb70 / quasar-svm cb7565d) and is built in CI by the separate -# legacy-metadata-examples job in .github/workflows/quasar.yml, which -# installs the older quasar CLI (rev 3d6fb0d8) that still parses this -# project's pre-0.1.0 Quasar.toml. Migrate once upstream ships a metadata -# story for 0.1.x. [package] name = "quasar-token-minter" version = "0.1.0" @@ -22,34 +14,27 @@ check-cfg = [ ] [lib] +# "lib" is required alongside "cdylib": the 0.1.0 IDL build compiles the +# crate as a host library. crate-type = ["cdylib", "lib"] [features] alloc = [] client = [] debug = [] +idl-build = ["quasar-lang/idl-build"] [dependencies] -# All quasar deps share one source-id (branch = "master") so trait-impls -# 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 -# (E0592 duplicate definitions for delegate / close_authority / mint_authority / -# freeze_authority). Upstream fix is on the zeropod branch (skip_accessor + bump), -# not yet merged to master. 623bb70 is the last working rev on master before zeropod -# 0.3 was bumped. Unpin (back to branch = "master") once upstream merges the fix. -quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } -# Metaplex metadata moved out of quasar-spl into its own crate (PR #196). -quasar-metadata = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +# Quasar 0.1.0-release, pinned by rev: crates.io still hosts 0.0.0 +# placeholders for quasar-lang/quasar-cli, so the release line installs from +# git. Keep this rev in lockstep with the CLI rev installed by +# .github/workflows/quasar.yml. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } +# Vendored: upstream removed the quasar-metadata crate before the 0.1.0 +# release with no replacement. See tokens/quasar-metadata/README.md. +quasar-metadata = { path = "../../quasar-metadata" } solana-instruction = { version = "3.2.0" } [dev-dependencies] -# Pinned: quasar-svm HEAD (c63afd2, "sbpf v3") moved to solana-program-runtime -# 4.1 / solana-address 2.6, which cannot co-resolve with the quasar-lang rev -# pinned above (needs solana-address <2.6). cb7565d is the last rev before the -# bump. Unpin together with the quasar-lang pin once upstream is consistent again. -quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm", rev = "cb7565d" } -spl-token-interface = { version = "2.0.0" } -solana-program-pack = { version = "3.1.0" } +quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/tokens/token-minter/quasar/Quasar.toml b/tokens/token-minter/quasar/Quasar.toml index 17745e4b..c5e706fe 100644 --- a/tokens/token-minter/quasar/Quasar.toml +++ b/tokens/token-minter/quasar/Quasar.toml @@ -1,22 +1,9 @@ [project] -name = "quasar_token_minter" - -[toolchain] -type = "solana" +name = "quasar-token-minter" [testing] -language = "rust" - -[testing.rust] -framework = "quasar-svm" - -[testing.rust.test] -program = "cargo" -args = [ - "test", - "tests::", -] +command = { program = "cargo", args = ["test"] } [clients] path = "target/client" -languages = ["rust"] +targets = ["rust"] diff --git a/tokens/token-minter/quasar/src/tests.rs b/tokens/token-minter/quasar/src/tests.rs index 41398aa7..899eb18c 100644 --- a/tokens/token-minter/quasar/src/tests.rs +++ b/tokens/token-minter/quasar/src/tests.rs @@ -1,50 +1,12 @@ -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, -}; +use {crate::cpi::MintTokenInstruction, quasar_test::prelude::*}; -fn setup() -> QuasarSvm { - let elf = std::fs::read("target/deploy/quasar_token_minter.so").unwrap(); - QuasarSvm::new() - .with_program(&crate::ID, &elf) - .with_token_program() -} - -fn signer(address: Pubkey) -> Account { - quasar_svm::token::create_keyed_system_account(&address, 5_000_000_000) -} - -fn mint(address: Pubkey, authority: Pubkey) -> Account { - quasar_svm::token::create_keyed_mint_account( - &address, - &Mint { - mint_authority: Some(authority).into(), - supply: 0, - decimals: 9, - is_initialized: true, - freeze_authority: Some(authority).into(), - }, - ) -} - -fn token_account(address: Pubkey, mint_address: Pubkey, owner: Pubkey, amount: u64) -> Account { - quasar_svm::token::create_keyed_token_account( - &address, - &TokenAccount { - mint: mint_address, - owner, - amount, - state: AccountState::Initialized, - ..TokenAccount::default() - }, - ) -} +// Deterministic addresses keep tests independent of discovery order. +const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); +const RECIPIENT: Pubkey = Pubkey::new_from_array([2; 32]); +const MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([4; 32]); -/// Decimals configured by the mint fixture above, matching the program's +/// Decimals configured by the mint fixture below, matching the program's /// `mint(decimals = 9)` constraint in `CreateTokenAccountConstraints`. const MINT_DECIMALS: u32 = 9; @@ -54,66 +16,28 @@ 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, in minor units] -fn build_mint_token_data(amount: u64) -> Vec { - let mut data = vec![1u8]; - data.extend_from_slice(&amount.to_le_bytes()); - data -} - -// Note: create_token test requires the Metaplex Token Metadata program -// deployed in the SVM. The quasar-svm harness does not currently ship it, +// Note: the create_token test requires the Metaplex Token Metadata program +// deployed in the SVM. The quasar-test harness does not currently ship it, // so we test mint_token (pure SPL Token CPI) only. -#[test] -fn test_mint_token() { - let mut svm = setup(); - - let authority = Pubkey::new_unique(); - let recipient = Pubkey::new_unique(); - let mint_address = Pubkey::new_unique(); - let token_addr = Pubkey::new_unique(); - let token_program = quasar_svm::SPL_TOKEN_PROGRAM_ID; - let system_program = quasar_svm::system_program::ID; +#[quasar_test] +fn mint_token_mints_the_exact_minor_unit_amount(test: &mut Test) { + test.add(Wallet::new().at(AUTHORITY)); + test.add(Wallet::new().at(RECIPIENT)); + test.add(Mint::new(AUTHORITY).at(MINT).decimals(9)); + test.add(TokenAccount::new(MINT, RECIPIENT).at(TOKEN_ACCOUNT)); let amount = to_minor_units(100); - let data = build_mint_token_data(amount); - - let instruction = Instruction { - program_id: crate::ID, - accounts: vec![ - solana_instruction::AccountMeta::new(authority.into(), true), - solana_instruction::AccountMeta::new_readonly(recipient.into(), false), - solana_instruction::AccountMeta::new(mint_address.into(), false), - solana_instruction::AccountMeta::new(token_addr.into(), false), - solana_instruction::AccountMeta::new_readonly(token_program.into(), false), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), - ], - data, - }; - - let result = svm.process_instruction( - &instruction, - &[ - signer(authority), - signer(recipient), - mint(mint_address, authority), - token_account(token_addr, mint_address, recipient, 0), - ], - ); - - assert!( - result.is_ok(), - "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); + test.send(MintTokenInstruction { + mint_authority: AUTHORITY, + recipient: RECIPIENT, + mint_account: MINT, + associated_token_account: TOKEN_ACCOUNT, + amount, + }) + .succeeds() + .has_tokens(TOKEN_ACCOUNT, amount); } From 89214d4be372484d39619b9d0c895b9a58911080 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:49:07 +0000 Subject: [PATCH 13/14] tokens/token-extensions: fix has_tokens on extended Token-2022 accounts CI caught immutable-owner and memo-transfer failing at runtime: Outcome::has_tokens decodes with the strict 165-byte base spl-token layout, which rejects Token-2022 accounts carrying extension TLV data (170 and 300 bytes here). Assert the zero balance from the amount field bytes (64..72, identical in both layouts) instead. The other has_tokens/has_supply sites in the repo operate on fixture-created base-layout accounts or classic SPL token accounts and are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- .../token-extensions/immutable-owner/quasar/src/tests.rs | 7 +++++-- tokens/token-extensions/memo-transfer/quasar/src/tests.rs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tokens/token-extensions/immutable-owner/quasar/src/tests.rs b/tokens/token-extensions/immutable-owner/quasar/src/tests.rs index b84812b7..22f0b006 100644 --- a/tokens/token-extensions/immutable-owner/quasar/src/tests.rs +++ b/tokens/token-extensions/immutable-owner/quasar/src/tests.rs @@ -24,12 +24,15 @@ fn initialize_creates_an_immutable_owner_token_account(test: &mut Test) { token_account: TOKEN_ACCOUNT, mint_account: MINT, }) - .succeeds() - .has_tokens(TOKEN_ACCOUNT, 0); + .succeeds(); let token_account = test.account(TOKEN_ACCOUNT).expect("token account exists"); assert_eq!(token_account.owner, SPL_TOKEN_2022_PROGRAM_ID); // 165 base + 1 account-type byte + 4 TLV header (ImmutableOwner is // zero-size) = 170 bytes. assert_eq!(token_account.data.len(), 170); + // Balance is zero. has_tokens can't be used here: it unpacks the strict + // 165-byte base layout, which rejects extended Token-2022 accounts. The + // amount field lives at bytes 64..72 in both layouts. + assert_eq!(&token_account.data[64..72], &0u64.to_le_bytes()); } diff --git a/tokens/token-extensions/memo-transfer/quasar/src/tests.rs b/tokens/token-extensions/memo-transfer/quasar/src/tests.rs index 2ffd7954..3b43e64d 100644 --- a/tokens/token-extensions/memo-transfer/quasar/src/tests.rs +++ b/tokens/token-extensions/memo-transfer/quasar/src/tests.rs @@ -24,11 +24,14 @@ fn initialize_creates_a_memo_transfer_token_account(test: &mut Test) { token_account: TOKEN_ACCOUNT, mint_account: MINT, }) - .succeeds() - .has_tokens(TOKEN_ACCOUNT, 0); + .succeeds(); let token_account = test.account(TOKEN_ACCOUNT).expect("token account exists"); assert_eq!(token_account.owner, SPL_TOKEN_2022_PROGRAM_ID); // Token account allocated with room for the MemoTransfer extension. assert_eq!(token_account.data.len(), 300); + // Balance is zero. has_tokens can't be used here: it unpacks the strict + // 165-byte base layout, which rejects extended Token-2022 accounts. The + // amount field lives at bytes 64..72 in both layouts. + assert_eq!(&token_account.data[64..72], &0u64.to_le_bytes()); } From 4f33d3ea8967c0652938d1e158fbda68c289d954 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 19:15:44 +0000 Subject: [PATCH 14/14] compression, betting-market: fix two 0.1.0 runtime breaks caught by CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quasar 0.1.0 clears ctx.data after decoding declared instruction args ("close the instruction argument zero-copy boundary"), so the pre-0.1.0 pattern of reading raw trailing bytes through ctx.data now sees an empty slice and every such handler failed with InvalidInstructionData. The cnft-vault withdraw handlers (caught by CI), plus the latent cnft-burn and cutils handlers whose placeholder tests hid the same break, now declare their payloads as typed instruction arguments (roots, hashes, nonce, index, proof lengths; cutils' URI as String<256, 2>) — only the variable-length proofs stay dynamic, as remaining accounts. The cnft-vault tests build instructions through the typed fields instead of appending raw bytes. betting-market's full-lifecycle test exhausted the 1.4M CU budget: the migration had replaced the inexpressible self-referential `address = Bet::seeds(&bet.outcome, ...)` constraint with the generated Bet::find_address helper, which is a const-context/client function whose software SHA-256 implementation (const_crypto) is ruinous on-chain. The claim_refund/claim_winnings/close_losing_bet handlers now verify the canonical bet PDA against the account's stored bump with quasar_lang::pda::verify_program_address — one sha256 syscall, same rejection behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012UhmBvDSQb4zPB5k4eueNB --- .../quasar/src/instructions/burn_cnft.rs | 26 +++++--- compression/cnft-burn/quasar/src/lib.rs | 25 +++++++- .../quasar/src/instructions/withdraw.rs | 44 ++++++++++--- .../quasar/src/instructions/withdraw_two.rs | 26 +++----- compression/cnft-vault/quasar/src/lib.rs | 61 +++++++++++++++++-- compression/cnft-vault/quasar/src/tests.rs | 50 ++++++++------- .../cutils/quasar/src/instructions/mint.rs | 14 ++--- .../cutils/quasar/src/instructions/verify.rs | 22 +++---- compression/cutils/quasar/src/lib.rs | 37 ++++++++--- .../quasar/src/instructions/claim_refund.rs | 16 ++++- .../quasar/src/instructions/claim_winnings.rs | 16 ++++- .../src/instructions/close_losing_bet.rs | 16 ++++- 12 files changed, 255 insertions(+), 98 deletions(-) diff --git a/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs b/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs index ae23e28f..cdc543fc 100644 --- a/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs +++ b/compression/cnft-burn/quasar/src/instructions/burn_cnft.rs @@ -30,18 +30,26 @@ pub struct BurnCnftAccountConstraints { pub system_program: Program, } -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 { - return Err(ProgramError::InvalidInstructionData); - } - - // Build instruction data: discriminator + args +#[allow(clippy::too_many_arguments)] +pub fn handle_burn_cnft( + accounts: &mut BurnCnftAccountConstraints, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, + remaining: RemainingAccounts<'_>, +) -> Result<(), ProgramError> { + // Build instruction data: discriminator + Bubblegum Burn wire layout + // root(32) + data_hash(32) + creator_hash(32) + nonce(8) + index(4). // 8 + 32 + 32 + 32 + 8 + 4 = 116 bytes let mut ix_data = [0u8; 116]; ix_data[0..8].copy_from_slice(&BURN_DISCRIMINATOR); - ix_data[8..116].copy_from_slice(&data[0..108]); + ix_data[8..40].copy_from_slice(&root); + ix_data[40..72].copy_from_slice(&data_hash); + ix_data[72..104].copy_from_slice(&creator_hash); + ix_data[104..112].copy_from_slice(&nonce.to_le_bytes()); + ix_data[112..116].copy_from_slice(&index.to_le_bytes()); // Collect remaining accounts (proof nodes) into a stack buffer. // diff --git a/compression/cnft-burn/quasar/src/lib.rs b/compression/cnft-burn/quasar/src/lib.rs index 980d7095..358e7943 100644 --- a/compression/cnft-burn/quasar/src/lib.rs +++ b/compression/cnft-burn/quasar/src/lib.rs @@ -30,10 +30,29 @@ declare_id!("C6qxH8n6mZxrrbtMtYWYSp8JR8vkQ55X1o4EBg7twnMv"); mod quasar_cnft_burn { use super::*; + /// The Bubblegum Burn args arrive as typed instruction arguments: 0.1.0 + /// clears `ctx.data` after decoding declared args (the instruction + /// argument zero-copy boundary), so the pre-0.1.0 raw-tail pattern reads + /// an empty slice. Only the variable-length proof stays dynamic, as + /// remaining accounts. #[instruction(discriminator = 0)] - pub fn burn_cnft(ctx: CtxWithRemaining) -> Result<(), ProgramError> { - let data = ctx.data; + pub fn burn_cnft( + ctx: CtxWithRemaining, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, + ) -> Result<(), ProgramError> { let remaining = ctx.remaining_accounts(); - instructions::handle_burn_cnft(&mut ctx.accounts, data, remaining) + instructions::handle_burn_cnft( + &mut ctx.accounts, + root, + data_hash, + creator_hash, + nonce, + index, + remaining, + ) } } diff --git a/compression/cnft-vault/quasar/src/instructions/withdraw.rs b/compression/cnft-vault/quasar/src/instructions/withdraw.rs index 2ff92a4c..b8528b27 100644 --- a/compression/cnft-vault/quasar/src/instructions/withdraw.rs +++ b/compression/cnft-vault/quasar/src/instructions/withdraw.rs @@ -13,7 +13,29 @@ const MAX_PROOF_NODES: usize = 24; 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; +pub(crate) const TRANSFER_ARGS_LEN: usize = 108; + +/// Bubblegum Transfer arguments, received as typed instruction args. +pub struct TransferArgs { + pub root: [u8; 32], + pub data_hash: [u8; 32], + pub creator_hash: [u8; 32], + pub nonce: u64, + pub index: u32, +} + +impl TransferArgs { + /// Serialize into the Bubblegum Transfer wire layout. + pub(crate) fn to_bytes(&self) -> [u8; TRANSFER_ARGS_LEN] { + let mut bytes = [0u8; TRANSFER_ARGS_LEN]; + bytes[0..32].copy_from_slice(&self.root); + bytes[32..64].copy_from_slice(&self.data_hash); + bytes[64..96].copy_from_slice(&self.creator_hash); + bytes[96..104].copy_from_slice(&self.nonce.to_le_bytes()); + bytes[104..108].copy_from_slice(&self.index.to_le_bytes()); + bytes + } +} /// Accounts for withdrawing a single compressed NFT from the vault. #[derive(Accounts)] @@ -56,17 +78,25 @@ fn build_transfer_data(args: &[u8]) -> [u8; 8 + TRANSFER_ARGS_LEN] { ix_data } +#[allow(clippy::too_many_arguments)] pub fn handle_withdraw_cnft( accounts: &mut WithdrawCnftAccountConstraints, - data: &[u8], + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, remaining: RemainingAccounts<'_>, vault_bump: u8, ) -> Result<(), ProgramError> { - if data.len() < TRANSFER_ARGS_LEN { - return Err(ProgramError::InvalidInstructionData); - } - - let ix_data = build_transfer_data(&data[0..TRANSFER_ARGS_LEN]); + let args = TransferArgs { + root, + data_hash, + creator_hash, + nonce, + index, + }; + let ix_data = build_transfer_data(&args.to_bytes()); // Collect proof nodes. // diff --git a/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs b/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs index ea9a3f85..586dd61c 100644 --- a/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs +++ b/compression/cnft-vault/quasar/src/instructions/withdraw_two.rs @@ -1,3 +1,4 @@ +use super::withdraw::{TransferArgs, TRANSFER_ARGS_LEN}; use crate::error::VaultError; use crate::state::Vault; use crate::*; @@ -12,13 +13,6 @@ const MAX_PROOF_NODES: usize = 24; /// 8 fixed accounts + proof nodes per CPI call. 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)] @@ -68,18 +62,18 @@ pub struct WithdrawTwoCnftsAccountConstraints { #[allow(clippy::too_many_lines)] pub fn handle_withdraw_two_cnfts( accounts: &mut WithdrawTwoCnftsAccountConstraints, - data: &[u8], + args1: TransferArgs, + proof_1_length: u8, + args2: TransferArgs, + proof_2_length: 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]; - let proof_2_length = data[TRANSFER_ARGS_LEN * 2 + 1] as usize; + let args1 = args1.to_bytes(); + let proof_1_length = proof_1_length as usize; + let args2 = args2.to_bytes(); + let proof_2_length = proof_2_length as usize; + let (args1, args2) = (&args1[..], &args2[..]); // PDA signer seeds let bump_bytes = [vault_bump]; diff --git a/compression/cnft-vault/quasar/src/lib.rs b/compression/cnft-vault/quasar/src/lib.rs index f676485f..98b253e7 100644 --- a/compression/cnft-vault/quasar/src/lib.rs +++ b/compression/cnft-vault/quasar/src/lib.rs @@ -34,27 +34,78 @@ mod quasar_cnft_vault { /// Withdraw a single compressed NFT from the vault PDA. Only the /// authority stored by initialize_vault may sign this. + /// + /// The Bubblegum Transfer args arrive as typed instruction arguments: + /// 0.1.0 clears `ctx.data` after decoding declared args (the instruction + /// argument zero-copy boundary), so the pre-0.1.0 raw-tail pattern reads + /// an empty slice. Only the variable-length proof stays dynamic, as + /// remaining accounts. #[instruction(discriminator = 0)] pub fn withdraw_cnft( ctx: CtxWithRemaining, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, ) -> Result<(), ProgramError> { - let data = ctx.data; let remaining = ctx.remaining_accounts(); let vault_bump = ctx.bumps.vault; - instructions::handle_withdraw_cnft(&mut ctx.accounts, data, remaining, vault_bump) + instructions::handle_withdraw_cnft( + &mut ctx.accounts, + root, + data_hash, + creator_hash, + nonce, + index, + remaining, + vault_bump, + ) } /// Withdraw two compressed NFTs from the vault PDA in a single /// transaction. Only the authority stored by initialize_vault may sign - /// this. + /// this. The two proofs share the remaining-accounts region; the proof + /// lengths say where to split it. #[instruction(discriminator = 1)] pub fn withdraw_two_cnfts( ctx: CtxWithRemaining, + root1: [u8; 32], + data_hash1: [u8; 32], + creator_hash1: [u8; 32], + nonce1: u64, + index1: u32, + proof_1_length: u8, + root2: [u8; 32], + data_hash2: [u8; 32], + creator_hash2: [u8; 32], + nonce2: u64, + index2: u32, + proof_2_length: u8, ) -> Result<(), ProgramError> { - let data = ctx.data; let remaining = ctx.remaining_accounts(); let vault_bump = ctx.bumps.vault; - instructions::handle_withdraw_two_cnfts(&mut ctx.accounts, data, remaining, vault_bump) + instructions::handle_withdraw_two_cnfts( + &mut ctx.accounts, + instructions::TransferArgs { + root: root1, + data_hash: data_hash1, + creator_hash: creator_hash1, + nonce: nonce1, + index: index1, + }, + proof_1_length, + instructions::TransferArgs { + root: root2, + data_hash: data_hash2, + creator_hash: creator_hash2, + nonce: nonce2, + index: index2, + }, + proof_2_length, + remaining, + vault_bump, + ) } /// Create the vault PDA and store the signer as its withdraw authority. diff --git a/compression/cnft-vault/quasar/src/tests.rs b/compression/cnft-vault/quasar/src/tests.rs index 70ff4ffb..50778548 100644 --- a/compression/cnft-vault/quasar/src/tests.rs +++ b/compression/cnft-vault/quasar/src/tests.rs @@ -320,18 +320,6 @@ fn create_tree_with_vault_cnft( // ---- 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 -} - /// Proof-node addresses enter the transaction as readonly metas; the runtime /// materializes the missing accounts as empty system accounts. fn proof_metas(nodes: &[[u8; 32]]) -> Vec { @@ -348,7 +336,10 @@ fn build_withdraw_cnft_instruction( ) -> Instruction { // The vault PDA and system program are canonical derivations, so the // generated instruction omits them. - let mut instruction: Instruction = WithdrawCnftInstruction { + // Leaf 0 in a fresh tree has nonce 0 and index 0. The Transfer args are + // typed instruction arguments in 0.1.0 (`ctx.data` no longer carries a + // raw tail); only the proof stays dynamic, as remaining accounts. + WithdrawCnftInstruction { authority: signer, tree_authority: tree.tree_config, new_leaf_owner: recipient, @@ -356,13 +347,14 @@ fn build_withdraw_cnft_instruction( log_wrapper: NOOP_ID, compression_program: COMPRESSION_ID, bubblegum_program: BUBBLEGUM_ID, + root: tree.root, + data_hash: tree.data_hash, + creator_hash: tree.creator_hash, + nonce: 0, + index: 0, 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 + .into() } fn build_withdraw_two_cnfts_instruction( @@ -375,7 +367,7 @@ fn build_withdraw_two_cnfts_instruction( ) -> Instruction { let mut remaining_accounts = proof_metas(&tree1.proof); remaining_accounts.extend(proof_metas(&tree2.proof)); - let mut instruction: Instruction = WithdrawTwoCnftsInstruction { + WithdrawTwoCnftsInstruction { authority: signer, tree_authority1: tree1.tree_config, new_leaf_owner1: recipient, @@ -386,15 +378,21 @@ fn build_withdraw_two_cnfts_instruction( log_wrapper: NOOP_ID, compression_program: COMPRESSION_ID, bubblegum_program: BUBBLEGUM_ID, + root1: tree1.root, + data_hash1: tree1.data_hash, + creator_hash1: tree1.creator_hash, + nonce1: 0, + index1: 0, + proof_1_length, + root2: tree2.root, + data_hash2: tree2.data_hash, + creator_hash2: tree2.creator_hash, + nonce2: 0, + index2: 0, + proof_2_length, 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 + .into() } // ---- Tests ------------------------------------------------------------------ diff --git a/compression/cutils/quasar/src/instructions/mint.rs b/compression/cutils/quasar/src/instructions/mint.rs index 3f9aff15..439322fa 100644 --- a/compression/cutils/quasar/src/instructions/mint.rs +++ b/compression/cutils/quasar/src/instructions/mint.rs @@ -53,16 +53,10 @@ pub struct MintAccountConstraints { pub system_program: Program, } -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); - } - let uri_len = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize; - if data.len() < 4 + uri_len || uri_len > MAX_URI_LEN { - return Err(ProgramError::InvalidInstructionData); - } - let uri = &data[4..4 + uri_len]; +pub fn handle_mint(accounts: &mut MintAccountConstraints, uri: &str) -> Result<(), ProgramError> { + // The bounded String<256, 2> argument already enforces MAX_URI_LEN and + // UTF-8 at the decode boundary; the CPI encoder consumes the raw bytes. + let uri = uri.as_bytes(); // Build CPI instruction data let mut ix_data = [0u8; MAX_IX_DATA]; diff --git a/compression/cutils/quasar/src/instructions/verify.rs b/compression/cutils/quasar/src/instructions/verify.rs index a970ad72..ecc3ff19 100644 --- a/compression/cutils/quasar/src/instructions/verify.rs +++ b/compression/cutils/quasar/src/instructions/verify.rs @@ -24,18 +24,16 @@ pub struct VerifyAccountConstraints { pub compression_program: UncheckedAccount, } -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 { - return Err(ProgramError::InvalidInstructionData); - } - - let root: [u8; 32] = data[0..32].try_into().unwrap(); - let data_hash: [u8; 32] = data[32..64].try_into().unwrap(); - let creator_hash: [u8; 32] = data[64..96].try_into().unwrap(); - let nonce = u64::from_le_bytes(data[96..104].try_into().unwrap()); - let index = u32::from_le_bytes(data[104..108].try_into().unwrap()); +#[allow(clippy::too_many_arguments)] +pub fn handle_verify( + accounts: &mut VerifyAccountConstraints, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, + remaining: RemainingAccounts<'_>, +) -> Result<(), ProgramError> { // Compute asset ID and leaf hash let asset_id = get_asset_id(accounts.merkle_tree.address(), nonce); diff --git a/compression/cutils/quasar/src/lib.rs b/compression/cutils/quasar/src/lib.rs index d06eba8c..e1e263cd 100644 --- a/compression/cutils/quasar/src/lib.rs +++ b/compression/cutils/quasar/src/lib.rs @@ -29,17 +29,40 @@ mod quasar_cutils { use super::*; /// Mint a compressed NFT to a collection via MintToCollectionV1. + /// + /// The URI arrives as a typed instruction argument: 0.1.0 clears + /// `ctx.data` after decoding declared args (the instruction argument + /// zero-copy boundary), so the pre-0.1.0 raw-tail pattern reads an empty + /// slice. `String<256, 2>` bounds it to MAX_URI_LEN with a u16 prefix. #[instruction(discriminator = 0)] - pub fn mint(ctx: Ctx) -> Result<(), ProgramError> { - let data = ctx.data; - instructions::handle_mint(&mut ctx.accounts, data) + pub fn mint( + ctx: Ctx, + uri: String<256, 2>, + ) -> Result<(), ProgramError> { + instructions::handle_mint(&mut ctx.accounts, uri) } - /// Verify a compressed NFT leaf exists in the merkle tree. + /// Verify a compressed NFT leaf exists in the merkle tree. The leaf args + /// are typed instruction arguments; the proof stays dynamic, as + /// remaining accounts. #[instruction(discriminator = 1)] - pub fn verify(ctx: CtxWithRemaining) -> Result<(), ProgramError> { - let data = ctx.data; + pub fn verify( + ctx: CtxWithRemaining, + root: [u8; 32], + data_hash: [u8; 32], + creator_hash: [u8; 32], + nonce: u64, + index: u32, + ) -> Result<(), ProgramError> { let remaining = ctx.remaining_accounts(); - instructions::handle_verify(&mut ctx.accounts, data, remaining) + instructions::handle_verify( + &mut ctx.accounts, + root, + data_hash, + creator_hash, + nonce, + index, + remaining, + ) } } diff --git a/finance/betting-market/quasar/src/instructions/claim_refund.rs b/finance/betting-market/quasar/src/instructions/claim_refund.rs index 3c389f51..46a7bf9d 100644 --- a/finance/betting-market/quasar/src/instructions/claim_refund.rs +++ b/finance/betting-market/quasar/src/instructions/claim_refund.rs @@ -25,7 +25,6 @@ pub struct ClaimRefundAccountConstraints { close(dest = bettor), has_one(bettor), has_one(event), - address = Bet::find_address(bet.outcome, *bettor.address(), &crate::ID), )] pub bet: Account, @@ -45,6 +44,21 @@ pub struct ClaimRefundAccountConstraints { pub fn handle_claim_refund( accounts: &mut ClaimRefundAccountConstraints, ) -> Result<(), ProgramError> { + // Canonical-PDA check for the bet account. The pre-0.1.0 constraint + // `address = Bet::seeds(&bet.outcome, ...)` is inexpressible in 0.1.0 + // (an Address-typed stored-data seed cannot both feed client codegen and + // typecheck on-chain), and the generated `Bet::find_address` helper is a + // const-context/client function whose software SHA-256 exhausts the CU + // budget on-chain. Verifying against the stored bump costs one sha256 + // syscall and rejects non-canonical bet accounts just the same. + quasar_lang::pda::verify_program_address( + &Bet::seeds(&accounts.bet.outcome, accounts.bettor.address()) + .with_bump(accounts.bet.bump) + .as_slices(), + &crate::ID, + accounts.bet.address(), + )?; + require!( accounts.event.status == EventStatus::Cancelled as u8, BettingError::EventNotCancelled diff --git a/finance/betting-market/quasar/src/instructions/claim_winnings.rs b/finance/betting-market/quasar/src/instructions/claim_winnings.rs index 62ac3837..35a0db0f 100644 --- a/finance/betting-market/quasar/src/instructions/claim_winnings.rs +++ b/finance/betting-market/quasar/src/instructions/claim_winnings.rs @@ -25,7 +25,6 @@ pub struct ClaimWinningsAccountConstraints { close(dest = bettor), has_one(bettor), has_one(event), - address = Bet::find_address(bet.outcome, *bettor.address(), &crate::ID), )] pub bet: Account, @@ -45,6 +44,21 @@ pub struct ClaimWinningsAccountConstraints { pub fn handle_claim_winnings( accounts: &mut ClaimWinningsAccountConstraints, ) -> Result<(), ProgramError> { + // Canonical-PDA check for the bet account. The pre-0.1.0 constraint + // `address = Bet::seeds(&bet.outcome, ...)` is inexpressible in 0.1.0 + // (an Address-typed stored-data seed cannot both feed client codegen and + // typecheck on-chain), and the generated `Bet::find_address` helper is a + // const-context/client function whose software SHA-256 exhausts the CU + // budget on-chain. Verifying against the stored bump costs one sha256 + // syscall and rejects non-canonical bet accounts just the same. + quasar_lang::pda::verify_program_address( + &Bet::seeds(&accounts.bet.outcome, accounts.bettor.address()) + .with_bump(accounts.bet.bump) + .as_slices(), + &crate::ID, + accounts.bet.address(), + )?; + require!( accounts.event.status == EventStatus::Settled as u8, BettingError::EventNotSettled diff --git a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs index 49d4ff68..861838a0 100644 --- a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs @@ -20,7 +20,6 @@ pub struct CloseLosingBetAccountConstraints { close(dest = bettor), has_one(bettor), has_one(event), - address = Bet::find_address(bet.outcome, *bettor.address(), &crate::ID), )] pub bet: Account, @@ -32,6 +31,21 @@ pub struct CloseLosingBetAccountConstraints { pub fn handle_close_losing_bet( accounts: &mut CloseLosingBetAccountConstraints, ) -> Result<(), ProgramError> { + // Canonical-PDA check for the bet account. The pre-0.1.0 constraint + // `address = Bet::seeds(&bet.outcome, ...)` is inexpressible in 0.1.0 + // (an Address-typed stored-data seed cannot both feed client codegen and + // typecheck on-chain), and the generated `Bet::find_address` helper is a + // const-context/client function whose software SHA-256 exhausts the CU + // budget on-chain. Verifying against the stored bump costs one sha256 + // syscall and rejects non-canonical bet accounts just the same. + quasar_lang::pda::verify_program_address( + &Bet::seeds(&accounts.bet.outcome, accounts.bettor.address()) + .with_bump(accounts.bet.bump) + .as_slices(), + &crate::ID, + accounts.bet.address(), + )?; + require!( accounts.event.status == EventStatus::Settled as u8, BettingError::EventNotSettled