From 6a7061cc240c63d5a28fb318705d066d830cc156 Mon Sep 17 00:00:00 2001 From: tilo-14 Date: Wed, 18 Feb 2026 00:46:22 +0000 Subject: [PATCH] Add example programs: escrow, fundraiser, token-swap, light-token-minter Peer-to-peer escrow, crowdfunding, AMM swap, and mint helper programs demonstrating rent-free Light Token vaults with full test coverage across SPL, Token-2022, and Light token standards. --- .github/actions/setup/action.yml | 1 - .github/workflows/rust-tests.yml | 5 + programs/anchor/Anchor.toml | 26 +- programs/anchor/Cargo.lock | 248 ++++- programs/anchor/Cargo.toml | 22 +- programs/anchor/escrow/Cargo.toml | 54 + programs/anchor/escrow/src/constants.rs | 4 + .../escrow/src/instructions/make_offer.rs | 149 +++ .../anchor/escrow/src/instructions/mod.rs | 5 + .../escrow/src/instructions/take_offer.rs | 177 ++++ programs/anchor/escrow/src/lib.rs | 39 + programs/anchor/escrow/src/state/mod.rs | 3 + programs/anchor/escrow/src/state/offer.rs | 15 + programs/anchor/escrow/tests/common/mod.rs | 607 +++++++++++ programs/anchor/escrow/tests/escrow.rs | 377 +++++++ programs/anchor/fundraiser/Cargo.toml | 52 + programs/anchor/fundraiser/Xargo.toml | 2 + programs/anchor/fundraiser/src/constants.rs | 7 + programs/anchor/fundraiser/src/error.rs | 23 + .../fundraiser/src/instructions/checker.rs | 124 +++ .../fundraiser/src/instructions/contribute.rs | 144 +++ .../fundraiser/src/instructions/initialize.rs | 95 ++ .../anchor/fundraiser/src/instructions/mod.rs | 9 + .../fundraiser/src/instructions/refund.rs | 142 +++ programs/anchor/fundraiser/src/lib.rs | 45 + .../fundraiser/src/state/contributor.rs | 7 + .../anchor/fundraiser/src/state/fundraiser.rs | 14 + programs/anchor/fundraiser/src/state/mod.rs | 5 + .../anchor/fundraiser/tests/common/mod.rs | 394 +++++++ .../anchor/fundraiser/tests/fundraiser.rs | 515 ++++++++++ programs/anchor/light-token-minter/Cargo.toml | 51 + .../src/instructions/create.rs | 79 ++ .../src/instructions/mint.rs | 69 ++ .../src/instructions/mod.rs | 5 + programs/anchor/light-token-minter/src/lib.rs | 37 + .../light-token-minter/tests/minter_test.rs | 330 ++++++ programs/anchor/shared-test-utils/Cargo.toml | 45 + programs/anchor/shared-test-utils/src/lib.rs | 828 +++++++++++++++ programs/anchor/token-swap/Cargo.toml | 55 + programs/anchor/token-swap/SPL_COMPARISON.md | 968 ++++++++++++++++++ programs/anchor/token-swap/Xargo.toml | 2 + programs/anchor/token-swap/src/constants.rs | 19 + programs/anchor/token-swap/src/errors.rs | 25 + .../token-swap/src/instructions/create_amm.rs | 36 + .../src/instructions/create_pool.rs | 132 +++ .../src/instructions/create_pool_light_lp.rs | 174 ++++ .../src/instructions/deposit_liquidity.rs | 297 ++++++ .../anchor/token-swap/src/instructions/mod.rs | 13 + .../swap_exact_tokens_for_tokens.rs | 339 ++++++ .../src/instructions/withdraw_liquidity.rs | 264 +++++ programs/anchor/token-swap/src/lib.rs | 68 ++ programs/anchor/token-swap/src/state.rs | 28 + .../anchor/token-swap/tests/common/mod.rs | 673 ++++++++++++ programs/anchor/token-swap/tests/swap.rs | 635 ++++++++++++ 54 files changed, 8426 insertions(+), 56 deletions(-) create mode 100644 programs/anchor/escrow/Cargo.toml create mode 100644 programs/anchor/escrow/src/constants.rs create mode 100644 programs/anchor/escrow/src/instructions/make_offer.rs create mode 100644 programs/anchor/escrow/src/instructions/mod.rs create mode 100644 programs/anchor/escrow/src/instructions/take_offer.rs create mode 100644 programs/anchor/escrow/src/lib.rs create mode 100644 programs/anchor/escrow/src/state/mod.rs create mode 100644 programs/anchor/escrow/src/state/offer.rs create mode 100644 programs/anchor/escrow/tests/common/mod.rs create mode 100644 programs/anchor/escrow/tests/escrow.rs create mode 100644 programs/anchor/fundraiser/Cargo.toml create mode 100644 programs/anchor/fundraiser/Xargo.toml create mode 100644 programs/anchor/fundraiser/src/constants.rs create mode 100644 programs/anchor/fundraiser/src/error.rs create mode 100644 programs/anchor/fundraiser/src/instructions/checker.rs create mode 100644 programs/anchor/fundraiser/src/instructions/contribute.rs create mode 100644 programs/anchor/fundraiser/src/instructions/initialize.rs create mode 100644 programs/anchor/fundraiser/src/instructions/mod.rs create mode 100644 programs/anchor/fundraiser/src/instructions/refund.rs create mode 100644 programs/anchor/fundraiser/src/lib.rs create mode 100644 programs/anchor/fundraiser/src/state/contributor.rs create mode 100644 programs/anchor/fundraiser/src/state/fundraiser.rs create mode 100644 programs/anchor/fundraiser/src/state/mod.rs create mode 100644 programs/anchor/fundraiser/tests/common/mod.rs create mode 100644 programs/anchor/fundraiser/tests/fundraiser.rs create mode 100644 programs/anchor/light-token-minter/Cargo.toml create mode 100644 programs/anchor/light-token-minter/src/instructions/create.rs create mode 100644 programs/anchor/light-token-minter/src/instructions/mint.rs create mode 100644 programs/anchor/light-token-minter/src/instructions/mod.rs create mode 100644 programs/anchor/light-token-minter/src/lib.rs create mode 100644 programs/anchor/light-token-minter/tests/minter_test.rs create mode 100644 programs/anchor/shared-test-utils/Cargo.toml create mode 100644 programs/anchor/shared-test-utils/src/lib.rs create mode 100644 programs/anchor/token-swap/Cargo.toml create mode 100644 programs/anchor/token-swap/SPL_COMPARISON.md create mode 100644 programs/anchor/token-swap/Xargo.toml create mode 100644 programs/anchor/token-swap/src/constants.rs create mode 100644 programs/anchor/token-swap/src/errors.rs create mode 100644 programs/anchor/token-swap/src/instructions/create_amm.rs create mode 100644 programs/anchor/token-swap/src/instructions/create_pool.rs create mode 100644 programs/anchor/token-swap/src/instructions/create_pool_light_lp.rs create mode 100644 programs/anchor/token-swap/src/instructions/deposit_liquidity.rs create mode 100644 programs/anchor/token-swap/src/instructions/mod.rs create mode 100644 programs/anchor/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs create mode 100644 programs/anchor/token-swap/src/instructions/withdraw_liquidity.rs create mode 100644 programs/anchor/token-swap/src/lib.rs create mode 100644 programs/anchor/token-swap/src/state.rs create mode 100644 programs/anchor/token-swap/tests/common/mod.rs create mode 100644 programs/anchor/token-swap/tests/swap.rs diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index a00c017..be63417 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -29,7 +29,6 @@ runs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ inputs.rust-toolchain }} - rustflags: "" cache-workspaces: ${{ inputs.example || '.' }} - name: Setup Node.js diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index bc03500..d7f2e8f 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -44,6 +44,11 @@ jobs: - light-token-macro-create-mint - light-token-macro-create-token-account - create-and-transfer + # example programs + - escrow + - fundraiser + - light-token-minter + - swap_example steps: - uses: actions/checkout@v4 diff --git a/programs/anchor/Anchor.toml b/programs/anchor/Anchor.toml index d19168f..651ef42 100644 --- a/programs/anchor/Anchor.toml +++ b/programs/anchor/Anchor.toml @@ -6,28 +6,18 @@ skip-lint = false [workspace] members = [ - "basic-macros/counter", - "basic-macros/create-mint", - "basic-macros/create-associated-token-account", - "basic-macros/create-token-account", - "create-and-transfer", - "basic-instructions/approve", - "basic-instructions/burn", - "basic-instructions/close", - "basic-instructions/create-associated-token-account", - "basic-instructions/create-mint", - "basic-instructions/create-token-account", - "basic-instructions/freeze", - "basic-instructions/mint-to", - "basic-instructions/revoke", - "basic-instructions/thaw", - "basic-instructions/transfer-checked", - "basic-instructions/transfer-interface", + "escrow", + "fundraiser", + "light-token-minter", + "token-swap", ] [programs.localnet] +escrow = "FKJs6rp6TXJtxzLiPtdYhqa9ExRuBXG2zwh4fda6WATN" +fundraiser = "Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC" +light_token_minter = "3EPJBoxM8Evtv3Wk7R2mSWsrSzUD7WSKAaYugLgpCitV" +swap_example = "AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn" counter = "PDAm7XVHEkBvzBYDh8qF3z8NxnYQzPjGQJKcHVmMZpT" -create_and_transfer = "672fL1Nm191MbPoygNM9DRiG2psBELn97XUpGbU3jW7E" [registry] url = "https://api.apr.dev" diff --git a/programs/anchor/Cargo.lock b/programs/anchor/Cargo.lock index 2c86c77..b227216 100644 --- a/programs/anchor/Cargo.lock +++ b/programs/anchor/Cargo.lock @@ -322,6 +322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c08cb5d762c0694f74bd02c9a5b04ea53cefc496e2c27b3234acffca5cd076b" dependencies = [ "anchor-lang", + "mpl-token-metadata", "spl-associated-token-account 6.0.0", "spl-pod", "spl-token 7.0.0", @@ -701,6 +702,12 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + [[package]] name = "base64" version = "0.12.3" @@ -1567,6 +1574,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "escrow" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "light-account", + "light-anchor-spl", + "light-client", + "light-compressed-account", + "light-hasher", + "light-program-test", + "light-sdk", + "light-token", + "light-token-minter", + "shared-test-utils", + "solana-account-info", + "solana-instruction", + "solana-keypair", + "solana-msg", + "solana-program", + "solana-program-error", + "solana-pubkey", + "solana-sdk", + "solana-signer", + "spl-pod", + "spl-token-2022 7.0.0", + "tokio", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1615,6 +1652,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5" +[[package]] +name = "fixed" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc715d38bea7b5bf487fcd79bcf8c209f0b58014f3018a7a19c2b855f472048" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1667,6 +1716,34 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fundraiser" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "light-account", + "light-anchor-spl", + "light-client", + "light-hasher", + "light-program-test", + "light-sdk", + "light-token", + "shared-test-utils", + "solana-account-info", + "solana-instruction", + "solana-keypair", + "solana-msg", + "solana-program", + "solana-program-error", + "solana-pubkey", + "solana-sdk", + "solana-signer", + "spl-pod", + "spl-token-2022 7.0.0", + "tokio", +] + [[package]] name = "funty" version = "2.0.0" @@ -1887,6 +1964,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -3463,6 +3551,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "light-token-minter" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "light-account", + "light-anchor-spl", + "light-client", + "light-hasher", + "light-program-test", + "light-sdk", + "light-token", + "solana-account-info", + "solana-instruction", + "solana-keypair", + "solana-msg", + "solana-program", + "solana-program-error", + "solana-pubkey", + "solana-sdk", + "solana-signer", + "spl-pod", + "spl-token 7.0.0", + "spl-token-2022 7.0.0", + "tokio", +] + [[package]] name = "light-token-types" version = "0.22.0" @@ -3672,6 +3787,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mpl-token-metadata" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046f0779684ec348e2759661361c8798d79021707b1392cb49f3b5eb911340ff" +dependencies = [ + "borsh 0.10.4", + "num-derive 0.3.3", + "num-traits", + "solana-program", + "thiserror 1.0.69", +] + [[package]] name = "native-tls" version = "0.2.16" @@ -3735,6 +3863,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -4926,6 +5065,29 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared-test-utils" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "light-account", + "light-client", + "light-hasher", + "light-program-test", + "light-sdk", + "light-token", + "light-token-minter", + "solana-instruction", + "solana-keypair", + "solana-pubkey", + "solana-sdk", + "solana-signer", + "spl-pod", + "spl-token-2022 7.0.0", + "tokio", +] + [[package]] name = "shlex" version = "1.3.0" @@ -6088,7 +6250,7 @@ dependencies = [ "log", "memoffset", "num-bigint 0.4.6", - "num-derive", + "num-derive 0.4.2", "num-traits", "rand 0.8.5", "serde", @@ -7104,7 +7266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" dependencies = [ "bincode", - "num-derive", + "num-derive 0.4.2", "num-traits", "serde", "serde_derive", @@ -7130,7 +7292,7 @@ dependencies = [ "agave-feature-set", "bincode", "log", - "num-derive", + "num-derive 0.4.2", "num-traits", "serde", "serde_derive", @@ -7163,7 +7325,7 @@ checksum = "70cea14481d8efede6b115a2581f27bc7c6fdfba0752c20398456c3ac1245fc4" dependencies = [ "agave-feature-set", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-instruction", "solana-log-collector", @@ -7187,7 +7349,7 @@ dependencies = [ "itertools 0.12.1", "js-sys", "merlin", - "num-derive", + "num-derive 0.4.2", "num-traits", "rand 0.8.5", "serde", @@ -7216,7 +7378,7 @@ checksum = "579752ad6ea2a671995f13c763bf28288c3c895cb857a518cc4ebab93c9a8dde" dependencies = [ "agave-feature-set", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-instruction", "solana-log-collector", @@ -7239,7 +7401,7 @@ dependencies = [ "curve25519-dalek 4.1.3", "itertools 0.12.1", "merlin", - "num-derive", + "num-derive 0.4.2", "num-traits", "rand 0.8.5", "serde", @@ -7267,7 +7429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76fee7d65013667032d499adc3c895e286197a35a0d3a4643c80e7fd3e9969e3" dependencies = [ "borsh 1.6.0", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-program", "spl-associated-token-account-client", @@ -7283,7 +7445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae179d4a26b3c7a20c839898e6aed84cb4477adf108a366c95532f058aea041b" dependencies = [ "borsh 1.6.0", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-program", "spl-associated-token-account-client", @@ -7407,7 +7569,7 @@ dependencies = [ "borsh 1.6.0", "bytemuck", "bytemuck_derive", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-decode-error", "solana-msg", @@ -7424,7 +7586,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d39b5186f42b2b50168029d81e58e800b690877ef0b30580d107659250da1d1" dependencies = [ - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-program", "spl-program-error-derive 0.4.1", @@ -7437,7 +7599,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdebc8b42553070b75aa5106f071fef2eb798c64a7ec63375da4b1f058688c6" dependencies = [ - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-decode-error", "solana-msg", @@ -7477,7 +7639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd99ff1e9ed2ab86e3fd582850d47a739fec1be9f4661cba1782d3a0f26805f3" dependencies = [ "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-decode-error", @@ -7499,7 +7661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1408e961215688715d5a1063cbdcf982de225c45f99c82b4f7d7e1dd22b998d7" dependencies = [ "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-decode-error", @@ -7522,7 +7684,7 @@ checksum = "ed320a6c934128d4f7e54fe00e16b8aeaecf215799d060ae14f93378da6dc834" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -7537,7 +7699,7 @@ checksum = "053067c6a82c705004f91dae058b11b4780407e9ccd6799dc9e7d0fab5f242da" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-account-info", @@ -7565,7 +7727,7 @@ checksum = "5b27f7405010ef816587c944536b0eafbcc35206ab6ba0f2ca79f1d28e488f4f" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -7593,7 +7755,7 @@ checksum = "9048b26b0df0290f929ff91317c83db28b3ef99af2b3493dd35baa146774924c" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -7621,7 +7783,7 @@ checksum = "31f0dfbb079eebaee55e793e92ca5f433744f4b71ee04880bfd6beefba5973e5" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-account-info", @@ -7665,7 +7827,7 @@ checksum = "62d7ae2ee6b856f8ddcbdc3b3a9f4d2141582bbe150f93e5298ee97e0251fa04" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-account-info", @@ -7805,7 +7967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d595667ed72dbfed8c251708f406d7c2814a3fa6879893b323d56a10bedfc799" dependencies = [ "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-decode-error", "solana-instruction", @@ -7824,7 +7986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5597b4cd76f85ce7cd206045b7dc22da8c25516573d42d267c8d1fd128db5129" dependencies = [ "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-decode-error", "solana-instruction", @@ -7843,7 +8005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfb9c89dbc877abd735f05547dcf9e6e12c00c11d6d74d8817506cab4c99fdbb" dependencies = [ "borsh 1.6.0", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-borsh", "solana-decode-error", @@ -7864,7 +8026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "304d6e06f0de0c13a621464b1fd5d4b1bebf60d15ca71a44d3839958e0da16ee" dependencies = [ "borsh 1.6.0", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-borsh", "solana-decode-error", @@ -7886,7 +8048,7 @@ checksum = "4aa7503d52107c33c88e845e1351565050362c2314036ddf19a36cd25137c043" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-cpi", @@ -7911,7 +8073,7 @@ checksum = "a7e905b849b6aba63bde8c4badac944ebb6c8e6e14817029cbe1bc16829133bd" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-cpi", @@ -7935,7 +8097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba70ef09b13af616a4c987797870122863cba03acc4284f226a4473b043923f9" dependencies = [ "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-decode-error", @@ -7953,7 +8115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d417eb548214fa822d93f84444024b4e57c13ed6719d4dcc68eec24fb481e9f5" dependencies = [ "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-decode-error", @@ -7988,6 +8150,36 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swap_example" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "az", + "fixed", + "light-account", + "light-anchor-spl", + "light-client", + "light-hasher", + "light-program-test", + "light-sdk", + "light-token", + "shared-test-utils", + "solana-account-info", + "solana-instruction", + "solana-keypair", + "solana-msg", + "solana-program", + "solana-program-error", + "solana-pubkey", + "solana-sdk", + "solana-signer", + "spl-pod", + "spl-token-2022 7.0.0", + "tokio", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/programs/anchor/Cargo.toml b/programs/anchor/Cargo.toml index e9c075b..fe447f3 100644 --- a/programs/anchor/Cargo.toml +++ b/programs/anchor/Cargo.toml @@ -1,14 +1,11 @@ [workspace] resolver = "2" members = [ - # Basic macro examples - "basic-macros/counter", - "basic-macros/create-mint", - "basic-macros/create-associated-token-account", - "basic-macros/create-token-account", - "create-and-transfer", - - # Basic instruction examples + "escrow", + "fundraiser", + "light-token-minter", + "token-swap", + "shared-test-utils", "basic-instructions/approve", "basic-instructions/burn", "basic-instructions/close", @@ -18,10 +15,15 @@ members = [ "basic-instructions/freeze", "basic-instructions/mint-to", "basic-instructions/revoke", + "basic-instructions/test-utils", "basic-instructions/thaw", "basic-instructions/transfer-checked", "basic-instructions/transfer-interface", - "basic-instructions/test-utils", + "basic-macros/counter", + "basic-macros/create-associated-token-account", + "basic-macros/create-mint", + "basic-macros/create-token-account", + "create-and-transfer", ] [profile.release] @@ -57,6 +59,7 @@ solana-signer = "2" # SPL spl-token = "7" spl-token-2022 = "7" +spl-pod = "0.5" # Light Protocol light-sdk = { version = "0.22.0", features = ["anchor", "v2", "cpi-context"] } @@ -83,4 +86,5 @@ borsh = "0.10.4" time = "=0.3.41" # Internal +shared-test-utils = { path = "shared-test-utils" } test-utils = { path = "basic-instructions/test-utils" } diff --git a/programs/anchor/escrow/Cargo.toml b/programs/anchor/escrow/Cargo.toml new file mode 100644 index 0000000..dfecc95 --- /dev/null +++ b/programs/anchor/escrow/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "escrow" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "escrow" + +[features] +no-entrypoint = [] +no-log-ix-name = [] +no-idl = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build", "light-anchor-spl/idl-build"] +test-sbf = [] + +[dependencies] +anchor-lang = { version = "=0.31.1" } + +light-sdk = { workspace = true, features = ["anchor", "cpi-context", "v2"] } +light-token = { workspace = true, features = ["anchor"] } +light-account = { workspace = true } +light-compressed-account = { workspace = true } +# Required by LightAccount derive macro +light-hasher = { workspace = true, features = ["solana"] } +light-anchor-spl = { workspace = true } +solana-program = { workspace = true } +solana-pubkey = { workspace = true } +solana-account-info = { workspace = true } +solana-program-error = { workspace = true } +solana-msg = { workspace = true } + +[dev-dependencies] +light-program-test = { workspace = true } +light-client = { workspace = true, features = ["v2", "anchor"] } +anchor-spl = { workspace = true } +tokio = { workspace = true, features = ["full"] } +solana-keypair = { workspace = true } +solana-signer = { workspace = true } +solana-instruction = { workspace = true } +solana-sdk = { workspace = true } +spl-token-2022 = { workspace = true } +spl-pod = { workspace = true } +shared-test-utils = { workspace = true } +light-token-minter = { path = "../light-token-minter", features = ["no-entrypoint"] } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/programs/anchor/escrow/src/constants.rs b/programs/anchor/escrow/src/constants.rs new file mode 100644 index 0000000..41af5c3 --- /dev/null +++ b/programs/anchor/escrow/src/constants.rs @@ -0,0 +1,4 @@ +pub const OFFER_SEED: &[u8] = b"offer"; +pub const VAULT_SEED: &[u8] = b"vault"; +pub const AUTH_SEED: &[u8] = b"authority"; +pub const ANCHOR_DISCRIMINATOR: usize = 8; diff --git a/programs/anchor/escrow/src/instructions/make_offer.rs b/programs/anchor/escrow/src/instructions/make_offer.rs new file mode 100644 index 0000000..9638a73 --- /dev/null +++ b/programs/anchor/escrow/src/instructions/make_offer.rs @@ -0,0 +1,149 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_account::CreateAccountsProof; +use light_account::LightAccounts; +use light_token::instruction::{TransferInterfaceCpi, LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR}; + +use crate::constants::{ANCHOR_DISCRIMINATOR, AUTH_SEED, OFFER_SEED, VAULT_SEED}; +use crate::state::Offer; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct MakeOfferParams { + pub create_accounts_proof: CreateAccountsProof, + pub id: u64, + pub token_a_offered_amount: u64, + pub token_b_wanted_amount: u64, + pub vault_bump: u8, + pub spl_interface_bump_a: u8, +} + +#[derive(Accounts, LightAccounts)] +#[instruction(params: MakeOfferParams)] +pub struct MakeOffer<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Authority PDA for the vault + #[account( + seeds = [AUTH_SEED], + bump, + )] + pub authority: UncheckedAccount<'info>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + /// CHECK: Per-program rent sponsor + #[account(mut)] + pub pda_rent_sponsor: AccountInfo<'info>, + + #[account(mint::token_program = token_program)] + pub token_mint_a: InterfaceAccount<'info, Mint>, + + #[account(mint::token_program = token_program)] + pub token_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + mut, + token::mint = token_mint_a, + token::authority = fee_payer, + )] + pub maker_token_account_a: InterfaceAccount<'info, TokenAccount>, + + #[account( + init, + payer = fee_payer, + space = ANCHOR_DISCRIMINATOR + Offer::INIT_SPACE, + seeds = [OFFER_SEED, fee_payer.key().as_ref(), params.id.to_le_bytes().as_ref()], + bump, + )] + #[light_account(init)] + pub offer: Account<'info, Offer>, + + /// CHECK: Vault PDA + #[account( + mut, + seeds = [VAULT_SEED, offer.key().as_ref()], + bump, + )] + #[light_account(init, + token::seeds = [VAULT_SEED, self.offer.key()], + token::mint = token_mint_a, + token::owner = authority, + token::owner_seeds = [AUTH_SEED], + token::bump = params.vault_bump + )] + pub vault: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// Light Token Program + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light Token config + #[account(address = LIGHT_TOKEN_CONFIG)] + pub light_token_config: AccountInfo<'info>, + + /// CHECK: Light Token rent sponsor + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light Token CPI authority + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA for mint A + #[account(mut)] + pub spl_interface_pda_a: UncheckedAccount<'info>, +} + +pub fn send_offered_tokens_to_vault<'info>( + ctx: &Context<'_, '_, '_, 'info, MakeOffer<'info>>, + params: &MakeOfferParams, +) -> Result<()> { + let decimals = ctx.accounts.token_mint_a.decimals; + + let cpi = TransferInterfaceCpi::new( + params.token_a_offered_amount, + decimals, + ctx.accounts.maker_token_account_a.to_account_info(), + ctx.accounts.vault.to_account_info(), + ctx.accounts.fee_payer.to_account_info(), + ctx.accounts.fee_payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.token_mint_a.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_a.to_account_info()), + Some(params.spl_interface_bump_a), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e).into()) +} + +pub fn save_offer<'info>( + ctx: &mut Context<'_, '_, '_, 'info, MakeOffer<'info>>, + params: &MakeOfferParams, +) -> Result<()> { + let offer = &mut ctx.accounts.offer; + offer.id = params.id; + offer.maker = ctx.accounts.fee_payer.key(); + offer.token_mint_a = ctx.accounts.token_mint_a.key(); + offer.token_mint_b = ctx.accounts.token_mint_b.key(); + offer.token_b_wanted_amount = params.token_b_wanted_amount; + offer.auth_bump = ctx.bumps.authority; + + msg!( + "Offer created: id={}, maker={}, offered={} token_a, wants={} token_b", + params.id, + ctx.accounts.fee_payer.key(), + params.token_a_offered_amount, + params.token_b_wanted_amount + ); + Ok(()) +} diff --git a/programs/anchor/escrow/src/instructions/mod.rs b/programs/anchor/escrow/src/instructions/mod.rs new file mode 100644 index 0000000..492f407 --- /dev/null +++ b/programs/anchor/escrow/src/instructions/mod.rs @@ -0,0 +1,5 @@ +pub mod make_offer; +pub mod take_offer; + +pub use make_offer::*; +pub use take_offer::*; diff --git a/programs/anchor/escrow/src/instructions/take_offer.rs b/programs/anchor/escrow/src/instructions/take_offer.rs new file mode 100644 index 0000000..1fd592d --- /dev/null +++ b/programs/anchor/escrow/src/instructions/take_offer.rs @@ -0,0 +1,177 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token::instruction::{CloseAccountCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; +use light_token::utils::get_token_account_balance; + +use crate::constants::{AUTH_SEED, OFFER_SEED, VAULT_SEED}; +use crate::state::Offer; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct TakeOfferParams { + pub spl_interface_bump_a: u8, + pub spl_interface_bump_b: u8, +} + +#[derive(Accounts)] +pub struct TakeOffer<'info> { + #[account(mut)] + pub taker: Signer<'info>, + + #[account(mut)] + pub maker: SystemAccount<'info>, + + /// CHECK: Authority PDA (writable for vault close) + #[account( + mut, + seeds = [AUTH_SEED], + bump, + )] + pub authority: UncheckedAccount<'info>, + + pub token_mint_a: InterfaceAccount<'info, Mint>, + + pub token_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + mut, + token::mint = token_mint_a, + token::authority = taker, + )] + pub taker_token_account_a: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = token_mint_b, + token::authority = taker, + )] + pub taker_token_account_b: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = token_mint_b, + token::authority = maker, + )] + pub maker_token_account_b: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + close = maker, + has_one = maker, + has_one = token_mint_a, + has_one = token_mint_b, + seeds = [OFFER_SEED, maker.key().as_ref(), offer.id.to_le_bytes().as_ref()], + bump, + )] + pub offer: Account<'info, Offer>, + + #[account( + mut, + seeds = [VAULT_SEED, offer.key().as_ref()], + bump, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// Light Token Program + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light Token CPI authority + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: Light Token rent sponsor + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: SPL interface PDA for mint A (vault->taker transfer) + #[account(mut)] + pub spl_interface_pda_a: UncheckedAccount<'info>, + + /// CHECK: SPL interface PDA for mint B (taker->maker transfer) + #[account(mut)] + pub spl_interface_pda_b: UncheckedAccount<'info>, +} + +pub fn send_wanted_tokens_to_maker(ctx: &Context, params: &TakeOfferParams) -> Result<()> { + let decimals_b = ctx.accounts.token_mint_b.decimals; + let token_b_wanted_amount = ctx.accounts.offer.token_b_wanted_amount; + + let cpi = TransferInterfaceCpi::new( + token_b_wanted_amount, + decimals_b, + ctx.accounts.taker_token_account_b.to_account_info(), + ctx.accounts.maker_token_account_b.to_account_info(), + ctx.accounts.taker.to_account_info(), + ctx.accounts.taker.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.token_mint_b.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_b.to_account_info()), + Some(params.spl_interface_bump_b), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e).into()) +} + +pub fn withdraw_from_vault(ctx: &Context, params: &TakeOfferParams) -> Result<()> { + let offer = &ctx.accounts.offer; + let authority_seeds: &[&[u8]] = &[AUTH_SEED, &[offer.auth_bump]]; + + let vault_balance = get_token_account_balance(&ctx.accounts.vault.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + + let decimals_a = ctx.accounts.token_mint_a.decimals; + + let cpi = TransferInterfaceCpi::new( + vault_balance, + decimals_a, + ctx.accounts.vault.to_account_info(), + ctx.accounts.taker_token_account_a.to_account_info(), + ctx.accounts.authority.to_account_info(), + ctx.accounts.taker.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.token_mint_a.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_a.to_account_info()), + Some(params.spl_interface_bump_a), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + msg!( + "Offer taken: id={}, taker={}, transferred {} token_b to maker, received {} token_a", + offer.id, + ctx.accounts.taker.key(), + offer.token_b_wanted_amount, + vault_balance + ); + + Ok(()) +} + +pub fn close_vault(ctx: &Context) -> Result<()> { + let auth_bump = ctx.accounts.offer.auth_bump; + let authority_seeds: &[&[u8]] = &[AUTH_SEED, &[auth_bump]]; + + CloseAccountCpi { + token_program: ctx.accounts.light_token_program.to_account_info(), + account: ctx.accounts.vault.to_account_info(), + destination: ctx.accounts.taker.to_account_info(), + owner: ctx.accounts.authority.to_account_info(), + rent_sponsor: ctx.accounts.light_token_rent_sponsor.to_account_info(), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e).into()) +} diff --git a/programs/anchor/escrow/src/lib.rs b/programs/anchor/escrow/src/lib.rs new file mode 100644 index 0000000..f1ad688 --- /dev/null +++ b/programs/anchor/escrow/src/lib.rs @@ -0,0 +1,39 @@ +#![allow(unexpected_cfgs, deprecated)] + +pub mod constants; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; +use light_account::{derive_light_cpi_signer, light_program, CpiSigner}; + +pub use constants::*; +pub use instructions::*; +pub use state::*; + +declare_id!("FKJs6rp6TXJtxzLiPtdYhqa9ExRuBXG2zwh4fda6WATN"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("FKJs6rp6TXJtxzLiPtdYhqa9ExRuBXG2zwh4fda6WATN"); + +#[light_program] +// Anchor's #[program] macro uses deprecated AccountInfo::realloc internally +#[allow(deprecated)] +#[program] +pub mod escrow { + use super::*; + + pub fn make_offer<'info>( + mut ctx: Context<'_, '_, '_, 'info, MakeOffer<'info>>, + params: MakeOfferParams, + ) -> Result<()> { + instructions::make_offer::send_offered_tokens_to_vault(&ctx, ¶ms)?; + instructions::make_offer::save_offer(&mut ctx, ¶ms) + } + + pub fn take_offer(ctx: Context, params: TakeOfferParams) -> Result<()> { + instructions::take_offer::send_wanted_tokens_to_maker(&ctx, ¶ms)?; + instructions::take_offer::withdraw_from_vault(&ctx, ¶ms)?; + instructions::take_offer::close_vault(&ctx) + } +} diff --git a/programs/anchor/escrow/src/state/mod.rs b/programs/anchor/escrow/src/state/mod.rs new file mode 100644 index 0000000..cfaa08e --- /dev/null +++ b/programs/anchor/escrow/src/state/mod.rs @@ -0,0 +1,3 @@ +pub mod offer; + +pub use offer::*; diff --git a/programs/anchor/escrow/src/state/offer.rs b/programs/anchor/escrow/src/state/offer.rs new file mode 100644 index 0000000..b37104f --- /dev/null +++ b/programs/anchor/escrow/src/state/offer.rs @@ -0,0 +1,15 @@ +use anchor_lang::prelude::*; +use light_sdk::LightDiscriminator; +use light_account::{CompressionInfo, LightAccount}; + +#[derive(Default, Debug, InitSpace, LightAccount)] +#[account] +pub struct Offer { + pub compression_info: CompressionInfo, + pub id: u64, + pub maker: Pubkey, + pub token_mint_a: Pubkey, + pub token_mint_b: Pubkey, + pub token_b_wanted_amount: u64, + pub auth_bump: u8, +} diff --git a/programs/anchor/escrow/tests/common/mod.rs b/programs/anchor/escrow/tests/common/mod.rs new file mode 100644 index 0000000..979f19b --- /dev/null +++ b/programs/anchor/escrow/tests/common/mod.rs @@ -0,0 +1,607 @@ +//! Escrow test setup for 5 token standard combinations: SPL, T22, Light. +//! +//! Each combination varies the mint type and associated token account type while the vault +//! is always a Light Token account: +//! +//! - `Spl` / `Token2022`: standard associated token accounts, transfers via `TransferInterfaceCpi` +//! - `Light`: Light Token accounts, transfers via `TransferCheckedCpi` +//! - `LightSpl` / `LightT22`: SPL/Token 2022 mints converted into Light Token accounts before +//! the escrow starts (tokens are minted to a temp associated token account, then +//! transferred to an associated Light Token account via `transfer_spl_to_light`) +//! +//! ## Setup flow +//! +//! 1. `create_test_rpc()` — start test validator with escrow + minter programs +//! 2. `setup_escrow_test(config)` — create mints, interface PDAs, maker/taker +//! 3. `create_token_account()` — create funded/unfunded accounts per config + +// ============================================================================ +// Imports +// ============================================================================ + +use anchor_spl::token; +use shared_test_utils::{ + light_tokens::{create_light_ata, create_light_mint, mint_light_tokens}, + setup::initialize_rent_free_config, + spl_interface::{create_spl_interface_pda, transfer_spl_to_light}, + spl_tokens::{create_spl_ata, create_spl_mint, mint_spl_tokens}, + t22_tokens::{create_t22_ata, create_t22_mint, mint_t22_tokens}, + Indexer, LightProgramTest, MintType, ProgramTestConfig, Rpc, + SplInterfaceResult, TestRpc, + LIGHT_TOKEN_MINTER_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID, +}; +use anchor_lang::AnchorDeserialize; +use escrow::escrow::{LightAccountVariant, OfferSeeds, VaultSeeds}; +use light_account::token::{Token as LightToken, TokenDataWithSeeds}; +use light_account::IntoVariant; +use light_client::interface::{ + create_load_instructions, AccountInterface, AccountSpec, PdaSpec, +}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +// ============================================================================ +// Token Configuration +// ============================================================================ + +/// Token configuration for parameterized escrow tests. +/// +/// Each variant determines the mint type and user account type. The vault is +/// always a Light Token account (rent-free). The user account type controls +/// which CPI path `TransferInterfaceCpi` selects at runtime: +/// +/// - SPL/Token 2022 user accounts → cross-standard transfer (needs interface PDA) +/// - Light user accounts → light-to-light transfer (no interface PDA) +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub enum TokenConfig { + /// SPL mint + SPL associated token accounts and Light Token vault. + Spl, + /// Token 2022 mint + Token 2022 associated token accounts and Light Token vault. + Token2022, + /// SPL mint + associated Light Token accounts. Tokens converted from SPL associated token accounts in setup. + LightSpl, + /// Token 2022 mint + associated Light Token accounts. Tokens converted from Token 2022 associated token accounts in setup. + LightT22, + /// Light Token mint + associated Light Token accounts and Light Token vault. + Light, +} + +impl TokenConfig { + /// Returns the underlying mint program type. + /// Light mints use SPL-compatible layout, so this returns `MintType::Spl`. + pub fn mint_type(&self) -> MintType { + match self { + TokenConfig::Spl | TokenConfig::LightSpl => MintType::Spl, + TokenConfig::Token2022 | TokenConfig::LightT22 => MintType::Token2022, + TokenConfig::Light => MintType::Spl, // Light mints use SPL-compatible layout + } + } + + /// Returns the token program ID passed as `token_program` in instructions. + pub fn token_program_id(&self) -> Pubkey { + match self { + TokenConfig::Spl | TokenConfig::LightSpl => token::ID, + TokenConfig::Token2022 | TokenConfig::LightT22 => spl_token_2022::ID, + TokenConfig::Light => Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + } + } + + /// Returns true if mints are Light Token mints (not SPL/Token 2022) + pub fn uses_light_mints(&self) -> bool { + matches!(self, TokenConfig::Light) + } + +} + +// ============================================================================ +// Test Context +// ============================================================================ + +/// Context for escrow tests containing all necessary accounts +#[allow(dead_code)] +pub struct EscrowTestContext { + pub program_id: Pubkey, + pub payer: Keypair, + pub token_config: TokenConfig, + pub compression_config: Pubkey, + /// Per-program rent sponsor PDA (derived from program_id) + pub rent_sponsor: Pubkey, + + // Mint A + pub mint_a_pubkey: Pubkey, + pub light_mint_a_authority: Option, + pub spl_interface_a: Option, + + // Mint B + pub mint_b_pubkey: Pubkey, + pub light_mint_b_authority: Option, + pub spl_interface_b: Option, + + // Participants + pub maker: Keypair, + pub taker: Keypair, + + // PDAs + pub authority_pda: Pubkey, +} + +// ============================================================================ +// Setup Functions +// ============================================================================ + +/// Create a new LightProgramTest instance for escrow tests +pub async fn create_test_rpc() -> LightProgramTest { + let program_id = escrow::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![ + ("escrow", program_id), + ("light_token_minter", LIGHT_TOKEN_MINTER_PROGRAM_ID), + ]), + ); + config = config.with_light_protocol_events(); + LightProgramTest::new(config).await.unwrap() +} + +/// Initialize mints, interface PDAs, and participants for a given token config. +/// +/// SPL interface PDAs are created for all SPL/Token 2022 configs (including `Spl` and +/// `Token2022`, not just `LightSpl`/`LightT22`) because the vault is always a +/// Light Token account — `TransferInterfaceCpi` needs the interface PDA when an +/// SPL/Token 2022 account transfers to a Light Token vault. +/// +/// For `Light` config, no interface PDAs are created (early return). +pub async fn setup_escrow_test( + rpc: &mut R, + config: TokenConfig, +) -> EscrowTestContext { + let program_id = escrow::ID; + let payer = rpc.get_payer().insecure_clone(); + + let (compression_config, rent_sponsor) = initialize_rent_free_config(rpc, &payer, &program_id).await; + + let maker = Keypair::new(); + let taker = Keypair::new(); + rpc.airdrop_lamports(&maker.pubkey(), 10_000_000_000) + .await + .unwrap(); + rpc.airdrop_lamports(&taker.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let (authority_pda, _) = + Pubkey::find_program_address(&[escrow::AUTH_SEED], &program_id); + + if config.uses_light_mints() { + let light_mint_a = create_light_mint( + rpc, + &payer, + 9, + "Escrow Token A", + "ETKA", + &compression_config, + ) + .await; + + let light_mint_b = create_light_mint( + rpc, + &payer, + 9, + "Escrow Token B", + "ETKB", + &compression_config, + ) + .await; + + return EscrowTestContext { + program_id, + payer, + token_config: config, + compression_config, + rent_sponsor, + mint_a_pubkey: light_mint_a.mint, + light_mint_a_authority: Some(light_mint_a.authority), + spl_interface_a: None, + mint_b_pubkey: light_mint_b.mint, + light_mint_b_authority: Some(light_mint_b.authority), + spl_interface_b: None, + maker, + taker, + authority_pda, + }; + } + + // ========== SPL/TOKEN 2022 MINT SETUP ========== + let (mint_a, mint_b) = match config { + TokenConfig::Spl | TokenConfig::LightSpl => { + let a = create_spl_mint(rpc, &payer, &payer.pubkey(), 9).await; + let b = create_spl_mint(rpc, &payer, &payer.pubkey(), 9).await; + (a, b) + } + TokenConfig::Token2022 | TokenConfig::LightT22 => { + let a = create_t22_mint(rpc, &payer, &payer.pubkey(), 9).await; + let b = create_t22_mint(rpc, &payer, &payer.pubkey(), 9).await; + (a, b) + } + TokenConfig::Light => unreachable!("Light config handled above"), + }; + + let mint_a_pubkey = mint_a.pubkey(); + let mint_b_pubkey = mint_b.pubkey(); + + // Required for `TransferInterfaceCpi` between SPL/Token 2022 accounts and Light Token vault. + let iface_a = create_spl_interface_pda( + rpc, + &payer, + &mint_a_pubkey, + config.mint_type(), + false, + ) + .await; + let iface_b = create_spl_interface_pda( + rpc, + &payer, + &mint_b_pubkey, + config.mint_type(), + false, + ) + .await; + let (spl_interface_a, spl_interface_b) = (Some(iface_a), Some(iface_b)); + + EscrowTestContext { + program_id, + payer, + token_config: config, + compression_config, + rent_sponsor, + mint_a_pubkey, + light_mint_a_authority: None, + spl_interface_a, + mint_b_pubkey, + light_mint_b_authority: None, + spl_interface_b, + maker, + taker, + authority_pda, + } +} + +// ============================================================================ +// Token Account Creation +// ============================================================================ + +/// Create a token account for a participant, optionally funded. +/// +/// Account creation varies by config: +/// - `Spl` / `Token2022`: create standard associated token account, mint directly +/// - `Light`: create associated Light Token account via `mint_light_tokens` (creates + mints in one call) +/// - `LightSpl` / `LightT22`: create temporary SPL/Token 2022 associated token account → mint → +/// create associated Light Token account → convert via `transfer_spl_to_light`. +/// If unfunded, just creates the associated Light Token account. +pub async fn create_token_account( + rpc: &mut R, + ctx: &EscrowTestContext, + owner: &Keypair, + mint_pubkey: &Pubkey, + light_mint_authority: Option<&Keypair>, + spl_interface: Option<&SplInterfaceResult>, + funding_amount: u64, +) -> Pubkey { + match ctx.token_config { + TokenConfig::Spl => { + let ata = create_spl_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await; + if funding_amount > 0 { + mint_spl_tokens(rpc, &ctx.payer, mint_pubkey, &ata, &ctx.payer, funding_amount) + .await; + } + ata + } + TokenConfig::Token2022 => { + let ata = create_t22_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await; + if funding_amount > 0 { + mint_t22_tokens(rpc, &ctx.payer, mint_pubkey, &ata, &ctx.payer, funding_amount) + .await; + } + ata + } + TokenConfig::LightSpl => { + if funding_amount > 0 { + let temp_ata = + create_spl_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await; + mint_spl_tokens( + rpc, + &ctx.payer, + mint_pubkey, + &temp_ata, + &ctx.payer, + funding_amount, + ) + .await; + + let light_ata = + create_light_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await; + + let iface = spl_interface.expect("LightSpl requires SPL interface PDA"); + transfer_spl_to_light( + rpc, + &ctx.payer, + owner, + mint_pubkey, + 9, + &temp_ata, + &light_ata, + &iface.pda, + iface.bump, + funding_amount, + MintType::Spl, + ) + .await; + + light_ata + } else { + create_light_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await + } + } + TokenConfig::LightT22 => { + if funding_amount > 0 { + let temp_ata = + create_t22_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await; + mint_t22_tokens( + rpc, + &ctx.payer, + mint_pubkey, + &temp_ata, + &ctx.payer, + funding_amount, + ) + .await; + + let light_ata = + create_light_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await; + + let iface = spl_interface.expect("LightT22 requires SPL interface PDA"); + transfer_spl_to_light( + rpc, + &ctx.payer, + owner, + mint_pubkey, + 9, + &temp_ata, + &light_ata, + &iface.pda, + iface.bump, + funding_amount, + MintType::Token2022, + ) + .await; + + light_ata + } else { + create_light_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await + } + } + TokenConfig::Light => { + let mint_authority = + light_mint_authority.expect("Light config requires mint authority"); + if funding_amount > 0 { + mint_light_tokens( + rpc, + &ctx.payer, + mint_authority, + mint_pubkey, + &owner.pubkey(), + funding_amount, + ) + .await + } else { + create_light_ata(rpc, &ctx.payer, mint_pubkey, &owner.pubkey()).await + } + } + } +} + +// ============================================================================ +// Cold/Hot Lifecycle +// ============================================================================ + +/// Simulate the cold/hot lifecycle by advancing slots past sponsored rent. +/// +/// Light Token accounts may turn cold between transactions. The Light Token +/// Program sponsors rent-exemption; when an account's virtual rent balance +/// drops below threshold, it auto-compresses: account data moves to a +/// state tree and on-chain lookups return `is_initialized: false`. +/// After compressing, the test loads cold accounts back to active state +/// before the next transaction. +/// Standard SPL/Token 2022 accounts are unaffected. +pub async fn warp_to_compress(rpc: &mut R) { + rpc.warp_epoch_forward(30) + .await + .expect("warp_epoch_forward should succeed"); +} + +/// Load all accounts referenced by the `make_offer` instruction. +/// +/// Loads cold mints A/B and maker's ATA for token A back to active state. +/// No-op for accounts that are already hot or non-Light (SPL/Token 2022). +pub async fn load_accounts_make_offer( + rpc: &mut R, + payer: &Keypair, + maker: &Keypair, + compression_config: Pubkey, + mint_a: &Pubkey, + mint_b: &Pubkey, +) { + let mut specs: Vec> = Vec::new(); + + // Mints + for mint in [mint_a, mint_b] { + if let Ok(response) = rpc.get_mint_interface(mint, None).await { + if let Some(iface) = response.value { + if iface.is_cold() { + specs.push(AccountSpec::Mint(AccountInterface::from(iface))); + } + } + } + } + + // Maker's ATA for token A + if let Ok(response) = rpc.get_associated_token_account_interface(&maker.pubkey(), mint_a, None).await { + if let Some(iface) = response.value { + if iface.is_cold() { + specs.push(AccountSpec::Ata(Box::new(iface))); + } + } + } + + if specs.is_empty() { + return; + } + + let ixs = create_load_instructions::( + &specs, + payer.pubkey(), + compression_config, + &*rpc, + ) + .await + .expect("create_load_instructions for make_offer should succeed"); + + if !ixs.is_empty() { + rpc.create_and_send_transaction(&ixs, &payer.pubkey(), &[payer, maker]) + .await + .expect("load for make_offer should succeed"); + } +} + +/// Load all accounts referenced by the `take_offer` instruction. +/// +/// Loads cold mints A/B, offer PDA, vault PDA, taker's ATAs for A/B, +/// and maker's ATA for B back to active state. +/// No-op for accounts that are already hot or non-Light (SPL/Token 2022). +pub async fn load_accounts_take_offer( + rpc: &mut R, + payer: &Keypair, + maker: &Keypair, + taker: &Keypair, + program_id: &Pubkey, + compression_config: Pubkey, + mint_a: &Pubkey, + mint_b: &Pubkey, + offer_pda: Pubkey, + vault_pda: Pubkey, +) { + let mut specs: Vec> = Vec::new(); + let mut needs_taker_signer = false; + let mut needs_maker_signer = false; + + // Mints + for mint in [mint_a, mint_b] { + if let Ok(response) = rpc.get_mint_interface(mint, None).await { + if let Some(iface) = response.value { + if iface.is_cold() { + specs.push(AccountSpec::Mint(AccountInterface::from(iface))); + } + } + } + } + + // Offer PDA + match rpc.get_account_interface(&offer_pda, None).await { + Ok(response) => { + if let Some(iface) = response.value { + if iface.is_cold() { + let data = iface.data(); + let offer: escrow::Offer = + AnchorDeserialize::deserialize(&mut &data[8..]) + .expect("deserialize Offer from cold data"); + let maker_pubkey = offer.maker; + let variant = OfferSeeds { + fee_payer: maker_pubkey, + id: offer.id, + } + .into_variant(&iface.data()[8..]) + .expect("seed verification should pass"); + specs.push(AccountSpec::Pda(PdaSpec::new(iface, variant, *program_id))); + } + } + } + _ => {} + } + + // Vault PDA + match rpc.get_token_account_interface(&vault_pda, None).await { + Ok(response) => { + if let Some(iface) = response.value { + if iface.is_cold() { + let token_data: LightToken = + AnchorDeserialize::deserialize(&mut &iface.account.data[..]) + .expect("deserialize Token from cold vault data"); + let vault_variant = + LightAccountVariant::Vault(TokenDataWithSeeds { + seeds: VaultSeeds { offer: offer_pda }, + token_data, + }); + specs.push(AccountSpec::Pda(PdaSpec::new( + AccountInterface::from(iface), + vault_variant, + *program_id, + ))); + } + } + } + _ => {} + } + + // Taker's ATAs for A and B + for mint in [mint_a, mint_b] { + if let Ok(response) = rpc.get_associated_token_account_interface(&taker.pubkey(), mint, None).await { + if let Some(iface) = response.value { + if iface.is_cold() { + specs.push(AccountSpec::Ata(Box::new(iface))); + needs_taker_signer = true; + } + } + } + } + + // Maker's ATA for B (receives taker's payment) + if let Ok(response) = rpc.get_associated_token_account_interface(&maker.pubkey(), mint_b, None).await { + if let Some(iface) = response.value { + if iface.is_cold() { + specs.push(AccountSpec::Ata(Box::new(iface))); + needs_maker_signer = true; + } + } + } + + if specs.is_empty() { + return; + } + + let ixs = create_load_instructions::( + &specs, + payer.pubkey(), + compression_config, + &*rpc, + ) + .await + .expect("create_load_instructions for take_offer should succeed"); + + if !ixs.is_empty() { + // Only include signers whose accounts are actually being loaded. + // ATA loading requires the wallet owner to sign; PDA/mint loading only needs the payer. + let mut signers: Vec<&Keypair> = vec![payer]; + if needs_maker_signer { + signers.push(maker); + } + if needs_taker_signer { + signers.push(taker); + } + + rpc.create_and_send_transaction(&ixs, &payer.pubkey(), &signers) + .await + .expect("load for take_offer should succeed"); + } +} diff --git a/programs/anchor/escrow/tests/escrow.rs b/programs/anchor/escrow/tests/escrow.rs new file mode 100644 index 0000000..cd9fd63 --- /dev/null +++ b/programs/anchor/escrow/tests/escrow.rs @@ -0,0 +1,377 @@ +//! Escrow tests for 5 token configurations. Vault is always a Light Token account. +//! +//! | Test | Mint | User accounts | Transfer path | +//! |------|------|---------------|---------------| +//! | `test_escrow_spl` | SPL | SPL | `TransferInterfaceCpi` | +//! | `test_escrow_t22` | Token 2022 | Token 2022 | `TransferInterfaceCpi` | +//! | `test_escrow_light` | Light Token | Light Token | `TransferInterfaceCpi` | +//! | `test_escrow_spl_light` | SPL | Light Token | `TransferInterfaceCpi` | +//! | `test_escrow_t22_light` | Token 2022 | Light Token | `TransferInterfaceCpi` | +//! +//! Each test also simulates the cold/hot lifecycle: +//! Light Token accounts auto-compress when sponsored rent expires. Before each +//! transaction, the test loads cold Light Token accounts to associated Light Token +//! accounts (hot balance) via per-instruction load functions. + +mod common; + +use anchor_lang::{InstructionData, ToAccountMetas}; +use common::{ + create_test_rpc, create_token_account, load_accounts_make_offer, load_accounts_take_offer, + setup_escrow_test, warp_to_compress, EscrowTestContext, TokenConfig, +}; +use light_client::interface::{get_create_accounts_proof, CreateAccountsProofInput}; +use light_token::spl_interface::find_spl_interface_pda; +use shared_test_utils::{ + helpers::verify_light_token_balance, Indexer, Rpc, TestRpc, COMPRESSIBLE_CONFIG_V1, + CPI_AUTHORITY_PDA, LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR, +}; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +/// SPL mint with SPL associated token accounts and Light Token vault. +#[tokio::test] +async fn test_escrow_spl() { + let mut rpc = create_test_rpc().await; + let ctx = setup_escrow_test(&mut rpc, TokenConfig::Spl).await; + run_escrow(&mut rpc, &ctx).await; +} + +/// Token 2022 mint with Token 2022 associated token accounts and Light Token vault. +#[tokio::test] +async fn test_escrow_t22() { + let mut rpc = create_test_rpc().await; + let ctx = setup_escrow_test(&mut rpc, TokenConfig::Token2022).await; + run_escrow(&mut rpc, &ctx).await; +} + +/// Light Token mint + Light Token associated accounts and a Light Token vault. +#[tokio::test] +async fn test_escrow_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_escrow_test(&mut rpc, TokenConfig::Light).await; + run_escrow(&mut rpc, &ctx).await; +} + +/// SPL mint + Light Token user accounts. SPL tokens converted to Light Token +/// associated token accounts in setup via `transfer_spl_to_light`. +#[tokio::test] +async fn test_escrow_spl_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_escrow_test(&mut rpc, TokenConfig::LightSpl).await; + run_escrow(&mut rpc, &ctx).await; +} + +/// Token 2022 mint + Light Token user accounts. Token 2022 tokens converted to Light Token +/// associated token accounts in setup via `transfer_spl_to_light`. +#[tokio::test] +async fn test_escrow_t22_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_escrow_test(&mut rpc, TokenConfig::LightT22).await; + run_escrow(&mut rpc, &ctx).await; +} + +/// Run the full escrow flow for any token configuration: SPL, Token 2022, or Light. +/// +/// 1. Create token accounts per `TokenConfig` (see `create_token_account`) +/// 2. Compress and load all accounts to active state (simulates cold/hot lifecycle) +/// 3. Make offer: deposit token A into vault, record escrow terms +/// 4. Compress and load again before take (includes program-owned PDAs) +/// 5. Take offer: taker sends token B to maker, vault releases token A to taker +/// 6. Verify balances and account closure +async fn run_escrow( + rpc: &mut R, + ctx: &EscrowTestContext, +) { + // Maker offers 1 token A, wants 0.5 token B in return + let token_a_offered = 1_000_000_000u64; // 1 token (9 decimals) + let token_b_wanted = 500_000_000u64; // 0.5 tokens + let offer_id = 1u64; + + // Maker needs: funded token A account, empty token B account (receives payment) + // Taker needs: empty token A account (receives offered tokens), funded token B account + + let maker_token_a = create_token_account( + rpc, + ctx, + &ctx.maker, + &ctx.mint_a_pubkey, + ctx.light_mint_a_authority.as_ref(), + ctx.spl_interface_a.as_ref(), + token_a_offered, + ) + .await; + + let maker_token_b = create_token_account( + rpc, + ctx, + &ctx.maker, + &ctx.mint_b_pubkey, + ctx.light_mint_b_authority.as_ref(), + ctx.spl_interface_b.as_ref(), + 0, + ) + .await; + + let taker_token_a = create_token_account( + rpc, + ctx, + &ctx.taker, + &ctx.mint_a_pubkey, + ctx.light_mint_a_authority.as_ref(), + ctx.spl_interface_a.as_ref(), + 0, + ) + .await; + + let taker_token_b = create_token_account( + rpc, + ctx, + &ctx.taker, + &ctx.mint_b_pubkey, + ctx.light_mint_b_authority.as_ref(), + ctx.spl_interface_b.as_ref(), + token_b_wanted, + ) + .await; + + verify_light_token_balance(rpc, maker_token_a, token_a_offered, "maker_token_a (initial)") + .await; + verify_light_token_balance(rpc, maker_token_b, 0, "maker_token_b (initial)").await; + verify_light_token_balance(rpc, taker_token_a, 0, "taker_token_a (initial)").await; + verify_light_token_balance(rpc, taker_token_b, token_b_wanted, "taker_token_b (initial)") + .await; + + // Simulate hot-cold lifecycle. No-op for non-Light configs. + warp_to_compress(rpc).await; + + // Load cold accounts referenced by make_offer to active state. + load_accounts_make_offer( + rpc, + &ctx.payer, + &ctx.maker, + ctx.compression_config, + &ctx.mint_a_pubkey, + &ctx.mint_b_pubkey, + ) + .await; + + let (offer_pda, vault_pda) = make_offer( + rpc, + ctx, + maker_token_a, + offer_id, + token_a_offered, + token_b_wanted, + ) + .await; + + verify_light_token_balance(rpc, vault_pda, token_a_offered, "vault (after make_offer)").await; + verify_light_token_balance(rpc, maker_token_a, 0, "maker_token_a (after make_offer)").await; + + let offer_account = rpc + .get_account(offer_pda) + .await + .unwrap() + .expect("Offer account should exist"); + assert!( + !offer_account.data.is_empty(), + "Offer account should have data" + ); + + // Simulate hot-cold lifecycle before take_offer. + warp_to_compress(rpc).await; + + // Load cold accounts referenced by take_offer to active state. + load_accounts_take_offer( + rpc, + &ctx.payer, + &ctx.maker, + &ctx.taker, + &ctx.program_id, + ctx.compression_config, + &ctx.mint_a_pubkey, + &ctx.mint_b_pubkey, + offer_pda, + vault_pda, + ) + .await; + + take_offer( + rpc, + ctx, + taker_token_a, + taker_token_b, + maker_token_b, + offer_pda, + vault_pda, + ) + .await; + + // Maker: gave token A, received token B + // Taker: gave token B, received token A + + verify_light_token_balance(rpc, maker_token_a, 0, "maker_token_a (final)").await; + verify_light_token_balance(rpc, maker_token_b, token_b_wanted, "maker_token_b (final)").await; + verify_light_token_balance(rpc, taker_token_a, token_a_offered, "taker_token_a (final)") + .await; + verify_light_token_balance(rpc, taker_token_b, 0, "taker_token_b (final)").await; + + let offer_after = rpc.get_account(offer_pda).await.unwrap(); + assert!( + offer_after.is_none(), + "Offer account should be closed after take_offer" + ); +} + +// ============================================================================ +// Instruction Helpers +// ============================================================================ + +/// Derive offer + vault PDAs, fetch validity proof, send `make_offer` instruction. +/// +/// Returns `(offer_pda, vault_pda)`. +async fn make_offer( + rpc: &mut R, + ctx: &EscrowTestContext, + maker_token_a: Pubkey, + offer_id: u64, + token_a_offered: u64, + token_b_wanted: u64, +) -> (Pubkey, Pubkey) { + let (offer_pda, _) = Pubkey::find_program_address( + &[ + escrow::OFFER_SEED, + ctx.maker.pubkey().as_ref(), + offer_id.to_le_bytes().as_ref(), + ], + &ctx.program_id, + ); + + let (vault_pda, vault_bump) = Pubkey::find_program_address( + &[escrow::VAULT_SEED, offer_pda.as_ref()], + &ctx.program_id, + ); + + // Validity proof: verifies that the offer PDA's derived address does not + // yet exist in the address tree. Required for Light Token account creation. + let proof_result = get_create_accounts_proof( + rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(offer_pda)], + ) + .await + .unwrap(); + + let (_, spl_interface_bump_a) = find_spl_interface_pda(&ctx.mint_a_pubkey, false); + + let accounts = escrow::accounts::MakeOffer { + fee_payer: ctx.maker.pubkey(), + authority: ctx.authority_pda, + compression_config: ctx.compression_config, + token_mint_a: ctx.mint_a_pubkey, + token_mint_b: ctx.mint_b_pubkey, + maker_token_account_a: maker_token_a, + offer: offer_pda, + vault: vault_pda, + token_program: ctx.token_config.token_program_id(), + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_config: COMPRESSIBLE_CONFIG_V1, + pda_rent_sponsor: ctx.rent_sponsor, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda_a: ctx + .spl_interface_a + .as_ref() + .map(|i| i.pda) + .unwrap_or_default(), + }; + + let data = escrow::instruction::MakeOffer { + params: escrow::MakeOfferParams { + create_accounts_proof: proof_result.create_accounts_proof, + id: offer_id, + token_a_offered_amount: token_a_offered, + token_b_wanted_amount: token_b_wanted, + vault_bump, + spl_interface_bump_a, + }, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: data.data(), + }; + + rpc.create_and_send_transaction(&[ix], &ctx.maker.pubkey(), &[&ctx.maker]) + .await + .expect("make_offer should succeed"); + + (offer_pda, vault_pda) +} + +/// Send `take_offer` instruction: taker sends token B to maker, vault releases token A to taker. +async fn take_offer( + rpc: &mut R, + ctx: &EscrowTestContext, + taker_token_a: Pubkey, + taker_token_b: Pubkey, + maker_token_b: Pubkey, + offer_pda: Pubkey, + vault_pda: Pubkey, +) { + let (_, spl_interface_bump_a) = find_spl_interface_pda(&ctx.mint_a_pubkey, false); + let (_, spl_interface_bump_b) = find_spl_interface_pda(&ctx.mint_b_pubkey, false); + + let accounts = escrow::accounts::TakeOffer { + taker: ctx.taker.pubkey(), + maker: ctx.maker.pubkey(), + authority: ctx.authority_pda, + token_mint_a: ctx.mint_a_pubkey, + token_mint_b: ctx.mint_b_pubkey, + taker_token_account_a: taker_token_a, + taker_token_account_b: taker_token_b, + maker_token_account_b: maker_token_b, + offer: offer_pda, + vault: vault_pda, + token_program: ctx.token_config.token_program_id(), + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_cpi_authority: CPI_AUTHORITY_PDA, + light_token_rent_sponsor: RENT_SPONSOR, + spl_interface_pda_a: ctx + .spl_interface_a + .as_ref() + .map(|i| i.pda) + .unwrap_or_default(), + spl_interface_pda_b: ctx + .spl_interface_b + .as_ref() + .map(|i| i.pda) + .unwrap_or_default(), + }; + + let data = escrow::instruction::TakeOffer { + params: escrow::TakeOfferParams { + spl_interface_bump_a, + spl_interface_bump_b, + }, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction(&[ix], &ctx.taker.pubkey(), &[&ctx.taker]) + .await + .expect("take_offer should succeed"); +} diff --git a/programs/anchor/fundraiser/Cargo.toml b/programs/anchor/fundraiser/Cargo.toml new file mode 100644 index 0000000..d87744e --- /dev/null +++ b/programs/anchor/fundraiser/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "fundraiser" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "fundraiser" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build", "light-anchor-spl/idl-build"] +test-sbf = [] + +[dependencies] +anchor-lang = { version = "=0.31.1", features = ["init-if-needed"] } + +light-sdk = { workspace = true, features = ["anchor", "cpi-context", "v2"] } +light-token = { workspace = true, features = ["anchor"] } +light-account = { workspace = true } +light-hasher = { workspace = true, features = ["solana"] } +light-anchor-spl = { workspace = true } +solana-program = { workspace = true } +solana-pubkey = { workspace = true } +solana-account-info = { workspace = true } +solana-program-error = { workspace = true } +solana-msg = { workspace = true } + +[dev-dependencies] +light-program-test = { workspace = true } +light-client = { workspace = true, features = ["v2", "anchor"] } +anchor-spl = { workspace = true } +tokio = { workspace = true, features = ["full"] } +solana-keypair = { workspace = true } +solana-signer = { workspace = true } +solana-instruction = { workspace = true } +solana-sdk = { workspace = true } +spl-token-2022 = { workspace = true } +spl-pod = { workspace = true } +shared-test-utils = { workspace = true } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/programs/anchor/fundraiser/Xargo.toml b/programs/anchor/fundraiser/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/programs/anchor/fundraiser/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/anchor/fundraiser/src/constants.rs b/programs/anchor/fundraiser/src/constants.rs new file mode 100644 index 0000000..8c1fe69 --- /dev/null +++ b/programs/anchor/fundraiser/src/constants.rs @@ -0,0 +1,7 @@ +pub const ANCHOR_DISCRIMINATOR: usize = 8; +pub const MIN_AMOUNT_TO_RAISE: u64 = 3; +pub const SECONDS_TO_DAYS: i64 = 86400; +pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; +pub const PERCENTAGE_SCALER: u64 = 100; +pub const AUTH_SEED: &[u8] = b"authority"; +pub const VAULT_SEED: &[u8] = b"vault"; diff --git a/programs/anchor/fundraiser/src/error.rs b/programs/anchor/fundraiser/src/error.rs new file mode 100644 index 0000000..d66583b --- /dev/null +++ b/programs/anchor/fundraiser/src/error.rs @@ -0,0 +1,23 @@ +use anchor_lang::error_code; + +#[error_code] +pub enum FundraiserError { + #[msg("The amount to raise has not been met")] + TargetNotMet, + #[msg("The amount to raise has been achieved")] + TargetMet, + #[msg("The contribution is too big")] + ContributionTooBig, + #[msg("The contribution is too small")] + ContributionTooSmall, + #[msg("The maximum amount to contribute has been reached")] + MaximumContributionsReached, + #[msg("The fundraiser has not ended yet")] + FundraiserNotEnded, + #[msg("The fundraiser has ended")] + FundraiserEnded, + #[msg("Invalid total amount. i should be bigger than 3")] + InvalidAmount, + #[msg("Arithmetic overflow in calculation")] + CalculationOverflow, +} diff --git a/programs/anchor/fundraiser/src/instructions/checker.rs b/programs/anchor/fundraiser/src/instructions/checker.rs new file mode 100644 index 0000000..0965a1e --- /dev/null +++ b/programs/anchor/fundraiser/src/instructions/checker.rs @@ -0,0 +1,124 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token::instruction::{TransferCheckedCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; +use light_token::utils::get_token_account_balance; + +use crate::constants::{AUTH_SEED, VAULT_SEED}; +use crate::state::Fundraiser; +use crate::FundraiserError; + +#[derive(Accounts)] +pub struct CheckContributions<'info> { + /// The maker who checks and claims the fundraiser (also the fee payer for Light Protocol) + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Authority PDA — signs vault operations. Writable for Light Token CPI. + #[account(mut, seeds = [AUTH_SEED], bump)] + pub authority: UncheckedAccount<'info>, + + #[account(mint::token_program = token_program)] + pub mint_to_raise: InterfaceAccount<'info, Mint>, + + #[account( + mut, + has_one = mint_to_raise, + seeds = [b"fundraiser".as_ref(), fee_payer.key().as_ref()], + bump = fundraiser.bump, + close = fee_payer, + )] + pub fundraiser: Account<'info, Fundraiser>, + + /// CHECK: Vault token account (Light token account) + #[account( + mut, + seeds = [VAULT_SEED, fundraiser.key().as_ref()], + bump, + )] + pub vault: UncheckedAccount<'info>, + + /// Maker's SPL ATA - must be pre-created before calling this instruction + /// Receives funds from the Light vault via SPL interface + #[account( + mut, + token::mint = mint_to_raise, + token::authority = fee_payer, + )] + pub maker_ata: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// Light token program for CPI calls + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light token rent sponsor - validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: light-token CPI authority - must be writable for Light token CPI + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA for mint (token pool holding SPL tokens) + /// Derived by light-token program: ["pool", mint] + #[account(mut)] + pub spl_interface_pda: UncheckedAccount<'info>, +} + +impl<'info> CheckContributions<'info> { + pub fn check_contributions(&self, _bumps: &CheckContributionsBumps, spl_interface_bump: u8) -> Result<()> { + let vault_balance = get_token_account_balance(&self.vault.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + require!( + vault_balance >= self.fundraiser.amount_to_raise, + FundraiserError::TargetNotMet + ); + + let authority_seeds: &[&[u8]] = &[ + AUTH_SEED, + &[self.fundraiser.auth_bump], + ]; + + let decimals = self.mint_to_raise.decimals; + + if self.spl_interface_pda.key() != Pubkey::default() { + let cpi = TransferInterfaceCpi::new( + vault_balance, + decimals, + self.vault.to_account_info(), + self.maker_ata.to_account_info(), + self.authority.to_account_info(), + self.fee_payer.to_account_info(), + self.light_token_cpi_authority.to_account_info(), + self.system_program.to_account_info(), + ) + .with_spl_interface( + Some(self.mint_to_raise.to_account_info()), + Some(self.token_program.to_account_info()), + Some(self.spl_interface_pda.to_account_info()), + Some(spl_interface_bump), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + TransferCheckedCpi { + source: self.vault.to_account_info(), + mint: self.mint_to_raise.to_account_info(), + destination: self.maker_ata.to_account_info(), + amount: vault_balance, + decimals, + authority: self.authority.to_account_info(), + system_program: self.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(self.fee_payer.to_account_info()), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + Ok(()) + } +} diff --git a/programs/anchor/fundraiser/src/instructions/contribute.rs b/programs/anchor/fundraiser/src/instructions/contribute.rs new file mode 100644 index 0000000..56a7ce6 --- /dev/null +++ b/programs/anchor/fundraiser/src/instructions/contribute.rs @@ -0,0 +1,144 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token::instruction::{TransferCheckedCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; + +use crate::constants::{ANCHOR_DISCRIMINATOR, VAULT_SEED}; +use crate::state::{Contributor, Fundraiser}; +use crate::{FundraiserError, MAX_CONTRIBUTION_PERCENTAGE, PERCENTAGE_SCALER, SECONDS_TO_DAYS}; + +#[derive(Accounts)] +pub struct Contribute<'info> { + #[account(mut)] + pub contributor: Signer<'info>, + + #[account(mint::token_program = token_program)] + pub mint_to_raise: InterfaceAccount<'info, Mint>, + + #[account( + mut, + has_one = mint_to_raise, + seeds = [b"fundraiser".as_ref(), fundraiser.maker.as_ref()], + bump = fundraiser.bump, + )] + pub fundraiser: Account<'info, Fundraiser>, + + #[account( + init_if_needed, + payer = contributor, + seeds = [b"contributor", fundraiser.key().as_ref(), contributor.key().as_ref()], + bump, + space = ANCHOR_DISCRIMINATOR + Contributor::INIT_SPACE, + )] + pub contributor_account: Account<'info, Contributor>, + + #[account( + mut, + token::mint = mint_to_raise, + token::authority = contributor + )] + pub contributor_ata: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: Vault token account + #[account( + mut, + seeds = [VAULT_SEED, fundraiser.key().as_ref()], + bump, + )] + pub vault: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// Light token program for CPI calls + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light token rent sponsor + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: light-token CPI authority - must be writable for Light token CPI + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA for mint (token pool holding SPL tokens) + /// Derived by light-token program: ["pool", mint] + #[account(mut)] + pub spl_interface_pda: UncheckedAccount<'info>, +} + +impl<'info> Contribute<'info> { + pub fn contribute(&mut self, amount: u64, spl_interface_bump: u8) -> Result<()> { + require!( + amount >= 10_u64.pow(self.mint_to_raise.decimals as u32), + FundraiserError::ContributionTooSmall + ); + + let max_contribution = self.fundraiser.amount_to_raise + .checked_mul(MAX_CONTRIBUTION_PERCENTAGE) + .and_then(|v| v.checked_div(PERCENTAGE_SCALER)) + .ok_or(FundraiserError::CalculationOverflow)?; + require!(amount <= max_contribution, FundraiserError::ContributionTooBig); + + let current_time = Clock::get()?.unix_timestamp; + let elapsed_days = ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16; + require!( + elapsed_days < self.fundraiser.duration, + FundraiserError::FundraiserEnded + ); + + require!( + self.contributor_account.amount <= max_contribution + && self.contributor_account.amount.checked_add(amount) + .map_or(false, |total| total <= max_contribution), + FundraiserError::MaximumContributionsReached + ); + + let decimals = self.mint_to_raise.decimals; + + if self.spl_interface_pda.key() != Pubkey::default() { + let cpi = TransferInterfaceCpi::new( + amount, + decimals, + self.contributor_ata.to_account_info(), + self.vault.to_account_info(), + self.contributor.to_account_info(), + self.contributor.to_account_info(), + self.light_token_cpi_authority.to_account_info(), + self.system_program.to_account_info(), + ) + .with_spl_interface( + Some(self.mint_to_raise.to_account_info()), + Some(self.token_program.to_account_info()), + Some(self.spl_interface_pda.to_account_info()), + Some(spl_interface_bump), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + TransferCheckedCpi { + source: self.contributor_ata.to_account_info(), + mint: self.mint_to_raise.to_account_info(), + destination: self.vault.to_account_info(), + amount, + decimals, + authority: self.contributor.to_account_info(), + system_program: self.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(self.contributor.to_account_info()), + } + .invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + self.fundraiser.current_amount = self.fundraiser.current_amount + .checked_add(amount) + .ok_or(FundraiserError::CalculationOverflow)?; + self.contributor_account.amount = self.contributor_account.amount + .checked_add(amount) + .ok_or(FundraiserError::CalculationOverflow)?; + + Ok(()) + } +} diff --git a/programs/anchor/fundraiser/src/instructions/initialize.rs b/programs/anchor/fundraiser/src/instructions/initialize.rs new file mode 100644 index 0000000..e0063c2 --- /dev/null +++ b/programs/anchor/fundraiser/src/instructions/initialize.rs @@ -0,0 +1,95 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenInterface}; +use light_account::CreateAccountsProof; +use light_account::LightAccounts; +use light_token::instruction::{LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR}; + +use crate::constants::{ANCHOR_DISCRIMINATOR, AUTH_SEED, VAULT_SEED}; +use crate::state::Fundraiser; +use crate::{FundraiserError, MIN_AMOUNT_TO_RAISE}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeParams { + pub create_accounts_proof: CreateAccountsProof, + pub amount: u64, + pub duration: u16, + pub vault_bump: u8, +} + +#[derive(Accounts, LightAccounts)] +#[instruction(params: InitializeParams)] +pub struct Initialize<'info> { + /// The maker who creates the fundraiser (also the fee payer for Light Protocol) + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// CHECK: Authority PDA for vault operations + #[account(seeds = [AUTH_SEED], bump)] + pub authority: UncheckedAccount<'info>, + + #[account(mint::token_program = token_program)] + pub mint_to_raise: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = fee_payer, + space = ANCHOR_DISCRIMINATOR + Fundraiser::INIT_SPACE, + seeds = [b"fundraiser", fee_payer.key().as_ref()], + bump, + )] + pub fundraiser: Account<'info, Fundraiser>, + + /// CHECK: Vault token account - initialized via light_account macro + #[account( + mut, + seeds = [VAULT_SEED, fundraiser.key().as_ref()], + bump, + )] + #[light_account(init, + token::seeds = [VAULT_SEED, self.fundraiser.key()], + token::mint = mint_to_raise, + token::owner = authority, + token::owner_seeds = [AUTH_SEED], + token::bump = params.vault_bump + )] + pub vault: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// Light token program for CPI calls + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light token compressible config - validated by address constraint + #[account(address = LIGHT_TOKEN_CONFIG)] + pub light_token_config: AccountInfo<'info>, + + /// CHECK: Light token rent sponsor - validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: light-token CPI authority + pub light_token_cpi_authority: AccountInfo<'info>, +} + +impl<'info> Initialize<'info> { + pub fn initialize(&mut self, params: &InitializeParams, bumps: &InitializeBumps) -> Result<()> { + require!( + params.amount >= MIN_AMOUNT_TO_RAISE.checked_mul(10_u64.pow(self.mint_to_raise.decimals as u32)).ok_or(FundraiserError::InvalidAmount)?, + FundraiserError::InvalidAmount + ); + + self.fundraiser.set_inner(Fundraiser { + maker: self.fee_payer.key(), + mint_to_raise: self.mint_to_raise.key(), + amount_to_raise: params.amount, + current_amount: 0, + time_started: Clock::get()?.unix_timestamp, + duration: params.duration, + bump: bumps.fundraiser, + auth_bump: bumps.authority, + }); + + Ok(()) + } +} diff --git a/programs/anchor/fundraiser/src/instructions/mod.rs b/programs/anchor/fundraiser/src/instructions/mod.rs new file mode 100644 index 0000000..84dbc25 --- /dev/null +++ b/programs/anchor/fundraiser/src/instructions/mod.rs @@ -0,0 +1,9 @@ +pub mod checker; +pub mod contribute; +pub mod initialize; +pub mod refund; + +pub use checker::*; +pub use contribute::*; +pub use initialize::*; +pub use refund::*; diff --git a/programs/anchor/fundraiser/src/instructions/refund.rs b/programs/anchor/fundraiser/src/instructions/refund.rs new file mode 100644 index 0000000..41fea1c --- /dev/null +++ b/programs/anchor/fundraiser/src/instructions/refund.rs @@ -0,0 +1,142 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token::instruction::{TransferCheckedCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; +use light_token::utils::get_token_account_balance; + +use crate::constants::{AUTH_SEED, VAULT_SEED}; +use crate::state::{Contributor, Fundraiser}; +use crate::{FundraiserError, SECONDS_TO_DAYS}; + +#[derive(Accounts)] +pub struct Refund<'info> { + #[account(mut)] + pub contributor: Signer<'info>, + + pub maker: SystemAccount<'info>, + + /// CHECK: Authority PDA — signs vault operations. Writable for Light Token CPI. + #[account(mut, seeds = [AUTH_SEED], bump)] + pub authority: UncheckedAccount<'info>, + + #[account(mint::token_program = token_program)] + pub mint_to_raise: InterfaceAccount<'info, Mint>, + + #[account( + mut, + has_one = mint_to_raise, + seeds = [b"fundraiser", maker.key().as_ref()], + bump = fundraiser.bump, + )] + pub fundraiser: Account<'info, Fundraiser>, + + #[account( + mut, + seeds = [b"contributor", fundraiser.key().as_ref(), contributor.key().as_ref()], + bump, + close = contributor, + )] + pub contributor_account: Account<'info, Contributor>, + + #[account( + mut, + token::mint = mint_to_raise, + token::authority = contributor + )] + pub contributor_ata: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: Vault token account + #[account( + mut, + seeds = [VAULT_SEED, fundraiser.key().as_ref()], + bump, + )] + pub vault: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// Light token program for CPI calls + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light token rent sponsor + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: light-token CPI authority - must be writable for Light token CPI + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA for mint (token pool holding SPL tokens) + /// Derived by light-token program: ["pool", mint] + #[account(mut)] + pub spl_interface_pda: UncheckedAccount<'info>, +} + +impl<'info> Refund<'info> { + pub fn refund(&mut self, _bumps: &RefundBumps, spl_interface_bump: u8) -> Result<()> { + let current_time = Clock::get()?.unix_timestamp; + require!( + self.fundraiser.duration + < ((current_time - self.fundraiser.time_started) / SECONDS_TO_DAYS) as u16, + FundraiserError::FundraiserNotEnded + ); + + let vault_balance = get_token_account_balance(&self.vault.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + require!( + vault_balance < self.fundraiser.amount_to_raise, + FundraiserError::TargetMet + ); + + let authority_seeds: &[&[u8]] = &[ + AUTH_SEED, + &[self.fundraiser.auth_bump], + ]; + + let decimals = self.mint_to_raise.decimals; + let refund_amount = self.contributor_account.amount; + + if self.spl_interface_pda.key() != Pubkey::default() { + let cpi = TransferInterfaceCpi::new( + refund_amount, + decimals, + self.vault.to_account_info(), + self.contributor_ata.to_account_info(), + self.authority.to_account_info(), + self.contributor.to_account_info(), + self.light_token_cpi_authority.to_account_info(), + self.system_program.to_account_info(), + ) + .with_spl_interface( + Some(self.mint_to_raise.to_account_info()), + Some(self.token_program.to_account_info()), + Some(self.spl_interface_pda.to_account_info()), + Some(spl_interface_bump), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + TransferCheckedCpi { + source: self.vault.to_account_info(), + mint: self.mint_to_raise.to_account_info(), + destination: self.contributor_ata.to_account_info(), + amount: refund_amount, + decimals, + authority: self.authority.to_account_info(), + system_program: self.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(self.contributor.to_account_info()), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + self.fundraiser.current_amount = self.fundraiser.current_amount + .checked_sub(refund_amount) + .ok_or(FundraiserError::CalculationOverflow)?; + + Ok(()) + } +} diff --git a/programs/anchor/fundraiser/src/lib.rs b/programs/anchor/fundraiser/src/lib.rs new file mode 100644 index 0000000..67e53cc --- /dev/null +++ b/programs/anchor/fundraiser/src/lib.rs @@ -0,0 +1,45 @@ +#![allow(unexpected_cfgs, deprecated)] + +use anchor_lang::prelude::*; +use light_account::{derive_light_cpi_signer, light_program, CpiSigner}; + +declare_id!("Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("Eoiuq1dXvHxh6dLx3wh9gj8kSAUpga11krTrbfF5XYsC"); + +mod constants; +mod error; +pub mod instructions; +mod state; + +pub use constants::*; +use error::*; +pub use instructions::*; + +#[light_program] +// Anchor's #[program] macro uses deprecated AccountInfo::realloc internally +#[allow(deprecated)] +#[program] +pub mod fundraiser { + use super::*; + + pub fn initialize<'info>( + ctx: Context<'_, '_, '_, 'info, Initialize<'info>>, + params: InitializeParams, + ) -> Result<()> { + ctx.accounts.initialize(¶ms, &ctx.bumps) + } + + pub fn contribute(ctx: Context, amount: u64, spl_interface_bump: u8) -> Result<()> { + ctx.accounts.contribute(amount, spl_interface_bump) + } + + pub fn check_contributions(ctx: Context, spl_interface_bump: u8) -> Result<()> { + ctx.accounts.check_contributions(&ctx.bumps, spl_interface_bump) + } + + pub fn refund(ctx: Context, spl_interface_bump: u8) -> Result<()> { + ctx.accounts.refund(&ctx.bumps, spl_interface_bump) + } +} diff --git a/programs/anchor/fundraiser/src/state/contributor.rs b/programs/anchor/fundraiser/src/state/contributor.rs new file mode 100644 index 0000000..ff83b0f --- /dev/null +++ b/programs/anchor/fundraiser/src/state/contributor.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct Contributor { + pub amount: u64, +} diff --git a/programs/anchor/fundraiser/src/state/fundraiser.rs b/programs/anchor/fundraiser/src/state/fundraiser.rs new file mode 100644 index 0000000..0b6c929 --- /dev/null +++ b/programs/anchor/fundraiser/src/state/fundraiser.rs @@ -0,0 +1,14 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct Fundraiser { + pub maker: Pubkey, + pub mint_to_raise: Pubkey, + pub amount_to_raise: u64, + pub current_amount: u64, + pub time_started: i64, + pub duration: u16, + pub bump: u8, + pub auth_bump: u8, +} diff --git a/programs/anchor/fundraiser/src/state/mod.rs b/programs/anchor/fundraiser/src/state/mod.rs new file mode 100644 index 0000000..d4c1b82 --- /dev/null +++ b/programs/anchor/fundraiser/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod contributor; +pub mod fundraiser; + +pub use contributor::*; +pub use fundraiser::*; diff --git a/programs/anchor/fundraiser/tests/common/mod.rs b/programs/anchor/fundraiser/tests/common/mod.rs new file mode 100644 index 0000000..e6fb6cf --- /dev/null +++ b/programs/anchor/fundraiser/tests/common/mod.rs @@ -0,0 +1,394 @@ +//! Fundraiser test setup for 5 token standard combinations: SPL, Token 2022, Light. +//! +//! Each combination varies the mint type and contributor account type while the vault +//! is always a Light Token account: +//! +//! - `Spl` / `Token2022`: standard associated token accounts, transfers via `TransferInterfaceCpi` +//! - `Light`: Light Token accounts, transfers via `TransferCheckedCpi` +//! - `LightSpl` / `LightT22`: SPL/Token 2022 mints converted into Light Token accounts before +//! the fundraiser starts (tokens are minted to a temporary associated token account, then +//! transferred to an associated Light Token account via `transfer_spl_to_light`) +//! +//! ## Setup flow +//! +//! 1. `create_test_rpc()` — start test validator with fundraiser + minter programs +//! 2. `setup_fundraiser_test(config)` — create mint, interface PDAs, maker, derive PDAs +//! 3. `create_contributor()` — create funded contributor accounts per config + +// ============================================================================ +// Imports +// ============================================================================ + +use anchor_spl::token; +use shared_test_utils::{ + light_tokens::{create_light_ata, create_light_mint, mint_light_tokens}, + setup::initialize_rent_free_config, + spl_interface::{create_spl_interface_pda, transfer_spl_to_light}, + spl_tokens::{create_spl_ata, create_spl_mint, mint_spl_tokens}, + t22_tokens::{create_t22_ata, create_t22_mint, mint_t22_tokens}, + Indexer, LightProgramTest, MintType, ProgramTestConfig, Rpc, + SplInterfaceResult, TestRpc, + LIGHT_TOKEN_MINTER_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID, +}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +// ============================================================================ +// Token Configuration +// ============================================================================ + +/// Token configuration for parameterized fundraiser tests. +/// +/// Each variant determines the mint type and contributor account type. The vault is +/// always a Light Token account (rent-free). The contributor account type controls +/// which CPI path `transfer_tokens()` selects at runtime: +/// +/// - SPL/Token 2022 contributor accounts → `TransferInterfaceCpi` (needs interface PDA) +/// - Light contributor accounts → `TransferCheckedCpi` (no interface PDA) +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub enum TokenConfig { + /// SPL mint + SPL associated token accounts and Light Token vault. + Spl, + /// Token 2022 mint + Token 2022 associated token accounts and Light Token vault. + Token2022, + /// SPL mint + associated Light Token accounts. Tokens converted from SPL associated token accounts in setup. + /// During fundraiser, all transfers are Light-to-Light. + LightSpl, + /// Token 2022 mint + associated Light Token accounts. Tokens converted from Token 2022 associated token accounts in setup. + /// During fundraiser, all transfers are Light-to-Light. + LightT22, + /// Light Token mint + associated Light Token accounts and Light Token vault. + Light, +} + +impl TokenConfig { + /// Returns the underlying mint program type. + /// Light mints use SPL-compatible layout, so this returns `MintType::Spl`. + pub fn mint_type(&self) -> MintType { + match self { + TokenConfig::Spl | TokenConfig::LightSpl => MintType::Spl, + TokenConfig::Token2022 | TokenConfig::LightT22 => MintType::Token2022, + TokenConfig::Light => MintType::Spl, // Light mints use SPL-compatible layout + } + } + + /// Returns the token program ID passed as `token_program` in instructions. + pub fn token_program_id(&self) -> Pubkey { + match self { + TokenConfig::Spl | TokenConfig::LightSpl => token::ID, + TokenConfig::Token2022 | TokenConfig::LightT22 => spl_token_2022::ID, + TokenConfig::Light => Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + } + } + + /// Returns true if mints are Light Token mints (not SPL/Token 2022) + pub fn uses_light_mints(&self) -> bool { + matches!(self, TokenConfig::Light) + } + +} + +// ============================================================================ +// Test Context +// ============================================================================ + +/// Context for fundraiser tests containing all necessary accounts +#[allow(dead_code)] +pub struct FundraiserTestContext { + pub program_id: Pubkey, + pub payer: Keypair, + pub token_config: TokenConfig, + pub compression_config: Pubkey, + /// Per-program rent sponsor PDA (derived from program_id) + pub rent_sponsor: Pubkey, + + // Mint + pub mint_pubkey: Pubkey, + pub light_mint_authority: Option, + pub spl_interface: Option, + + // Participants + pub maker: Keypair, + + // PDAs + pub fundraiser_pda: Pubkey, + pub vault_pda: Pubkey, + pub vault_bump: u8, + pub authority_pda: Pubkey, + + // Fundraiser terms + pub amount_to_raise: u64, + pub duration: u16, +} + +// ============================================================================ +// Setup Functions +// ============================================================================ + +/// Create a new LightProgramTest instance for fundraiser tests +pub async fn create_test_rpc() -> LightProgramTest { + let program_id = fundraiser::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![ + ("fundraiser", program_id), + ("light_token_minter", LIGHT_TOKEN_MINTER_PROGRAM_ID), + ]), + ); + config = config.with_light_protocol_events(); + LightProgramTest::new(config).await.unwrap() +} + +/// Initialize mint, interface PDAs, and participants for a given token config. +/// +/// SPL interface PDAs are created for all SPL/T22 configs (including `Spl` and +/// `Token2022`, not just `LightSpl`/`LightT22`) because the vault is always a +/// Light Token account — `TransferInterfaceCpi` needs the interface PDA when an +/// SPL/Token 2022 account transfers to a Light Token vault. +/// +/// For `Light` config, no interface PDA is created (early return). +/// +/// Pass `duration_override` to set a custom fundraiser duration (in days). +/// Defaults to 7 days. Use `Some(1)` for refund tests to minimize warp time. +pub async fn setup_fundraiser_test( + rpc: &mut R, + config: TokenConfig, + duration_override: Option, +) -> FundraiserTestContext { + let program_id = fundraiser::ID; + let payer = rpc.get_payer().insecure_clone(); + + let (compression_config, rent_sponsor) = + initialize_rent_free_config(rpc, &payer, &program_id).await; + + let maker = Keypair::new(); + rpc.airdrop_lamports(&maker.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let amount_to_raise = 1_000_000_000_000u64; // 1000 tokens (9 decimals) + let duration = duration_override.unwrap_or(7u16); + + let (authority_pda, _) = Pubkey::find_program_address(&[b"authority"], &program_id); + + let (fundraiser_pda, _) = + Pubkey::find_program_address(&[b"fundraiser", maker.pubkey().as_ref()], &program_id); + + let (vault_pda, vault_bump) = Pubkey::find_program_address( + &[fundraiser::VAULT_SEED, fundraiser_pda.as_ref()], + &program_id, + ); + + if config.uses_light_mints() { + // ========== LIGHT MINT SETUP ========== + let light_mint = create_light_mint( + rpc, + &payer, + 9, + "Fundraiser Token", + "FUND", + &compression_config, + ) + .await; + + return FundraiserTestContext { + program_id, + payer, + token_config: config, + compression_config, + rent_sponsor, + mint_pubkey: light_mint.mint, + light_mint_authority: Some(light_mint.authority), + spl_interface: None, + maker, + fundraiser_pda, + vault_pda, + vault_bump, + authority_pda, + amount_to_raise, + duration, + }; + } + + // ========== SPL/T22 MINT SETUP ========== + let mint = match config { + TokenConfig::Spl | TokenConfig::LightSpl => { + create_spl_mint(rpc, &payer, &payer.pubkey(), 9).await + } + TokenConfig::Token2022 | TokenConfig::LightT22 => { + create_t22_mint(rpc, &payer, &payer.pubkey(), 9).await + } + TokenConfig::Light => unreachable!("Light config handled above"), + }; + + let mint_pubkey = mint.pubkey(); + + // Required for `TransferInterfaceCpi` between SPL/Token 2022 and Light Token vault. + let iface = + create_spl_interface_pda(rpc, &payer, &mint_pubkey, config.mint_type(), false).await; + + FundraiserTestContext { + program_id, + payer, + token_config: config, + compression_config, + rent_sponsor, + mint_pubkey, + light_mint_authority: None, + spl_interface: Some(iface), + maker, + fundraiser_pda, + vault_pda, + vault_bump, + authority_pda, + amount_to_raise, + duration, + } +} + +// ============================================================================ +// Contributor Account Creation +// ============================================================================ + +/// Create a contributor with a funded token account. +/// +/// Account creation varies by config: +/// - `Spl` / `Token2022`: create standard associated token account, mint directly +/// - `Light`: create associated Light Token account via `mint_light_tokens` (creates + mints in one call) +/// - `LightSpl` / `LightT22`: create temporary SPL/Token 2022 associated token account → mint → +/// create associated Light Token account → convert via `transfer_spl_to_light`. +pub async fn create_contributor( + rpc: &mut R, + ctx: &FundraiserTestContext, + funding_amount: u64, +) -> (Keypair, Pubkey) { + let contributor = Keypair::new(); + rpc.airdrop_lamports(&contributor.pubkey(), 5_000_000_000) + .await + .unwrap(); + + let contributor_ata = match ctx.token_config { + TokenConfig::Spl => { + let ata = + create_spl_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &contributor.pubkey()).await; + mint_spl_tokens( + rpc, + &ctx.payer, + &ctx.mint_pubkey, + &ata, + &ctx.payer, + funding_amount, + ) + .await; + ata + } + TokenConfig::Token2022 => { + let ata = + create_t22_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &contributor.pubkey()).await; + mint_t22_tokens( + rpc, + &ctx.payer, + &ctx.mint_pubkey, + &ata, + &ctx.payer, + funding_amount, + ) + .await; + ata + } + TokenConfig::LightSpl => { + let temp_ata = + create_spl_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &contributor.pubkey()).await; + mint_spl_tokens( + rpc, + &ctx.payer, + &ctx.mint_pubkey, + &temp_ata, + &ctx.payer, + funding_amount, + ) + .await; + + let light_ata = + create_light_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &contributor.pubkey()).await; + + let iface = ctx + .spl_interface + .as_ref() + .expect("LightSpl requires SPL interface PDA"); + transfer_spl_to_light( + rpc, + &ctx.payer, + &contributor, + &ctx.mint_pubkey, + 9, + &temp_ata, + &light_ata, + &iface.pda, + iface.bump, + funding_amount, + MintType::Spl, + ) + .await; + + light_ata + } + TokenConfig::LightT22 => { + let temp_ata = + create_t22_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &contributor.pubkey()).await; + mint_t22_tokens( + rpc, + &ctx.payer, + &ctx.mint_pubkey, + &temp_ata, + &ctx.payer, + funding_amount, + ) + .await; + + let light_ata = + create_light_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &contributor.pubkey()).await; + + let iface = ctx + .spl_interface + .as_ref() + .expect("LightT22 requires SPL interface PDA"); + transfer_spl_to_light( + rpc, + &ctx.payer, + &contributor, + &ctx.mint_pubkey, + 9, + &temp_ata, + &light_ata, + &iface.pda, + iface.bump, + funding_amount, + MintType::Token2022, + ) + .await; + + light_ata + } + TokenConfig::Light => { + let mint_authority = ctx + .light_mint_authority + .as_ref() + .expect("Light config requires mint authority"); + + mint_light_tokens( + rpc, + &ctx.payer, + mint_authority, + &ctx.mint_pubkey, + &contributor.pubkey(), + funding_amount, + ) + .await + } + }; + + (contributor, contributor_ata) +} diff --git a/programs/anchor/fundraiser/tests/fundraiser.rs b/programs/anchor/fundraiser/tests/fundraiser.rs new file mode 100644 index 0000000..b61a0f5 --- /dev/null +++ b/programs/anchor/fundraiser/tests/fundraiser.rs @@ -0,0 +1,515 @@ +//! Light Token fundraiser: crowdfunding with rent-free vault. +//! +//! This test shows fundraiser patterns adapted for Light Token. +//! +//! 1. **Authority PDA owns the vault** — not the fundraiser account. This lets +//! the authority sign vault withdrawals without needing account data +//! (see `initialize.rs` `#[light_account(init, token::owner = authority)]`). +//! +//! 2. **Vault is rent-free** — it's a Light Token account sponsored by +//! `RENT_SPONSOR`, eliminating the ~0.002 SOL rent per fundraiser. +//! +//! 3. **Validity proof for account creation** — `get_create_accounts_proof()` +//! fetches a validity proof that the vault PDA's derived address does not +//! yet exist in Light's address tree. Only needed in `initialize` (creating +//! new state); `contribute`, `check_contributions`, and `refund` read +//! existing accounts and need no proof. +//! +//! ## Token scenarios +//! +//! The vault is always a Light Token account. The contributor's account type +//! determines which CPI path `TransferInterfaceCpi` selects: +//! +//! | Test | Mint | Contributor accounts | Transfer path | Purpose | +//! |------|------|---------------------|---------------|---------| +//! | `test_fundraiser_spl` | SPL | SPL associated token accounts | `TransferInterfaceCpi` | SPL mints work with Light vault | +//! | `test_fundraiser_t22` | Token 2022 | Token 2022 associated token accounts | `TransferInterfaceCpi` | Token 2022 mints work with Light vault | +//! | `test_fundraiser_light` | Light | Associated Light Token accounts | `TransferCheckedCpi` | Rent-free path | +//! | `test_fundraiser_spl_light` | SPL | Associated Light Token accounts | `TransferCheckedCpi` | SPL mint, converted contributor accounts | +//! | `test_fundraiser_t22_light` | Token 2022 | Associated Light Token accounts | `TransferCheckedCpi` | Token 2022 mint, converted contributor accounts | +//! +//! For `Spl`/`Token2022`: contributor accounts are SPL/Token 2022, vault is Light → +//! `TransferInterfaceCpi` (with SPL interface PDA). +//! +//! For `Light`/`LightSpl`/`LightT22`: all accounts are Light Token → +//! `TransferCheckedCpi` (no interface PDA needed). +//! +//! `LightSpl`/`LightT22` convert tokens from SPL/Token 2022 associated token accounts +//! into associated Light Token accounts *before* the fundraiser starts (in `create_contributor`). +//! The fundraiser itself only sees Light Token accounts. +//! +//! ## Cold/hot lifecycle +//! +//! The vault is a PDA-owned Light Token account that turns cold when +//! sponsored rent expires. Loading a cold vault back to active requires +//! `VaultSeeds` / `LightAccountVariant::Vault` (generated by `#[light_program]` +//! when state derives `LightAccount`). Since `Fundraiser` uses standard +//! `#[account]`, these types aren't generated, so the test doesn't exercise +//! cold/hot lifecycle. See the escrow tests for full cold/hot coverage. + +mod common; + +use anchor_lang::{InstructionData, ToAccountMetas}; +use common::{ + create_contributor, create_test_rpc, setup_fundraiser_test, FundraiserTestContext, TokenConfig, +}; +use light_token::spl_interface::find_spl_interface_pda; +use light_client::interface::{get_create_accounts_proof, CreateAccountsProofInput}; +use shared_test_utils::{ + helpers::verify_light_token_balance, + light_tokens::create_light_ata, + spl_tokens::create_spl_ata, + t22_tokens::create_t22_ata, + Indexer, LightProgramTest, Rpc, TestRpc, COMPRESSIBLE_CONFIG_V1, CPI_AUTHORITY_PDA, + LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR, +}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +// ============================================================================ +// Tests — one per token configuration: SPL, Token 2022, Light + 1 refund test. +// ============================================================================ + +/// SPL mint with SPL associated token accounts and Light Token vault. +/// +/// All transfers use `TransferInterfaceCpi` (SPL ↔ Light Token vault) +/// with SPL interface PDAs. This is the baseline: same mint type as standard +/// SPL fundraiser, but the vault is a rent-free Light Token account. +#[tokio::test] +async fn test_fundraiser_spl() { + let mut rpc = create_test_rpc().await; + let ctx = setup_fundraiser_test(&mut rpc, TokenConfig::Spl, None).await; + run_fundraiser_full_flow(&mut rpc, &ctx).await; +} + +/// Token 2022 mint with Token 2022 associated token accounts and Light Token vault. +/// +/// All transfers use `TransferInterfaceCpi` (Token 2022 ↔ Light Token vault) +/// with SPL interface PDAs. +#[tokio::test] +async fn test_fundraiser_t22() { + let mut rpc = create_test_rpc().await; + let ctx = setup_fundraiser_test(&mut rpc, TokenConfig::Token2022, None).await; + run_fundraiser_full_flow(&mut rpc, &ctx).await; +} + +/// Light Token mint + Light Token contributor accounts and Light Token vault. +/// +/// All transfers use `TransferCheckedCpi`. +#[tokio::test] +async fn test_fundraiser_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_fundraiser_test(&mut rpc, TokenConfig::Light, None).await; + run_fundraiser_full_flow(&mut rpc, &ctx).await; +} + +/// SPL mint + Light Token contributor accounts. SPL tokens converted to associated +/// Light Token accounts in setup via `transfer_spl_to_light`. +/// +/// All transfers use `TransferCheckedCpi`. +#[tokio::test] +async fn test_fundraiser_spl_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_fundraiser_test(&mut rpc, TokenConfig::LightSpl, None).await; + run_fundraiser_full_flow(&mut rpc, &ctx).await; +} + +/// Token 2022 mint + Light Token contributor accounts. Token 2022 tokens converted to +/// associated Light Token accounts in setup via `transfer_spl_to_light`. +/// +/// All transfers use `TransferCheckedCpi`. +#[tokio::test] +async fn test_fundraiser_t22_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_fundraiser_test(&mut rpc, TokenConfig::LightT22, None).await; + run_fundraiser_full_flow(&mut rpc, &ctx).await; +} + +/// Light Token mint refund flow: deadline passes, target not met, contributors refund. +/// +/// Uses `duration: 1` (1 day) to minimize warp time. Only 3 contributors +/// contribute (30% of target), so the target is not met. After advancing +/// past the deadline, each contributor calls `refund` to reclaim tokens. +#[tokio::test] +async fn test_fundraiser_refund() { + let mut rpc = create_test_rpc().await; + let ctx = setup_fundraiser_test(&mut rpc, TokenConfig::Light, Some(1)).await; + run_fundraiser_refund_flow(&mut rpc, &ctx).await; +} + +// ============================================================================ +// Full Flow — Claim +// ============================================================================ + +/// Run the full fundraiser claim flow for any token configuration: SPL, Token 2022, Light. +/// +/// 1. Initialize fundraiser: create vault, record terms +/// 2. Verify vault has 0 balance +/// 3. 10 contributors each contribute 10% of target +/// 4. Verify vault reached target +/// 5. Maker claims funds via check_contributions +/// 6. Verify final balances and account closure +async fn run_fundraiser_full_flow( + rpc: &mut R, + ctx: &FundraiserTestContext, +) { + initialize(rpc, ctx).await; + + verify_light_token_balance(rpc, ctx.vault_pda, 0, "vault (initial)").await; + + let contribution_amount = ctx.amount_to_raise * 10 / 100; + let contributor_funding = contribution_amount * 2; // extra for fees + + for _i in 0..10 { + let (contributor, contributor_ata) = + create_contributor(rpc, ctx, contributor_funding).await; + contribute(rpc, ctx, &contributor, contributor_ata, contribution_amount).await; + } + + verify_light_token_balance( + rpc, + ctx.vault_pda, + ctx.amount_to_raise, + "vault (target reached)", + ) + .await; + + let maker_ata = check_contributions(rpc, ctx).await; + + verify_light_token_balance(rpc, ctx.vault_pda, 0, "vault (after claim)").await; + + verify_light_token_balance( + rpc, + maker_ata, + ctx.amount_to_raise, + "maker_ata (after claim)", + ) + .await; + + let fundraiser_account = rpc.get_account(ctx.fundraiser_pda).await.unwrap(); + assert!( + fundraiser_account.is_none(), + "Fundraiser account should be closed after check_contributions" + ); +} + +// ============================================================================ +// Refund Flow +// ============================================================================ + +/// Run the fundraiser refund flow: deadline passes, target not met, contributors reclaim. +/// +/// 1. Initialize fundraiser with short duration (1 day) +/// 2. 3 contributors each contribute 10% (30% total — target not met) +/// 3. Advance clock past deadline (set unix_timestamp directly — no slot warp) +/// 4. Each contributor calls refund to reclaim tokens +/// 5. Verify vault empty, contributor balances restored, fundraiser still exists +async fn run_fundraiser_refund_flow( + rpc: &mut LightProgramTest, + ctx: &FundraiserTestContext, +) { + initialize(rpc, ctx).await; + + verify_light_token_balance(rpc, ctx.vault_pda, 0, "vault (initial)").await; + + let contribution_amount = ctx.amount_to_raise * 10 / 100; + let contributor_funding = contribution_amount * 2; + let num_contributors = 3; + + let mut contributors: Vec<(Keypair, Pubkey)> = Vec::new(); + for _i in 0..num_contributors { + let (contributor, contributor_ata) = + create_contributor(rpc, ctx, contributor_funding).await; + contribute(rpc, ctx, &contributor, contributor_ata, contribution_amount).await; + contributors.push((contributor, contributor_ata)); + } + + let total_contributed = contribution_amount * num_contributors as u64; + verify_light_token_balance(rpc, ctx.vault_pda, total_contributed, "vault (partial)").await; + + // Advance the Clock sysvar's unix_timestamp directly — `warp_epoch_forward` + // only updates the slot, not unix_timestamp, in LiteSVM. + // + // We don't warp slots here because `warp_epoch_forward` turns all Light + // accounts cold (vault, mints, contributor token accounts), and the fundraiser can't load + // the vault from cold: `Fundraiser` doesn't derive `LightAccount`, so + // `#[light_program]` doesn't generate `VaultSeeds` / `LightAccountVariant::Vault` + // needed by `create_load_instructions`. + // + // Duration is 1 day. Refund requires `duration < elapsed_days`, so we need + // the elapsed time to exceed 1 full day (86,400 seconds). Use 2 days. + { + use solana_sdk::clock::Clock; + let mut clock = rpc.context.get_sysvar::(); + clock.unix_timestamp += 2 * 86_400; // advance 2 days + rpc.context.set_sysvar(&clock); + } + + for (contributor, contributor_ata) in &contributors { + refund(rpc, ctx, contributor, *contributor_ata).await; + + // Verify contributor received tokens back (started with contributor_funding, + // contributed contribution_amount, got it back → net = contributor_funding) + verify_light_token_balance( + rpc, + *contributor_ata, + contributor_funding, + "contributor_ata (after refund)", + ) + .await; + + let (contributor_account_pda, _) = Pubkey::find_program_address( + &[ + b"contributor", + ctx.fundraiser_pda.as_ref(), + contributor.pubkey().as_ref(), + ], + &ctx.program_id, + ); + let contributor_acct = rpc.get_account(contributor_account_pda).await.unwrap(); + assert!( + contributor_acct.is_none(), + "Contributor account should be closed after refund" + ); + } + + verify_light_token_balance(rpc, ctx.vault_pda, 0, "vault (after all refunds)").await; + + let fundraiser_account = rpc.get_account(ctx.fundraiser_pda).await.unwrap(); + assert!( + fundraiser_account.is_some(), + "Fundraiser account should still exist after refunds (only closed by check_contributions)" + ); +} + +// ============================================================================ +// Instruction Helpers +// ============================================================================ + +/// Fetch validity proof for vault creation, send `initialize` instruction. +async fn initialize( + rpc: &mut R, + ctx: &FundraiserTestContext, +) { + // Validity proof: verifies that the vault PDA's derived address does not + // yet exist in Light's address tree. + let proof_result = get_create_accounts_proof( + rpc, + &ctx.program_id, + vec![CreateAccountsProofInput::pda(ctx.vault_pda)], + ) + .await + .unwrap(); + + let accounts = fundraiser::accounts::Initialize { + fee_payer: ctx.maker.pubkey(), + authority: ctx.authority_pda, + mint_to_raise: ctx.mint_pubkey, + fundraiser: ctx.fundraiser_pda, + vault: ctx.vault_pda, + token_program: ctx.token_config.token_program_id(), + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + }; + + let data = fundraiser::instruction::Initialize { + params: fundraiser::instructions::InitializeParams { + create_accounts_proof: proof_result.create_accounts_proof, + amount: ctx.amount_to_raise, + duration: ctx.duration, + vault_bump: ctx.vault_bump, + }, + }; + + // Remaining accounts: Light system accounts for proof verification + // and address tree insertion (see `get_create_accounts_proof`). + let ix = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: data.data(), + }; + + rpc.create_and_send_transaction( + &[ix], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.maker], + ) + .await + .expect("initialize should succeed"); +} + +/// Send `contribute` instruction: contributor transfers tokens into the vault. +async fn contribute( + rpc: &mut R, + ctx: &FundraiserTestContext, + contributor: &Keypair, + contributor_ata: Pubkey, + amount: u64, +) { + let (contributor_account_pda, _) = Pubkey::find_program_address( + &[ + b"contributor", + ctx.fundraiser_pda.as_ref(), + contributor.pubkey().as_ref(), + ], + &ctx.program_id, + ); + + let accounts = fundraiser::accounts::Contribute { + contributor: contributor.pubkey(), + mint_to_raise: ctx.mint_pubkey, + fundraiser: ctx.fundraiser_pda, + contributor_account: contributor_account_pda, + contributor_ata, + vault: ctx.vault_pda, + token_program: ctx.token_config.token_program_id(), + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda: ctx + .spl_interface + .as_ref() + .map(|i| i.pda) + .unwrap_or_default(), + }; + + let (_, spl_interface_bump) = find_spl_interface_pda(&ctx.mint_pubkey, false); + let data = fundraiser::instruction::Contribute { amount, spl_interface_bump }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction( + &[ix], + &ctx.payer.pubkey(), + &[&ctx.payer, contributor], + ) + .await + .expect("contribute should succeed"); +} + +/// Create maker's token account, send `check_contributions` instruction to claim funds. +/// +/// Returns the maker's ATA pubkey. +async fn check_contributions( + rpc: &mut R, + ctx: &FundraiserTestContext, +) -> Pubkey { + let maker_ata = match ctx.token_config { + TokenConfig::Spl | TokenConfig::LightSpl => { + create_spl_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &ctx.maker.pubkey()).await + } + TokenConfig::Token2022 | TokenConfig::LightT22 => { + create_t22_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &ctx.maker.pubkey()).await + } + TokenConfig::Light => { + create_light_ata(rpc, &ctx.payer, &ctx.mint_pubkey, &ctx.maker.pubkey()).await + } + }; + + let accounts = fundraiser::accounts::CheckContributions { + fee_payer: ctx.maker.pubkey(), + authority: ctx.authority_pda, + mint_to_raise: ctx.mint_pubkey, + fundraiser: ctx.fundraiser_pda, + vault: ctx.vault_pda, + maker_ata, + token_program: ctx.token_config.token_program_id(), + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda: ctx + .spl_interface + .as_ref() + .map(|i| i.pda) + .unwrap_or_default(), + }; + + let (_, spl_interface_bump) = find_spl_interface_pda(&ctx.mint_pubkey, false); + let data = fundraiser::instruction::CheckContributions { spl_interface_bump }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction( + &[ix], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.maker], + ) + .await + .expect("check_contributions should succeed"); + + maker_ata +} + +/// Send `refund` instruction: contributor reclaims tokens from vault after deadline. +async fn refund( + rpc: &mut R, + ctx: &FundraiserTestContext, + contributor: &Keypair, + contributor_ata: Pubkey, +) { + let (contributor_account_pda, _) = Pubkey::find_program_address( + &[ + b"contributor", + ctx.fundraiser_pda.as_ref(), + contributor.pubkey().as_ref(), + ], + &ctx.program_id, + ); + + let accounts = fundraiser::accounts::Refund { + contributor: contributor.pubkey(), + maker: ctx.maker.pubkey(), + authority: ctx.authority_pda, + mint_to_raise: ctx.mint_pubkey, + fundraiser: ctx.fundraiser_pda, + contributor_account: contributor_account_pda, + contributor_ata, + vault: ctx.vault_pda, + token_program: ctx.token_config.token_program_id(), + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda: ctx + .spl_interface + .as_ref() + .map(|i| i.pda) + .unwrap_or_default(), + }; + + let (_, spl_interface_bump) = find_spl_interface_pda(&ctx.mint_pubkey, false); + let data = fundraiser::instruction::Refund { spl_interface_bump }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction( + &[ix], + &ctx.payer.pubkey(), + &[&ctx.payer, contributor], + ) + .await + .expect("refund should succeed"); +} diff --git a/programs/anchor/light-token-minter/Cargo.toml b/programs/anchor/light-token-minter/Cargo.toml new file mode 100644 index 0000000..df16018 --- /dev/null +++ b/programs/anchor/light-token-minter/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "light-token-minter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "light_token_minter" + +[features] +no-entrypoint = [] +no-log-ix-name = [] +no-idl = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build", "light-token/idl-build", "light-anchor-spl/idl-build"] +test-sbf = [] + +[dependencies] +anchor-lang = { version = "=0.31.1", features = ["init-if-needed"] } + +light-sdk = { workspace = true, features = ["anchor", "cpi-context", "v2"] } +light-token = { workspace = true, features = ["anchor"] } +light-account = { workspace = true } +# Required by LightAccount derive macro +light-hasher = { workspace = true, features = ["solana"] } +light-anchor-spl = { workspace = true } +solana-program = { workspace = true } +solana-pubkey = { workspace = true } +solana-account-info = { workspace = true } +solana-program-error = { workspace = true } +solana-msg = { workspace = true } + +[dev-dependencies] +light-program-test = { workspace = true } +light-client = { workspace = true, features = ["v2", "anchor"] } +tokio = { workspace = true, features = ["full"] } +spl-token = { workspace = true } +solana-keypair = { workspace = true } +solana-signer = { workspace = true } +solana-instruction = { workspace = true } +solana-sdk = { workspace = true } +spl-token-2022 = { workspace = true } +spl-pod = { workspace = true } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/programs/anchor/light-token-minter/src/instructions/create.rs b/programs/anchor/light-token-minter/src/instructions/create.rs new file mode 100644 index 0000000..eb4466d --- /dev/null +++ b/programs/anchor/light-token-minter/src/instructions/create.rs @@ -0,0 +1,79 @@ +use anchor_lang::prelude::*; +use light_account::CreateAccountsProof; +use light_account::LightAccounts; +use light_token::instruction::{LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR}; + +use crate::MINT_SIGNER_SEED; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreateMintParams { + pub create_accounts_proof: CreateAccountsProof, + pub decimals: u8, + pub mint_signer_bump: u8, + pub token_name: String, + pub token_symbol: String, + pub token_uri: String, +} + +#[derive(Accounts, LightAccounts)] +#[instruction(params: CreateMintParams)] +pub struct CreateMint<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub authority: Signer<'info>, + + /// CHECK: PDA derived from authority, used as mint signer seed. + #[account( + seeds = [MINT_SIGNER_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer: UncheckedAccount<'info>, + + /// CHECK: Initialized by light_account macro. + #[account(mut)] + #[light_account(init, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = params.decimals, + mint::seeds = &[MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_bump, + mint::name = params.token_name.clone().into_bytes(), + mint::symbol = params.token_symbol.clone().into_bytes(), + mint::uri = params.token_uri.clone().into_bytes(), + mint::update_authority = authority + )] + pub light_mint: UncheckedAccount<'info>, + + /// CHECK: Config for light mint creation. + pub compression_config: AccountInfo<'info>, + + /// CHECK: Light token compressible config. + #[account(address = LIGHT_TOKEN_CONFIG)] + pub light_token_config: AccountInfo<'info>, + + /// CHECK: Light token rent sponsor. + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token program. + pub light_token_program: AccountInfo<'info>, + + /// CHECK: Light token CPI authority. + pub light_token_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn create_token<'info>( + ctx: &Context<'_, '_, '_, 'info, CreateMint<'info>>, + params: &CreateMintParams, +) -> Result<()> { + msg!("Light mint created with metadata"); + msg!("Name: {}", params.token_name); + msg!("Symbol: {}", params.token_symbol); + msg!("URI: {}", params.token_uri); + msg!("Mint signer: {}", ctx.accounts.mint_signer.key()); + msg!("Mint authority: {}", ctx.accounts.authority.key()); + Ok(()) +} diff --git a/programs/anchor/light-token-minter/src/instructions/mint.rs b/programs/anchor/light-token-minter/src/instructions/mint.rs new file mode 100644 index 0000000..7b3dc03 --- /dev/null +++ b/programs/anchor/light-token-minter/src/instructions/mint.rs @@ -0,0 +1,69 @@ +use anchor_lang::prelude::*; +use light_token::instruction::{MintToCpi, LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct MintTokenParams { + pub amount: u64, +} + +#[derive(Accounts)] +pub struct MintTo<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// Must be writable because light-token MintTo marks authority as writable in CPI. + #[account(mut)] + pub mint_authority: Signer<'info>, + + /// CHECK: Light mint account. + #[account(mut)] + pub mint: AccountInfo<'info>, + + /// CHECK: Recipient and owner of the destination token account. + pub recipient: AccountInfo<'info>, + + /// CHECK: Destination Light ATA, derived client-side via derive_token_ata. + /// Created by the Light Token Program during MintTo CPI if it does not exist. + #[account(mut)] + pub destination: UncheckedAccount<'info>, + + /// CHECK: Light token program. + pub light_token_program: AccountInfo<'info>, + + pub system_program: Program<'info, System>, + + /// CHECK: Light token compressible config. + #[account(address = LIGHT_TOKEN_CONFIG)] + pub light_token_config: AccountInfo<'info>, + + /// CHECK: Light token rent sponsor. + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority. + pub light_token_cpi_authority: AccountInfo<'info>, +} + +pub fn mint_token<'info>( + ctx: &Context<'_, '_, '_, 'info, MintTo<'info>>, + params: &MintTokenParams, +) -> Result<()> { + MintToCpi { + mint: ctx.accounts.mint.to_account_info(), + destination: ctx.accounts.destination.to_account_info(), + amount: params.amount, + authority: ctx.accounts.mint_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.fee_payer.to_account_info()), + } + .invoke()?; + + msg!( + "Minted {} tokens to {} (owner: {})", + params.amount, + ctx.accounts.destination.key(), + ctx.accounts.recipient.key() + ); + Ok(()) +} diff --git a/programs/anchor/light-token-minter/src/instructions/mod.rs b/programs/anchor/light-token-minter/src/instructions/mod.rs new file mode 100644 index 0000000..b5fc428 --- /dev/null +++ b/programs/anchor/light-token-minter/src/instructions/mod.rs @@ -0,0 +1,5 @@ +pub mod create; +pub mod mint; + +pub use create::*; +pub use mint::*; diff --git a/programs/anchor/light-token-minter/src/lib.rs b/programs/anchor/light-token-minter/src/lib.rs new file mode 100644 index 0000000..b91f62d --- /dev/null +++ b/programs/anchor/light-token-minter/src/lib.rs @@ -0,0 +1,37 @@ +#![allow(unexpected_cfgs, deprecated)] + +pub mod instructions; + +use anchor_lang::prelude::*; +use light_account::{derive_light_cpi_signer, light_program, CpiSigner}; + +pub use instructions::*; + +declare_id!("3EPJBoxM8Evtv3Wk7R2mSWsrSzUD7WSKAaYugLgpCitV"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("3EPJBoxM8Evtv3Wk7R2mSWsrSzUD7WSKAaYugLgpCitV"); + +pub const MINT_SIGNER_SEED: &[u8] = b"mint_signer"; + +#[light_program] +// Anchor's #[program] macro uses deprecated AccountInfo::realloc internally +#[allow(deprecated)] +#[program] +pub mod light_token_minter { + use super::*; + + pub fn create_mint<'info>( + ctx: Context<'_, '_, '_, 'info, CreateMint<'info>>, + params: CreateMintParams, + ) -> Result<()> { + instructions::create::create_token(&ctx, ¶ms) + } + + pub fn mint_to<'info>( + ctx: Context<'_, '_, '_, 'info, MintTo<'info>>, + params: MintTokenParams, + ) -> Result<()> { + instructions::mint::mint_token(&ctx, ¶ms) + } +} diff --git a/programs/anchor/light-token-minter/tests/minter_test.rs b/programs/anchor/light-token-minter/tests/minter_test.rs new file mode 100644 index 0000000..c373061 --- /dev/null +++ b/programs/anchor/light-token-minter/tests/minter_test.rs @@ -0,0 +1,330 @@ +//! Integration tests for Light Token mint creation using #[light_account(init, mint)] macro. + +use anchor_lang::{InstructionData, ToAccountMetas}; +use light_account::derive_rent_sponsor_pda; +use light_client::interface::{ + get_create_accounts_proof, CreateAccountsProofInput, InitializeRentFreeConfig, +}; +use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest}, + ProgramTestConfig, Rpc, +}; +use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; +use light_token::instruction::{ + derive_token_ata, find_mint_address, LIGHT_TOKEN_CONFIG as COMPRESSIBLE_CONFIG_V1, + LIGHT_TOKEN_RENT_SPONSOR as RENT_SPONSOR, +}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +/// Test creating a Light Token mint using the #[light_account(init, mint)] macro. +#[tokio::test] +async fn test_create_light_mint() { + let program_id = light_token_minter::ID; + let mut config = + ProgramTestConfig::new_v2(true, Some(vec![("light_token_minter", program_id)])); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (rent_sponsor, _) = derive_rent_sponsor_pda(&program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + rent_sponsor, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let authority = Keypair::new(); + + let (mint_signer_pda, mint_signer_bump) = Pubkey::find_program_address( + &[ + light_token_minter::MINT_SIGNER_SEED, + authority.pubkey().as_ref(), + ], + &program_id, + ); + + let (light_mint_pda, _) = find_mint_address(&mint_signer_pda); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::mint(mint_signer_pda)], + ) + .await + .unwrap(); + + let accounts = light_token_minter::accounts::CreateMint { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + mint_signer: mint_signer_pda, + light_mint: light_mint_pda, + compression_config: config_pda, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + light_token_cpi_authority: light_token::constants::LIGHT_TOKEN_CPI_AUTHORITY.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = light_token_minter::instruction::CreateMint { + params: light_token_minter::CreateMintParams { + create_accounts_proof: proof_result.create_accounts_proof, + decimals: 9, + mint_signer_bump, + token_name: "Test Token".to_string(), + token_symbol: "TEST".to_string(), + token_uri: "https://example.com/metadata.json".to_string(), + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("CreateMint should succeed"); + + let light_mint_account = rpc + .get_account(light_mint_pda) + .await + .unwrap() + .expect("Mint should exist on-chain"); + + assert!( + !light_mint_account.data.is_empty(), + "Mint account should have data" + ); + + assert!( + light_mint_account.data.len() > 50, + "Mint account should have sufficient data for a Light Token mint" + ); + +} + +/// Test basic mint signer PDA derivation without creating the mint. +#[tokio::test] +async fn test_mint_signer_derivation() { + let program_id = light_token_minter::ID; + let mut config = + ProgramTestConfig::new_v2(true, Some(vec![("light_token_minter", program_id)])); + config = config.with_light_protocol_events(); + + let rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let (mint_signer_pda, bump) = Pubkey::find_program_address( + &[ + light_token_minter::MINT_SIGNER_SEED, + payer.pubkey().as_ref(), + ], + &program_id, + ); + + let (mint_pda, _) = find_mint_address(&mint_signer_pda); + + let (verify_signer, verify_bump) = Pubkey::find_program_address( + &[ + light_token_minter::MINT_SIGNER_SEED, + payer.pubkey().as_ref(), + ], + &program_id, + ); + assert_eq!(mint_signer_pda, verify_signer); + assert_eq!(bump, verify_bump); +} + +/// Test the full flow: create Light Token mint, create token account, mint tokens. +#[tokio::test] +async fn test_mint_to() { + let program_id = light_token_minter::ID; + let mut config = + ProgramTestConfig::new_v2(true, Some(vec![("light_token_minter", program_id)])); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let (rent_sponsor, _) = derive_rent_sponsor_pda(&program_id); + + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + rent_sponsor, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 1_000_000_000) + .await + .unwrap(); + + let (mint_signer_pda, mint_signer_bump) = Pubkey::find_program_address( + &[ + light_token_minter::MINT_SIGNER_SEED, + authority.pubkey().as_ref(), + ], + &program_id, + ); + + let (light_mint_pda, _) = find_mint_address(&mint_signer_pda); + + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::mint(mint_signer_pda)], + ) + .await + .unwrap(); + + let create_mint_accounts = light_token_minter::accounts::CreateMint { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + mint_signer: mint_signer_pda, + light_mint: light_mint_pda, + compression_config: config_pda, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + light_token_cpi_authority: light_token::constants::LIGHT_TOKEN_CPI_AUTHORITY.into(), + system_program: solana_sdk::system_program::ID, + }; + + let create_mint_data = light_token_minter::instruction::CreateMint { + params: light_token_minter::CreateMintParams { + create_accounts_proof: proof_result.create_accounts_proof, + decimals: 9, + mint_signer_bump, + token_name: "Test Token".to_string(), + token_symbol: "TEST".to_string(), + token_uri: "https://example.com/metadata.json".to_string(), + }, + }; + + let create_mint_ix = Instruction { + program_id, + accounts: [ + create_mint_accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: create_mint_data.data(), + }; + + rpc.create_and_send_transaction(&[create_mint_ix], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("CreateMint should succeed"); + + let light_mint_account = rpc + .get_account(light_mint_pda) + .await + .unwrap() + .expect("Mint should exist on-chain"); + assert!( + !light_mint_account.data.is_empty(), + "Mint account should have data" + ); + + let recipient = Keypair::new(); + rpc.airdrop_lamports(&recipient.pubkey(), 1_000_000_000) + .await + .unwrap(); + + let recipient_ata = derive_token_ata(&recipient.pubkey(), &light_mint_pda); + + let create_ata_ix = light_token::instruction::CreateAssociatedTokenAccount::new( + payer.pubkey(), + recipient.pubkey(), + light_mint_pda, + ) + .instruction() + .unwrap(); + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Create recipient ATA"); + + let mint_amount = 1_000_000_000u64; + + let mint_to_accounts = light_token_minter::accounts::MintTo { + fee_payer: payer.pubkey(), + mint_authority: authority.pubkey(), + mint: light_mint_pda, + recipient: recipient.pubkey(), + destination: recipient_ata, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + system_program: solana_sdk::system_program::ID, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: light_token::constants::LIGHT_TOKEN_CPI_AUTHORITY.into(), + }; + + let mint_to_data = light_token_minter::instruction::MintTo { + params: light_token_minter::MintTokenParams { + amount: mint_amount, + }, + }; + + let mint_to_ix = Instruction { + program_id, + accounts: mint_to_accounts.to_account_metas(None), + data: mint_to_data.data(), + }; + + rpc.create_and_send_transaction(&[mint_to_ix], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("MintTo should succeed"); + + let ata_account_after = rpc + .get_account(recipient_ata) + .await + .unwrap() + .expect("ATA should still exist"); + + // Light Token accounts have SPL-compatible data in first 165 bytes + use spl_token_2022::pod::PodAccount; + if ata_account_after.data.len() >= 165 { + let token_state = + spl_pod::bytemuck::pod_from_bytes::(&ata_account_after.data[..165]) + .unwrap(); + let balance = u64::from(token_state.amount); + assert_eq!( + balance, mint_amount, + "Token balance should match minted amount" + ); + } else { + panic!( + "ATA data too short: {} bytes (expected >= 165)", + ata_account_after.data.len() + ); + } + +} diff --git a/programs/anchor/shared-test-utils/Cargo.toml b/programs/anchor/shared-test-utils/Cargo.toml new file mode 100644 index 0000000..b441145 --- /dev/null +++ b/programs/anchor/shared-test-utils/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "shared-test-utils" +version = "0.1.0" +description = "Shared test utilities for Light Protocol example programs" +edition = "2021" + +[lib] +name = "shared_test_utils" + +[dependencies] +# Light Protocol dependencies (from workspace) +light-program-test.workspace = true +light-client = { workspace = true, features = ["v2", "anchor"] } +light-sdk.workspace = true +light-token = { workspace = true, features = ["anchor"] } +light-account = { workspace = true } +light-hasher = { workspace = true, features = ["solana"] } + +# Example program dependencies (for calling Light mint creation instructions) +light-token-minter = { path = "../light-token-minter", features = ["no-entrypoint"] } + +# Anchor dependencies +anchor-lang = { workspace = true } +anchor-spl = { workspace = true } + +# Solana dependencies +solana-keypair = { workspace = true } +solana-signer = { workspace = true } +solana-instruction = { workspace = true } +solana-sdk = { workspace = true } +solana-pubkey = { workspace = true } + +# SPL Token dependencies +spl-token-2022 = { workspace = true } +spl-pod = { workspace = true } + +# Async +tokio = { workspace = true, features = ["full"] } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/programs/anchor/shared-test-utils/src/lib.rs b/programs/anchor/shared-test-utils/src/lib.rs new file mode 100644 index 0000000..2d59056 --- /dev/null +++ b/programs/anchor/shared-test-utils/src/lib.rs @@ -0,0 +1,828 @@ +//! Shared test utilities for Light Protocol example programs. +//! +//! This crate provides helper functions for creating and managing: +//! - SPL mints and token accounts +//! - Token-2022 mints and token accounts +//! - Light Protocol token accounts +//! - SPL interface PDAs for SPL<->Light transfers +//! - Balance verification utilities + +use anchor_spl::token; +use light_token::spl_interface::{find_spl_interface_pda, CreateSplInterfacePda}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; +use spl_token_2022::pod::PodAccount; + +// Re-exports for convenience +pub use light_account::derive_rent_sponsor_pda; +pub use light_client::interface::{ + get_create_accounts_proof, CreateAccountsProofInput, CreateAccountsProofResult, + InitializeRentFreeConfig, +}; +pub use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest, TestRpc}, + Indexer, ProgramTestConfig, Rpc, +}; +pub use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; +pub use light_token::constants::LIGHT_TOKEN_CPI_AUTHORITY as CPI_AUTHORITY_PDA; +pub use light_token::instruction::{LIGHT_TOKEN_CONFIG as COMPRESSIBLE_CONFIG_V1, LIGHT_TOKEN_RENT_SPONSOR as RENT_SPONSOR}; + +// Re-export light-token-minter program ID for tests that need to create Light mints +pub use light_token_minter::ID as LIGHT_TOKEN_MINTER_PROGRAM_ID; + +/// Token type for mint creation +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MintType { + /// Standard SPL Token (token::ID) + Spl, + /// Token-2022 (spl_token_2022::ID) + Token2022, +} + +impl MintType { + pub fn program_id(&self) -> Pubkey { + match self { + MintType::Spl => token::ID, + MintType::Token2022 => spl_token_2022::ID, + } + } +} + +/// Result of creating an SPL interface PDA +pub struct SplInterfaceResult { + pub pda: Pubkey, + pub bump: u8, +} + +// ============================================================================ +// Setup Module +// ============================================================================ + +pub mod setup { + use super::*; + + /// Initialize rent-free config for a program. + /// + /// Derives a per-program rent sponsor PDA from `[RENT_SPONSOR_SEED]` + `program_id`. + /// Returns `(config_pda, rent_sponsor_pda)`. + pub async fn initialize_rent_free_config( + rpc: &mut R, + payer: &Keypair, + program_id: &Pubkey, + ) -> (Pubkey, Pubkey) { + // Setup program data for rent-free config + let program_data_pda = setup_mock_program_data(rpc, payer, program_id); + + // Derive per-program rent sponsor PDA + let (rent_sponsor, _) = derive_rent_sponsor_pda(program_id); + + // Initialize rent-free config + let (init_config_ix, config_pda) = InitializeRentFreeConfig::new( + program_id, + &payer.pubkey(), + &program_data_pda, + rent_sponsor, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[payer]) + .await + .expect("Initialize rent-free config should succeed"); + + // Fund the per-program rent sponsor PDA so it can pay for PDA rent + rpc.airdrop_lamports(&rent_sponsor, 100_000_000_000) + .await + .expect("Fund rent sponsor PDA should succeed"); + + println!("Rent-free config initialized at: {:?}", config_pda); + println!(" Rent sponsor PDA: {:?} (funded)", rent_sponsor); + (config_pda, rent_sponsor) + } + + /// Create a new LightProgramTest instance with the given program + pub async fn create_program_test( + program_name: &'static str, + program_id: Pubkey, + ) -> (LightProgramTest, Keypair) { + let mut config = ProgramTestConfig::new_v2(true, Some(vec![(program_name, program_id)])); + config = config.with_light_protocol_events(); + + let rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + (rpc, payer) + } +} + +// ============================================================================ +// SPL Tokens Module +// ============================================================================ + +pub mod spl_tokens { + use super::*; + + /// Create an SPL mint with the given decimals + pub async fn create_spl_mint( + rpc: &mut R, + payer: &Keypair, + mint_authority: &Pubkey, + decimals: u8, + ) -> Keypair { + create_mint_internal(rpc, payer, mint_authority, decimals, MintType::Spl).await + } + + /// Create an SPL ATA for the given owner and mint + pub async fn create_spl_ata( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + owner: &Pubkey, + ) -> Pubkey { + create_ata_internal(rpc, payer, mint, owner, MintType::Spl).await + } + + /// Mint SPL tokens to an account + pub async fn mint_spl_tokens( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + destination: &Pubkey, + mint_authority: &Keypair, + amount: u64, + ) { + mint_tokens_internal( + rpc, + payer, + mint, + destination, + mint_authority, + amount, + MintType::Spl, + ) + .await + } +} + +// ============================================================================ +// Token-2022 Module +// ============================================================================ + +pub mod t22_tokens { + use super::*; + + /// Create a Token-2022 mint with the given decimals (no extensions) + pub async fn create_t22_mint( + rpc: &mut R, + payer: &Keypair, + mint_authority: &Pubkey, + decimals: u8, + ) -> Keypair { + create_mint_internal(rpc, payer, mint_authority, decimals, MintType::Token2022).await + } + + /// Create a Token-2022 ATA for the given owner and mint + pub async fn create_t22_ata( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + owner: &Pubkey, + ) -> Pubkey { + create_ata_internal(rpc, payer, mint, owner, MintType::Token2022).await + } + + /// Mint Token-2022 tokens to an account + pub async fn mint_t22_tokens( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + destination: &Pubkey, + mint_authority: &Keypair, + amount: u64, + ) { + mint_tokens_internal( + rpc, + payer, + mint, + destination, + mint_authority, + amount, + MintType::Token2022, + ) + .await + } +} + +// ============================================================================ +// Light Tokens Module +// ============================================================================ + +pub mod light_tokens { + use super::*; + use anchor_lang::{InstructionData, ToAccountMetas}; + use light_token::instruction::{ + derive_token_ata, find_mint_address, CompressibleParams, CreateAssociatedTokenAccount, + TokenDataVersion, + }; + use solana_instruction::Instruction; + + /// Result of creating a Light mint + pub struct LightMintResult { + /// The mint PDA + pub mint: Pubkey, + /// The mint signer PDA (derived from authority) + pub mint_signer: Pubkey, + /// The bump for the mint signer PDA + pub mint_signer_bump: u8, + /// The authority keypair (used as mint authority) + pub authority: Keypair, + } + + /// Create a Light mint using the light-token-minter program. + /// + /// This creates a new light mint with metadata. + /// Returns information about the created mint including the mint PDA, + /// mint signer PDA, and authority keypair. + /// + /// # Arguments + /// * `rpc` - RPC client (must implement Rpc + Indexer) + /// * `payer` - Transaction fee payer + /// * `decimals` - Token decimals + /// * `token_name` - Token name for metadata + /// * `token_symbol` - Token symbol for metadata + /// * `compression_config` - The config PDA (from initialize_rent_free_config) + /// + /// # Returns + /// * `LightMintResult` - Contains mint PDA, mint_signer PDA, bump, and authority keypair + pub async fn create_light_mint( + rpc: &mut R, + payer: &Keypair, + decimals: u8, + token_name: &str, + token_symbol: &str, + compression_config: &Pubkey, + ) -> LightMintResult { + let program_id = light_token_minter::ID; + + // Create a new authority keypair for this mint + let authority = Keypair::new(); + + // Derive mint signer PDA from authority + let (mint_signer_pda, mint_signer_bump) = Pubkey::find_program_address( + &[ + light_token_minter::MINT_SIGNER_SEED, + authority.pubkey().as_ref(), + ], + &program_id, + ); + + // Derive mint PDA from mint signer + let (mint_pda, _) = find_mint_address(&mint_signer_pda); + + // Get proof for creating the mint + let proof_result = get_create_accounts_proof( + rpc, + &program_id, + vec![CreateAccountsProofInput::mint(mint_signer_pda)], + ) + .await + .expect("Get create accounts proof should succeed"); + + // Build create_mint instruction + let accounts = light_token_minter::accounts::CreateMint { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + mint_signer: mint_signer_pda, + light_mint: mint_pda, + compression_config: *compression_config, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_cpi_authority: CPI_AUTHORITY_PDA, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = light_token_minter::instruction::CreateMint { + params: light_token_minter::CreateMintParams { + create_accounts_proof: proof_result.create_accounts_proof, + decimals, + mint_signer_bump, + token_name: token_name.to_string(), + token_symbol: token_symbol.to_string(), + token_uri: format!("https://example.com/{}.json", token_symbol.to_lowercase()), + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, &authority]) + .await + .expect("Create Light mint should succeed"); + + println!("Light mint created: {:?}", mint_pda); + println!(" Mint signer: {:?}", mint_signer_pda); + println!(" Authority: {:?}", authority.pubkey()); + println!(" Decimals: {}", decimals); + + LightMintResult { + mint: mint_pda, + mint_signer: mint_signer_pda, + mint_signer_bump, + authority, + } + } + + /// Mint Light tokens to a Light ATA, creating the ATA if needed. + /// + /// This mints tokens from a Light mint to a destination owner's Light ATA. + /// The ATA is created automatically if it doesn't exist. + /// + /// # Arguments + /// * `rpc` - RPC client (must implement Rpc + Indexer) + /// * `payer` - Transaction fee payer + /// * `mint_authority` - Keypair that is the mint authority + /// * `mint` - The Light mint PDA + /// * `destination_owner` - Owner of the destination ATA + /// * `amount` - Amount of tokens to mint + /// + /// # Returns + /// * `Pubkey` - The destination ATA address + pub async fn mint_light_tokens( + rpc: &mut R, + payer: &Keypair, + mint_authority: &Keypair, + mint: &Pubkey, + destination_owner: &Pubkey, + amount: u64, + ) -> Pubkey { + let program_id = light_token_minter::ID; + + let destination_ata = derive_token_ata(destination_owner, mint); + + let create_ata_ix = CreateAssociatedTokenAccount::new( + payer.pubkey(), + *destination_owner, + *mint, + ) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[payer]) + .await + .expect("Create destination ATA should succeed"); + + // Build mint_to instruction + let accounts = light_token_minter::accounts::MintTo { + fee_payer: payer.pubkey(), + mint_authority: mint_authority.pubkey(), + mint: *mint, + recipient: *destination_owner, + destination: destination_ata, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + system_program: solana_sdk::system_program::ID, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + }; + + let instruction_data = light_token_minter::instruction::MintTo { + params: light_token_minter::MintTokenParams { + amount, + }, + }; + + let instruction = Instruction { + program_id, + accounts: accounts.to_account_metas(None), + data: instruction_data.data(), + }; + + // Sign with both payer and mint_authority + let signers: Vec<&Keypair> = if payer.pubkey() == mint_authority.pubkey() { + vec![payer] + } else { + vec![payer, mint_authority] + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &signers) + .await + .expect("Mint Light tokens should succeed"); + + println!( + "Minted {} Light tokens to {:?} (owner: {:?})", + amount, destination_ata, destination_owner + ); + + destination_ata + } + + /// Create a Light token account (ATA) for the given owner and mint + /// + /// This creates a light token account for the given owner. + pub async fn create_light_ata( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + owner: &Pubkey, + ) -> Pubkey { + let compressible_params = CompressibleParams { + compressible_config: COMPRESSIBLE_CONFIG_V1, + rent_sponsor: RENT_SPONSOR, + pre_pay_num_epochs: 2, + lamports_per_write: Some(1000), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }; + + let create_ata_ix = CreateAssociatedTokenAccount::new(payer.pubkey(), *owner, *mint) + .with_compressible(compressible_params) + .idempotent() + .instruction() + .expect("Create Light ATA instruction should succeed"); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[payer]) + .await + .expect("Create Light ATA should succeed"); + + let ata = derive_token_ata(owner, mint); + println!("Light ATA created: {:?} for owner {:?}", ata, owner); + ata + } + + /// Derive the Light token ATA address for an owner and mint + pub fn derive_light_token_ata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + derive_token_ata(owner, mint) + } +} + +// ============================================================================ +// SPL Interface Module +// ============================================================================ + +pub mod spl_interface { + use super::*; + use light_token::instruction::TransferFromSpl; + + /// Create an SPL interface PDA for the given mint + /// + /// # Arguments + /// * `rpc` - RPC client + /// * `payer` - Transaction fee payer + /// * `mint` - The mint public key + /// * `mint_type` - Whether this is SPL or Token-2022 + /// * `restricted` - Whether the mint has restricted T22 extensions + pub async fn create_spl_interface_pda( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + mint_type: MintType, + restricted: bool, + ) -> SplInterfaceResult { + let (pda, bump) = find_spl_interface_pda(mint, restricted); + + let create_ix = + CreateSplInterfacePda::new(payer.pubkey(), *mint, mint_type.program_id(), restricted) + .instruction(); + + rpc.create_and_send_transaction(&[create_ix], &payer.pubkey(), &[payer]) + .await + .expect("Create SPL interface PDA should succeed"); + + println!( + "SPL interface PDA created: {:?} (restricted={})", + pda, restricted + ); + SplInterfaceResult { pda, bump } + } + + /// Get the SPL interface PDA address without creating it + pub fn get_spl_interface_pda(mint: &Pubkey, restricted: bool) -> SplInterfaceResult { + let (pda, bump) = find_spl_interface_pda(mint, restricted); + SplInterfaceResult { pda, bump } + } + + /// Transfer tokens from an SPL/T22 token account to a Light token account + /// + /// This transfers SPL tokens from a standard SPL/T22 ATA to a light token account. + /// + /// # Arguments + /// * `rpc` - RPC client + /// * `payer` - Transaction fee payer + /// * `authority` - Authority for the source SPL account (must be signer) + /// * `mint` - The mint public key + /// * `decimals` - Token decimals + /// * `source_spl_ata` - Source SPL/T22 token account + /// * `destination_light_ata` - Destination Light token account + /// * `spl_interface_pda` - SPL interface PDA (created via create_spl_interface_pda) + /// * `spl_interface_bump` - Bump seed for the SPL interface PDA + /// * `amount` - Amount of tokens to transfer + /// * `mint_type` - Whether this is SPL or Token-2022 + pub async fn transfer_spl_to_light( + rpc: &mut R, + payer: &Keypair, + authority: &Keypair, + mint: &Pubkey, + decimals: u8, + source_spl_ata: &Pubkey, + destination_light_ata: &Pubkey, + spl_interface_pda: &Pubkey, + spl_interface_bump: u8, + amount: u64, + mint_type: MintType, + ) { + let transfer_ix = TransferFromSpl { + amount, + spl_interface_pda_bump: spl_interface_bump, + decimals, + source_spl_token_account: *source_spl_ata, + destination: *destination_light_ata, + authority: authority.pubkey(), + mint: *mint, + payer: payer.pubkey(), + spl_interface_pda: *spl_interface_pda, + spl_token_program: mint_type.program_id(), + } + .instruction() + .expect("TransferFromSpl instruction should succeed"); + + // Sign with both payer and authority if they're different + let signers: Vec<&Keypair> = if payer.pubkey() == authority.pubkey() { + vec![payer] + } else { + vec![payer, authority] + }; + + rpc.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &signers) + .await + .expect("Transfer SPL to Light should succeed"); + + println!( + "Transferred {} tokens from SPL {:?} to Light {:?}", + amount, source_spl_ata, destination_light_ata + ); + } +} + +// ============================================================================ +// Helpers Module +// ============================================================================ + +pub mod helpers { + use super::*; + + /// Verify Light Token account balance + pub async fn verify_light_token_balance( + rpc: &mut R, + account: Pubkey, + expected: u64, + name: &str, + ) { + let account_data = rpc.get_account(account).await.unwrap(); + + if let Some(data) = account_data { + // Light Token accounts have first 165 bytes as SPL-compatible token account data + if data.data.len() >= 165 { + let token_state = + spl_pod::bytemuck::pod_from_bytes::(&data.data[..165]).unwrap(); + let actual = u64::from(token_state.amount); + println!("{}: balance = {} (expected {})", name, actual, expected); + assert_eq!( + actual, expected, + "{} balance mismatch: expected {}, got {}", + name, expected, actual + ); + } else { + panic!( + "{}: account data too short: {} bytes", + name, + data.data.len() + ); + } + } else if expected == 0 { + println!("{}: account not found (expected 0 balance)", name); + } else { + panic!( + "{}: account not found but expected balance {}", + name, expected + ); + } + } + + /// Verify SPL/T22 token account balance + pub async fn verify_spl_token_balance( + rpc: &mut R, + account: Pubkey, + expected: u64, + name: &str, + ) { + let account_data = rpc + .get_account(account) + .await + .unwrap() + .unwrap_or_else(|| panic!("{} should exist", name)); + + let token_state = + spl_pod::bytemuck::pod_from_bytes::(&account_data.data[..165]).unwrap(); + let actual = u64::from(token_state.amount); + println!("{}: balance = {} (expected {})", name, actual, expected); + assert_eq!( + actual, expected, + "{} balance mismatch: expected {}, got {}", + name, expected, actual + ); + } + + /// Get proof for creating Light token accounts (PDAs) + pub async fn get_creation_proof( + rpc: &R, + program_id: &Pubkey, + accounts: Vec, + ) -> CreateAccountsProofResult { + let inputs: Vec = accounts + .into_iter() + .map(CreateAccountsProofInput::pda) + .collect(); + + get_create_accounts_proof(rpc, program_id, inputs) + .await + .expect("Get creation proof should succeed") + } + + /// Airdrop lamports to an account + pub async fn airdrop(rpc: &mut R, pubkey: &Pubkey, lamports: u64) { + rpc.airdrop_lamports(pubkey, lamports) + .await + .expect("Airdrop should succeed"); + } +} + +// ============================================================================ +// Internal Helper Functions +// ============================================================================ + +/// SPL mint account size (fixed at 82 bytes) +const SPL_MINT_SIZE: usize = 82; + +/// Token-2022 mint account size (base size without extensions) +const T22_MINT_SIZE: usize = 82; + +async fn create_mint_internal( + rpc: &mut R, + payer: &Keypair, + mint_authority: &Pubkey, + decimals: u8, + mint_type: MintType, +) -> Keypair { + let mint = Keypair::new(); + let program_id = mint_type.program_id(); + + // Get the mint account size based on token program + let mint_size = match mint_type { + MintType::Spl => SPL_MINT_SIZE, + MintType::Token2022 => T22_MINT_SIZE, + }; + + let rent = rpc + .get_minimum_balance_for_rent_exemption(mint_size) + .await + .unwrap(); + + let create_account_ix = solana_sdk::system_instruction::create_account( + &payer.pubkey(), + &mint.pubkey(), + rent, + mint_size as u64, + &program_id, + ); + + let init_mint_ix = match mint_type { + MintType::Spl => token::spl_token::instruction::initialize_mint( + &program_id, + &mint.pubkey(), + mint_authority, + None, + decimals, + ) + .unwrap(), + MintType::Token2022 => spl_token_2022::instruction::initialize_mint( + &program_id, + &mint.pubkey(), + mint_authority, + None, + decimals, + ) + .unwrap(), + }; + + rpc.create_and_send_transaction( + &[create_account_ix, init_mint_ix], + &payer.pubkey(), + &[payer, &mint], + ) + .await + .expect("Create mint should succeed"); + + println!("{:?} mint created: {:?}", mint_type, mint.pubkey()); + mint +} + +async fn create_ata_internal( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + owner: &Pubkey, + mint_type: MintType, +) -> Pubkey { + let program_id = mint_type.program_id(); + + let ata = anchor_spl::associated_token::spl_associated_token_account::get_associated_token_address_with_program_id( + owner, + mint, + &program_id, + ); + + let create_ata_ix = + anchor_spl::associated_token::spl_associated_token_account::instruction::create_associated_token_account( + &payer.pubkey(), + owner, + mint, + &program_id, + ); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[payer]) + .await + .expect("Create ATA should succeed"); + + println!( + "{:?} ATA created: {:?} for owner {:?}", + mint_type, ata, owner + ); + ata +} + +async fn mint_tokens_internal( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + destination: &Pubkey, + mint_authority: &Keypair, + amount: u64, + mint_type: MintType, +) { + let program_id = mint_type.program_id(); + + let mint_to_ix = match mint_type { + MintType::Spl => token::spl_token::instruction::mint_to( + &program_id, + mint, + destination, + &mint_authority.pubkey(), + &[], + amount, + ) + .unwrap(), + MintType::Token2022 => spl_token_2022::instruction::mint_to( + &program_id, + mint, + destination, + &mint_authority.pubkey(), + &[], + amount, + ) + .unwrap(), + }; + + // If payer is the same as mint_authority, only sign once + let signers: Vec<&Keypair> = if payer.pubkey() == mint_authority.pubkey() { + vec![payer] + } else { + vec![payer, mint_authority] + }; + + rpc.create_and_send_transaction(&[mint_to_ix], &payer.pubkey(), &signers) + .await + .expect("Mint tokens should succeed"); + + println!("Minted {} tokens to {:?}", amount, destination); +} + +/// Common test constants +pub mod constants { + /// Default decimals for test tokens + pub const DEFAULT_DECIMALS: u8 = 9; + + /// Default token amount (1000 tokens with 9 decimals) + pub const DEFAULT_TOKEN_AMOUNT: u64 = 1_000_000_000_000; + + /// Default airdrop amount (10 SOL) + pub const DEFAULT_AIRDROP: u64 = 10_000_000_000; +} diff --git a/programs/anchor/token-swap/Cargo.toml b/programs/anchor/token-swap/Cargo.toml new file mode 100644 index 0000000..972dca5 --- /dev/null +++ b/programs/anchor/token-swap/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "swap_example" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "swap_example" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build", "light-sdk/idl-build", "light-anchor-spl/idl-build"] +test-sbf = [] + +[dependencies] +anchor-lang = { version = "=0.31.1", features = ["init-if-needed"] } +anchor-spl = { version = "0.31.1", features = ["metadata"] } +fixed = "=1.27.0" +az = "=1.2.1" + +light-sdk = { workspace = true, features = ["anchor", "cpi-context", "v2"] } +light-token = { workspace = true, features = ["anchor"] } +light-account = { workspace = true } +light-hasher = { workspace = true, features = ["solana"] } +light-anchor-spl = { workspace = true } +solana-program = { workspace = true } +solana-pubkey = { workspace = true } +solana-account-info = { workspace = true } +solana-program-error = { workspace = true } +solana-msg = { workspace = true } + +[dev-dependencies] +light-program-test = { workspace = true } +light-client = { workspace = true, features = ["v2", "anchor"] } +anchor-spl = { workspace = true } +tokio = { workspace = true, features = ["full"] } +solana-keypair = { workspace = true } +solana-signer = { workspace = true } +solana-instruction = { workspace = true } +solana-sdk = { workspace = true } +spl-token-2022 = { workspace = true } +spl-pod = { workspace = true } +shared-test-utils = { workspace = true } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/programs/anchor/token-swap/SPL_COMPARISON.md b/programs/anchor/token-swap/SPL_COMPARISON.md new file mode 100644 index 0000000..9dd13c3 --- /dev/null +++ b/programs/anchor/token-swap/SPL_COMPARISON.md @@ -0,0 +1,968 @@ +# Repository Comparison: solana-program-examples vs light-token-escrow-fixes + +This document compares the standard Solana Program Examples (SPL) AMM token-swap with the Light Token implementation, showing exact code differences section by section. + +--- + +## 1. Program Entry Point + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/lib.rs +use anchor_lang::prelude::*; + +declare_id!("AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn"); + +#[program] +pub mod swap_example { + pub use super::instructions::*; + use super::*; + + pub fn create_amm(ctx: Context, id: Pubkey, fee: u16) -> Result<()> { + instructions::create_amm(ctx, id, fee) + } + + pub fn create_pool(ctx: Context) -> Result<()> { + instructions::create_pool(ctx) + } + + pub fn deposit_liquidity( + ctx: Context, + amount_a: u64, + amount_b: u64, + ) -> Result<()> { + instructions::deposit_liquidity(ctx, amount_a, amount_b) + } + + pub fn withdraw_liquidity(ctx: Context, amount: u64) -> Result<()> { + instructions::withdraw_liquidity(ctx, amount) + } + + pub fn swap_exact_tokens_for_tokens( + ctx: Context, + swap_a: bool, + input_amount: u64, + min_output_amount: u64, + ) -> Result<()> { + instructions::swap_exact_tokens_for_tokens(ctx, swap_a, input_amount, min_output_amount) + } +} +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/lib.rs +use anchor_lang::prelude::*; +use light_token::anchor::{derive_light_cpi_signer, light_program, CpiSigner}; + +declare_id!("AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn"); + +// NEW: CPI signer for Light Protocol operations +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn"); + +#[light_program] // NEW: Light Protocol macro +#[allow(deprecated)] +#[program] +pub mod swap_example { + pub use super::instructions::*; + use super::*; + + pub fn create_amm(ctx: Context, id: Pubkey, fee: u16) -> Result<()> { + instructions::create_amm(ctx, id, fee) + } + + // Parameters wrapped in struct for proof data + pub fn create_pool<'info>( + ctx: Context<'_, '_, '_, 'info, CreatePool<'info>>, + params: CreatePoolParams, + ) -> Result<()> { + instructions::create_pool(ctx, params) + } + + pub fn deposit_liquidity( + ctx: Context, + amount_a: u64, + amount_b: u64, + ) -> Result<()> { + instructions::deposit_liquidity(ctx, amount_a, amount_b) + } + + pub fn withdraw_liquidity(ctx: Context, amount: u64) -> Result<()> { + instructions::withdraw_liquidity(ctx, amount) + } + + pub fn swap_exact_tokens_for_tokens( + ctx: Context, + swap_a: bool, + input_amount: u64, + min_output_amount: u64, + ) -> Result<()> { + instructions::swap_exact_tokens_for_tokens(ctx, swap_a, input_amount, min_output_amount) + } + + // NEW: Create pool with Light LP mint for fully rent-free operations + pub fn create_pool_light_lp<'info>( + ctx: Context<'_, '_, '_, 'info, CreatePoolLightLp<'info>>, + params: CreatePoolLightLpParams, + ) -> Result<()> { + instructions::create_pool_light_lp(ctx, params) + } +} +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Macro | `#[program]` | `#[light_program]` + `#[program]` | +| CPI Signer | None | `derive_light_cpi_signer!` constant | +| create_pool params | None | `CreatePoolParams` with proof data | +| Instructions | 5 | 6 (adds `create_pool_light_lp`) | + +--- + +## 2. Instruction Parameters + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/lib.rs:20-22 +// No parameters for create_pool +pub fn create_pool(ctx: Context) -> Result<()> { + instructions::create_pool(ctx) +} +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/instructions/create_pool.rs:12-17 +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreatePoolParams { + pub create_accounts_proof: CreateAccountsProof, // ZK proof for account creation + pub pool_account_a_bump: u8, // Pre-computed bump for vault A + pub pool_account_b_bump: u8, // Pre-computed bump for vault B +} + +// programs/anchor/token-swap/src/instructions/create_pool_light_lp.rs:14-21 +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreatePoolLightLpParams { + pub create_accounts_proof: CreateAccountsProof, + pub pool_account_a_bump: u8, + pub pool_account_b_bump: u8, + pub lp_mint_signer_bump: u8, // For Light LP mint derivation + pub pool_authority_bump: u8, // For pool authority PDA +} +``` + +**Key Differences:** + +- Light requires `CreateAccountsProof` for compressed account creation +- Light pre-computes bump seeds client-side +- `CreatePoolLightLpParams` adds bumps for Light LP mint operations + +--- + +## 3. State Account Definition + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/state.rs +use anchor_lang::prelude::*; + +#[account] +#[derive(Default)] +pub struct Amm { + pub id: Pubkey, + pub admin: Pubkey, + pub fee: u16, +} + +impl Amm { + pub const LEN: usize = 8 + 32 + 32 + 2; +} + +#[account] +#[derive(Default)] +pub struct Pool { + pub amm: Pubkey, + pub mint_a: Pubkey, + pub mint_b: Pubkey, +} + +impl Pool { + pub const LEN: usize = 8 + 32 + 32 + 32; +} +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/state.rs +use anchor_lang::prelude::*; + +#[account] +#[derive(Default)] +pub struct Amm { + pub id: Pubkey, + pub admin: Pubkey, + pub fee: u16, +} + +impl Amm { + pub const LEN: usize = 8 + 32 + 32 + 2; +} + +#[account] +#[derive(Default)] +pub struct Pool { + pub amm: Pubkey, + pub mint_a: Pubkey, + pub mint_b: Pubkey, + pub lp_supply: u64, // NEW: Tracked on-chain for Light LP mint compatibility +} + +impl Pool { + pub const LEN: usize = 8 + 32 + 32 + 32 + 8; +} +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Pool size | 104 bytes | 112 bytes (+8) | +| LP supply tracking | Via mint.supply | `lp_supply` field for Light mints | + +The `lp_supply` field is needed because Light mints don't store supply on-chain like SPL mints. + +--- + +## 4. CreatePool Account Constraints + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rs:21-99 +#[derive(Accounts)] +pub struct CreatePool<'info> { + #[account(seeds = [amm.id.as_ref()], bump)] + pub amm: Box>, + + #[account( + init, + payer = payer, + space = Pool::LEN, + seeds = [amm.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref()], + bump, + )] + pub pool: Box>, + + /// CHECK: Read only authority + #[account( + seeds = [amm.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + #[account( + init, + payer = payer, + seeds = [amm.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), LIQUIDITY_SEED], + bump, + mint::decimals = 6, + mint::authority = pool_authority, + )] + pub mint_liquidity: Box>, + + pub mint_a: Box>, + pub mint_b: Box>, + + #[account( + init, + payer = payer, + associated_token::mint = mint_a, + associated_token::authority = pool_authority, + )] + pub pool_account_a: Box>, + + #[account( + init, + payer = payer, + associated_token::mint = mint_b, + associated_token::authority = pool_authority, + )] + pub pool_account_b: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/instructions/create_pool.rs:29-135 +#[derive(Accounts, LightAccounts)] // NEW: LightAccounts derive +#[instruction(params: CreatePoolParams)] +pub struct CreatePool<'info> { + #[account(seeds = [amm.id.as_ref()], bump)] + pub amm: Box>, + + #[account( + init, + payer = fee_payer, + space = Pool::LEN, + seeds = [amm.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref()], + bump, + )] + pub pool: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [amm.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + seeds = [amm.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), LIQUIDITY_SEED], + bump, + mint::decimals = 6, + mint::authority = pool_authority, + mint::token_program = liquidity_token_program, + )] + pub mint_liquidity: Box>, + + #[account(mint::token_program = token_program)] + pub mint_a: Box>, + + #[account(mint::token_program = token_program)] + pub mint_b: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account(mut, seeds = [POOL_ACCOUNT_A_SEED, pool.key().as_ref()], bump)] + #[light_account(init, + token::authority = [POOL_ACCOUNT_A_SEED, self.pool.key()], + token::mint = mint_a, + token::owner = pool_authority, + token::bump = params.pool_account_a_bump + )] + pub pool_account_a: UncheckedAccount<'info>, + + /// CHECK: PDA verified by seeds constraint + #[account(mut, seeds = [POOL_ACCOUNT_B_SEED, pool.key().as_ref()], bump)] + #[light_account(init, + token::authority = [POOL_ACCOUNT_B_SEED, self.pool.key()], + token::mint = mint_b, + token::owner = pool_authority, + token::bump = params.pool_account_b_bump + )] + pub pool_account_b: UncheckedAccount<'info>, + + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub liquidity_token_program: Interface<'info, TokenInterface>, + pub light_token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + // ========== Light Protocol Accounts ========== + /// CHECK: Validated by address constraint + #[account(address = COMPRESSIBLE_CONFIG_V1)] + pub light_token_compressible_config: AccountInfo<'info>, + + /// CHECK: Validated by address constraint + #[account(mut, address = RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority + pub light_token_cpi_authority: AccountInfo<'info>, +} +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Derive | `Accounts` | `Accounts` + `LightAccounts` | +| Vault type | `Account` | `UncheckedAccount` with `#[light_account]` | +| Vault seeds | ATA derivation | Custom `POOL_ACCOUNT_A_SEED`, `POOL_ACCOUNT_B_SEED` | +| Token program | Single `Token` | Three: `token_program`, `liquidity_token_program`, `light_token_program` | +| Extra accounts | 3 (system, token, ata) | 6+ (+ Light protocol accounts) | + +--- + +## 5. Token Transfer Logic + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs:78-100 +// Transfer tokens to the pool +token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.depositor_account_a.to_account_info(), + to: ctx.accounts.pool_account_a.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + amount_a, +)?; +token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.depositor_account_b.to_account_info(), + to: ctx.accounts.pool_account_b.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + amount_b, +)?; +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/instructions/shared.rs:5-81 +pub struct SplInterfaceConfig<'info> { + pub mint: AccountInfo<'info>, + pub spl_token_program: AccountInfo<'info>, + pub spl_interface_pda: AccountInfo<'info>, + pub spl_interface_pda_bump: u8, +} + +fn is_light_account(account: &AccountInfo) -> bool { + account.owner == &Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID) +} + +pub fn transfer_tokens<'info>( + amount: u64, + decimals: u8, + from: AccountInfo<'info>, + to: AccountInfo<'info>, + mint: AccountInfo<'info>, + authority: AccountInfo<'info>, + payer: AccountInfo<'info>, + light_token_cpi_authority: AccountInfo<'info>, + system_program: AccountInfo<'info>, + signer_seeds: Option<&[&[u8]]>, + spl_interface: Option>, +) -> Result<()> { + let is_light_to_light = is_light_account(&from) && is_light_account(&to); + + if is_light_to_light { + // Pure Light-to-Light transfer + let cpi = TransferCheckedCpi { + source: from, + mint, + destination: to, + amount, + decimals, + authority, + system_program, + max_top_up: Some(0), + fee_payer: Some(payer), + }; + + if let Some(seeds) = signer_seeds { + cpi.invoke_signed(&[seeds]) + } else { + cpi.invoke() + } + .map_err(|e| anchor_lang::prelude::ProgramError::from(e).into()) + } else { + // SPL<->Light transfer via interface + let mut cpi = TransferInterfaceCpi::new( + amount, + decimals, + from, + to, + authority, + payer, + light_token_cpi_authority, + system_program, + ); + + if let Some(spl) = spl_interface { + cpi = cpi + .with_spl_interface( + Some(spl.mint), + Some(spl.spl_token_program), + Some(spl.spl_interface_pda), + Some(spl.spl_interface_pda_bump), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + if let Some(seeds) = signer_seeds { + cpi.invoke_signed(&[seeds]) + } else { + cpi.invoke() + } + .map_err(|e| anchor_lang::prelude::ProgramError::from(e).into()) + } +} +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Function params | 4 typed params | 11 params including Light infra | +| Transfer type | Single `token::transfer` | Conditional: `TransferCheckedCpi` or `TransferInterfaceCpi` | +| Account detection | N/A | `is_light_account()` checks owner | +| Cross-protocol | Not supported | `SplInterfaceConfig` for SPL<->Light | +| CPI pattern | `CpiContext::new()` | Direct struct construction + `invoke()` | + +--- + +## 6. LP Token Minting/Burning + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs:102-123 +// Mint the liquidity to user +let authority_bump = ctx.bumps.pool_authority; +let authority_seeds = &[ + &ctx.accounts.pool.amm.to_bytes(), + &ctx.accounts.mint_a.key().to_bytes(), + &ctx.accounts.mint_b.key().to_bytes(), + AUTHORITY_SEED, + &[authority_bump], +]; +let signer_seeds = &[&authority_seeds[..]]; +token::mint_to( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + MintTo { + mint: ctx.accounts.mint_liquidity.to_account_info(), + to: ctx.accounts.depositor_account_liquidity.to_account_info(), + authority: ctx.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + liquidity, +)?; + +// tokens/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs:69-81 +// Burn the liquidity tokens +token::burn( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Burn { + mint: ctx.accounts.mint_liquidity.to_account_info(), + from: ctx.accounts.depositor_account_liquidity.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + amount, +)?; +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/instructions/deposit_liquidity.rs:140-167 +let is_light_lp = ctx.accounts.liquidity_token_program.key().to_bytes() == LIGHT_TOKEN_PROGRAM_ID; + +if is_light_lp { + // Light LP mint + MintToCpi { + mint: ctx.accounts.mint_liquidity.to_account_info(), + destination: ctx.accounts.depositor_account_liquidity.to_account_info(), + amount: liquidity, + authority: ctx.accounts.pool_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke_signed(&[authority_seeds])?; +} else { + // SPL/T22 LP mint + let signer_seeds = &[authority_seeds]; + light_anchor_spl::token_interface::mint_to( + CpiContext::new_with_signer( + ctx.accounts.liquidity_token_program.to_account_info(), + MintTo { + mint: ctx.accounts.mint_liquidity.to_account_info(), + to: ctx.accounts.depositor_account_liquidity.to_account_info(), + authority: ctx.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + liquidity, + )?; +} + +// programs/anchor/token-swap/src/instructions/withdraw_liquidity.rs:100-123 +if is_light_lp { + BurnCpi { + source: ctx.accounts.depositor_account_liquidity.to_account_info(), + mint: ctx.accounts.mint_liquidity.to_account_info(), + amount, + authority: ctx.accounts.depositor.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: None, + } + .invoke()?; +} else { + token_interface::burn( + CpiContext::new( + ctx.accounts.liquidity_token_program.to_account_info(), + Burn { + mint: ctx.accounts.mint_liquidity.to_account_info(), + from: ctx.accounts.depositor_account_liquidity.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + amount, + )?; +} +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Mint CPI | Single `token::mint_to` | Conditional: `MintToCpi` (Light) or `mint_to` (SPL/T22) | +| Burn CPI | Single `token::burn` | Conditional: `BurnCpi` (Light) or `burn` (SPL/T22) | +| LP type detection | N/A | Check `liquidity_token_program` against `LIGHT_TOKEN_PROGRAM_ID` | +| LP supply tracking | Via mint.supply | Manual `pool.lp_supply` field updates | + +--- + +## 7. Balance Reading + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs:32-35 +let pool_a = &ctx.accounts.pool_account_a; +let pool_b = &ctx.accounts.pool_account_b; +let pool_creation = pool_a.amount == 0 && pool_b.amount == 0; + +// tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs:135-136 +// Reload accounts because of the CPIs +ctx.accounts.pool_account_a.reload()?; +ctx.accounts.pool_account_b.reload()?; +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/instructions/deposit_liquidity.rs:23-26 +let pool_a_balance = get_token_account_balance(&ctx.accounts.pool_account_a.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; +let pool_b_balance = get_token_account_balance(&ctx.accounts.pool_account_b.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + +// programs/anchor/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs:174-179 +let new_pool_a_balance = + get_token_account_balance(&ctx.accounts.pool_account_a.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; +let new_pool_b_balance = + get_token_account_balance(&ctx.accounts.pool_account_b.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Balance access | Direct `.amount` field | `get_token_account_balance()` helper | +| Account type | Typed `InterfaceAccount` | `UncheckedAccount` (Light-aware parsing) | +| Refresh method | `.reload()` | Re-call `get_token_account_balance()` | + +--- + +## 8. Constants + +### SPL (solana-program-examples) + +```rust +// tokens/token-swap/anchor/programs/token-swap/src/constants.rs +use anchor_lang::prelude::*; + +#[constant] +pub const MINIMUM_LIQUIDITY: u64 = 100; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const LIQUIDITY_SEED: &[u8] = b"liquidity"; +``` + +### Light (light-token-escrow-fixes) + +```rust +// programs/anchor/token-swap/src/constants.rs +use anchor_lang::prelude::*; + +#[constant] +pub const MINIMUM_LIQUIDITY: u64 = 100; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const LIQUIDITY_SEED: &[u8] = b"liquidity"; + +// NEW: Pool vault seeds (Light token accounts use custom PDAs, not ATAs) +#[constant] +pub const POOL_ACCOUNT_A_SEED: &[u8] = b"pool_a"; + +#[constant] +pub const POOL_ACCOUNT_B_SEED: &[u8] = b"pool_b"; + +// NEW: LP mint signer seed for FullLight config +#[constant] +pub const LP_MINT_SIGNER_SEED: &[u8] = b"lp_mint_signer"; +``` + +**Key Differences:** + +- SPL: 3 constants +- Light: 6 constants (adds `POOL_ACCOUNT_A_SEED`, `POOL_ACCOUNT_B_SEED`, `LP_MINT_SIGNER_SEED`) +- Light vaults use custom PDA derivation instead of standard ATAs + +--- + +## 9. Dependencies (Cargo.toml) + +### SPL (solana-program-examples) + +```toml +# tokens/token-swap/anchor/programs/token-swap/Cargo.toml +[dependencies] +anchor-lang = "0.32.1" +anchor-spl = "0.32.1" +fixed = "1.20.0" + +[dev-dependencies] +# None - tests are in TypeScript +``` + +### Light (light-token-escrow-fixes) + +```toml +# programs/anchor/token-swap/Cargo.toml +[dependencies] +anchor-lang.workspace = true # 0.31.1 + +# Light Protocol core +light-sdk = { workspace = true, features = [ + "anchor", "anchor-discriminator", "idl-build", "cpi-context", "v2" +] } # 0.19.0 +light-token = { workspace = true, features = ["anchor"] } # 0.4.0 +light-anchor-spl = { workspace = true, features = ["idl-build"] } # 0.31.1 + +# Math +fixed.workspace = true # 1.20.0 + +# Solana (modular imports) +solana-program.workspace = true # 2.x +solana-pubkey.workspace = true +solana-account-info.workspace = true +solana-program-error.workspace = true +solana-msg.workspace = true + +[dev-dependencies] +light-program-test.workspace = true # 0.19.0 +light-client = { workspace = true, features = ["v2", "anchor"] } # 0.19.0 +spl-token-2022.workspace = true # 7.x +shared-test-utils.workspace = true # Local test utilities +tokio.workspace = true # 1.43.0 +anchor-spl.workspace = true # 0.31.1 +spl-pod.workspace = true # 0.6.0 +``` + +**Key Differences:** + +- SPL: 3 dependencies (anchor-lang, anchor-spl, fixed) +- Light: 10+ dependencies including Light Protocol SDK, token, and test infrastructure +- Light uses workspace dependencies for version consistency +- Light has extensive dev-dependencies for Rust-based testing + +--- + +## 10. Test Structure + +### SPL (solana-program-examples) + +``` +tokens/token-swap/anchor/ +└── tests/ + └── swap.test.ts # Single TypeScript test file +``` + +### Light (light-token-escrow-fixes) + +``` +programs/anchor/token-swap/ +└── tests/ + ├── common/ + │ └── mod.rs # Shared test utilities + ├── user_spl.rs # SPL mint + SPL ATAs + ├── user_t22.rs # T22 mint + T22 ATAs + ├── user_spl_light.rs # SPL mint + Light ATAs + ├── user_t22_light.rs # T22 mint + Light ATAs + ├── user_light.rs # Light mint + Light ATAs (SPL LP) + └── user_light_full.rs # Light mint + Light ATAs + Light LP +``` + +### TokenConfig Enum + +```rust +// tests/common/mod.rs:46-59 +#[derive(Clone, Copy, Debug)] +pub enum TokenConfig { + Spl, // SPL mint + SPL ATAs + Token2022, // T22 mint + T22 ATAs + LightSpl, // SPL mint + Light ATAs (offchain compress) + LightT22, // T22 mint + Light ATAs (offchain compress) + Light, // Light mint + Light ATAs (SPL LP mint) + FullLight, // Light mint + Light ATAs + Light LP mint +} +``` + +### Test Context + +```rust +// tests/common/mod.rs:108-137 +pub struct AmmTestContext { + pub program_id: Pubkey, + pub payer: Keypair, + pub mint_a_pubkey: Pubkey, + pub mint_b_pubkey: Pubkey, + pub amm_pda: Pubkey, + pub amm_id: Pubkey, + pub pool_pda: Pubkey, + pub pool_authority: Pubkey, + pub pool_authority_bump: u8, + pub mint_liquidity: Pubkey, + pub pool_account_a: Pubkey, + pub pool_account_b: Pubkey, + pub pool_a_bump: u8, + pub pool_b_bump: u8, + pub spl_interface_pda_a: Pubkey, + pub spl_interface_pda_b: Pubkey, + pub spl_interface_bump_a: u8, + pub depositor: Keypair, + pub depositor_ata_a: Pubkey, + pub depositor_ata_b: Pubkey, + pub token_config: TokenConfig, + pub light_mint_authority_a: Option, + pub lp_mint_signer: Option, + pub lp_mint_signer_bump: u8, + pub compression_config: Pubkey, +} +``` + +**Key Differences:** + +| Aspect | SPL | Light | +| ------ | --- | ----- | +| Language | TypeScript | Rust | +| Framework | Mocha + Chai | tokio + cargo test-sbf | +| Test files | 1 | 6 (per token combination) | +| Setup | Manual or helpers | `shared-test-utils` crate | +| Parametric | No | Yes (`TokenConfig` enum) | + +--- + +## 11. Additional Light Protocol Infrastructure + +These components exist only in the Light version: + +### Rent-Free Configuration + +```rust +// tests/common/mod.rs:166 +// Initialize rent-free config (returns the config PDA) +let compression_config = initialize_rent_free_config(rpc, &payer, &program_id).await; +``` + +### SPL Interface PDAs + +```rust +// tests/common/mod.rs:329-332 +// Create SPL interface PDAs for both mints +let spl_interface_result_a = + create_spl_interface_pda(rpc, &payer, &mint_a_pubkey, config.mint_type(), false).await; +let spl_interface_result_b = + create_spl_interface_pda(rpc, &payer, &mint_b_pubkey, config.mint_type(), false).await; +``` + +### Create Pool with Light LP Mint + +```rust +// programs/anchor/token-swap/src/instructions/create_pool_light_lp.rs +// Instruction for fully rent-free pool operations +pub fn create_pool_light_lp<'info>( + ctx: Context<'_, '_, '_, 'info, CreatePoolLightLp<'info>>, + params: CreatePoolLightLpParams, +) -> Result<()> { + // 1. Create pool vaults via explicit CPI (not macro) + CreateTokenAccountCpi::rent_free(/* ... */).invoke()?; + + // 2. Initialize pool state + let pool = &mut ctx.accounts.pool; + pool.amm = ctx.accounts.amm.key(); + pool.mint_a = ctx.accounts.mint_a.key(); + pool.mint_b = ctx.accounts.mint_b.key(); + pool.lp_supply = 0; + + // LP mint created via #[light_account(init, ...)] macro + Ok(()) +} +``` + +### Pool Authority Funding + +```rust +// tests/common/mod.rs:1134-1138 +// Fund pool_authority for Light-to-Light transfers (rent top-ups) +if ctx.token_config.uses_light_user_accounts() { + rpc.airdrop_lamports(&ctx.pool_authority, 1_000_000_000) + .await + .expect("Fund pool_authority for rent top-ups"); +} +``` + +--- + +## Summary + +| Category | SPL (solana-program-examples) | Light (light-token-escrow-fixes) | +| -------- | ----------------------------- | -------------------------------- | +| **Purpose** | Educational examples | Production Light Protocol AMM | +| **Account model** | Standard Anchor | Compressed (Light accounts) | +| **Rent** | User pays rent-exempt | Sponsor-based (rent-free) | +| **Token support** | SPL only | SPL + Token-2022 + Light native | +| **Cross-protocol** | No | Yes (SPL<->Light via interface) | +| **LP mint options** | SPL only | SPL, Token-2022, or Light | +| **Pool vaults** | ATAs | Light token PDAs | +| **Dependencies** | 3 crates | 10+ crates | +| **Test language** | TypeScript | Rust | +| **Test coverage** | Single flow | 6 token combinations | +| **Instructions** | 5 | 6 (adds `create_pool_light_lp`) | +| **Complexity** | Lower | Higher (more infrastructure) | +| **Use case** | Learning Solana AMM | Building rent-free DeFi | diff --git a/programs/anchor/token-swap/Xargo.toml b/programs/anchor/token-swap/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/programs/anchor/token-swap/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/anchor/token-swap/src/constants.rs b/programs/anchor/token-swap/src/constants.rs new file mode 100644 index 0000000..28ef13c --- /dev/null +++ b/programs/anchor/token-swap/src/constants.rs @@ -0,0 +1,19 @@ +use anchor_lang::prelude::*; + +#[constant] +pub const MINIMUM_LIQUIDITY: u64 = 100; + +#[constant] +pub const AUTHORITY_SEED: &[u8] = b"authority"; + +#[constant] +pub const LIQUIDITY_SEED: &[u8] = b"liquidity"; + +#[constant] +pub const POOL_ACCOUNT_A_SEED: &[u8] = b"pool_a"; + +#[constant] +pub const POOL_ACCOUNT_B_SEED: &[u8] = b"pool_b"; + +#[constant] +pub const LP_MINT_SIGNER_SEED: &[u8] = b"lp_mint_signer"; diff --git a/programs/anchor/token-swap/src/errors.rs b/programs/anchor/token-swap/src/errors.rs new file mode 100644 index 0000000..ae97d61 --- /dev/null +++ b/programs/anchor/token-swap/src/errors.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum SwapError { + #[msg("Invalid fee value")] + InvalidFee, + + #[msg("Invalid mint for the pool")] + InvalidMint, + + #[msg("Depositing too little liquidity")] + DepositTooSmall, + + #[msg("Output is below the minimum expected")] + OutputTooSmall, + + #[msg("Invariant does not hold")] + InvariantViolated, + + #[msg("Arithmetic overflow")] + Overflow, + + #[msg("Arithmetic underflow")] + Underflow, +} diff --git a/programs/anchor/token-swap/src/instructions/create_amm.rs b/programs/anchor/token-swap/src/instructions/create_amm.rs new file mode 100644 index 0000000..cb804f7 --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/create_amm.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; + +use crate::{errors::*, state::Amm}; + +pub fn create_amm(ctx: Context, id: Pubkey, fee: u16) -> Result<()> { + let amm = &mut ctx.accounts.amm; + amm.id = id; + amm.admin = ctx.accounts.admin.key(); + amm.fee = fee; + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(id: Pubkey, fee: u16)] +pub struct CreateAmm<'info> { + #[account( + init, + payer = payer, + space = Amm::LEN, + seeds = [ + id.as_ref() + ], + bump, + constraint = fee < 10000 @ SwapError::InvalidFee, + )] + pub amm: Account<'info, Amm>, + + /// CHECK: Read only, delegatable creation + pub admin: AccountInfo<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/programs/anchor/token-swap/src/instructions/create_pool.rs b/programs/anchor/token-swap/src/instructions/create_pool.rs new file mode 100644 index 0000000..c49c60e --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/create_pool.rs @@ -0,0 +1,132 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::{Mint, TokenInterface}; +use light_account::CreateAccountsProof; +use light_account::LightAccounts; +use light_token::instruction::{LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR}; + +use crate::{ + constants::{AUTHORITY_SEED, LIQUIDITY_SEED, POOL_ACCOUNT_A_SEED, POOL_ACCOUNT_B_SEED}, + state::{Amm, Pool}, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreatePoolParams { + pub create_accounts_proof: CreateAccountsProof, + pub pool_account_a_bump: u8, + pub pool_account_b_bump: u8, +} + +pub fn create_pool(ctx: Context, _params: CreatePoolParams) -> Result<()> { + let pool = &mut ctx.accounts.pool; + pool.amm = ctx.accounts.amm.key(); + pool.mint_a = ctx.accounts.mint_a.key(); + pool.mint_b = ctx.accounts.mint_b.key(); + pool.lp_supply = 0; + + Ok(()) +} + +#[derive(Accounts, LightAccounts)] +#[instruction(params: CreatePoolParams)] +pub struct CreatePool<'info> { + #[account( + seeds = [ + amm.id.as_ref() + ], + bump, + )] + pub amm: Box>, + + #[account( + init, + payer = fee_payer, + space = Pool::LEN, + seeds = [ + amm.key().as_ref(), + mint_a.key().as_ref(), + mint_b.key().as_ref(), + ], + bump, + )] + pub pool: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + seeds = [ + amm.key().as_ref(), + mint_a.key().as_ref(), + mint_b.key().as_ref(), + LIQUIDITY_SEED, + ], + bump, + mint::decimals = 6, + mint::authority = pool_authority, + mint::token_program = liquidity_token_program, + )] + pub mint_liquidity: Box>, + + #[account(mint::token_program = token_program)] + pub mint_a: Box>, + + #[account(mint::token_program = token_program)] + pub mint_b: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_A_SEED, pool.key().as_ref()], + bump, + )] + #[light_account(init, + token::seeds = [POOL_ACCOUNT_A_SEED, self.pool.key()], + token::mint = mint_a, + token::owner = pool_authority, + token::owner_seeds = [AUTHORITY_SEED], + token::bump = params.pool_account_a_bump + )] + pub pool_account_a: UncheckedAccount<'info>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_B_SEED, pool.key().as_ref()], + bump, + )] + #[light_account(init, + token::seeds = [POOL_ACCOUNT_B_SEED, self.pool.key()], + token::mint = mint_b, + token::owner = pool_authority, + token::owner_seeds = [AUTHORITY_SEED], + token::bump = params.pool_account_b_bump + )] + pub pool_account_b: UncheckedAccount<'info>, + + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// Token program for mint_a and mint_b (SPL, T22, or Light). + pub token_program: Interface<'info, TokenInterface>, + /// Token program for LP mint (may differ from token_program). + pub liquidity_token_program: Interface<'info, TokenInterface>, + pub light_token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + + /// CHECK: Validated by address constraint + #[account(address = LIGHT_TOKEN_CONFIG)] + pub light_token_config: AccountInfo<'info>, + + /// CHECK: Validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority + pub light_token_cpi_authority: AccountInfo<'info>, +} diff --git a/programs/anchor/token-swap/src/instructions/create_pool_light_lp.rs b/programs/anchor/token-swap/src/instructions/create_pool_light_lp.rs new file mode 100644 index 0000000..3a2afeb --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/create_pool_light_lp.rs @@ -0,0 +1,174 @@ +use anchor_lang::prelude::*; +use light_anchor_spl::token_interface::TokenInterface; +use light_account::CreateAccountsProof; +use light_account::LightAccounts; +use light_token::instruction::{CreateTokenAccountCpi, LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_RENT_SPONSOR}; + +use crate::{ + constants::{AUTHORITY_SEED, LP_MINT_SIGNER_SEED, POOL_ACCOUNT_A_SEED, POOL_ACCOUNT_B_SEED}, + state::{Amm, Pool}, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct CreatePoolLightLpParams { + pub create_accounts_proof: CreateAccountsProof, + pub pool_account_a_bump: u8, + pub pool_account_b_bump: u8, + pub lp_mint_signer_bump: u8, + pub pool_authority_bump: u8, +} + +pub fn create_pool_light_lp( + ctx: Context, + params: CreatePoolLightLpParams, +) -> Result<()> { + let pool = &mut ctx.accounts.pool; + pool.amm = ctx.accounts.amm.key(); + pool.mint_a = ctx.accounts.mint_a.key(); + pool.mint_b = ctx.accounts.mint_b.key(); + pool.lp_supply = 0; + + let pool_key = ctx.accounts.pool.key(); + + CreateTokenAccountCpi { + payer: ctx.accounts.fee_payer.to_account_info(), + account: ctx.accounts.pool_vault_a.to_account_info(), + mint: ctx.accounts.mint_a.to_account_info(), + owner: ctx.accounts.pool_authority.key(), + } + .rent_free( + ctx.accounts.light_token_config.to_account_info(), + ctx.accounts.light_token_rent_sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + &crate::ID, + ) + .invoke_signed(&[ + POOL_ACCOUNT_A_SEED, + pool_key.as_ref(), + &[params.pool_account_a_bump], + ])?; + + CreateTokenAccountCpi { + payer: ctx.accounts.fee_payer.to_account_info(), + account: ctx.accounts.pool_vault_b.to_account_info(), + mint: ctx.accounts.mint_b.to_account_info(), + owner: ctx.accounts.pool_authority.key(), + } + .rent_free( + ctx.accounts.light_token_config.to_account_info(), + ctx.accounts.light_token_rent_sponsor.to_account_info(), + ctx.accounts.system_program.to_account_info(), + &crate::ID, + ) + .invoke_signed(&[ + POOL_ACCOUNT_B_SEED, + pool_key.as_ref(), + &[params.pool_account_b_bump], + ])?; + + Ok(()) +} + +#[derive(Accounts, LightAccounts)] +#[instruction(params: CreatePoolLightLpParams)] +pub struct CreatePoolLightLp<'info> { + #[account( + seeds = [ + amm.id.as_ref() + ], + bump, + )] + pub amm: Box>, + + #[account( + init, + payer = fee_payer, + space = Pool::LEN, + seeds = [ + amm.key().as_ref(), + mint_a.key().as_ref(), + mint_b.key().as_ref(), + ], + bump, + )] + pub pool: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [LP_MINT_SIGNER_SEED, pool.key().as_ref()], + bump, + )] + pub lp_mint_signer: UncheckedAccount<'info>, + + /// CHECK: Initialized by light_account macro + #[account(mut)] + #[light_account(init, + mint::signer = lp_mint_signer, + mint::authority = pool_authority, + mint::decimals = 6, + mint::seeds = &[LP_MINT_SIGNER_SEED, self.pool.to_account_info().key.as_ref()], + mint::bump = params.lp_mint_signer_bump, + mint::name = b"LP Token".to_vec(), + mint::symbol = b"LP".to_vec(), + mint::uri = b"".to_vec(), + mint::update_authority = pool_authority, + mint::authority_seeds = &[AUTHORITY_SEED], + mint::authority_bump = params.pool_authority_bump + )] + pub mint_liquidity: UncheckedAccount<'info>, + + /// CHECK: Validated by pool_vault_a light_account constraint + pub mint_a: AccountInfo<'info>, + + /// CHECK: Validated by pool_vault_b light_account constraint + pub mint_b: AccountInfo<'info>, + + /// CHECK: Created via CreateTokenAccountCpi in handler + #[account( + mut, + seeds = [POOL_ACCOUNT_A_SEED, pool.key().as_ref()], + bump, + )] + #[light_account(token::seeds = [POOL_ACCOUNT_A_SEED, self.pool.key()], token::owner_seeds = [AUTHORITY_SEED])] + pub pool_vault_a: UncheckedAccount<'info>, + + /// CHECK: Created via CreateTokenAccountCpi in handler + #[account( + mut, + seeds = [POOL_ACCOUNT_B_SEED, pool.key().as_ref()], + bump, + )] + #[light_account(token::seeds = [POOL_ACCOUNT_B_SEED, self.pool.key()], token::owner_seeds = [AUTHORITY_SEED])] + pub pool_vault_b: UncheckedAccount<'info>, + + #[account(mut)] + pub fee_payer: Signer<'info>, + + /// Token program for mint_a and mint_b (SPL, T22, or Light). + pub token_program: Interface<'info, TokenInterface>, + + /// CHECK: Program-specific config for light mint creation + pub compression_config: AccountInfo<'info>, + + /// CHECK: Validated by address constraint + #[account(address = LIGHT_TOKEN_CONFIG)] + pub light_token_config: AccountInfo<'info>, + + /// CHECK: Validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Light token CPI authority + pub light_token_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/programs/anchor/token-swap/src/instructions/deposit_liquidity.rs b/programs/anchor/token-swap/src/instructions/deposit_liquidity.rs new file mode 100644 index 0000000..f992d07 --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/deposit_liquidity.rs @@ -0,0 +1,297 @@ +use anchor_lang::prelude::*; +use fixed::types::I64F64; +use light_anchor_spl::token_interface::{Mint, MintTo, TokenAccount, TokenInterface}; +use light_token::instruction::{MintToCpi, TransferCheckedCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; +use light_token::utils::get_token_account_balance; +use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + +use crate::{ + constants::{ + AUTHORITY_SEED, MINIMUM_LIQUIDITY, POOL_ACCOUNT_A_SEED, POOL_ACCOUNT_B_SEED, + }, + errors::SwapError, + state::Pool, +}; + +pub fn deposit_liquidity( + ctx: Context, + amount_a: u64, + amount_b: u64, + spl_interface_bump_a: u8, + spl_interface_bump_b: u8, +) -> Result<()> { + let pool_a_balance = get_token_account_balance(&ctx.accounts.pool_account_a.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + let pool_b_balance = get_token_account_balance(&ctx.accounts.pool_account_b.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + + let mut amount_a = if amount_a > ctx.accounts.depositor_account_a.amount { + ctx.accounts.depositor_account_a.amount + } else { + amount_a + }; + let mut amount_b = if amount_b > ctx.accounts.depositor_account_b.amount { + ctx.accounts.depositor_account_b.amount + } else { + amount_b + }; + + // Frontrun risk: attackers can frontrun pool creation with bad ratios + let pool_creation = pool_a_balance == 0 && pool_b_balance == 0; + (amount_a, amount_b) = if pool_creation { + (amount_a, amount_b) + } else { + let ratio = I64F64::from_num(pool_a_balance) + .checked_div(I64F64::from_num(pool_b_balance)) + .ok_or(SwapError::Underflow)?; + if pool_a_balance > pool_b_balance { + ( + I64F64::from_num(amount_b) + .checked_mul(ratio) + .ok_or(SwapError::Overflow)? + .to_num::(), + amount_b, + ) + } else { + ( + amount_a, + I64F64::from_num(amount_a) + .checked_div(ratio) + .ok_or(SwapError::Underflow)? + .to_num::(), + ) + } + }; + + let mut liquidity = I64F64::from_num(amount_a) + .checked_mul(I64F64::from_num(amount_b)) + .ok_or(SwapError::Overflow)? + .sqrt() + .to_num::(); + + if pool_creation { + if liquidity < MINIMUM_LIQUIDITY { + return err!(SwapError::DepositTooSmall); + } + + liquidity -= MINIMUM_LIQUIDITY; + } + + let decimals_a = ctx.accounts.mint_a.decimals; + let decimals_b = ctx.accounts.mint_b.decimals; + + let is_spl = ctx.accounts.spl_interface_pda_a.key() != Pubkey::default(); + + if !is_spl { + // fee_payer: Some ensures authority is readonly (required for PDA with account data) + TransferCheckedCpi { + source: ctx.accounts.depositor_account_a.to_account_info(), + mint: ctx.accounts.mint_a.to_account_info(), + destination: ctx.accounts.pool_account_a.to_account_info(), + amount: amount_a, + decimals: decimals_a, + authority: ctx.accounts.depositor.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + TransferCheckedCpi { + source: ctx.accounts.depositor_account_b.to_account_info(), + mint: ctx.accounts.mint_b.to_account_info(), + destination: ctx.accounts.pool_account_b.to_account_info(), + amount: amount_b, + decimals: decimals_b, + authority: ctx.accounts.depositor.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + let cpi_a = TransferInterfaceCpi::new( + amount_a, + decimals_a, + ctx.accounts.depositor_account_a.to_account_info(), + ctx.accounts.pool_account_a.to_account_info(), + ctx.accounts.depositor.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_a.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_a.to_account_info()), + Some(spl_interface_bump_a), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_a.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + let cpi_b = TransferInterfaceCpi::new( + amount_b, + decimals_b, + ctx.accounts.depositor_account_b.to_account_info(), + ctx.accounts.pool_account_b.to_account_info(), + ctx.accounts.depositor.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_b.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_b.to_account_info()), + Some(spl_interface_bump_b), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_b.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + let authority_bump = ctx.bumps.pool_authority; + let authority_seeds: &[&[u8]] = &[ + AUTHORITY_SEED, + &[authority_bump], + ]; + + let is_light_lp = ctx.accounts.liquidity_token_program.key().to_bytes() == LIGHT_TOKEN_PROGRAM_ID; + + if is_light_lp { + MintToCpi { + mint: ctx.accounts.mint_liquidity.to_account_info(), + destination: ctx.accounts.depositor_account_liquidity.to_account_info(), + amount: liquidity, + authority: ctx.accounts.pool_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke_signed(&[authority_seeds])?; + } else { + let signer_seeds = &[authority_seeds]; + light_anchor_spl::token_interface::mint_to( + CpiContext::new_with_signer( + ctx.accounts.liquidity_token_program.to_account_info(), + MintTo { + mint: ctx.accounts.mint_liquidity.to_account_info(), + to: ctx.accounts.depositor_account_liquidity.to_account_info(), + authority: ctx.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + liquidity, + )?; + } + + ctx.accounts.pool.lp_supply = ctx + .accounts + .pool + .lp_supply + .checked_add(liquidity) + .ok_or(SwapError::Overflow)?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct DepositLiquidity<'info> { + #[account( + mut, + seeds = [ + pool.amm.as_ref(), + pool.mint_a.key().as_ref(), + pool.mint_b.key().as_ref(), + ], + bump, + has_one = mint_a, + has_one = mint_b, + )] + pub pool: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + #[account(mut)] + pub depositor: Signer<'info>, + + /// CHECK: Can be SPL, T22, or Light + #[account(mut)] + pub mint_liquidity: UncheckedAccount<'info>, + + #[account(mint::token_program = token_program)] + pub mint_a: Box>, + + #[account(mint::token_program = token_program)] + pub mint_b: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_A_SEED, pool.key().as_ref()], + bump, + )] + pub pool_account_a: UncheckedAccount<'info>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_B_SEED, pool.key().as_ref()], + bump, + )] + pub pool_account_b: UncheckedAccount<'info>, + + /// CHECK: Can be SPL, T22, or Light + #[account(mut)] + pub depositor_account_liquidity: UncheckedAccount<'info>, + + #[account( + mut, + token::mint = mint_a, + token::authority = depositor, + )] + pub depositor_account_a: Box>, + + #[account( + mut, + token::mint = mint_b, + token::authority = depositor, + )] + pub depositor_account_b: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// Token program for mint_a and mint_b (SPL, T22, or Light). + pub token_program: Interface<'info, TokenInterface>, + /// Token program for LP mint (may differ from token_program). + pub liquidity_token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA derived by light-token: ["pool", mint_a] + #[account(mut)] + pub spl_interface_pda_a: UncheckedAccount<'info>, + + /// CHECK: SPL interface PDA derived by light-token: ["pool", mint_b] + #[account(mut)] + pub spl_interface_pda_b: UncheckedAccount<'info>, +} diff --git a/programs/anchor/token-swap/src/instructions/mod.rs b/programs/anchor/token-swap/src/instructions/mod.rs new file mode 100644 index 0000000..af26d29 --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/mod.rs @@ -0,0 +1,13 @@ +mod create_amm; +mod create_pool; +mod create_pool_light_lp; +mod deposit_liquidity; +mod swap_exact_tokens_for_tokens; +mod withdraw_liquidity; + +pub use create_amm::*; +pub use create_pool::*; +pub use create_pool_light_lp::*; +pub use deposit_liquidity::*; +pub use swap_exact_tokens_for_tokens::*; +pub use withdraw_liquidity::*; diff --git a/programs/anchor/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs b/programs/anchor/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs new file mode 100644 index 0000000..b3d5acc --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs @@ -0,0 +1,339 @@ +use anchor_lang::prelude::*; +use fixed::types::I64F64; +use light_anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use light_token::instruction::{TransferCheckedCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; +use light_token::utils::get_token_account_balance; + +use crate::{ + constants::{AUTHORITY_SEED, POOL_ACCOUNT_A_SEED, POOL_ACCOUNT_B_SEED}, + errors::*, + state::{Amm, Pool}, +}; + +pub fn swap_exact_tokens_for_tokens( + ctx: Context, + swap_a: bool, + input_amount: u64, + min_output_amount: u64, + spl_interface_bump_a: u8, + spl_interface_bump_b: u8, +) -> Result<()> { + let input = if swap_a && input_amount > ctx.accounts.trader_account_a.amount { + ctx.accounts.trader_account_a.amount + } else if !swap_a && input_amount > ctx.accounts.trader_account_b.amount { + ctx.accounts.trader_account_b.amount + } else { + input_amount + }; + + let pool_a_balance = get_token_account_balance(&ctx.accounts.pool_account_a.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + let pool_b_balance = get_token_account_balance(&ctx.accounts.pool_account_b.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + + let amm = &ctx.accounts.amm; + let fee_amount = (input as u128 * amm.fee as u128 / 10000) as u64; + let taxed_input = input - fee_amount; + + let output = if swap_a { + I64F64::from_num(taxed_input) + .checked_mul(I64F64::from_num(pool_b_balance)) + .ok_or(SwapError::Overflow)? + .checked_div( + I64F64::from_num(pool_a_balance) + .checked_add(I64F64::from_num(taxed_input)) + .ok_or(SwapError::Overflow)?, + ) + .ok_or(SwapError::Underflow)? + } else { + I64F64::from_num(taxed_input) + .checked_mul(I64F64::from_num(pool_a_balance)) + .ok_or(SwapError::Overflow)? + .checked_div( + I64F64::from_num(pool_b_balance) + .checked_add(I64F64::from_num(taxed_input)) + .ok_or(SwapError::Overflow)?, + ) + .ok_or(SwapError::Underflow)? + } + .to_num::(); + + if output < min_output_amount { + return err!(SwapError::OutputTooSmall); + } + + let invariant = (pool_a_balance as u128) * (pool_b_balance as u128); + + let authority_bump = ctx.bumps.pool_authority; + let authority_seeds: &[&[u8]] = &[ + AUTHORITY_SEED, + &[authority_bump], + ]; + + let decimals_a = ctx.accounts.mint_a.decimals; + let decimals_b = ctx.accounts.mint_b.decimals; + + let is_spl = ctx.accounts.spl_interface_pda_a.key() != Pubkey::default(); + + if swap_a { + if !is_spl { + // fee_payer: Some ensures authority is readonly (required for PDA with account data) + TransferCheckedCpi { + source: ctx.accounts.trader_account_a.to_account_info(), + mint: ctx.accounts.mint_a.to_account_info(), + destination: ctx.accounts.pool_account_a.to_account_info(), + amount: input, + decimals: decimals_a, + authority: ctx.accounts.trader.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + TransferCheckedCpi { + source: ctx.accounts.pool_account_b.to_account_info(), + mint: ctx.accounts.mint_b.to_account_info(), + destination: ctx.accounts.trader_account_b.to_account_info(), + amount: output, + decimals: decimals_b, + authority: ctx.accounts.pool_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + let cpi_input = TransferInterfaceCpi::new( + input, + decimals_a, + ctx.accounts.trader_account_a.to_account_info(), + ctx.accounts.pool_account_a.to_account_info(), + ctx.accounts.trader.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_a.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_a.to_account_info()), + Some(spl_interface_bump_a), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_input.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + let cpi_output = TransferInterfaceCpi::new( + output, + decimals_b, + ctx.accounts.pool_account_b.to_account_info(), + ctx.accounts.trader_account_b.to_account_info(), + ctx.accounts.pool_authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_b.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_b.to_account_info()), + Some(spl_interface_bump_b), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_output.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + } else { + if !is_spl { + TransferCheckedCpi { + source: ctx.accounts.trader_account_b.to_account_info(), + mint: ctx.accounts.mint_b.to_account_info(), + destination: ctx.accounts.pool_account_b.to_account_info(), + amount: input, + decimals: decimals_b, + authority: ctx.accounts.trader.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + TransferCheckedCpi { + source: ctx.accounts.pool_account_a.to_account_info(), + mint: ctx.accounts.mint_a.to_account_info(), + destination: ctx.accounts.trader_account_a.to_account_info(), + amount: output, + decimals: decimals_a, + authority: ctx.accounts.pool_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + let cpi_input = TransferInterfaceCpi::new( + input, + decimals_b, + ctx.accounts.trader_account_b.to_account_info(), + ctx.accounts.pool_account_b.to_account_info(), + ctx.accounts.trader.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_b.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_b.to_account_info()), + Some(spl_interface_bump_b), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_input.invoke() + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + let cpi_output = TransferInterfaceCpi::new( + output, + decimals_a, + ctx.accounts.pool_account_a.to_account_info(), + ctx.accounts.trader_account_a.to_account_info(), + ctx.accounts.pool_authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_a.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_a.to_account_info()), + Some(spl_interface_bump_a), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_output.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + } + + msg!( + "Traded {} tokens ({} after fees) for {}", + input, + taxed_input, + output + ); + + // Higher invariant OK - rounding benefits LPs + let new_pool_a_balance = + get_token_account_balance(&ctx.accounts.pool_account_a.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + let new_pool_b_balance = + get_token_account_balance(&ctx.accounts.pool_account_b.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + + if invariant > (new_pool_a_balance as u128) * (new_pool_b_balance as u128) { + return err!(SwapError::InvariantViolated); + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct SwapExactTokensForTokens<'info> { + #[account( + seeds = [ + amm.id.as_ref() + ], + bump, + )] + pub amm: Account<'info, Amm>, + + #[account( + seeds = [ + pool.amm.as_ref(), + pool.mint_a.key().as_ref(), + pool.mint_b.key().as_ref(), + ], + bump, + has_one = amm, + has_one = mint_a, + has_one = mint_b, + )] + pub pool: Account<'info, Pool>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + #[account(mut)] + pub trader: Signer<'info>, + + #[account(mint::token_program = token_program)] + pub mint_a: Box>, + + #[account(mint::token_program = token_program)] + pub mint_b: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_A_SEED, pool.key().as_ref()], + bump, + )] + pub pool_account_a: UncheckedAccount<'info>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_B_SEED, pool.key().as_ref()], + bump, + )] + pub pool_account_b: UncheckedAccount<'info>, + + #[account( + mut, + token::mint = mint_a, + token::authority = trader, + )] + pub trader_account_a: Box>, + + #[account( + mut, + token::mint = mint_b, + token::authority = trader, + )] + pub trader_account_b: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// Token program for mint_a and mint_b (SPL, T22, or Light). + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA derived by light-token: ["pool", mint_a] + #[account(mut)] + pub spl_interface_pda_a: UncheckedAccount<'info>, + + /// CHECK: SPL interface PDA derived by light-token: ["pool", mint_b] + #[account(mut)] + pub spl_interface_pda_b: UncheckedAccount<'info>, +} diff --git a/programs/anchor/token-swap/src/instructions/withdraw_liquidity.rs b/programs/anchor/token-swap/src/instructions/withdraw_liquidity.rs new file mode 100644 index 0000000..c6eb15d --- /dev/null +++ b/programs/anchor/token-swap/src/instructions/withdraw_liquidity.rs @@ -0,0 +1,264 @@ +use anchor_lang::prelude::*; +use fixed::types::I64F64; +use light_anchor_spl::token_interface::{self, Burn, Mint, TokenAccount, TokenInterface}; +use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; +use light_token::instruction::{BurnCpi, TransferCheckedCpi, TransferInterfaceCpi, LIGHT_TOKEN_RENT_SPONSOR}; +use light_token::utils::get_token_account_balance; + +use crate::{ + constants::{AUTHORITY_SEED, MINIMUM_LIQUIDITY, POOL_ACCOUNT_A_SEED, POOL_ACCOUNT_B_SEED}, + errors::SwapError, + state::{Amm, Pool}, +}; + +pub fn withdraw_liquidity(ctx: Context, amount: u64, spl_interface_bump_a: u8, spl_interface_bump_b: u8) -> Result<()> { + let authority_bump = ctx.bumps.pool_authority; + let authority_seeds: &[&[u8]] = &[ + AUTHORITY_SEED, + &[authority_bump], + ]; + + let is_light_lp = + ctx.accounts.liquidity_token_program.key().to_bytes() == LIGHT_TOKEN_PROGRAM_ID; + + let lp_supply = ctx.accounts.pool.lp_supply; + + let pool_a_balance = get_token_account_balance(&ctx.accounts.pool_account_a.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + let pool_b_balance = get_token_account_balance(&ctx.accounts.pool_account_b.to_account_info()) + .map_err(|_| anchor_lang::prelude::ProgramError::InvalidAccountData)?; + + let amount_a = I64F64::from_num(amount) + .checked_mul(I64F64::from_num(pool_a_balance)) + .ok_or(SwapError::Overflow)? + .checked_div(I64F64::from_num(lp_supply + MINIMUM_LIQUIDITY)) + .ok_or(SwapError::Underflow)? + .floor() + .to_num::(); + + let decimals_a = ctx.accounts.mint_a.decimals; + + let is_spl = ctx.accounts.spl_interface_pda_a.key() != Pubkey::default(); + + if !is_spl { + // fee_payer: Some ensures authority is readonly (required for PDA with account data) + TransferCheckedCpi { + source: ctx.accounts.pool_account_a.to_account_info(), + mint: ctx.accounts.mint_a.to_account_info(), + destination: ctx.accounts.depositor_account_a.to_account_info(), + amount: amount_a, + decimals: decimals_a, + authority: ctx.accounts.pool_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + let cpi_a = TransferInterfaceCpi::new( + amount_a, + decimals_a, + ctx.accounts.pool_account_a.to_account_info(), + ctx.accounts.depositor_account_a.to_account_info(), + ctx.accounts.pool_authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_a.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_a.to_account_info()), + Some(spl_interface_bump_a), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_a.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + let amount_b = I64F64::from_num(amount) + .checked_mul(I64F64::from_num(pool_b_balance)) + .ok_or(SwapError::Overflow)? + .checked_div(I64F64::from_num(lp_supply + MINIMUM_LIQUIDITY)) + .ok_or(SwapError::Underflow)? + .floor() + .to_num::(); + + let decimals_b = ctx.accounts.mint_b.decimals; + + if !is_spl { + TransferCheckedCpi { + source: ctx.accounts.pool_account_b.to_account_info(), + mint: ctx.accounts.mint_b.to_account_info(), + destination: ctx.accounts.depositor_account_b.to_account_info(), + amount: amount_b, + decimals: decimals_b, + authority: ctx.accounts.pool_authority.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: Some(ctx.accounts.payer.to_account_info()), + } + .invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } else { + let cpi_b = TransferInterfaceCpi::new( + amount_b, + decimals_b, + ctx.accounts.pool_account_b.to_account_info(), + ctx.accounts.depositor_account_b.to_account_info(), + ctx.accounts.pool_authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.light_token_cpi_authority.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ) + .with_spl_interface( + Some(ctx.accounts.mint_b.to_account_info()), + Some(ctx.accounts.token_program.to_account_info()), + Some(ctx.accounts.spl_interface_pda_b.to_account_info()), + Some(spl_interface_bump_b), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + + cpi_b.invoke_signed(&[authority_seeds]) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + if is_light_lp { + BurnCpi { + source: ctx.accounts.depositor_account_liquidity.to_account_info(), + mint: ctx.accounts.mint_liquidity.to_account_info(), + amount, + authority: ctx.accounts.depositor.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + max_top_up: None, + fee_payer: None, + } + .invoke()?; + } else { + token_interface::burn( + CpiContext::new( + ctx.accounts.liquidity_token_program.to_account_info(), + Burn { + mint: ctx.accounts.mint_liquidity.to_account_info(), + from: ctx.accounts.depositor_account_liquidity.to_account_info(), + authority: ctx.accounts.depositor.to_account_info(), + }, + ), + amount, + )?; + } + + ctx.accounts.pool.lp_supply = ctx + .accounts + .pool + .lp_supply + .checked_sub(amount) + .ok_or(SwapError::Underflow)?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct WithdrawLiquidity<'info> { + #[account( + seeds = [ + amm.id.as_ref() + ], + bump, + )] + pub amm: Account<'info, Amm>, + + #[account( + mut, + seeds = [ + pool.amm.as_ref(), + pool.mint_a.key().as_ref(), + pool.mint_b.key().as_ref(), + ], + bump, + has_one = mint_a, + has_one = mint_b, + )] + pub pool: Account<'info, Pool>, + + /// CHECK: PDA verified by seeds constraint + #[account( + seeds = [AUTHORITY_SEED], + bump, + )] + pub pool_authority: AccountInfo<'info>, + + pub depositor: Signer<'info>, + + /// CHECK: Can be SPL, T22, or Light + #[account(mut)] + pub mint_liquidity: UncheckedAccount<'info>, + + #[account(mut, mint::token_program = token_program)] + pub mint_a: Box>, + + #[account(mut, mint::token_program = token_program)] + pub mint_b: Box>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_A_SEED, pool.key().as_ref()], + bump, + )] + pub pool_account_a: UncheckedAccount<'info>, + + /// CHECK: PDA verified by seeds constraint + #[account( + mut, + seeds = [POOL_ACCOUNT_B_SEED, pool.key().as_ref()], + bump, + )] + pub pool_account_b: UncheckedAccount<'info>, + + /// CHECK: Can be SPL, T22, or Light + #[account(mut)] + pub depositor_account_liquidity: UncheckedAccount<'info>, + + #[account( + mut, + token::mint = mint_a, + token::authority = depositor, + )] + pub depositor_account_a: Box>, + + #[account( + mut, + token::mint = mint_b, + token::authority = depositor, + )] + pub depositor_account_b: Box>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// Token program for mint_a and mint_b (SPL, T22, or Light). + pub token_program: Interface<'info, TokenInterface>, + /// Token program for LP mint (may differ from token_program). + pub liquidity_token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, + pub light_token_program: Interface<'info, TokenInterface>, + + /// CHECK: Validated by address constraint + #[account(mut, address = LIGHT_TOKEN_RENT_SPONSOR)] + pub light_token_rent_sponsor: AccountInfo<'info>, + + /// CHECK: Light token CPI authority + #[account(mut)] + pub light_token_cpi_authority: AccountInfo<'info>, + + /// CHECK: SPL interface PDA derived by light-token: ["pool", mint_a] + #[account(mut)] + pub spl_interface_pda_a: UncheckedAccount<'info>, + + /// CHECK: SPL interface PDA derived by light-token: ["pool", mint_b] + #[account(mut)] + pub spl_interface_pda_b: UncheckedAccount<'info>, +} diff --git a/programs/anchor/token-swap/src/lib.rs b/programs/anchor/token-swap/src/lib.rs new file mode 100644 index 0000000..8736d04 --- /dev/null +++ b/programs/anchor/token-swap/src/lib.rs @@ -0,0 +1,68 @@ +#![allow(clippy::result_large_err, unexpected_cfgs, deprecated)] + +use anchor_lang::prelude::*; +use light_account::{derive_light_cpi_signer, light_program, CpiSigner}; + +mod constants; +mod errors; +mod instructions; +mod state; + +pub use constants::{AUTHORITY_SEED, LP_MINT_SIGNER_SEED, POOL_ACCOUNT_A_SEED, POOL_ACCOUNT_B_SEED}; +pub use instructions::{CreatePoolLightLpParams, CreatePoolParams}; + +declare_id!("AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn"); + +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("AsGVFxWqEn8icRBFQApxJe68x3r9zvfSbmiEzYFATGYn"); + +#[light_program] +#[allow(deprecated)] // Anchor's #[program] uses deprecated AccountInfo::realloc +#[program] +pub mod swap_example { + pub use super::instructions::*; + use super::*; + + pub fn create_amm(ctx: Context, id: Pubkey, fee: u16) -> Result<()> { + instructions::create_amm(ctx, id, fee) + } + + pub fn create_pool<'info>( + ctx: Context<'_, '_, '_, 'info, CreatePool<'info>>, + params: CreatePoolParams, + ) -> Result<()> { + instructions::create_pool(ctx, params) + } + + pub fn deposit_liquidity( + ctx: Context, + amount_a: u64, + amount_b: u64, + spl_interface_bump_a: u8, + spl_interface_bump_b: u8, + ) -> Result<()> { + instructions::deposit_liquidity(ctx, amount_a, amount_b, spl_interface_bump_a, spl_interface_bump_b) + } + + pub fn withdraw_liquidity(ctx: Context, amount: u64, spl_interface_bump_a: u8, spl_interface_bump_b: u8) -> Result<()> { + instructions::withdraw_liquidity(ctx, amount, spl_interface_bump_a, spl_interface_bump_b) + } + + pub fn swap_exact_tokens_for_tokens( + ctx: Context, + swap_a: bool, + input_amount: u64, + min_output_amount: u64, + spl_interface_bump_a: u8, + spl_interface_bump_b: u8, + ) -> Result<()> { + instructions::swap_exact_tokens_for_tokens(ctx, swap_a, input_amount, min_output_amount, spl_interface_bump_a, spl_interface_bump_b) + } + + pub fn create_pool_light_lp<'info>( + ctx: Context<'_, '_, '_, 'info, CreatePoolLightLp<'info>>, + params: CreatePoolLightLpParams, + ) -> Result<()> { + instructions::create_pool_light_lp(ctx, params) + } +} diff --git a/programs/anchor/token-swap/src/state.rs b/programs/anchor/token-swap/src/state.rs new file mode 100644 index 0000000..80f21b0 --- /dev/null +++ b/programs/anchor/token-swap/src/state.rs @@ -0,0 +1,28 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(Default)] +pub struct Amm { + pub id: Pubkey, + pub admin: Pubkey, + /// LP fee in basis points + pub fee: u16, +} + +impl Amm { + pub const LEN: usize = 8 + 32 + 32 + 2; +} + +#[account] +#[derive(Default)] +pub struct Pool { + pub amm: Pubkey, + pub mint_a: Pubkey, + pub mint_b: Pubkey, + /// Tracked on-chain for Light LP mint compatibility + pub lp_supply: u64, +} + +impl Pool { + pub const LEN: usize = 8 + 32 + 32 + 32 + 8; +} diff --git a/programs/anchor/token-swap/tests/common/mod.rs b/programs/anchor/token-swap/tests/common/mod.rs new file mode 100644 index 0000000..f8e37b2 --- /dev/null +++ b/programs/anchor/token-swap/tests/common/mod.rs @@ -0,0 +1,673 @@ +//! AMM test setup for 6 token standard combinations: SPL, T22, Light. +//! +//! Each combination varies the mint type and user account type while the pool +//! vaults are always Light Token accounts: +//! +//! - `Spl` / `Token2022`: standard associated token accounts, transfers via `TransferInterfaceCpi` +//! - `Light` / `FullLight`: Light Token accounts, transfers via `TransferCheckedCpi` +//! - `LightSpl` / `LightT22`: SPL/Token 2022 mints converted into Light Token accounts before +//! the AMM starts (tokens are minted to a temp associated token account, then +//! transferred to an associated Light Token account via `transfer_spl_to_light`) +//! +//! `FullLight` additionally creates the LP mint as a Light Token mint via `create_pool_light_lp`, +//! making the entire pool rent-free. +//! +//! ## Setup flow +//! +//! 1. `create_test_rpc()` — start test validator with swap_example + minter programs +//! 2. `setup_amm_test(config)` — create mints, interface PDAs, depositor, derive PDAs +//! 3. `create_trader()` — create funded trader accounts per config + +// ============================================================================ +// Imports +// ============================================================================ + +use anchor_spl::token; +use light_token::instruction::find_mint_address; +use shared_test_utils::{ + light_tokens::{create_light_ata, create_light_mint, mint_light_tokens}, + setup::initialize_rent_free_config, + spl_interface::{create_spl_interface_pda, transfer_spl_to_light}, + spl_tokens::{create_spl_ata, create_spl_mint, mint_spl_tokens}, + t22_tokens::{create_t22_ata, create_t22_mint, mint_t22_tokens}, + Indexer, LightProgramTest, MintType, ProgramTestConfig, Rpc, TestRpc, + LIGHT_TOKEN_MINTER_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID, +}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; +use spl_token_2022::pod::PodAccount; + +// ============================================================================ +// Token Configuration +// ============================================================================ + +/// Token configuration for parameterized AMM tests. +/// +/// Each variant determines the mint type and user account type. The pool vaults +/// are always Light Token accounts (rent-free). The user account type controls +/// which CPI path `transfer_tokens()` selects at runtime: +/// +/// - SPL/Token 2022 user accounts → `TransferInterfaceCpi` (needs interface PDA) +/// - Light user accounts → `TransferCheckedCpi` (no interface PDA) +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub enum TokenConfig { + /// SPL mint + SPL ATAs and Light Token vault. Transfers via `TransferInterfaceCpi`. + Spl, + /// Token 2022 mint + Token 2022 ATAs and Light Token vault. Transfers via `TransferInterfaceCpi`. + Token2022, + /// SPL mint + associated Light Token accounts. Tokens converted from SPL ATAs in setup. + /// During AMM operations, all transfers are Light-to-Light. + LightSpl, + /// Token 2022 mint + associated Light Token accounts. Tokens converted from Token 2022 ATAs in setup. + /// During AMM operations, all transfers are Light-to-Light. + LightT22, + /// Light Token mint + associated Light Token accounts + SPL LP mint. + Light, + /// Light Token mints + associated Light Token accounts + Light Token LP mint. + FullLight, +} + +impl TokenConfig { + /// Returns the underlying mint program type. + /// Light Token mints use SPL-compatible layout, so this returns `MintType::Spl`. + pub fn mint_type(&self) -> MintType { + match self { + TokenConfig::Spl | TokenConfig::LightSpl => MintType::Spl, + TokenConfig::Token2022 | TokenConfig::LightT22 => MintType::Token2022, + TokenConfig::Light | TokenConfig::FullLight => MintType::Spl, + } + } + + /// Returns the token program ID passed as `token_program` in instructions. + pub fn token_program_id(&self) -> Pubkey { + match self { + TokenConfig::Spl | TokenConfig::LightSpl => token::ID, + TokenConfig::Token2022 | TokenConfig::LightT22 => spl_token_2022::ID, + TokenConfig::Light | TokenConfig::FullLight => { + Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID) + } + } + } + + /// Returns true if user accounts are Light Token accounts. + pub fn uses_light_user_accounts(&self) -> bool { + matches!( + self, + TokenConfig::LightSpl + | TokenConfig::LightT22 + | TokenConfig::Light + | TokenConfig::FullLight + ) + } + + /// Returns true if mints are Light Token mints (not SPL/Token 2022). + pub fn uses_light_mints(&self) -> bool { + matches!(self, TokenConfig::Light | TokenConfig::FullLight) + } + + /// Returns true if LP mint is a Light Token mint. + pub fn uses_light_lp_mint(&self) -> bool { + matches!(self, TokenConfig::FullLight) + } +} + +// ============================================================================ +// Test Context +// ============================================================================ + +/// Context for AMM tests containing all necessary accounts. +pub struct AmmTestContext { + pub program_id: Pubkey, + pub payer: Keypair, + pub mint_a_pubkey: Pubkey, + pub mint_b_pubkey: Pubkey, + pub amm_pda: Pubkey, + pub amm_id: Pubkey, + pub pool_pda: Pubkey, + pub pool_authority: Pubkey, + pub pool_authority_bump: u8, + pub mint_liquidity: Pubkey, + pub pool_account_a: Pubkey, + pub pool_account_b: Pubkey, + pub pool_a_bump: u8, + pub pool_b_bump: u8, + pub spl_interface_pda_a: Pubkey, + pub spl_interface_pda_b: Pubkey, + pub spl_interface_bump_a: u8, + pub depositor: Keypair, + pub depositor_ata_a: Pubkey, + pub depositor_ata_b: Pubkey, + pub token_config: TokenConfig, + /// Authority keypair for Light Token mint A (if Light config). + pub light_mint_authority_a: Option, + /// LP mint signer PDA (for FullLight config). + pub lp_mint_signer: Option, + pub lp_mint_signer_bump: u8, + /// Config PDA for rent-free Light Token mint creation. + pub compression_config: Pubkey, +} + +// ============================================================================ +// Setup Functions +// ============================================================================ + +/// Create a new LightProgramTest instance for token-swap tests. +pub async fn create_test_rpc() -> LightProgramTest { + let program_id = swap_example::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![ + ("swap_example", program_id), + ("light_token_minter", LIGHT_TOKEN_MINTER_PROGRAM_ID), + ]), + ); + config = config.with_light_protocol_events(); + LightProgramTest::new(config).await.unwrap() +} + +/// Initialize mints, interface PDAs, depositor, and derive PDAs for a given token config. +/// +/// SPL interface PDAs are created for all SPL/Token 2022 configs (including `Spl` and +/// `Token2022`, not just `LightSpl`/`LightT22`) because the pool vaults are always +/// Light Token accounts — `TransferInterfaceCpi` needs the interface PDA when an +/// SPL/Token 2022 account transfers to a Light Token vault. +/// +/// For `Light`/`FullLight` configs, no interface PDA is created (early return). +pub async fn setup_amm_test( + rpc: &mut R, + config: TokenConfig, +) -> AmmTestContext { + let program_id = swap_example::ID; + let payer = rpc.get_payer().insecure_clone(); + + let (compression_config, _rent_sponsor) = + initialize_rent_free_config(rpc, &payer, &program_id).await; + + let depositor = Keypair::new(); + rpc.airdrop_lamports(&depositor.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let amount = 10_000_000_000_000u64; // 10,000 tokens with 9 decimals + + if config.uses_light_mints() { + // ========== LIGHT MINT SETUP ========== + let light_mint_a = + create_light_mint(rpc, &payer, 9, "Token A", "TOKA", &compression_config).await; + let light_mint_b = + create_light_mint(rpc, &payer, 9, "Token B", "TOKB", &compression_config).await; + + let mint_a_pubkey = light_mint_a.mint; + let mint_b_pubkey = light_mint_b.mint; + + let spl_interface_pda_a = Pubkey::default(); + let spl_interface_pda_b = Pubkey::default(); + + let depositor_ata_a = mint_light_tokens( + rpc, + &payer, + &light_mint_a.authority, + &mint_a_pubkey, + &depositor.pubkey(), + amount, + ) + .await; + let depositor_ata_b = mint_light_tokens( + rpc, + &payer, + &light_mint_b.authority, + &mint_b_pubkey, + &depositor.pubkey(), + amount, + ) + .await; + + let amm_id = Pubkey::new_unique(); + let (amm_pda, _) = Pubkey::find_program_address(&[amm_id.as_ref()], &program_id); + + let (pool_pda, _) = Pubkey::find_program_address( + &[ + amm_pda.as_ref(), + mint_a_pubkey.as_ref(), + mint_b_pubkey.as_ref(), + ], + &program_id, + ); + + let (pool_authority, pool_authority_bump) = + Pubkey::find_program_address(&[b"authority"], &program_id); + + let (pool_account_a, pool_a_bump) = + Pubkey::find_program_address(&[b"pool_a", pool_pda.as_ref()], &program_id); + let (pool_account_b, pool_b_bump) = + Pubkey::find_program_address(&[b"pool_b", pool_pda.as_ref()], &program_id); + + // Light config: SPL LP mint via standard PDA. + // FullLight config: Light Token LP mint via lp_mint_signer PDA. + let (mint_liquidity, lp_mint_signer, lp_mint_signer_bump) = + if config.uses_light_lp_mint() { + let (lp_mint_signer, lp_mint_signer_bump) = Pubkey::find_program_address( + &[b"lp_mint_signer", pool_pda.as_ref()], + &program_id, + ); + let (light_lp_mint, _) = find_mint_address(&lp_mint_signer); + (light_lp_mint, Some(lp_mint_signer), lp_mint_signer_bump) + } else { + let (mint_liquidity, _) = Pubkey::find_program_address( + &[ + amm_pda.as_ref(), + mint_a_pubkey.as_ref(), + mint_b_pubkey.as_ref(), + b"liquidity", + ], + &program_id, + ); + (mint_liquidity, None, 0) + }; + + return AmmTestContext { + program_id, + payer, + mint_a_pubkey, + mint_b_pubkey, + amm_pda, + amm_id, + pool_pda, + pool_authority, + pool_authority_bump, + mint_liquidity, + pool_account_a, + pool_account_b, + pool_a_bump, + pool_b_bump, + spl_interface_pda_a, + spl_interface_pda_b, + spl_interface_bump_a: 0, + depositor, + depositor_ata_a, + depositor_ata_b, + token_config: config, + light_mint_authority_a: Some(light_mint_a.authority), + lp_mint_signer, + lp_mint_signer_bump, + compression_config, + }; + } + + // ========== SPL/T22 MINT SETUP ========== + let (mint_a, mint_b) = match config { + TokenConfig::Spl | TokenConfig::LightSpl => { + let mint_a = create_spl_mint(rpc, &payer, &payer.pubkey(), 9).await; + let mint_b = create_spl_mint(rpc, &payer, &payer.pubkey(), 9).await; + (mint_a, mint_b) + } + TokenConfig::Token2022 | TokenConfig::LightT22 => { + let mint_a = create_t22_mint(rpc, &payer, &payer.pubkey(), 9).await; + let mint_b = create_t22_mint(rpc, &payer, &payer.pubkey(), 9).await; + (mint_a, mint_b) + } + TokenConfig::Light | TokenConfig::FullLight => { + unreachable!("Light/FullLight config handled above") + } + }; + + let mint_a_pubkey = mint_a.pubkey(); + let mint_b_pubkey = mint_b.pubkey(); + + // Required for `TransferInterfaceCpi` between SPL/Token 2022 accounts and Light Token pool vaults. + let spl_interface_result_a = + create_spl_interface_pda(rpc, &payer, &mint_a_pubkey, config.mint_type(), false).await; + let spl_interface_result_b = + create_spl_interface_pda(rpc, &payer, &mint_b_pubkey, config.mint_type(), false).await; + + let (depositor_ata_a, depositor_ata_b) = match config { + TokenConfig::Spl => { + let ata_a = create_spl_ata(rpc, &payer, &mint_a_pubkey, &depositor.pubkey()).await; + let ata_b = create_spl_ata(rpc, &payer, &mint_b_pubkey, &depositor.pubkey()).await; + mint_spl_tokens(rpc, &payer, &mint_a_pubkey, &ata_a, &payer, amount).await; + mint_spl_tokens(rpc, &payer, &mint_b_pubkey, &ata_b, &payer, amount).await; + (ata_a, ata_b) + } + TokenConfig::Token2022 => { + let ata_a = create_t22_ata(rpc, &payer, &mint_a_pubkey, &depositor.pubkey()).await; + let ata_b = create_t22_ata(rpc, &payer, &mint_b_pubkey, &depositor.pubkey()).await; + mint_t22_tokens(rpc, &payer, &mint_a_pubkey, &ata_a, &payer, amount).await; + mint_t22_tokens(rpc, &payer, &mint_b_pubkey, &ata_b, &payer, amount).await; + (ata_a, ata_b) + } + TokenConfig::LightSpl => { + // Tokens route through temp SPL ATAs because Light Token accounts + // cannot be direct mint targets for SPL mints. + let temp_ata_a = + create_spl_ata(rpc, &payer, &mint_a_pubkey, &depositor.pubkey()).await; + let temp_ata_b = + create_spl_ata(rpc, &payer, &mint_b_pubkey, &depositor.pubkey()).await; + mint_spl_tokens(rpc, &payer, &mint_a_pubkey, &temp_ata_a, &payer, amount).await; + mint_spl_tokens(rpc, &payer, &mint_b_pubkey, &temp_ata_b, &payer, amount).await; + + let light_ata_a = + create_light_ata(rpc, &payer, &mint_a_pubkey, &depositor.pubkey()).await; + let light_ata_b = + create_light_ata(rpc, &payer, &mint_b_pubkey, &depositor.pubkey()).await; + + transfer_spl_to_light( + rpc, + &payer, + &depositor, + &mint_a_pubkey, + 9, + &temp_ata_a, + &light_ata_a, + &spl_interface_result_a.pda, + spl_interface_result_a.bump, + amount, + MintType::Spl, + ) + .await; + transfer_spl_to_light( + rpc, + &payer, + &depositor, + &mint_b_pubkey, + 9, + &temp_ata_b, + &light_ata_b, + &spl_interface_result_b.pda, + spl_interface_result_b.bump, + amount, + MintType::Spl, + ) + .await; + + (light_ata_a, light_ata_b) + } + TokenConfig::LightT22 => { + // Tokens route through temp Token 2022 ATAs because Light Token accounts + // cannot be direct mint targets for Token 2022 mints. + let temp_ata_a = + create_t22_ata(rpc, &payer, &mint_a_pubkey, &depositor.pubkey()).await; + let temp_ata_b = + create_t22_ata(rpc, &payer, &mint_b_pubkey, &depositor.pubkey()).await; + mint_t22_tokens(rpc, &payer, &mint_a_pubkey, &temp_ata_a, &payer, amount).await; + mint_t22_tokens(rpc, &payer, &mint_b_pubkey, &temp_ata_b, &payer, amount).await; + + let light_ata_a = + create_light_ata(rpc, &payer, &mint_a_pubkey, &depositor.pubkey()).await; + let light_ata_b = + create_light_ata(rpc, &payer, &mint_b_pubkey, &depositor.pubkey()).await; + + transfer_spl_to_light( + rpc, + &payer, + &depositor, + &mint_a_pubkey, + 9, + &temp_ata_a, + &light_ata_a, + &spl_interface_result_a.pda, + spl_interface_result_a.bump, + amount, + MintType::Token2022, + ) + .await; + transfer_spl_to_light( + rpc, + &payer, + &depositor, + &mint_b_pubkey, + 9, + &temp_ata_b, + &light_ata_b, + &spl_interface_result_b.pda, + spl_interface_result_b.bump, + amount, + MintType::Token2022, + ) + .await; + + (light_ata_a, light_ata_b) + } + TokenConfig::Light | TokenConfig::FullLight => { + unreachable!("Light/FullLight config handled above") + } + }; + + let amm_id = Pubkey::new_unique(); + let (amm_pda, _) = Pubkey::find_program_address(&[amm_id.as_ref()], &program_id); + + let (pool_pda, _) = Pubkey::find_program_address( + &[ + amm_pda.as_ref(), + mint_a_pubkey.as_ref(), + mint_b_pubkey.as_ref(), + ], + &program_id, + ); + + let (pool_authority, pool_authority_bump) = + Pubkey::find_program_address(&[b"authority"], &program_id); + + let (mint_liquidity, _) = Pubkey::find_program_address( + &[ + amm_pda.as_ref(), + mint_a_pubkey.as_ref(), + mint_b_pubkey.as_ref(), + b"liquidity", + ], + &program_id, + ); + + let (pool_account_a, pool_a_bump) = + Pubkey::find_program_address(&[b"pool_a", pool_pda.as_ref()], &program_id); + let (pool_account_b, pool_b_bump) = + Pubkey::find_program_address(&[b"pool_b", pool_pda.as_ref()], &program_id); + + AmmTestContext { + program_id, + payer, + mint_a_pubkey, + mint_b_pubkey, + amm_pda, + amm_id, + pool_pda, + pool_authority, + pool_authority_bump, + mint_liquidity, + pool_account_a, + pool_account_b, + pool_a_bump, + pool_b_bump, + spl_interface_pda_a: spl_interface_result_a.pda, + spl_interface_pda_b: spl_interface_result_b.pda, + spl_interface_bump_a: spl_interface_result_a.bump, + depositor, + depositor_ata_a, + depositor_ata_b, + token_config: config, + light_mint_authority_a: None, + lp_mint_signer: None, + lp_mint_signer_bump: 0, + compression_config, + } +} + +// ============================================================================ +// Account Creation +// ============================================================================ + +/// Create a trader with funded token accounts. +/// +/// Account creation varies by config: +/// - `Spl` / `Token2022`: create standard ATAs, mint directly +/// - `Light` / `FullLight`: create associated Light Token accounts via `mint_light_tokens` (creates + mints in one call) +/// - `LightSpl` / `LightT22`: create temp SPL/Token 2022 ATAs, mint, then +/// create associated Light Token accounts and convert via `transfer_spl_to_light` +pub async fn create_trader( + rpc: &mut R, + ctx: &AmmTestContext, + initial_a: u64, +) -> (Keypair, Pubkey, Pubkey) { + let trader = Keypair::new(); + rpc.airdrop_lamports(&trader.pubkey(), 5_000_000_000) + .await + .unwrap(); + + let (trader_ata_a, trader_ata_b) = match ctx.token_config { + TokenConfig::Spl => { + let ata_a = + create_spl_ata(rpc, &ctx.payer, &ctx.mint_a_pubkey, &trader.pubkey()).await; + let ata_b = + create_spl_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + mint_spl_tokens( + rpc, + &ctx.payer, + &ctx.mint_a_pubkey, + &ata_a, + &ctx.payer, + initial_a, + ) + .await; + (ata_a, ata_b) + } + TokenConfig::Token2022 => { + let ata_a = + create_t22_ata(rpc, &ctx.payer, &ctx.mint_a_pubkey, &trader.pubkey()).await; + let ata_b = + create_t22_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + mint_t22_tokens( + rpc, + &ctx.payer, + &ctx.mint_a_pubkey, + &ata_a, + &ctx.payer, + initial_a, + ) + .await; + (ata_a, ata_b) + } + TokenConfig::LightSpl => { + // Tokens route through temp SPL ATAs because Light Token accounts + // cannot be direct mint targets for SPL mints. + let temp_ata_a = + create_spl_ata(rpc, &ctx.payer, &ctx.mint_a_pubkey, &trader.pubkey()).await; + let _temp_ata_b = + create_spl_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + + mint_spl_tokens( + rpc, + &ctx.payer, + &ctx.mint_a_pubkey, + &temp_ata_a, + &ctx.payer, + initial_a, + ) + .await; + + let light_ata_a = + create_light_ata(rpc, &ctx.payer, &ctx.mint_a_pubkey, &trader.pubkey()).await; + let light_ata_b = + create_light_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + + transfer_spl_to_light( + rpc, + &ctx.payer, + &trader, + &ctx.mint_a_pubkey, + 9, + &temp_ata_a, + &light_ata_a, + &ctx.spl_interface_pda_a, + ctx.spl_interface_bump_a, + initial_a, + MintType::Spl, + ) + .await; + + (light_ata_a, light_ata_b) + } + TokenConfig::LightT22 => { + // Tokens route through temp Token 2022 ATAs because Light Token accounts + // cannot be direct mint targets for Token 2022 mints. + let temp_ata_a = + create_t22_ata(rpc, &ctx.payer, &ctx.mint_a_pubkey, &trader.pubkey()).await; + let _temp_ata_b = + create_t22_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + + mint_t22_tokens( + rpc, + &ctx.payer, + &ctx.mint_a_pubkey, + &temp_ata_a, + &ctx.payer, + initial_a, + ) + .await; + + let light_ata_a = + create_light_ata(rpc, &ctx.payer, &ctx.mint_a_pubkey, &trader.pubkey()).await; + let light_ata_b = + create_light_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + + transfer_spl_to_light( + rpc, + &ctx.payer, + &trader, + &ctx.mint_a_pubkey, + 9, + &temp_ata_a, + &light_ata_a, + &ctx.spl_interface_pda_a, + ctx.spl_interface_bump_a, + initial_a, + MintType::Token2022, + ) + .await; + + (light_ata_a, light_ata_b) + } + TokenConfig::Light | TokenConfig::FullLight => { + // Light Token mints support direct minting to associated Light Token accounts. + let mint_authority_a = ctx + .light_mint_authority_a + .as_ref() + .expect("Light/FullLight config should have mint authority A"); + + let light_ata_a = mint_light_tokens( + rpc, + &ctx.payer, + mint_authority_a, + &ctx.mint_a_pubkey, + &trader.pubkey(), + initial_a, + ) + .await; + + // Token B: trader starts with zero balance, only needs the account created. + let light_ata_b = + create_light_ata(rpc, &ctx.payer, &ctx.mint_b_pubkey, &trader.pubkey()).await; + + (light_ata_a, light_ata_b) + } + }; + + (trader, trader_ata_a, trader_ata_b) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Get token balance from an active token account (SPL/Token 2022/Light Token). +pub async fn get_token_balance(rpc: &mut R, account: Pubkey) -> u64 { + let account_data = rpc + .get_account(account) + .await + .unwrap() + .expect("Token account should exist"); + + let token_state = + spl_pod::bytemuck::pod_from_bytes::(&account_data.data[..165]).unwrap(); + u64::from(token_state.amount) +} diff --git a/programs/anchor/token-swap/tests/swap.rs b/programs/anchor/token-swap/tests/swap.rs new file mode 100644 index 0000000..8127dda --- /dev/null +++ b/programs/anchor/token-swap/tests/swap.rs @@ -0,0 +1,635 @@ +//! Light Token AMM: automated market maker with rent-free pool vaults. +//! +//! This test shows constant-product AMM patterns adapted for Light Token. +//! +//! 1. **Authority PDA owns the pool vaults** — not the pool account. This lets +//! the authority sign vault operations (deposit, swap, withdraw) without +//! needing account data (see `create_pool.rs` +//! `#[light_account(init, token::owner = pool_authority)]`). +//! +//! 2. **Pool vaults are rent-free** — they're Light Token accounts sponsored by +//! `RENT_SPONSOR`, eliminating ~0.002 SOL rent per pool vault. +//! +//! 3. **Validity proof for pool creation** — `get_create_accounts_proof()` +//! fetches a validity proof that the vault PDAs' derived addresses do not +//! yet exist in Light's address tree. Only needed in `create_pool` / +//! `create_pool_light_lp` (creating new state); deposit, swap, and withdraw +//! read existing accounts and need no proof. +//! +//! ## Token scenarios +//! +//! The pool vaults are always Light Token accounts. The user's account type +//! determines which CPI path `transfer_tokens()` in `shared.rs` selects: +//! +//! | Test | Mint | User accounts | LP mint | Transfer path | Purpose | +//! |------|------|---------------|---------|---------------|---------| +//! | `test_swap_spl` | SPL | SPL ATAs | SPL | `TransferInterfaceCpi` | SPL mints work with Light vault | +//! | `test_swap_t22` | Token 2022 | Token 2022 ATAs | Token 2022 | `TransferInterfaceCpi` | Token 2022 mints work with Light Token vault | +//! | `test_swap_light` | Light | Light ATAs | SPL | `TransferCheckedCpi` | Light Token mints, SPL LP mint | +//! | `test_swap_spl_light` | SPL | Light ATAs | SPL | `TransferCheckedCpi` | SPL mint, converted user accounts | +//! | `test_swap_t22_light` | Token 2022 | Light ATAs | Token 2022 | `TransferCheckedCpi` | Token 2022 mint, converted user accounts | +//! | `test_swap_full_light` | Light | Light ATAs | Light | `TransferCheckedCpi` | Fully rent-free (Light LP mint) | +//! +//! For `Spl`/`Token2022`: user accounts are SPL/Token 2022, vaults are Light → +//! `TransferInterfaceCpi` (with SPL interface PDA). +//! +//! For `Light`/`FullLight`/`LightSpl`/`LightT22`: all accounts are Light Token → +//! `TransferCheckedCpi` (no interface PDA needed). +//! +//! `LightSpl`/`LightT22` convert tokens from SPL/Token 2022 associated token accounts +//! into Light Token accounts *before* the AMM interactions start (in +//! `create_trader` / setup). The AMM itself only sees Light Token accounts. +//! +//! ## Pool creation modes +//! +//! `FullLight` uses `create_pool_light_lp` which creates pool vaults via +//! explicit `CreateTokenAccountCpi` and the LP mint as a Light Token mint. +//! All other configs use `create_pool` with macro-initialized vaults and +//! an SPL/Token 2022 LP mint. +//! +//! ## Cold/hot lifecycle +//! +//! `Pool` and `Amm` use standard `#[account]` (no `LightAccount` derive), so +//! `#[light_program]` does not generate `VaultSeeds` / `LightAccountVariant` +//! needed by `create_load_instructions`. The test does not exercise cold/hot +//! lifecycle. See the escrow tests for full cold/hot coverage. + +mod common; + +use anchor_lang::{InstructionData, ToAccountMetas}; +use anchor_spl::token; +use common::{ + create_test_rpc, create_trader, get_token_balance, setup_amm_test, AmmTestContext, TokenConfig, +}; +use light_client::interface::{get_create_accounts_proof, CreateAccountsProofInput}; +use light_token::spl_interface::find_spl_interface_pda; +use shared_test_utils::{ + helpers::verify_light_token_balance, + light_tokens::create_light_ata, + spl_tokens::create_spl_ata, + t22_tokens::create_t22_ata, + Indexer, Rpc, TestRpc, COMPRESSIBLE_CONFIG_V1, CPI_AUTHORITY_PDA, LIGHT_TOKEN_PROGRAM_ID, + RENT_SPONSOR, +}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +// ============================================================================ +// Tests — one per token configuration, plus a standalone AMM creation test. +// ============================================================================ + +/// SPL mint with SPL ATAs, SPL LP mint, and Light Token pool vaults. +/// +/// All transfers use `TransferInterfaceCpi` (SPL ↔ Light Token vault) +/// with SPL interface PDAs. This is the baseline: same mint type as standard +/// SPL AMM, but pool vaults are rent-free Light Token accounts. +#[tokio::test] +async fn test_swap_spl() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::Spl).await; + run_amm_full_flow(&mut rpc, &ctx).await; +} + +/// Token 2022 mint with Token 2022 ATAs, Token 2022 LP mint, and Light Token pool vaults. +/// +/// All transfers use `TransferInterfaceCpi` (Token 2022 ↔ Light Token vault) +/// with SPL interface PDAs. +#[tokio::test] +async fn test_swap_t22() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::Token2022).await; + run_amm_full_flow(&mut rpc, &ctx).await; +} + +/// Light Token mint + Light Token user accounts, SPL LP mint, and Light Token pool vaults. +/// +/// All token A/B transfers are Light-to-Light (`TransferCheckedCpi`). +/// LP mint is SPL because `create_pool` uses Anchor's `init` macro +/// which only supports SPL/T22 mints. +#[tokio::test] +async fn test_swap_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::Light).await; + run_amm_full_flow(&mut rpc, &ctx).await; +} + +/// SPL mint + Light Token user accounts, SPL LP mint. SPL tokens converted to +/// Light Token accounts in setup via `transfer_spl_to_light`. +/// +/// All transfers use `TransferCheckedCpi`. +#[tokio::test] +async fn test_swap_spl_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::LightSpl).await; + run_amm_full_flow(&mut rpc, &ctx).await; +} + +/// Token 2022 mint + Light Token user accounts, Token 2022 LP mint. Token 2022 tokens +/// converted to Light Token accounts in setup via `transfer_spl_to_light`. +/// +/// All transfers use `TransferCheckedCpi`. +#[tokio::test] +async fn test_swap_t22_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::LightT22).await; + run_amm_full_flow(&mut rpc, &ctx).await; +} + +/// Light Token mints + Light Token user accounts + Light Token LP mint. +/// +/// Uses `create_pool_light_lp` to create the LP mint as a Light Token mint via CPI. +/// All transfers use `TransferCheckedCpi`. +#[tokio::test] +async fn test_swap_full_light() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::FullLight).await; + run_amm_full_flow(&mut rpc, &ctx).await; +} + +/// AMM creation is config-independent (fee + admin only). Verifies that +/// `create_amm` works in isolation without pool creation. +#[tokio::test] +async fn test_create_amm() { + let mut rpc = create_test_rpc().await; + let ctx = setup_amm_test(&mut rpc, TokenConfig::Spl).await; + create_amm(&mut rpc, &ctx, 0).await; +} + +// ============================================================================ +// Full Flow +// ============================================================================ + +/// Run the full AMM flow for any token configuration: SPL, Token 2022, Light. +/// +/// 1. Create AMM with 2.5% fee +/// 2. Create pool with Light Token vaults (standard or FullLight path) +/// 3. Deposit initial liquidity, receive LP tokens +/// 4. Create trader with funded accounts +/// 5. Swap A→B +/// 6. Swap B→A (half of received B) +/// 7. Withdraw half of LP tokens +/// 8. Verify final LP balance +async fn run_amm_full_flow( + rpc: &mut R, + ctx: &AmmTestContext, +) { + let token_program = ctx.token_config.token_program_id(); + let liquidity_token_program = match ctx.token_config { + TokenConfig::Light | TokenConfig::LightSpl | TokenConfig::Spl => token::ID, + TokenConfig::Token2022 | TokenConfig::LightT22 => spl_token_2022::ID, + TokenConfig::FullLight => Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + }; + + create_amm(rpc, ctx, 250).await; + + create_pool(rpc, ctx, token_program, liquidity_token_program).await; + + verify_light_token_balance(rpc, ctx.pool_account_a, 0, "pool_account_a (initial)").await; + verify_light_token_balance(rpc, ctx.pool_account_b, 0, "pool_account_b (initial)").await; + + let deposit_amount = 1_000_000_000u64; // 1 token + let depositor_liquidity_ata = deposit_liquidity( + rpc, + ctx, + token_program, + liquidity_token_program, + deposit_amount, + ) + .await; + + verify_light_token_balance( + rpc, + ctx.pool_account_a, + deposit_amount, + "pool_account_a (after deposit)", + ) + .await; + verify_light_token_balance( + rpc, + ctx.pool_account_b, + deposit_amount, + "pool_account_b (after deposit)", + ) + .await; + + let trader_initial_a = 100_000_000u64; // 0.1 tokens + let (trader, trader_ata_a, trader_ata_b) = create_trader(rpc, ctx, trader_initial_a).await; + + let swap_input = 10_000_000u64; // 0.01 tokens + swap_exact_tokens_for_tokens( + rpc, + ctx, + &trader, + trader_ata_a, + trader_ata_b, + token_program, + true, + swap_input, + 1, + ) + .await; + + let trader_b_balance = get_token_balance(rpc, trader_ata_b).await; + assert!(trader_b_balance > 0, "Trader should have received token B"); + + let swap_b_input = trader_b_balance / 2; + swap_exact_tokens_for_tokens( + rpc, + ctx, + &trader, + trader_ata_a, + trader_ata_b, + token_program, + false, + swap_b_input, + 1, + ) + .await; + + let lp_balance = get_token_balance(rpc, depositor_liquidity_ata).await; + let withdraw_amount = lp_balance / 2; + withdraw_liquidity( + rpc, + ctx, + token_program, + liquidity_token_program, + depositor_liquidity_ata, + withdraw_amount, + ) + .await; + + let lp_balance_after = get_token_balance(rpc, depositor_liquidity_ata).await; + assert_eq!( + lp_balance_after, + lp_balance - withdraw_amount, + "LP tokens should be burned" + ); +} + +// ============================================================================ +// Instruction Helpers +// ============================================================================ + +/// Send `create_amm` instruction with the given fee (basis points, e.g. 250 = 2.5%). +async fn create_amm( + rpc: &mut R, + ctx: &AmmTestContext, + fee: u16, +) { + let accounts = swap_example::accounts::CreateAmm { + amm: ctx.amm_pda, + admin: ctx.payer.pubkey(), + payer: ctx.payer.pubkey(), + system_program: solana_sdk::system_program::ID, + }; + + let data = swap_example::instruction::CreateAmm { + id: ctx.amm_id, + fee, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction(&[ix], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("create_amm should succeed"); +} + +/// Fetch validity proof and send `create_pool` or `create_pool_light_lp` instruction. +/// +/// FullLight proves LP mint signer (vaults created via explicit CPI). +/// Standard proves pool vault PDAs (vaults created via macro). +async fn create_pool( + rpc: &mut R, + ctx: &AmmTestContext, + token_program: Pubkey, + liquidity_token_program: Pubkey, +) { + // Validity proof: verifies the vault PDAs (or LP mint signer for FullLight) + // do not yet exist in the address tree. Required for Light Token account creation. + let proof_inputs = if ctx.token_config.uses_light_lp_mint() { + vec![CreateAccountsProofInput::mint( + ctx.lp_mint_signer + .expect("FullLight config should have lp_mint_signer"), + )] + } else { + vec![ + CreateAccountsProofInput::pda(ctx.pool_account_a), + CreateAccountsProofInput::pda(ctx.pool_account_b), + ] + }; + + let proof_result = get_create_accounts_proof(rpc, &ctx.program_id, proof_inputs) + .await + .unwrap(); + + if ctx.token_config.uses_light_lp_mint() { + let lp_mint_signer = ctx + .lp_mint_signer + .expect("FullLight config should have lp_mint_signer"); + + let accounts = swap_example::accounts::CreatePoolLightLp { + amm: ctx.amm_pda, + pool: ctx.pool_pda, + pool_authority: ctx.pool_authority, + lp_mint_signer, + mint_liquidity: ctx.mint_liquidity, + mint_a: ctx.mint_a_pubkey, + mint_b: ctx.mint_b_pubkey, + pool_vault_a: ctx.pool_account_a, + pool_vault_b: ctx.pool_account_b, + fee_payer: ctx.payer.pubkey(), + token_program, + compression_config: ctx.compression_config, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_cpi_authority: CPI_AUTHORITY_PDA, + system_program: solana_sdk::system_program::ID, + }; + + let data = swap_example::instruction::CreatePoolLightLp { + params: swap_example::CreatePoolLightLpParams { + create_accounts_proof: proof_result.create_accounts_proof, + pool_account_a_bump: ctx.pool_a_bump, + pool_account_b_bump: ctx.pool_b_bump, + lp_mint_signer_bump: ctx.lp_mint_signer_bump, + pool_authority_bump: ctx.pool_authority_bump, + }, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: data.data(), + }; + + rpc.create_and_send_transaction(&[ix], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("create_pool_light_lp should succeed"); + } else { + let accounts = swap_example::accounts::CreatePool { + amm: ctx.amm_pda, + pool: ctx.pool_pda, + pool_authority: ctx.pool_authority, + mint_liquidity: ctx.mint_liquidity, + mint_a: ctx.mint_a_pubkey, + mint_b: ctx.mint_b_pubkey, + pool_account_a: ctx.pool_account_a, + pool_account_b: ctx.pool_account_b, + fee_payer: ctx.payer.pubkey(), + token_program, + liquidity_token_program, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + system_program: solana_sdk::system_program::ID, + light_token_config: COMPRESSIBLE_CONFIG_V1, + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + }; + + let data = swap_example::instruction::CreatePool { + params: swap_example::CreatePoolParams { + create_accounts_proof: proof_result.create_accounts_proof, + pool_account_a_bump: ctx.pool_a_bump, + pool_account_b_bump: ctx.pool_b_bump, + }, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: data.data(), + }; + + rpc.create_and_send_transaction(&[ix], &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("create_pool should succeed"); + } + + // pool_authority needs lamports to pay rent top-ups when + // transferring from pool vaults to Light Token user accounts. + if ctx.token_config.uses_light_user_accounts() { + rpc.airdrop_lamports(&ctx.pool_authority, 1_000_000_000) + .await + .expect("Fund pool_authority for rent top-ups"); + } +} + +/// Create depositor LP ATA and send `deposit_liquidity` instruction. +/// +/// Returns the depositor's LP token account address. +async fn deposit_liquidity( + rpc: &mut R, + ctx: &AmmTestContext, + token_program: Pubkey, + liquidity_token_program: Pubkey, + amount: u64, +) -> Pubkey { + let depositor_liquidity_ata = match ctx.token_config { + TokenConfig::Spl | TokenConfig::LightSpl | TokenConfig::Light => { + create_spl_ata( + rpc, + &ctx.payer, + &ctx.mint_liquidity, + &ctx.depositor.pubkey(), + ) + .await + } + TokenConfig::Token2022 | TokenConfig::LightT22 => { + create_t22_ata( + rpc, + &ctx.payer, + &ctx.mint_liquidity, + &ctx.depositor.pubkey(), + ) + .await + } + TokenConfig::FullLight => { + create_light_ata( + rpc, + &ctx.payer, + &ctx.mint_liquidity, + &ctx.depositor.pubkey(), + ) + .await + } + }; + + let accounts = swap_example::accounts::DepositLiquidity { + pool: ctx.pool_pda, + pool_authority: ctx.pool_authority, + depositor: ctx.depositor.pubkey(), + mint_liquidity: ctx.mint_liquidity, + mint_a: ctx.mint_a_pubkey, + mint_b: ctx.mint_b_pubkey, + pool_account_a: ctx.pool_account_a, + pool_account_b: ctx.pool_account_b, + depositor_account_liquidity: depositor_liquidity_ata, + depositor_account_a: ctx.depositor_ata_a, + depositor_account_b: ctx.depositor_ata_b, + payer: ctx.payer.pubkey(), + token_program, + liquidity_token_program, + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda_a: if ctx.token_config.uses_light_user_accounts() { Pubkey::default() } else { ctx.spl_interface_pda_a }, + spl_interface_pda_b: if ctx.token_config.uses_light_user_accounts() { Pubkey::default() } else { ctx.spl_interface_pda_b }, + }; + + let (_, spl_interface_bump_a) = find_spl_interface_pda(&ctx.mint_a_pubkey, false); + let (_, spl_interface_bump_b) = find_spl_interface_pda(&ctx.mint_b_pubkey, false); + + let data = swap_example::instruction::DepositLiquidity { + amount_a: amount, + amount_b: amount, + spl_interface_bump_a, + spl_interface_bump_b, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction( + &[ix], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.depositor], + ) + .await + .expect("deposit_liquidity should succeed"); + + depositor_liquidity_ata +} + +/// Send `swap_exact_tokens_for_tokens` instruction. +/// +/// `swap_a = true` swaps A→B; `swap_a = false` swaps B→A. +async fn swap_exact_tokens_for_tokens( + rpc: &mut R, + ctx: &AmmTestContext, + trader: &Keypair, + trader_ata_a: Pubkey, + trader_ata_b: Pubkey, + token_program: Pubkey, + swap_a: bool, + input_amount: u64, + min_output_amount: u64, +) { + let accounts = swap_example::accounts::SwapExactTokensForTokens { + amm: ctx.amm_pda, + pool: ctx.pool_pda, + pool_authority: ctx.pool_authority, + trader: trader.pubkey(), + mint_a: ctx.mint_a_pubkey, + mint_b: ctx.mint_b_pubkey, + pool_account_a: ctx.pool_account_a, + pool_account_b: ctx.pool_account_b, + trader_account_a: trader_ata_a, + trader_account_b: trader_ata_b, + payer: ctx.payer.pubkey(), + token_program, + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda_a: if ctx.token_config.uses_light_user_accounts() { Pubkey::default() } else { ctx.spl_interface_pda_a }, + spl_interface_pda_b: if ctx.token_config.uses_light_user_accounts() { Pubkey::default() } else { ctx.spl_interface_pda_b }, + }; + + let (_, spl_interface_bump_a) = find_spl_interface_pda(&ctx.mint_a_pubkey, false); + let (_, spl_interface_bump_b) = find_spl_interface_pda(&ctx.mint_b_pubkey, false); + + let data = swap_example::instruction::SwapExactTokensForTokens { + swap_a, + input_amount, + min_output_amount, + spl_interface_bump_a, + spl_interface_bump_b, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + let direction = if swap_a { "A->B" } else { "B->A" }; + rpc.create_and_send_transaction(&[ix], &ctx.payer.pubkey(), &[&ctx.payer, trader]) + .await + .unwrap_or_else(|e| panic!("swap {direction} should succeed: {e}")); +} + +/// Send `withdraw_liquidity` instruction to burn LP tokens and receive pool tokens. +async fn withdraw_liquidity( + rpc: &mut R, + ctx: &AmmTestContext, + token_program: Pubkey, + liquidity_token_program: Pubkey, + depositor_liquidity_ata: Pubkey, + amount: u64, +) { + let accounts = swap_example::accounts::WithdrawLiquidity { + amm: ctx.amm_pda, + pool: ctx.pool_pda, + pool_authority: ctx.pool_authority, + depositor: ctx.depositor.pubkey(), + mint_liquidity: ctx.mint_liquidity, + mint_a: ctx.mint_a_pubkey, + mint_b: ctx.mint_b_pubkey, + pool_account_a: ctx.pool_account_a, + pool_account_b: ctx.pool_account_b, + depositor_account_liquidity: depositor_liquidity_ata, + depositor_account_a: ctx.depositor_ata_a, + depositor_account_b: ctx.depositor_ata_b, + payer: ctx.payer.pubkey(), + token_program, + liquidity_token_program, + system_program: solana_sdk::system_program::ID, + light_token_program: Pubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID), + light_token_rent_sponsor: RENT_SPONSOR, + light_token_cpi_authority: CPI_AUTHORITY_PDA, + spl_interface_pda_a: if ctx.token_config.uses_light_user_accounts() { Pubkey::default() } else { ctx.spl_interface_pda_a }, + spl_interface_pda_b: if ctx.token_config.uses_light_user_accounts() { Pubkey::default() } else { ctx.spl_interface_pda_b }, + }; + + let (_, spl_interface_bump_a) = find_spl_interface_pda(&ctx.mint_a_pubkey, false); + let (_, spl_interface_bump_b) = find_spl_interface_pda(&ctx.mint_b_pubkey, false); + + let data = swap_example::instruction::WithdrawLiquidity { + amount, + spl_interface_bump_a, + spl_interface_bump_b, + }; + + let ix = Instruction { + program_id: ctx.program_id, + accounts: accounts.to_account_metas(None), + data: data.data(), + }; + + rpc.create_and_send_transaction( + &[ix], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.depositor], + ) + .await + .expect("withdraw_liquidity should succeed"); +}