diff --git a/.cargo-patches/light-program-profiler-0.1.1/.cargo_vcs_info.json b/.cargo-patches/light-program-profiler-0.1.1/.cargo_vcs_info.json new file mode 100644 index 0000000000..9d4a93965f --- /dev/null +++ b/.cargo-patches/light-program-profiler-0.1.1/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "313f623c72bfaaba1f18551e94a3f863d318cc8d" + }, + "path_in_vcs": "light-program-profiler" +} \ No newline at end of file diff --git a/.cargo-patches/light-program-profiler-0.1.1/Cargo.lock b/.cargo-patches/light-program-profiler-0.1.1/Cargo.lock new file mode 100644 index 0000000000..971c4253e2 --- /dev/null +++ b/.cargo-patches/light-program-profiler-0.1.1/Cargo.lock @@ -0,0 +1,56 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "light-profiler-macro" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a8be18fe4de58a6f754caa74a3fbc6d8a758a26f1f3c24d5b0f5b55df5f5408" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "light-program-profiler" +version = "0.1.1" +dependencies = [ + "light-profiler-macro", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" diff --git a/.cargo-patches/light-program-profiler-0.1.1/Cargo.toml b/.cargo-patches/light-program-profiler-0.1.1/Cargo.toml new file mode 100644 index 0000000000..c93568118f --- /dev/null +++ b/.cargo-patches/light-program-profiler-0.1.1/Cargo.toml @@ -0,0 +1,46 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +name = "light-program-profiler" +version = "0.1.1" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Profiler macros with custom profiler syscalls for Solana programs" +homepage = "https://github.com/Lightprotocol/light-program-profiler" +readme = false +license = "Apache-2.0" +repository = "https://github.com/Lightprotocol/light-program-profiler" + +[features] +inline = ["light-profiler-macro/inline"] +profile-heap = ["light-profiler-macro/profile-heap"] +profile-program = ["light-profiler-macro/profile-program"] + +[lib] +name = "light_program_profiler" +path = "src/lib.rs" + +[dependencies.light-profiler-macro] +version = "0.1.1" + +[lints.rust.unexpected_cfgs] +level = "allow" +priority = 0 +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/.cargo-patches/light-program-profiler-0.1.1/Cargo.toml.orig b/.cargo-patches/light-program-profiler-0.1.1/Cargo.toml.orig new file mode 100644 index 0000000000..d3d7cc043a --- /dev/null +++ b/.cargo-patches/light-program-profiler-0.1.1/Cargo.toml.orig @@ -0,0 +1,23 @@ +[package] +name = "light-program-profiler" +version.workspace = true +edition.workspace = true +description = "Profiler macros with custom profiler syscalls for Solana programs" +license = "Apache-2.0" +repository = "https://github.com/Lightprotocol/light-program-profiler" +homepage = "https://github.com/Lightprotocol/light-program-profiler" + +[features] +profile-program = ["light-profiler-macro/profile-program"] +profile-heap = ["light-profiler-macro/profile-heap"] +inline = ["light-profiler-macro/inline"] + +[dependencies] +light-profiler-macro.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/.cargo-patches/light-program-profiler-0.1.1/src/lib.rs b/.cargo-patches/light-program-profiler-0.1.1/src/lib.rs new file mode 100644 index 0000000000..3e5399e48c --- /dev/null +++ b/.cargo-patches/light-program-profiler-0.1.1/src/lib.rs @@ -0,0 +1,57 @@ +#![allow(unused_variables)] +pub use light_profiler_macro::profile; + +#[inline(always)] +pub fn log_compute_units_start(id: &str, id_len: u64) { + #[cfg(target_os = "solana")] + unsafe { + sol_log_compute_units_start(id.as_ptr() as u64, id_len, 0, 0, 0); + } +} + +#[inline(always)] +pub fn log_compute_units_end(id: &str, id_len: u64) { + #[cfg(target_os = "solana")] + unsafe { + sol_log_compute_units_end(id.as_ptr() as u64, id_len, 0, 0, 0); + } +} + +#[cfg(feature = "profile-heap")] +pub fn log_compute_units_start_with_heap(id: &str, id_len: u64, heap_value: u64) { + #[cfg(target_os = "solana")] + unsafe { + sol_log_compute_units_start(id.as_ptr() as u64, id_len, heap_value, 1, 0); + } +} + +#[cfg(feature = "profile-heap")] +pub fn log_compute_units_end_with_heap(id: &str, id_len: u64, heap_value: u64) { + #[cfg(target_os = "solana")] + unsafe { + sol_log_compute_units_end(id.as_ptr() as u64, id_len, heap_value, 1, 0); + } +} + +#[cfg(target_os = "solana")] +extern "C" { + fn sol_log_compute_units_start( + id_addr: u64, + id_len: u64, + heap_value: u64, + with_heap: u64, + _arg5: u64, + ); +} + +#[cfg(target_os = "solana")] +extern "C" { + + fn sol_log_compute_units_end( + id_addr: u64, + id_len: u64, + heap_value: u64, + with_heap: u64, + _arg5: u64, + ); +} diff --git a/Cargo.lock b/Cargo.lock index 5c2130e879..d467b1b82a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ version = "1.1.0" dependencies = [ "account-compression", "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "ark-bn254 0.5.0", "ark-ff 0.5.0", "light-account-checks", @@ -294,7 +294,7 @@ version = "2.0.0" dependencies = [ "account-compression", "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "light-compressed-account", "light-ctoken-types", "light-hasher", @@ -410,6 +410,22 @@ dependencies = [ "spl-token-metadata-interface 0.6.0", ] +[[package]] +name = "anchor-spl" +version = "0.31.1" +source = "git+https://github.com/lightprotocol/anchor?rev=d8a2b3d9#d8a2b3d99d61ef900d1f6cdaabcef14eb9af6279" +dependencies = [ + "anchor-lang", + "mpl-token-metadata", + "spl-associated-token-account 6.0.0", + "spl-memo", + "spl-pod", + "spl-token 7.0.0", + "spl-token-2022 6.0.0", + "spl-token-group-interface 0.5.0", + "spl-token-metadata-interface 0.6.0", +] + [[package]] name = "anchor-syn" version = "0.31.1" @@ -1392,7 +1408,7 @@ version = "1.1.0" dependencies = [ "account-compression", "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "forester-utils", "light-batched-merkle-tree", "light-client", @@ -1604,6 +1620,40 @@ dependencies = [ "subtle", ] +[[package]] +name = "csdk-anchor-test" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl 0.31.1 (git+https://github.com/lightprotocol/anchor?rev=d8a2b3d9)", + "bincode", + "borsh 0.10.4", + "light-client", + "light-compressed-account", + "light-compressed-token-sdk", + "light-compressed-token-types", + "light-compressible", + "light-compressible-client", + "light-ctoken-types", + "light-hasher", + "light-macros", + "light-program-test", + "light-sdk", + "light-sdk-types", + "light-test-utils", + "light-token-client", + "solana-account", + "solana-instruction", + "solana-keypair", + "solana-logger", + "solana-program", + "solana-pubkey 2.4.0", + "solana-sdk", + "solana-signature", + "solana-signer", + "tokio", +] + [[package]] name = "ctr" version = "0.9.2" @@ -3428,6 +3478,7 @@ dependencies = [ name = "light-client" version = "0.16.0" dependencies = [ + "anchor-lang", "async-trait", "base64 0.13.1", "borsh 0.10.4", @@ -3458,11 +3509,13 @@ dependencies = [ "solana-hash 2.3.0", "solana-instruction", "solana-keypair", + "solana-message", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", "solana-rpc-client", "solana-rpc-client-api", "solana-signature", + "solana-signer", "solana-transaction", "solana-transaction-error", "solana-transaction-status-client-types", @@ -3604,6 +3657,20 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "light-compressible-client" +version = "0.13.1" +dependencies = [ + "anchor-lang", + "borsh 0.10.4", + "light-client", + "light-sdk", + "solana-account", + "solana-instruction", + "solana-pubkey 2.4.0", + "thiserror 2.0.17", +] + [[package]] name = "light-concurrent-merkle-tree" version = "4.0.0" @@ -3818,8 +3885,6 @@ dependencies = [ [[package]] name = "light-program-profiler" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d345871581aebd8825868a3f08410290aa1cdddcb189ca7f7e588f61d79fcf" dependencies = [ "light-profiler-macro", ] @@ -3842,6 +3907,7 @@ dependencies = [ "light-compressed-token", "light-compressed-token-sdk", "light-compressible", + "light-compressible-client", "light-concurrent-merkle-tree", "light-ctoken-types", "light-event", @@ -3925,6 +3991,7 @@ name = "light-sdk" version = "0.16.0" dependencies = [ "anchor-lang", + "bincode", "borsh 0.10.4", "light-account-checks", "light-compressed-account", @@ -3936,11 +4003,15 @@ dependencies = [ "light-zero-copy", "num-bigint 0.4.6", "solana-account-info", + "solana-clock", "solana-cpi", "solana-instruction", "solana-msg 2.2.1", + "solana-program", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", + "solana-system-interface 1.0.0", + "solana-sysvar", "thiserror 2.0.17", ] @@ -4054,7 +4125,7 @@ version = "1.2.1" dependencies = [ "account-compression", "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "base64 0.13.1", "create-address-test-program", "forester-utils", @@ -4334,6 +4405,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "multer" version = "2.1.0" @@ -4466,6 +4550,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[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" @@ -5817,7 +5912,7 @@ name = "sdk-token-test" version = "1.0.0" dependencies = [ "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "arrayvec", "light-batched-merkle-tree", "light-client", @@ -7553,7 +7648,7 @@ dependencies = [ "log", "memoffset", "num-bigint 0.4.6", - "num-derive", + "num-derive 0.4.2", "num-traits", "rand 0.8.5", "serde", @@ -7837,7 +7932,7 @@ dependencies = [ "dialoguer", "hidapi", "log", - "num-derive", + "num-derive 0.4.2", "num-traits", "parking_lot", "qstring", @@ -8873,7 +8968,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", @@ -8899,7 +8994,7 @@ dependencies = [ "agave-feature-set", "bincode", "log", - "num-derive", + "num-derive 0.4.2", "num-traits", "serde", "serde_derive", @@ -8932,7 +9027,7 @@ checksum = "70cea14481d8efede6b115a2581f27bc7c6fdfba0752c20398456c3ac1245fc4" dependencies = [ "agave-feature-set", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-instruction", "solana-log-collector", @@ -8956,7 +9051,7 @@ dependencies = [ "itertools 0.12.1", "js-sys", "merlin", - "num-derive", + "num-derive 0.4.2", "num-traits", "rand 0.8.5", "serde", @@ -8985,7 +9080,7 @@ checksum = "579752ad6ea2a671995f13c763bf28288c3c895cb857a518cc4ebab93c9a8dde" dependencies = [ "agave-feature-set", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-instruction", "solana-log-collector", @@ -9008,7 +9103,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", @@ -9051,7 +9146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76fee7d65013667032d499adc3c895e286197a35a0d3a4643c80e7fd3e9969e3" dependencies = [ "borsh 1.5.7", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-program", "spl-associated-token-account-client", @@ -9067,7 +9162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae179d4a26b3c7a20c839898e6aed84cb4477adf108a366c95532f058aea041b" dependencies = [ "borsh 1.5.7", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-program", "spl-associated-token-account-client", @@ -9203,7 +9298,7 @@ dependencies = [ "borsh 1.5.7", "bytemuck", "bytemuck_derive", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-decode-error", "solana-msg 2.2.1", @@ -9220,7 +9315,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", @@ -9233,7 +9328,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 2.2.1", @@ -9273,7 +9368,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", @@ -9295,7 +9390,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", @@ -9318,7 +9413,7 @@ checksum = "ed320a6c934128d4f7e54fe00e16b8aeaecf215799d060ae14f93378da6dc834" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -9333,7 +9428,7 @@ checksum = "053067c6a82c705004f91dae058b11b4780407e9ccd6799dc9e7d0fab5f242da" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-account-info", @@ -9361,7 +9456,7 @@ checksum = "5b27f7405010ef816587c944536b0eafbcc35206ab6ba0f2ca79f1d28e488f4f" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -9389,7 +9484,7 @@ checksum = "9048b26b0df0290f929ff91317c83db28b3ef99af2b3493dd35baa146774924c" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -9416,7 +9511,7 @@ source = "git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73 dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-program", @@ -9444,7 +9539,7 @@ checksum = "31f0dfbb079eebaee55e793e92ca5f433744f4b71ee04880bfd6beefba5973e5" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "num_enum", "solana-account-info", @@ -9612,7 +9707,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", @@ -9631,7 +9726,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", @@ -9650,7 +9745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfb9c89dbc877abd735f05547dcf9e6e12c00c11d6d74d8817506cab4c99fdbb" dependencies = [ "borsh 1.5.7", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-borsh", "solana-decode-error", @@ -9671,7 +9766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "304d6e06f0de0c13a621464b1fd5d4b1bebf60d15ca71a44d3839958e0da16ee" dependencies = [ "borsh 1.5.7", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-borsh", "solana-decode-error", @@ -9693,7 +9788,7 @@ checksum = "4aa7503d52107c33c88e845e1351565050362c2314036ddf19a36cd25137c043" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-cpi", @@ -9718,7 +9813,7 @@ checksum = "a7e905b849b6aba63bde8c4badac944ebb6c8e6e14817029cbe1bc16829133bd" dependencies = [ "arrayref", "bytemuck", - "num-derive", + "num-derive 0.4.2", "num-traits", "solana-account-info", "solana-cpi", @@ -9742,7 +9837,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", @@ -9760,7 +9855,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", @@ -9909,7 +10004,7 @@ version = "1.1.0" dependencies = [ "account-compression", "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "create-address-test-program", "light-account-checks", "light-batched-merkle-tree", @@ -9938,7 +10033,7 @@ version = "0.1.0" dependencies = [ "account-compression", "anchor-lang", - "anchor-spl", + "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "create-address-test-program", "light-account-checks", "light-batched-merkle-tree", diff --git a/Cargo.toml b/Cargo.toml index eb405bf0e9..d2dadb7f39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "sdk-libs/sdk-types", "sdk-libs/photon-api", "sdk-libs/program-test", + "sdk-libs/compressible-client", "xtask", "program-tests/account-compression-test", "program-tests/batched-merkle-tree-test", @@ -53,6 +54,7 @@ members = [ "sdk-tests/sdk-native-test", "sdk-tests/sdk-v1-native-test", "sdk-tests/sdk-token-test", + "sdk-tests/csdk-anchor-test", "forester-utils", "forester", "sparse-merkle-tree", @@ -180,6 +182,7 @@ light-sdk-macros = { path = "sdk-libs/macros", version = "0.16.0" } light-sdk-types = { path = "sdk-libs/sdk-types", version = "0.16.0", default-features = false } light-compressed-account = { path = "program-libs/compressed-account", version = "0.6.1", default-features = false } light-compressible = { path = "program-libs/compressible", version = "0.1.0" } +light-compressible-client = { path = "sdk-libs/compressible-client", version = "0.13.1" } light-ctoken-types = { path = "program-libs/ctoken-types", version = "0.1.0" } light-account-checks = { path = "program-libs/account-checks", version = "0.5.0", default-features = false } light-verifier = { path = "program-libs/verifier", version = "5.0.0" } @@ -243,6 +246,8 @@ governor = "0.8.0" rand = "0.8.5" [patch.crates-io] +# Patched to fix unsafe extern "C" syntax for Rust 1.82+ +light-program-profiler = { path = ".cargo-patches/light-program-profiler-0.1.1" } # Profiling logs and state is handled here solana-program-runtime = { git = "https://github.com/Lightprotocol/agave", rev = "3a9e4e0a4411df4d9961aaa7c9f190d3fa15bc21" } # Profiling syscalls are defined here diff --git a/cli/scripts/copyLocalProgramBinaries.sh b/cli/scripts/copyLocalProgramBinaries.sh index deddd04e8a..afddb155e9 100755 --- a/cli/scripts/copyLocalProgramBinaries.sh +++ b/cli/scripts/copyLocalProgramBinaries.sh @@ -11,6 +11,18 @@ fi keys="account_compression light_system_program_pinocchio light_compressed_token light_registry" for key in $keys do - cp "$root_dir/target/deploy/$key.so" "$out_dir"/"$key".so + # cli build process deletes target/deploy contents, so fall back to + # sbf-solana-solana + src_deploy="$root_dir/target/deploy/$key.so" + src_sbf_release="$root_dir/target/sbf-solana-solana/release/$key.so" + + if [ -f "$src_deploy" ]; then + cp "$src_deploy" "$out_dir/$key.so" + elif [ -f "$src_sbf_release" ]; then + cp "$src_sbf_release" "$out_dir/$key.so" + else + echo "Error: $key.so not found in $src_deploy or $src_sbf_release" >&2 + exit 1 + fi done cp "$root_dir"/third-party/solana-program-library/spl_noop.so "$out_dir"/spl_noop.so diff --git a/program-libs/compressed-account/src/instruction_data/compressed_proof.rs b/program-libs/compressed-account/src/instruction_data/compressed_proof.rs index de6c7c6a15..e6310fb3cd 100644 --- a/program-libs/compressed-account/src/instruction_data/compressed_proof.rs +++ b/program-libs/compressed-account/src/instruction_data/compressed_proof.rs @@ -141,3 +141,96 @@ impl Into> for ValidityProof { self.0 } } + +// Borsh compatible validity proof. Use this in your anchor program unless you +// have zero-copy instruction data. Convert to zero-copy by calling `let proof = +// compression_params.proof.into();`. +// + +pub mod borsh_compat { + #[cfg_attr( + all(feature = "std", feature = "anchor"), + derive(anchor_lang::AnchorDeserialize, anchor_lang::AnchorSerialize) + )] + #[cfg_attr( + not(feature = "anchor"), + derive(borsh::BorshDeserialize, borsh::BorshSerialize) + )] + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct CompressedProof { + pub a: [u8; 32], + pub b: [u8; 64], + pub c: [u8; 32], + } + + impl Default for CompressedProof { + fn default() -> Self { + Self { + a: [0; 32], + b: [0; 64], + c: [0; 32], + } + } + } + + #[cfg_attr( + all(feature = "std", feature = "anchor"), + derive(anchor_lang::AnchorDeserialize, anchor_lang::AnchorSerialize) + )] + #[cfg_attr( + not(feature = "anchor"), + derive(borsh::BorshDeserialize, borsh::BorshSerialize) + )] + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] + pub struct ValidityProof(pub Option); + + impl ValidityProof { + pub fn new(proof: Option) -> Self { + Self(proof) + } + } + + impl From for CompressedProof { + fn from(proof: super::CompressedProof) -> Self { + Self { + a: proof.a, + b: proof.b, + c: proof.c, + } + } + } + + impl From for super::CompressedProof { + fn from(proof: CompressedProof) -> Self { + Self { + a: proof.a, + b: proof.b, + c: proof.c, + } + } + } + + impl From for ValidityProof { + fn from(proof: super::ValidityProof) -> Self { + Self(proof.0.map(|p| p.into())) + } + } + + impl From for super::ValidityProof { + fn from(proof: ValidityProof) -> Self { + Self(proof.0.map(|p| p.into())) + } + } + + impl From for ValidityProof { + fn from(proof: CompressedProof) -> Self { + Self(Some(proof)) + } + } + + impl From> for ValidityProof { + fn from(proof: Option) -> Self { + Self(proof) + } + } +} diff --git a/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs b/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs index c9fd938261..e1b11b1f16 100644 --- a/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs +++ b/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs @@ -438,7 +438,7 @@ async fn test_compressible_account_with_custom_rent_payer_close_with_compression // Initialize compressible token account let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: token_account_pubkey, mint_pubkey: context.mint_pubkey, diff --git a/program-tests/compressed-token-test/tests/ctoken/create.rs b/program-tests/compressed-token-test/tests/ctoken/create.rs index dbdeade9b5..4ea62aeba8 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create.rs @@ -9,7 +9,7 @@ use solana_sdk::instruction::Instruction; use super::shared::*; #[tokio::test] -async fn test_create_compressible_token_account() { +async fn test_create_compressible_token_account_instruction() { let mut context = setup_account_test().await.unwrap(); let payer_pubkey = context.payer.pubkey(); @@ -227,7 +227,7 @@ async fn test_create_compressible_token_account_failing() { let token_account_pubkey = Keypair::new(); let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: token_account_pubkey.pubkey(), mint_pubkey: context.mint_pubkey, @@ -364,7 +364,7 @@ async fn test_create_compressible_token_account_failing() { }; let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: token_account_pubkey, mint_pubkey: context.mint_pubkey, @@ -412,7 +412,7 @@ async fn test_create_compressible_token_account_failing() { .unwrap(); let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: context.token_account_keypair.pubkey(), mint_pubkey: context.mint_pubkey, @@ -452,7 +452,7 @@ async fn test_create_compressible_token_account_failing() { let wrong_account_type = context.rpc.test_accounts.protocol.governance_authority_pda; let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: context.token_account_keypair.pubkey(), mint_pubkey: context.mint_pubkey, diff --git a/program-tests/compressed-token-test/tests/ctoken/functional.rs b/program-tests/compressed-token-test/tests/ctoken/functional.rs index e1ce56d148..222ff42845 100644 --- a/program-tests/compressed-token-test/tests/ctoken/functional.rs +++ b/program-tests/compressed-token-test/tests/ctoken/functional.rs @@ -123,7 +123,7 @@ async fn test_compressible_account_with_compression_authority_lifecycle() { // Initialize compressible token account let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: token_account_pubkey, mint_pubkey: context.mint_pubkey, diff --git a/program-tests/compressed-token-test/tests/ctoken/shared.rs b/program-tests/compressed-token-test/tests/ctoken/shared.rs index fa12d0a25b..c7c5a49a20 100644 --- a/program-tests/compressed-token-test/tests/ctoken/shared.rs +++ b/program-tests/compressed-token-test/tests/ctoken/shared.rs @@ -90,7 +90,7 @@ pub async fn create_and_assert_token_account( let token_account_pubkey = context.token_account_keypair.pubkey(); let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: token_account_pubkey, mint_pubkey: context.mint_pubkey, @@ -142,7 +142,7 @@ pub async fn create_and_assert_token_account_fails( let token_account_pubkey = context.token_account_keypair.pubkey(); let create_token_account_ix = - light_compressed_token_sdk::instructions::create_compressible_token_account( + light_compressed_token_sdk::instructions::create_compressible_token_account_instruction( light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount { account_pubkey: token_account_pubkey, mint_pubkey: context.mint_pubkey, diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 1c0b6b6dd5..51b95818a1 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -29,7 +29,7 @@ use light_test_utils::{ Rpc, }; use light_token_client::{ - actions::{create_mint, ctoken_transfer, mint_to_compressed, transfer2}, + actions::{create_mint, mint_to_compressed, transfer2, transfer_ctoken}, instructions::transfer2::{ create_decompress_instruction, create_generic_transfer2_instruction, CompressInput, DecompressInput, Transfer2InstructionType, TransferInput, @@ -820,7 +820,7 @@ async fn test_ctoken_transfer() { second_recipient_ata_balance ); // Execute the decompressed transfer - let transfer_result = ctoken_transfer( + let transfer_result = transfer_ctoken( &mut rpc, recipient_ata, // Source account (has 1000 tokens) second_recipient_ata, // Destination account diff --git a/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs b/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs index 859ed58628..3df688aada 100644 --- a/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs +++ b/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs @@ -128,7 +128,7 @@ async fn test_spl_to_ctoken_transfer() { println!("Testing reverse transfer: ctoken to SPL"); // Transfer from recipient's compressed token account back to sender's SPL token account - transfer2::ctoken_to_spl_transfer( + transfer2::transfer_ctoken_to_spl( &mut rpc, associated_token_account, spl_token_account_keypair.pubkey(), diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index bdcba1ab76..76e610433e 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -93,6 +93,21 @@ fn set_input_compressed_account_inner( ProgramError::Custom(AccountError::NotEnoughAccountKeys.into()) })?; + anchor_lang::solana_program::msg!("DEBUG token_input - mint_account: {:?}", mint_account.key()); + anchor_lang::solana_program::msg!( + "DEBUG token_input - owner_account: {:?}", + owner_account.key() + ); + anchor_lang::solana_program::msg!("DEBUG token_input - amount: {:?}", input_token_data.amount); + anchor_lang::solana_program::msg!( + "DEBUG token_input - has_delegate: {:?}", + input_token_data.has_delegate + ); + anchor_lang::solana_program::msg!( + "DEBUG token_input - leaf_index: {:?}", + input_token_data.merkle_context.leaf_index + ); + let data_hash = { match token_version { TokenDataVersion::ShaFlat => { @@ -140,6 +155,8 @@ fn set_input_compressed_account_inner( } }; + anchor_lang::solana_program::msg!("DEBUG token_input - computed data_hash: {:?}", data_hash); + input_compressed_account.set_z( token_version.discriminator(), data_hash, diff --git a/prover/client/src/helpers.rs b/prover/client/src/helpers.rs index 61e7dfb6d9..459ab27617 100644 --- a/prover/client/src/helpers.rs +++ b/prover/client/src/helpers.rs @@ -58,7 +58,7 @@ pub fn compute_root_from_merkle_proof( let mut current_index = path_index; for (level, path_element) in path_elements.iter().enumerate() { changelog_entry.path[level] = Some(current_hash); - if current_index.is_multiple_of(2) { + if current_index % 2 == 0 { current_hash = Poseidon::hashv(&[¤t_hash, path_element]).unwrap(); } else { current_hash = Poseidon::hashv(&[path_element, ¤t_hash]).unwrap(); diff --git a/sdk-libs/client/Cargo.toml b/sdk-libs/client/Cargo.toml index ea89387e43..0e14530484 100644 --- a/sdk-libs/client/Cargo.toml +++ b/sdk-libs/client/Cargo.toml @@ -27,6 +27,7 @@ solana-clock = { workspace = true } solana-signature = { workspace = true } solana-commitment-config = { workspace = true } solana-account = { workspace = true } +solana-signer = { workspace = true } solana-epoch-info = { workspace = true } solana-keypair = { workspace = true } solana-compute-budget-interface = { workspace = true } @@ -35,6 +36,9 @@ solana-address-lookup-table-interface = { version = "2.2.1", features = [ "bytemuck", "bincode", ] } +solana-message = "2.2" +# TODO: check if we can move. +anchor-lang = { workspace = true, features = ["idl-build"], optional = true } # Light Protocol dependencies light-merkle-tree-metadata = { workspace = true, features = ["solana"] } diff --git a/sdk-libs/client/src/constants.rs b/sdk-libs/client/src/constants.rs index ec1c0432b9..1ad9dfb6e1 100644 --- a/sdk-libs/client/src/constants.rs +++ b/sdk-libs/client/src/constants.rs @@ -19,3 +19,28 @@ pub const STATE_TREE_LOOKUP_TABLE_DEVNET: Pubkey = /// Used to reduce transaction size by referencing queues via lookup table indices. pub const NULLIFIED_STATE_TREE_LOOKUP_TABLE_DEVNET: Pubkey = pubkey!("5dhaJLBjnVBQFErr8oiCJmcVsx3Zj6xDekGB2zULPsnP"); + +/// Address lookup table with zk compression related keys. Use to reduce +/// transaction size. +/// +/// Keys include: all protocol pubkeys, default state trees, address trees, and +/// more. +/// +/// Example usage: +/// ```bash +/// +/// # By cloning from mainnet +/// light test-validator --validator-args "\ +/// --clone 9NYFyEqPkyXUhkerbGHXUXkvb4qpzeEdHuGpgbgpH1NJ \ +/// --url https://api.mainnet-beta.solana.com \ +/// --upgradeable-program ~/.config/solana/id.json" +/// +/// # With a local LUT file +/// light test-validator --validator-args "\ +/// --account 9NYFyEqPkyXUhkerbGHXUXkvb4qpzeEdHuGpgbgpH1NJ ./scripts/lut.json \ +/// --url https://api.mainnet-beta.solana.com \ +/// --upgradeable-program ~/.config/solana/id.json" +/// +/// ``` +pub const LIGHT_PROTOCOL_LOOKUP_TABLE_ADDRESS: Pubkey = + pubkey!("9NYFyEqPkyXUhkerbGHXUXkvb4qpzeEdHuGpgbgpH1NJ"); diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index d0c062a5bb..0d2f273e8b 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -527,16 +527,30 @@ impl TryFrom for CompressedAccount { .hash() .map_err(|_| IndexerError::InvalidResponseData)?; // Breaks light-program-test - let tree_info = QUEUE_TREE_MAPPING.get( - &Pubkey::new_from_array(account.merkle_context.merkle_tree_pubkey.to_bytes()) - .to_string(), - ); - let cpi_context = if let Some(tree_info) = tree_info { - tree_info.cpi_context - } else { - warn!("Cpi context not found in queue tree mapping"); - None - }; + // let tree_info = QUEUE_TREE_MAPPING.get( + // &Pubkey::new_from_array(account.merkle_context.merkle_tree_pubkey.to_bytes()) + // .to_string(), + // ); + + let tree_pubkey = + Pubkey::new_from_array(account.merkle_context.merkle_tree_pubkey.to_bytes()); + let tree_info = QUEUE_TREE_MAPPING + .get(&tree_pubkey.to_string()) + .ok_or_else(|| { + println!( + "ERROR: No tree_info found for tree pubkey: {:?}", + tree_pubkey.to_string() + ); + IndexerError::InvalidResponseData + })?; + + if tree_info.cpi_context.is_none() { + panic!( + "Cpi context not found in queue tree mapping for tree pubkey: {:?}", + tree_pubkey.to_string() + ); + } + Ok(CompressedAccount { address: account.compressed_account.address, data: account.compressed_account.data, @@ -544,10 +558,10 @@ impl TryFrom for CompressedAccount { lamports: account.compressed_account.lamports, leaf_index: account.merkle_context.leaf_index, tree_info: TreeInfo { - tree: Pubkey::new_from_array(account.merkle_context.merkle_tree_pubkey.to_bytes()), + tree: tree_pubkey, queue: Pubkey::new_from_array(account.merkle_context.queue_pubkey.to_bytes()), tree_type: account.merkle_context.tree_type, - cpi_context, + cpi_context: tree_info.cpi_context, next_tree_info: None, }, owner: Pubkey::new_from_array(account.compressed_account.owner.to_bytes()), @@ -618,6 +632,18 @@ impl TryFrom<&photon_api::models::AccountV2> for CompressedAccount { .map(|ctx| NextTreeInfo::try_from(ctx.as_ref())) .transpose()?, }; + // TODO: check if the above handles it fine. + // let tree_pubkey = + // Pubkey::new_from_array(decode_base58_to_fixed_array(&account.merkle_context.tree)?); + // let tree_info = QUEUE_TREE_MAPPING + // .get(&tree_pubkey.to_string()) + // .ok_or_else(|| { + // println!( + // "ERROR: No tree_info found for tree pubkey: {}", + // account.merkle_context.tree + // ); + // IndexerError::InvalidResponseData + // })?; Ok(CompressedAccount { owner, diff --git a/sdk-libs/client/src/rpc/lut.rs b/sdk-libs/client/src/rpc/lut.rs new file mode 100644 index 0000000000..e1adfe9872 --- /dev/null +++ b/sdk-libs/client/src/rpc/lut.rs @@ -0,0 +1,37 @@ +pub use solana_address_lookup_table_interface::{ + error, instruction, program, state::AddressLookupTable, +}; +use solana_message::AddressLookupTableAccount; +use solana_pubkey::Pubkey; +use solana_rpc_client::rpc_client::RpcClient; + +use crate::rpc::errors::RpcError; + +/// Gets a lookup table account state from the network. +/// +/// # Arguments +/// +/// * `client` - The RPC client to use to get the lookup table account state. +/// * `lookup_table_address` - The address of the lookup table account to get. +/// +/// # Returns +/// +/// * `AddressLookupTableAccount` - The lookup table account state. +pub fn load_lookup_table( + client: &RpcClient, + lookup_table_address: &Pubkey, +) -> Result { + let raw_account = client.get_account(lookup_table_address)?; + let address_lookup_table = AddressLookupTable::deserialize(&raw_account.data).map_err(|e| { + RpcError::CustomError(format!("Failed to deserialize AddressLookupTable: {e:?}")) + })?; + let address_lookup_table_account = AddressLookupTableAccount { + key: lookup_table_address.to_bytes().into(), + addresses: address_lookup_table + .addresses + .iter() + .map(|p| p.to_bytes().into()) + .collect(), + }; + Ok(address_lookup_table_account) +} diff --git a/sdk-libs/client/src/rpc/mod.rs b/sdk-libs/client/src/rpc/mod.rs index 0b968c26c5..bfb9210295 100644 --- a/sdk-libs/client/src/rpc/mod.rs +++ b/sdk-libs/client/src/rpc/mod.rs @@ -11,3 +11,6 @@ pub use client::{LightClient, RetryConfig}; pub use errors::RpcError; pub use rpc_trait::{LightClientConfig, Rpc}; pub mod get_light_state_tree_infos; + +pub use lut::load_lookup_table; +pub mod lut; diff --git a/sdk-libs/compressed-token-sdk/src/account2.rs b/sdk-libs/compressed-token-sdk/src/account2.rs index bbd8b69e2c..dd4b267a5e 100644 --- a/sdk-libs/compressed-token-sdk/src/account2.rs +++ b/sdk-libs/compressed-token-sdk/src/account2.rs @@ -409,153 +409,4 @@ impl Deref for CTokenAccount2 { fn deref(&self) -> &Self::Target { &self.output } -} - -#[allow(clippy::too_many_arguments)] -#[profile] -pub fn create_spl_to_ctoken_transfer_instruction( - source_spl_token_account: Pubkey, - to: Pubkey, - amount: u64, - authority: Pubkey, - mint: Pubkey, - payer: Pubkey, - token_pool_pda: Pubkey, - token_pool_pda_bump: u8, -) -> Result { - let packed_accounts = vec![ - // Mint (index 0) - AccountMeta::new_readonly(mint, false), - // Destination token account (index 1) - AccountMeta::new(to, false), - // Authority for compression (index 2) - signer - AccountMeta::new_readonly(authority, true), - // Source SPL token account (index 3) - writable - AccountMeta::new(source_spl_token_account, false), - // Token pool PDA (index 4) - writable - AccountMeta::new(token_pool_pda, false), - // SPL Token program (index 5) - needed for CPI - AccountMeta::new_readonly( - Pubkey::from(light_compressed_token_types::constants::SPL_TOKEN_PROGRAM_ID), - false, - ), - ]; - - let wrap_spl_to_ctoken_account = CTokenAccount2 { - inputs: vec![], - output: MultiTokenTransferOutputData::default(), - compression: Some(Compression::compress_spl( - amount, - 0, // mint - 3, // source or recpient - 2, // authority - 4, // pool_account_index: - 0, // pool_index - token_pool_pda_bump, - )), - delegate_is_set: false, - method_used: true, - }; - - let ctoken_account = CTokenAccount2 { - inputs: vec![], - output: MultiTokenTransferOutputData::default(), - compression: Some(Compression::decompress_ctoken(amount, 0, 1)), - delegate_is_set: false, - method_used: true, - }; - - // Create Transfer2Inputs following the test pattern - let inputs = Transfer2Inputs { - validity_proof: ValidityProof::default(), - transfer_config: Transfer2Config::default().filter_zero_amount_outputs(), - meta_config: Transfer2AccountsMetaConfig::new_decompressed_accounts_only( - payer, - packed_accounts, - ), - in_lamports: None, - out_lamports: None, - token_accounts: vec![wrap_spl_to_ctoken_account, ctoken_account], - output_queue: 0, // Decompressed accounts only, no output queue needed - }; - - // Create the actual transfer2 instruction - create_transfer2_instruction(inputs) -} - -#[allow(clippy::too_many_arguments)] -#[profile] -pub fn create_ctoken_to_spl_transfer_instruction( - source_ctoken_account: Pubkey, - destination_spl_token_account: Pubkey, - amount: u64, - authority: Pubkey, - mint: Pubkey, - payer: Pubkey, - token_pool_pda: Pubkey, - token_pool_pda_bump: u8, -) -> Result { - let packed_accounts = vec![ - // Mint (index 0) - AccountMeta::new_readonly(mint, false), - // Source ctoken account (index 1) - writable - AccountMeta::new(source_ctoken_account, false), - // Destination SPL token account (index 2) - writable - AccountMeta::new(destination_spl_token_account, false), - // Authority (index 3) - signer - AccountMeta::new_readonly(authority, true), - // Token pool PDA (index 4) - writable - AccountMeta::new(token_pool_pda, false), - // SPL Token program (index 5) - needed for CPI - AccountMeta::new_readonly( - Pubkey::from(light_compressed_token_types::constants::SPL_TOKEN_PROGRAM_ID), - false, - ), - ]; - - // First operation: compress from ctoken account to pool using compress_spl - let compress_to_pool = CTokenAccount2 { - inputs: vec![], - output: MultiTokenTransferOutputData::default(), - compression: Some(Compression::compress_ctoken( - amount, 0, // mint index - 1, // source ctoken account index - 3, // authority index - )), - delegate_is_set: false, - method_used: true, - }; - - // Second operation: decompress from pool to SPL token account using decompress_spl - let decompress_to_spl = CTokenAccount2 { - inputs: vec![], - output: MultiTokenTransferOutputData::default(), - compression: Some(Compression::decompress_spl( - amount, - 0, // mint index - 2, // destination SPL token account index - 4, // pool_account_index - 0, // pool_index (TODO: make dynamic) - token_pool_pda_bump, - )), - delegate_is_set: false, - method_used: true, - }; - - // Create Transfer2Inputs - let inputs = Transfer2Inputs { - validity_proof: ValidityProof::default(), - transfer_config: Transfer2Config::default().filter_zero_amount_outputs(), - meta_config: Transfer2AccountsMetaConfig::new_decompressed_accounts_only( - payer, - packed_accounts, - ), - in_lamports: None, - out_lamports: None, - token_accounts: vec![compress_to_pool, decompress_to_spl], - output_queue: 0, // Decompressed accounts only, no output queue needed - }; - - // Create the actual transfer2 instruction - create_transfer2_instruction(inputs) -} +} \ No newline at end of file diff --git a/sdk-libs/compressed-token-sdk/src/error.rs b/sdk-libs/compressed-token-sdk/src/error.rs index 3f4206a02a..f067db9542 100644 --- a/sdk-libs/compressed-token-sdk/src/error.rs +++ b/sdk-libs/compressed-token-sdk/src/error.rs @@ -49,6 +49,14 @@ pub enum TokenSdkError { CannotMintWithDecompressedInCpiWrite, #[error("RentAuthorityIsNone")] RentAuthorityIsNone, + #[error("Incomplete SPL bridge config")] + IncompleteSplBridgeConfig, + #[error("SPL bridge config required")] + SplBridgeConfigRequired, + #[error("Use regular SPL transfer")] + UseRegularSplTransfer, + #[error("Cannot determine account type")] + CannotDetermineAccountType, #[error(transparent)] CompressedTokenTypes(#[from] LightTokenSdkTypeError), #[error(transparent)] @@ -97,6 +105,10 @@ impl From for u32 { TokenSdkError::PackedAccountIndexOutOfBounds => 17017, TokenSdkError::CannotMintWithDecompressedInCpiWrite => 17018, TokenSdkError::RentAuthorityIsNone => 17019, + TokenSdkError::SplBridgeConfigRequired => 17020, + TokenSdkError::IncompleteSplBridgeConfig => 17021, + TokenSdkError::UseRegularSplTransfer => 17022, + TokenSdkError::CannotDetermineAccountType => 17023, TokenSdkError::CompressedTokenTypes(e) => e.into(), TokenSdkError::CTokenError(e) => e.into(), TokenSdkError::LightSdkTypesError(e) => e.into(), diff --git a/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs b/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs index 26c141545e..970476f955 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/compress_and_close.rs @@ -9,6 +9,7 @@ use light_sdk::{ }; use light_zero_copy::traits::ZeroCopyAt; use solana_account_info::AccountInfo; +use solana_cpi::invoke_signed; use solana_instruction::{AccountMeta, Instruction}; use solana_msg::msg; use solana_pubkey::Pubkey; @@ -23,6 +24,7 @@ use crate::{ }, CTokenDefaultAccounts, }, + AccountInfoToCompress, }; /// Struct to hold all the indices needed for CompressAndClose operation @@ -401,6 +403,66 @@ pub fn compress_and_close_ctoken_accounts<'info>( ) } +/// Compress and close ctoken accounts, and invoke cpi. +/// +/// Wraps `compress_and_close_ctoken_accounts`, builds the instruction, and +/// calls `invoke_signed` with provided seeds. +/// +/// `remaining_accounts` must include required Light system accounts for +/// `transfer2`, followed by any additional accounts. Post_system accounts are a +/// subset of `remaining_accounts`. +#[allow(clippy::too_many_arguments)] +#[profile] +pub fn compress_and_close_ctoken_accounts_signed<'b, 'info>( + token_accounts_to_compress: &[AccountInfoToCompress<'info>], + fee_payer: AccountInfo<'info>, + output_queue: AccountInfo<'info>, + compressed_token_rent_recipient: AccountInfo<'info>, + compressed_token_cpi_authority: AccountInfo<'info>, + cpi_authority: AccountInfo<'info>, + post_system: &[AccountInfo<'info>], + remaining_accounts: &[AccountInfo<'info>], +) -> Result<(), TokenSdkError> { + let mut packed_accounts = Vec::with_capacity(post_system.len() + 4); + packed_accounts.extend_from_slice(post_system); + packed_accounts.push(cpi_authority); + packed_accounts.push(compressed_token_rent_recipient.clone()); + + let ctoken_infos: Vec<&AccountInfo<'info>> = token_accounts_to_compress + .iter() + .map(|t| t.account_info.as_ref()) + .collect(); + + let instruction = compress_and_close_ctoken_accounts( + *fee_payer.key, + false, + output_queue, + &ctoken_infos, + &packed_accounts, + ) + .map_err(|_| TokenSdkError::InvalidAccountData)?; + + // infos + let total_capacity = packed_accounts.len() + remaining_accounts.len() + 1; + let mut account_infos: Vec> = Vec::with_capacity(total_capacity); + account_infos.extend_from_slice(&packed_accounts); + account_infos.push(compressed_token_cpi_authority); + account_infos.extend_from_slice(&remaining_accounts); + + let token_seeds_refs: Vec> = token_accounts_to_compress + .iter() + .map(|t| t.signer_seeds.iter().map(|v| v.as_slice()).collect()) + .collect(); + let mut all_signer_seeds: Vec<&[&[u8]]> = Vec::with_capacity(token_seeds_refs.len()); + for seeds in &token_seeds_refs { + all_signer_seeds.push(seeds.as_slice()); + } + + invoke_signed(&instruction, &account_infos, &all_signer_seeds) + .map_err(|e| TokenSdkError::CpiError(e.to_string()))?; + Ok(()) +} + pub struct CompressAndCloseAccounts { pub compressed_token_program: Pubkey, pub cpi_authority_pda: Pubkey, diff --git a/sdk-libs/compressed-token-sdk/src/instructions/create_associated_token_account.rs b/sdk-libs/compressed-token-sdk/src/instructions/create_associated_token_account.rs index f7e7280654..036aeedded 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/create_associated_token_account.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/create_associated_token_account.rs @@ -7,6 +7,7 @@ use light_ctoken_types::{ }, state::TokenDataVersion, }; +use solana_account_info::AccountInfo; use solana_instruction::Instruction; use solana_pubkey::Pubkey; @@ -401,3 +402,89 @@ fn create_ata2_instruction_unified( + payer: AccountInfo<'info>, + associated_token_account: AccountInfo<'info>, + system_program: AccountInfo<'info>, + compressible_config: AccountInfo<'info>, + rent_recipient: AccountInfo<'info>, + authority: AccountInfo<'info>, + mint: Pubkey, + bump: u8, + pre_pay_num_epochs: Option, + lamports_per_write: Option, +) -> std::result::Result<(), solana_program_error::ProgramError> { + let inputs = CreateCompressibleAssociatedTokenAccountInputs { + payer: *payer.key, + owner: *authority.key, + mint, + compressible_config: *compressible_config.key, + rent_sponsor: *rent_recipient.key, + pre_pay_num_epochs: pre_pay_num_epochs.unwrap_or(1), + lamports_per_write, + token_account_version: TokenDataVersion::ShaFlat, + }; + + let ix = create_compressible_associated_token_account_with_bump( + inputs, + *associated_token_account.key, + bump, + )?; + + solana_cpi::invoke( + &ix, + &[ + payer, + associated_token_account, + system_program, + compressible_config, + rent_recipient, + authority, + ], + ) +} + +/// CPI wrapper to create a compressible c-token associated token account +/// idempotently. +pub fn create_associated_ctoken_account_idempotent<'info>( + payer: AccountInfo<'info>, + associated_token_account: AccountInfo<'info>, + system_program: AccountInfo<'info>, + compressible_config: AccountInfo<'info>, + rent_sponsor: AccountInfo<'info>, + authority: Pubkey, + mint: Pubkey, + bump: u8, + pre_pay_num_epochs: Option, + lamports_per_write: Option, +) -> std::result::Result<(), solana_program_error::ProgramError> { + let inputs = CreateCompressibleAssociatedTokenAccountInputs { + payer: *payer.key, + owner: authority, + mint, + compressible_config: *compressible_config.key, + rent_sponsor: *rent_sponsor.key, + pre_pay_num_epochs: pre_pay_num_epochs.unwrap_or(1), + lamports_per_write, + token_account_version: TokenDataVersion::ShaFlat, + }; + + let ix = create_compressible_associated_token_account_with_bump_and_mode::( + inputs, + *associated_token_account.key, + bump, + )?; + + solana_cpi::invoke( + &ix, + &[ + payer, + associated_token_account, + system_program, + compressible_config, + rent_sponsor, + ], + ) +} diff --git a/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/instruction.rs b/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/instruction.rs index 19aee23017..8996c949f0 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/instruction.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/instruction.rs @@ -220,3 +220,14 @@ pub fn find_spl_mint_address(mint_seed: &Pubkey) -> (Pubkey, u8) { &Pubkey::new_from_array(light_ctoken_types::COMPRESSED_TOKEN_PROGRAM_ID), ) } + +/// DEPRECATED: Use derive_compressed_mint_address instead +/// Derives the compressed mint address from the mint seed and address tree +pub fn derive_ctoken_mint_address(mint_seed: &Pubkey, address_tree_pubkey: &Pubkey) -> [u8; 32] { + derive_compressed_mint_address(mint_seed, address_tree_pubkey) +} + +/// Alias for find_spl_mint_address +pub fn find_mint_address(signer: &Pubkey) -> (Pubkey, u8) { + find_spl_mint_address(signer) +} diff --git a/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/mod.rs b/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/mod.rs index 3d2f390bf6..24f33c57a9 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/mod.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/create_compressed_mint/mod.rs @@ -6,44 +6,44 @@ pub use account_metas::{ }; pub use instruction::{ create_compressed_mint, create_compressed_mint_cpi, derive_compressed_mint_address, - derive_compressed_mint_from_spl_mint, find_spl_mint_address, CreateCompressedMintInputs, - CREATE_COMPRESSED_MINT_DISCRIMINATOR, + derive_compressed_mint_from_spl_mint, derive_ctoken_mint_address, find_mint_address, + find_spl_mint_address, CreateCompressedMintInputs, CREATE_COMPRESSED_MINT_DISCRIMINATOR, }; use light_account_checks::AccountInfoTrait; use light_sdk::cpi::CpiSigner; -#[derive(Clone, Debug)] -pub struct CpiContextWriteAccounts<'a, T: AccountInfoTrait + Clone> { - pub mint_signer: &'a T, - pub light_system_program: &'a T, - pub fee_payer: &'a T, - pub cpi_authority_pda: &'a T, - pub cpi_context: &'a T, - pub cpi_signer: CpiSigner, -} +// #[derive(Clone, Debug)] +// pub struct CpiContextWriteAccounts<'a, T: AccountInfoTrait + Clone> { +// pub mint_signer: &'a T, +// pub light_system_program: &'a T, +// pub fee_payer: &'a T, +// pub cpi_authority_pda: &'a T, +// pub cpi_context: &'a T, +// pub cpi_signer: CpiSigner, +// } -impl CpiContextWriteAccounts<'_, T> { - pub fn bump(&self) -> u8 { - self.cpi_signer.bump - } +// impl CpiContextWriteAccounts<'_, T> { +// pub fn bump(&self) -> u8 { +// self.cpi_signer.bump +// } - pub fn invoking_program(&self) -> [u8; 32] { - self.cpi_signer.program_id - } +// pub fn invoking_program(&self) -> [u8; 32] { +// self.cpi_signer.program_id +// } - pub fn to_account_infos(&self) -> Vec { - // The 5 accounts expected by create_compressed_mint_cpi_write: - // [mint_signer, light_system_program, fee_payer, cpi_authority_pda, cpi_context] - vec![ - self.mint_signer.clone(), - self.light_system_program.clone(), - self.fee_payer.clone(), - self.cpi_authority_pda.clone(), - self.cpi_context.clone(), - ] - } +// pub fn to_account_infos(&self) -> Vec { +// // The 5 accounts expected by create_compressed_mint_cpi_write: +// // [mint_signer, light_system_program, fee_payer, cpi_authority_pda, cpi_context] +// vec![ +// self.mint_signer.clone(), +// self.light_system_program.clone(), +// self.fee_payer.clone(), +// self.cpi_authority_pda.clone(), +// self.cpi_context.clone(), +// ] +// } - pub fn to_account_info_refs(&self) -> [&T; 3] { - [self.mint_signer, self.fee_payer, self.cpi_context] - } -} +// pub fn to_account_info_refs(&self) -> [&T; 3] { +// [self.mint_signer, self.fee_payer, self.cpi_context] +// } +// } diff --git a/sdk-libs/compressed-token-sdk/src/instructions/create_token_account/instruction.rs b/sdk-libs/compressed-token-sdk/src/instructions/create_token_account/instruction.rs index 8a7c0c6952..ebe580d452 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/create_token_account/instruction.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/create_token_account/instruction.rs @@ -5,7 +5,9 @@ use light_ctoken_types::{ extensions::compressible::{CompressToPubkey, CompressibleExtensionInstructionData}, }, state::TokenDataVersion, + CTokenError, }; +use solana_account_info::AccountInfo; use solana_instruction::Instruction; use solana_pubkey::Pubkey; @@ -35,7 +37,7 @@ pub struct CreateCompressibleTokenAccount { pub token_account_version: TokenDataVersion, } -pub fn create_compressible_token_account( +pub fn create_compressible_token_account_instruction( inputs: CreateCompressibleTokenAccount, ) -> Result { // Create the CompressibleExtensionInstructionData @@ -114,3 +116,54 @@ pub fn create_token_account( data, }) } + +/// Create a c-token account with signer seeds. +pub fn create_ctoken_account_signed<'info>( + program_id: Pubkey, + payer: AccountInfo<'info>, + token_account: AccountInfo<'info>, + mint_account: AccountInfo<'info>, + authority: Pubkey, + signer_seeds: &[&[u8]], + ctoken_rent_sponsor: AccountInfo<'info>, + ctoken_config_account: AccountInfo<'info>, + pre_pay_num_epochs: Option, + lamports_per_write: Option, +) -> std::result::Result<(), solana_program_error::ProgramError> { + let bump = signer_seeds[signer_seeds.len() - 1][0]; + let seeds: Vec> = signer_seeds[..signer_seeds.len() - 1] + .iter() + .map(|seed| seed.to_vec()) + .collect(); + + let params = CreateCompressibleTokenAccount { + payer: *payer.key, + account_pubkey: *token_account.key, + mint_pubkey: *mint_account.key, + owner_pubkey: authority, + compressible_config: *ctoken_config_account.key, + rent_sponsor: *ctoken_rent_sponsor.key, + pre_pay_num_epochs: pre_pay_num_epochs.unwrap_or(0), + lamports_per_write, + compress_to_account_pubkey: Some(CompressToPubkey { + bump, + program_id: program_id.to_bytes(), + seeds, + }), + token_account_version: TokenDataVersion::ShaFlat, + }; + let ix = create_compressible_token_account_instruction(params) + .map_err(|_| TokenSdkError::CTokenError(CTokenError::InvalidInstructionData))?; + + solana_cpi::invoke_signed( + &ix, + &[ + payer, + token_account, + mint_account, + ctoken_rent_sponsor, + ctoken_config_account, + ], + &[signer_seeds], + ) +} diff --git a/sdk-libs/compressed-token-sdk/src/instructions/decompress_full.rs b/sdk-libs/compressed-token-sdk/src/instructions/decompress_full.rs index 5297a9d6e1..22895c807d 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/decompress_full.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/decompress_full.rs @@ -10,6 +10,7 @@ use light_sdk::{ }; use solana_account_info::AccountInfo; use solana_instruction::{AccountMeta, Instruction}; +use solana_msg::msg; use solana_pubkey::Pubkey; use crate::{ @@ -59,6 +60,12 @@ pub fn decompress_full_ctoken_accounts_with_indices<'info>( // Process each set of indices let mut token_accounts = Vec::with_capacity(indices.len()); + // Convert packed_accounts to AccountMetas + // TODO: we may have to add conditional delegate signers for delegate + // support via CPI. + // Build signer flags in O(n) instead of scanning on every meta push + let mut signer_flags = vec![false; packed_accounts.len()]; + for idx in indices.iter() { // Create CTokenAccount2 with the source data // For decompress_full, we don't have an output tree since everything goes to the destination @@ -67,18 +74,22 @@ pub fn decompress_full_ctoken_accounts_with_indices<'info>( // Set up decompress_full - decompress entire balance to destination ctoken account token_account.decompress_ctoken(idx.source.amount, idx.destination_index)?; token_accounts.push(token_account); + + let owner_idx = idx.source.owner as usize; + if owner_idx < signer_flags.len() { + signer_flags[owner_idx] = true; + } } - // Convert packed_accounts to AccountMetas let mut packed_account_metas = Vec::with_capacity(packed_accounts.len()); - for info in packed_accounts.iter() { + + for (i, info) in packed_accounts.iter().enumerate() { packed_account_metas.push(AccountMeta { pubkey: *info.key, - is_signer: info.is_signer, + is_signer: info.is_signer || signer_flags[i], is_writable: info.is_writable, }); } - let (meta_config, transfer_config) = if let Some(cpi_context) = cpi_context_pubkey { let cpi_context_config = CompressedCpiContext { set_context: false, diff --git a/sdk-libs/compressed-token-sdk/src/instructions/mod.rs b/sdk-libs/compressed-token-sdk/src/instructions/mod.rs index b5234076ee..baea1e3612 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/mod.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/mod.rs @@ -13,6 +13,8 @@ pub mod mint_action; pub mod mint_to_compressed; pub mod transfer; pub mod transfer2; +pub mod transfer_ctoken; +pub mod transfer_interface; pub mod update_compressed_mint; pub mod withdraw_funding_pool; @@ -34,7 +36,8 @@ pub use create_associated_token_account::*; pub use create_compressed_mint::*; pub use create_spl_mint::*; pub use create_token_account::{ - create_compressible_token_account, create_token_account, CreateCompressibleTokenAccount, + create_compressible_token_account_instruction, create_ctoken_account_signed, + create_token_account, CreateCompressibleTokenAccount, }; pub use ctoken_accounts::*; pub use decompress_full::{decompress_full_ctoken_accounts_with_indices, DecompressFullIndices}; @@ -55,6 +58,13 @@ pub use update_compressed_mint::{ }; pub use withdraw_funding_pool::withdraw_funding_pool; +pub use transfer_ctoken::{transfer_ctoken, transfer_ctoken_signed}; +// TODO: export the others too. +pub use transfer_interface::{ + create_transfer_ctoken_to_spl_instruction, create_transfer_spl_to_ctoken_instruction, + transfer_interface, transfer_interface_signed, +}; + /// Derive token pool information for a given mint pub fn derive_token_pool(mint: &solana_pubkey::Pubkey, index: u8) -> mint_action::TokenPool { let (pubkey, bump) = crate::token_pool::find_token_pool_pda_with_index(mint, index); diff --git a/sdk-libs/compressed-token-sdk/src/instructions/transfer2/instruction.rs b/sdk-libs/compressed-token-sdk/src/instructions/transfer2/instruction.rs index 30cc41a77e..dcc93eb671 100644 --- a/sdk-libs/compressed-token-sdk/src/instructions/transfer2/instruction.rs +++ b/sdk-libs/compressed-token-sdk/src/instructions/transfer2/instruction.rs @@ -5,6 +5,7 @@ use light_ctoken_types::{ }; use light_program_profiler::profile; use solana_instruction::Instruction; +use solana_msg::msg; use solana_pubkey::Pubkey; use crate::{ @@ -98,6 +99,11 @@ pub fn create_transfer2_instruction(inputs: Transfer2Inputs) -> Result Instruction { + Instruction { + program_id: Pubkey::from(C_TOKEN_PROGRAM_ID), + accounts: vec![ + AccountMeta::new(source, false), + AccountMeta::new(destination, false), + AccountMeta::new_readonly(authority, true), + ], + data: { + let mut data = vec![3u8]; // DecompressedTransfer discriminator + data.push(3u8); // SPL Transfer discriminator + data.extend_from_slice(&amount.to_le_bytes()); + data + }, + } +} + +/// Transfer decompressed ctokens +pub fn transfer_ctoken<'info>( + from: &AccountInfo<'info>, + to: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + amount: u64, +) -> Result<(), ProgramError> { + let ix = create_transfer_ctoken_instruction(*from.key, *to.key, amount, *authority.key); + + // Return Result directly, as is best practice for CPI helpers in native Solana programs. + invoke(&ix, &[from.clone(), to.clone(), authority.clone()]) +} + +/// Transfer decompressed ctokens with signer seeds +pub fn transfer_ctoken_signed<'info>( + from: &AccountInfo<'info>, + to: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + amount: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<(), ProgramError> { + let ix = create_transfer_ctoken_instruction(*from.key, *to.key, amount, *authority.key); + + invoke_signed( + &ix, + &[from.clone(), to.clone(), authority.clone()], + signer_seeds, + ) +} diff --git a/sdk-libs/compressed-token-sdk/src/instructions/transfer_interface.rs b/sdk-libs/compressed-token-sdk/src/instructions/transfer_interface.rs new file mode 100644 index 0000000000..9e9bf9c6e3 --- /dev/null +++ b/sdk-libs/compressed-token-sdk/src/instructions/transfer_interface.rs @@ -0,0 +1,564 @@ +use light_compressed_account::instruction_data::compressed_proof::borsh_compat::ValidityProof; +use light_ctoken_types::instructions::transfer2::{Compression, MultiTokenTransferOutputData}; +use light_program_profiler::profile; +use solana_account_info::AccountInfo; +use solana_cpi::{invoke, invoke_signed}; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use crate::{ + account2::CTokenAccount2, + error::TokenSdkError, + instructions::transfer2::{ + account_metas::Transfer2AccountsMetaConfig, create_transfer2_instruction, Transfer2Config, + Transfer2Inputs, + }, + utils::is_ctoken_account, +}; + +use super::transfer_ctoken::{transfer_ctoken, transfer_ctoken_signed}; + +#[allow(clippy::too_many_arguments)] +#[profile] +pub fn create_transfer_spl_to_ctoken_instruction( + source_spl_token_account: Pubkey, + to: Pubkey, + amount: u64, + authority: Pubkey, + mint: Pubkey, + payer: Pubkey, + token_pool_pda: Pubkey, + token_pool_pda_bump: u8, + spl_token_program: Pubkey, +) -> Result { + let packed_accounts = vec![ + // Mint (index 0) + AccountMeta::new_readonly(mint, false), + // Destination token account (index 1) + AccountMeta::new(to, false), + // Authority for compression (index 2) - signer + AccountMeta::new_readonly(authority, true), + // Source SPL token account (index 3) - writable + AccountMeta::new(source_spl_token_account, false), + // Token pool PDA (index 4) - writable + AccountMeta::new(token_pool_pda, false), + // SPL Token program (index 5) - needed for CPI + AccountMeta::new_readonly(spl_token_program, false), + ]; + + let wrap_spl_to_ctoken_account = CTokenAccount2 { + inputs: vec![], + output: MultiTokenTransferOutputData::default(), + compression: Some(Compression::compress_spl( + amount, + 0, // mint + 3, // source or recpient + 2, // authority + 4, // pool_account_index: + 0, // pool_index + token_pool_pda_bump, + )), + delegate_is_set: false, + method_used: true, + }; + + let ctoken_account = CTokenAccount2 { + inputs: vec![], + output: MultiTokenTransferOutputData::default(), + compression: Some(Compression::decompress_ctoken(amount, 0, 1)), + delegate_is_set: false, + method_used: true, + }; + + // Create Transfer2Inputs following the test + let inputs = Transfer2Inputs { + validity_proof: ValidityProof::new(None).into(), + transfer_config: Transfer2Config::default().filter_zero_amount_outputs(), + meta_config: Transfer2AccountsMetaConfig::new_decompressed_accounts_only( + payer, + packed_accounts, + ), + in_lamports: None, + out_lamports: None, + token_accounts: vec![wrap_spl_to_ctoken_account, ctoken_account], + output_queue: 0, // Decompressed accounts only, no output queue needed + }; + + create_transfer2_instruction(inputs) +} + +#[allow(clippy::too_many_arguments)] +#[profile] +pub fn create_transfer_ctoken_to_spl_instruction( + source_ctoken_account: Pubkey, + destination_spl_token_account: Pubkey, + amount: u64, + authority: Pubkey, + mint: Pubkey, + payer: Pubkey, + token_pool_pda: Pubkey, + token_pool_pda_bump: u8, + spl_token_program: Pubkey, +) -> Result { + let packed_accounts = vec![ + // Mint (index 0) + AccountMeta::new_readonly(mint, false), + // Source ctoken account (index 1) - writable + AccountMeta::new(source_ctoken_account, false), + // Destination SPL token account (index 2) - writable + AccountMeta::new(destination_spl_token_account, false), + // Authority (index 3) - signer + AccountMeta::new_readonly(authority, true), + // Token pool PDA (index 4) - writable + AccountMeta::new(token_pool_pda, false), + // SPL Token program (index 5) - needed for CPI + AccountMeta::new_readonly(spl_token_program, false), + ]; + + // First operation: compress from ctoken account to pool using compress_spl + let compress_to_pool = CTokenAccount2 { + inputs: vec![], + output: MultiTokenTransferOutputData::default(), + compression: Some(Compression::compress_ctoken( + amount, 0, // mint index + 1, // source ctoken account index + 3, // authority index + )), + delegate_is_set: false, + method_used: true, + }; + + // Second operation: decompress from pool to SPL token account using decompress_spl + let decompress_to_spl = CTokenAccount2 { + inputs: vec![], + output: MultiTokenTransferOutputData::default(), + compression: Some(Compression::decompress_spl( + amount, + 0, // mint index + 2, // destination SPL token account index + 4, // pool_account_index + 0, // pool_index (TODO: make dynamic) + token_pool_pda_bump, + )), + delegate_is_set: false, + method_used: true, + }; + + let inputs = Transfer2Inputs { + validity_proof: ValidityProof::new(None).into(), + transfer_config: Transfer2Config::default().filter_zero_amount_outputs(), + meta_config: Transfer2AccountsMetaConfig::new_decompressed_accounts_only( + payer, + packed_accounts, + ), + in_lamports: None, + out_lamports: None, + token_accounts: vec![compress_to_pool, decompress_to_spl], + output_queue: 0, // Decompressed accounts only, no output queue needed + }; + + create_transfer2_instruction(inputs) +} + +/// Transfer SPL tokens to compressed tokens +/// +/// This function creates the instruction and immediately invokes it. +/// Similar to SPL Token's transfer wrapper functions. +#[allow(clippy::too_many_arguments)] +pub fn transfer_spl_to_ctoken<'info>( + payer: AccountInfo<'info>, + authority: AccountInfo<'info>, + source_spl_token_account: AccountInfo<'info>, + destination_ctoken_account: AccountInfo<'info>, + mint: AccountInfo<'info>, + spl_token_program: AccountInfo<'info>, + compressed_token_pool_pda: AccountInfo<'info>, + compressed_token_pool_pda_bump: u8, + compressed_token_program_authority: AccountInfo<'info>, + amount: u64, +) -> Result<(), ProgramError> { + let instruction = create_transfer_spl_to_ctoken_instruction( + *source_spl_token_account.key, + *destination_ctoken_account.key, + amount, + *authority.key, + *mint.key, + *payer.key, + *compressed_token_pool_pda.key, + compressed_token_pool_pda_bump, + *spl_token_program.key, + ) + .map_err(|_| ProgramError::InvalidInstructionData)?; + + // let mut account_infos = remaining_accounts.to_vec(); + let account_infos = vec![ + authority.clone(), + compressed_token_program_authority, + mint, // Index 0: Mint + destination_ctoken_account, // Index 1: Destination owner + authority, // Index 2: Authority (signer) + source_spl_token_account, // Index 3: Source SPL token account + compressed_token_pool_pda, // Index 4: Token pool PDA + spl_token_program, // Index 5: SPL Token program + ]; + + invoke(&instruction, &account_infos)?; + Ok(()) +} + +// TODO: must test this. +/// Transfer SPL tokens to compressed tokens via CPI signer. +/// +/// This function creates the instruction and invokes it with the provided +/// signer seeds. +#[allow(clippy::too_many_arguments)] +pub fn transfer_spl_to_ctoken_signed<'info>( + payer: AccountInfo<'info>, + authority: AccountInfo<'info>, + source_spl_token_account: AccountInfo<'info>, + destination_ctoken_account: AccountInfo<'info>, + mint: AccountInfo<'info>, + spl_token_program: AccountInfo<'info>, + compressed_token_pool_pda: AccountInfo<'info>, + compressed_token_pool_pda_bump: u8, + compressed_token_program_authority: AccountInfo<'info>, + amount: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<(), ProgramError> { + let instruction = create_transfer_spl_to_ctoken_instruction( + *source_spl_token_account.key, + *destination_ctoken_account.key, + amount, + *authority.key, + *mint.key, + *payer.key, + *compressed_token_pool_pda.key, + compressed_token_pool_pda_bump, + *spl_token_program.key, + ) + .map_err(|_| TokenSdkError::MethodUsed)?; + + let account_infos = vec![ + payer.clone(), + compressed_token_program_authority, + mint, // Index 0: Mint + destination_ctoken_account, // Index 1: Destination owner + authority, // Index 2: Authority (signer) + source_spl_token_account, // Index 3: Source SPL token account + compressed_token_pool_pda, // Index 4: Token pool PDA + spl_token_program, // Index 5: SPL Token program + ]; + + invoke_signed(&instruction, &account_infos, signer_seeds) + .map_err(|_| TokenSdkError::MethodUsed)?; + Ok(()) +} + +// TODO: TEST. +/// Transfer compressed tokens to SPL tokens +/// +/// This function creates the instruction and invokes it. +#[allow(clippy::too_many_arguments)] +pub fn transfer_ctoken_to_spl<'info>( + payer: AccountInfo<'info>, + authority: AccountInfo<'info>, + source_ctoken_account: AccountInfo<'info>, + destination_spl_token_account: AccountInfo<'info>, + mint: AccountInfo<'info>, + spl_token_program: AccountInfo<'info>, + compressed_token_pool_pda: AccountInfo<'info>, + compressed_token_pool_pda_bump: u8, + compressed_token_program_authority: AccountInfo<'info>, + amount: u64, +) -> Result<(), ProgramError> { + let instruction = create_transfer_ctoken_to_spl_instruction( + *source_ctoken_account.key, + *destination_spl_token_account.key, + amount, + *authority.key, + *mint.key, + *payer.key, + *compressed_token_pool_pda.key, + compressed_token_pool_pda_bump, + *spl_token_program.key, + ) + .map_err(|_| ProgramError::InvalidInstructionData)?; + + let account_infos = vec![ + authority.clone(), + compressed_token_program_authority, + mint, // Index 0: Mint + destination_spl_token_account, // Index 1: Destination owner + authority, // Index 2: Authority (signer) + source_ctoken_account, // Index 3: Source SPL token account + compressed_token_pool_pda, // Index 4: Token pool PDA + spl_token_program, // Index 5: SPL Token program + ]; + + invoke(&instruction, &account_infos)?; + Ok(()) +} + +/// Transfer compressed tokens to SPL tokens via CPI signer. +/// +/// This function creates the instruction and invokes it with the provided +/// signer seeds. +#[allow(clippy::too_many_arguments)] +pub fn transfer_ctoken_to_spl_signed<'info>( + payer: AccountInfo<'info>, + authority: AccountInfo<'info>, + source_ctoken_account: AccountInfo<'info>, + destination_spl_token_account: AccountInfo<'info>, + mint: AccountInfo<'info>, + spl_token_program: AccountInfo<'info>, + compressed_token_pool_pda: AccountInfo<'info>, + compressed_token_pool_pda_bump: u8, + compressed_token_program_authority: AccountInfo<'info>, + amount: u64, + signer_seeds: &[&[&[u8]]], +) -> Result<(), ProgramError> { + let instruction = create_transfer_ctoken_to_spl_instruction( + *source_ctoken_account.key, + *destination_spl_token_account.key, + amount, + *authority.key, + *mint.key, + *payer.key, + *compressed_token_pool_pda.key, + compressed_token_pool_pda_bump, + *spl_token_program.key, + ) + .map_err(|_| ProgramError::InvalidInstructionData)?; + + let account_infos = vec![ + payer.clone(), + compressed_token_program_authority, + mint, // Index 0: Mint + destination_spl_token_account, // Index 1: Destination owner + authority, // Index 2: Authority (signer) + source_ctoken_account, // Index 3: Source SPL token account + compressed_token_pool_pda, // Index 4: Token pool PDA + spl_token_program, // Index 5: SPL Token program + ]; + + invoke_signed(&instruction, &account_infos, signer_seeds)?; + Ok(()) +} + +/// Unified transfer interface that automatically handles both ctoken<->ctoken and ctoken<->spl transfers +/// +/// This function inspects the source and destination accounts to determine the transfer type +/// and validates that the correct optional parameters are provided. +/// +/// # Arguments +/// * `source_account` - Source token account (can be ctoken or SPL) +/// * `destination_account` - Destination token account (can be ctoken or SPL) +/// * `authority` - Authority for the transfer (must be signer) +/// * `amount` - Amount to transfer +/// * `payer` - Payer for the transaction +/// * `compressed_token_program_authority` - Compressed token program authority +/// * `mint` - Optional mint account (required for SPL<->ctoken transfers) +/// * `spl_token_program` - Optional SPL token program (required for SPL<->ctoken transfers) +/// * `compressed_token_pool_pda` - Optional token pool PDA (required for SPL<->ctoken transfers) +/// * `compressed_token_pool_pda_bump` - Optional bump seed for token pool PDA +/// +/// # Errors +/// * `SplBridgeConfigRequired` - If transferring to/from SPL without required accounts +/// * `UseRegularSplTransfer` - If both source and destination are SPL accounts +/// * `CannotDetermineAccountType` - If account type cannot be determined +#[allow(clippy::too_many_arguments)] +pub fn transfer_interface<'info>( + source_account: &AccountInfo<'info>, + destination_account: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + amount: u64, + payer: &AccountInfo<'info>, + compressed_token_program_authority: &AccountInfo<'info>, + mint: Option<&AccountInfo<'info>>, + spl_token_program: Option<&AccountInfo<'info>>, + compressed_token_pool_pda: Option<&AccountInfo<'info>>, + compressed_token_pool_pda_bump: Option, +) -> Result<(), ProgramError> { + // Determine account types + let source_is_ctoken = + is_ctoken_account(source_account).map_err(|_| ProgramError::InvalidAccountData)?; + let dest_is_ctoken = + is_ctoken_account(destination_account).map_err(|_| ProgramError::InvalidAccountData)?; + + match (source_is_ctoken, dest_is_ctoken) { + // ctoken -> ctoken: Direct transfer (bridge accounts not needed) + (true, true) => transfer_ctoken(source_account, destination_account, authority, amount), + + // ctoken -> spl: Requires bridge accounts + (true, false) => { + // Validate all required accounts are provided + let (mint_acct, spl_program, pool_pda, bump) = match ( + mint, + spl_token_program, + compressed_token_pool_pda, + compressed_token_pool_pda_bump, + ) { + (Some(m), Some(p), Some(pd), Some(b)) => (m, p, pd, b), + _ => { + return Err(ProgramError::Custom( + TokenSdkError::IncompleteSplBridgeConfig.into(), + )) + } + }; + + transfer_ctoken_to_spl( + payer.clone(), + authority.clone(), + source_account.clone(), + destination_account.clone(), + mint_acct.clone(), + spl_program.clone(), + pool_pda.clone(), + bump, + compressed_token_program_authority.clone(), + amount, + ) + } + + // spl -> ctoken: Requires bridge accounts + (false, true) => { + // Validate all required accounts are provided + let (mint_acct, spl_program, pool_pda, bump) = match ( + mint, + spl_token_program, + compressed_token_pool_pda, + compressed_token_pool_pda_bump, + ) { + (Some(m), Some(p), Some(pd), Some(b)) => (m, p, pd, b), + _ => { + return Err(ProgramError::Custom( + TokenSdkError::IncompleteSplBridgeConfig.into(), + )) + } + }; + + transfer_spl_to_ctoken( + payer.clone(), + authority.clone(), + source_account.clone(), + destination_account.clone(), + mint_acct.clone(), + spl_program.clone(), + pool_pda.clone(), + bump, + compressed_token_program_authority.clone(), + amount, + ) + } + + // spl -> spl: Not supported + (false, false) => Err(ProgramError::Custom( + TokenSdkError::UseRegularSplTransfer.into(), + )), + } +} + +/// Unified transfer interface with signer seeds for CPI +/// +/// Same as `transfer_interface` but uses invoke_signed for CPI calls +#[allow(clippy::too_many_arguments)] +pub fn transfer_interface_signed<'info>( + source_account: &AccountInfo<'info>, + destination_account: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + amount: u64, + payer: &AccountInfo<'info>, + compressed_token_program_authority: &AccountInfo<'info>, + mint: Option<&AccountInfo<'info>>, + spl_token_program: Option<&AccountInfo<'info>>, + compressed_token_pool_pda: Option<&AccountInfo<'info>>, + compressed_token_pool_pda_bump: Option, + signer_seeds: &[&[&[u8]]], +) -> Result<(), ProgramError> { + // Determine account types + let source_is_ctoken = + is_ctoken_account(source_account).map_err(|_| ProgramError::InvalidAccountData)?; + let dest_is_ctoken = + is_ctoken_account(destination_account).map_err(|_| ProgramError::InvalidAccountData)?; + + match (source_is_ctoken, dest_is_ctoken) { + // ctoken -> ctoken: Direct transfer (bridge accounts not needed) + (true, true) => transfer_ctoken_signed( + source_account, + destination_account, + authority, + amount, + signer_seeds, + ), + + // ctoken -> spl: Requires bridge accounts + (true, false) => { + // Validate all required accounts are provided + let (mint_acct, spl_program, pool_pda, bump) = match ( + mint, + spl_token_program, + compressed_token_pool_pda, + compressed_token_pool_pda_bump, + ) { + (Some(m), Some(p), Some(pd), Some(b)) => (m, p, pd, b), + _ => { + return Err(ProgramError::Custom( + TokenSdkError::IncompleteSplBridgeConfig.into(), + )) + } + }; + + transfer_ctoken_to_spl_signed( + payer.clone(), + authority.clone(), + source_account.clone(), + destination_account.clone(), + mint_acct.clone(), + spl_program.clone(), + pool_pda.clone(), + bump, + compressed_token_program_authority.clone(), + amount, + signer_seeds, + ) + } + + // spl -> ctoken: Requires bridge accounts + (false, true) => { + // Validate all required accounts are provided + let (mint_acct, spl_program, pool_pda, bump) = match ( + mint, + spl_token_program, + compressed_token_pool_pda, + compressed_token_pool_pda_bump, + ) { + (Some(m), Some(p), Some(pd), Some(b)) => (m, p, pd, b), + _ => { + return Err(ProgramError::Custom( + TokenSdkError::IncompleteSplBridgeConfig.into(), + )) + } + }; + + transfer_spl_to_ctoken_signed( + payer.clone(), + authority.clone(), + source_account.clone(), + destination_account.clone(), + mint_acct.clone(), + spl_program.clone(), + pool_pda.clone(), + bump, + compressed_token_program_authority.clone(), + amount, + signer_seeds, + ) + } + + // spl -> spl: Not supported + (false, false) => Err(ProgramError::Custom( + TokenSdkError::UseRegularSplTransfer.into(), + )), + } +} diff --git a/sdk-libs/compressed-token-sdk/src/lib.rs b/sdk-libs/compressed-token-sdk/src/lib.rs index 45ac38becd..c1d3f4cd09 100644 --- a/sdk-libs/compressed-token-sdk/src/lib.rs +++ b/sdk-libs/compressed-token-sdk/src/lib.rs @@ -12,3 +12,5 @@ use anchor_lang::{AnchorDeserialize, AnchorSerialize}; #[cfg(not(feature = "anchor"))] use borsh::{BorshDeserialize as AnchorDeserialize, BorshSerialize as AnchorSerialize}; pub use light_compressed_token_types::*; + +pub use utils::*; diff --git a/sdk-libs/compressed-token-sdk/src/utils.rs b/sdk-libs/compressed-token-sdk/src/utils.rs index b8d3050649..d3e35fe4bc 100644 --- a/sdk-libs/compressed-token-sdk/src/utils.rs +++ b/sdk-libs/compressed-token-sdk/src/utils.rs @@ -1,4 +1,14 @@ +#[cfg(feature = "anchor")] +use anchor_lang::prelude::InterfaceAccount; +#[cfg(feature = "anchor")] +use anchor_lang::{AnchorDeserialize, AnchorSerialize}; +// #[cfg(feature = "anchor")] +// use anchor_spl::token_interface; +use light_ctoken_types::instructions::transfer2::MultiInputTokenDataWithContext; +use light_sdk_types::C_TOKEN_PROGRAM_ID; use solana_account_info::AccountInfo; +use solana_instruction::AccountMeta; +use solana_pubkey::Pubkey; use spl_pod::bytemuck::pod_from_bytes; use spl_token_2022::pod::PodAccount; @@ -16,3 +26,66 @@ pub fn get_token_account_balance(token_account_info: &AccountInfo) -> Result Result { + let ctoken_program_id = Pubkey::from(C_TOKEN_PROGRAM_ID); + + if account_info.owner == &ctoken_program_id { + return Ok(true); + } + + let token_22 = spl_token_2022::ID; + let spl_token = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + + if account_info.owner == &token_22 || account_info.owner == &spl_token { + return Ok(false); + } + + // Must be one of the three. + Err(TokenSdkError::CannotDetermineAccountType) +} + +/// Same as SPL-token discriminator +pub const CLOSE_TOKEN_ACCOUNT_DISCRIMINATOR: u8 = 9; + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] +pub struct PackedCompressedTokenDataWithContext { + pub mint: u8, + pub source_or_recipient_token_account: u8, + pub multi_input_token_data_with_context: MultiInputTokenDataWithContext, +} + +pub fn account_meta_from_account_info(account_info: &AccountInfo) -> AccountMeta { + AccountMeta { + pubkey: *account_info.key, + is_signer: account_info.is_signer, + is_writable: account_info.is_writable, + } +} + +// /// Structure to hold token account data for batch compression +// #[cfg(feature = "anchor")] +// #[derive(Debug, Clone)] +// pub struct TokenAccountToCompress<'info> { +// pub token_account: InterfaceAccount<'info, token_interface::TokenAccount>, +// pub signer_seeds: Vec>, +// } + +#[derive(Debug, Clone)] +pub struct AccountInfoToCompress<'info> { + pub account_info: AccountInfo<'info>, + pub signer_seeds: Vec>, +} + +fn add_or_get_index(vec: &mut Vec, item: T) -> u8 { + if let Some(idx) = vec.iter().position(|x| x == &item) { + idx as u8 + } else { + vec.push(item); + (vec.len() - 1) as u8 + } +} diff --git a/sdk-libs/compressible-client/Cargo.toml b/sdk-libs/compressible-client/Cargo.toml new file mode 100644 index 0000000000..0b6b192373 --- /dev/null +++ b/sdk-libs/compressible-client/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "light-compressible-client" +version = "0.13.1" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/lightprotocol/light-protocol" +description = "Client instruction builders for Light Protocol compressible accounts" + +[features] +anchor = ["anchor-lang", "light-sdk/anchor"] + +[dependencies] +# Solana dependencies +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +solana-account = { workspace = true } + +# Light Protocol dependencies +light-client = { workspace = true, features = ["v2"] } +light-sdk = { workspace = true, features = ["v2"] } + +# Conditional dependencies +anchor-lang = { workspace = true, features = ["idl-build"], optional = true } +borsh = { workspace = true } + +# External dependencies +thiserror = { workspace = true } \ No newline at end of file diff --git a/sdk-libs/compressible-client/src/get_compressible_account.rs b/sdk-libs/compressible-client/src/get_compressible_account.rs new file mode 100644 index 0000000000..9c46f263bf --- /dev/null +++ b/sdk-libs/compressible-client/src/get_compressible_account.rs @@ -0,0 +1,192 @@ +use light_client::{ + indexer::{Indexer, TreeInfo}, + rpc::{Rpc, RpcError}, +}; +use light_sdk::address::v1::derive_address; +use solana_pubkey::Pubkey; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CompressibleAccountError { + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + + #[error("Indexer error: {0}")] + Indexer(#[from] light_client::indexer::IndexerError), + + #[error("Compressed account has no data")] + NoData, + + #[error("Deserialization error: {0}")] + Deserialization(String), + + #[cfg(feature = "anchor")] + #[error("Anchor deserialization error: {0}")] + AnchorDeserialization(#[from] anchor_lang::error::Error), + + #[error("Borsh deserialization error: {0}")] + BorshDeserialization(#[from] std::io::Error), +} + +/// Fetch account data from either compressed or on-chain storage. Returns unified. +/// +/// This function first checks if the account exists on-chain. If not found, +/// it derives the compressed address and fetches from compressed storage. +/// +/// # Arguments +/// +/// * `address` - The account address (PDA or regular address) +/// * `program_id` - The program that owns the account +/// * `address_tree_info` - The address tree information for compressed accounts +/// * `rpc` - An RPC client implementing both `Rpc` and `Indexer` traits +/// +/// # Returns +/// +/// Returns the account data as bytes, including the discriminator if present. +/// +/// # Example +/// +/// ```no_run +/// use light_compressible_client::account_fetcher::get_compressible_account_data; +/// use light_client::{ +/// indexer::TreeInfo, +/// rpc::{LightClient, LightClientConfig, Rpc}, +/// }; +/// use solana_pubkey::Pubkey; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let mut rpc = LightClient::new(LightClientConfig::local()).await?; +/// +/// let address = Pubkey::new_unique(); +/// let program_id = Pubkey::new_unique(); +/// let address_tree_info = rpc.get_address_tree_v1(); +/// +/// let account_data = get_compressible_account_data( +/// &address, +/// &program_id, +/// &address_tree_info, +/// &mut rpc, +/// ).await?; +/// +/// Ok(()) +/// } +/// ``` +pub async fn get_compressible_account_data( + address: &Pubkey, + program_id: &Pubkey, + address_tree_info: &TreeInfo, + rpc: &mut R, +) -> Result, CompressibleAccountError> +where + R: Rpc + Indexer, +{ + // First check if account exists on-chain + if let Ok(Some(onchain_account)) = rpc.get_account(*address).await { + return Ok(onchain_account.data); + } + + // If not on-chain, check compressed storage + // Derive the compressed address using the account address as seed + let (compressed_address, _) = + derive_address(&[&address.to_bytes()], &address_tree_info.tree, program_id); + + let compressed_account = rpc + .get_compressed_account(compressed_address, None) + .await? + .value; + + let account_data = compressed_account + .data + .as_ref() + .ok_or(CompressibleAccountError::NoData)?; + + // Combine discriminator and data + let mut data_slice = + Vec::with_capacity(account_data.discriminator.len() + account_data.data.len()); + data_slice.extend_from_slice(&account_data.discriminator); + data_slice.extend_from_slice(&account_data.data); + + Ok(data_slice) +} + +#[cfg(feature = "anchor")] +/// Fetch and deserialize a compressible account using Anchor. +/// +/// This function combines fetching from either compressed or on-chain storage +/// with Anchor deserialization. +/// +/// # Arguments +/// +/// * `address` - The account address (PDA or regular address) +/// * `program_id` - The program that owns the account +/// * `address_tree_info` - The address tree information for compressed accounts +/// * `rpc` - An RPC client implementing both `Rpc` and `Indexer` traits +/// +/// # Type Parameters +/// +/// * `T` - The account type implementing `AccountDeserialize` +/// +/// # Example +/// +/// ```no_run +/// use light_compressible_client::account_fetcher::get_compressible_account; +/// use light_client::{ +/// indexer::TreeInfo, +/// rpc::{LightClient, LightClientConfig, Rpc}, +/// }; +/// use solana_pubkey::Pubkey; +/// use anchor_lang::AccountDeserialize; +/// +/// #[derive(AccountDeserialize)] +/// struct MyAccount { +/// pub data: u64, +/// } +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let mut rpc = LightClient::new(LightClientConfig::local()).await?; +/// +/// let address = Pubkey::new_unique(); +/// let program_id = Pubkey::new_unique(); +/// let address_tree_info = rpc.get_address_tree_v1(); +/// +/// let account: MyAccount = get_compressible_account( +/// &address, +/// &program_id, +/// &address_tree_info, +/// &mut rpc, +/// ).await?; +/// +/// Ok(()) +/// } +/// ``` +pub async fn get_compressible_account( + address: &Pubkey, + program_id: &Pubkey, + address_tree_info: &TreeInfo, + rpc: &mut R, +) -> Result +where + T: anchor_lang::AccountDeserialize, + R: Rpc + Indexer, +{ + let data = get_compressible_account_data(address, program_id, address_tree_info, rpc).await?; + + T::try_deserialize(&mut data.as_slice()) + .map_err(CompressibleAccountError::AnchorDeserialization) +} + +#[cfg(feature = "anchor")] +/// Deserialize an on-chain account using Anchor. +/// +/// This is a utility function that deserializes an already fetched account. +pub fn deserialize_anchor_account( + account: &solana_account::Account, +) -> Result +where + T: anchor_lang::AccountDeserialize, +{ + T::try_deserialize(&mut &account.data[..]) + .map_err(CompressibleAccountError::AnchorDeserialization) +} \ No newline at end of file diff --git a/sdk-libs/compressible-client/src/lib.rs b/sdk-libs/compressible-client/src/lib.rs new file mode 100644 index 0000000000..41dd10eec4 --- /dev/null +++ b/sdk-libs/compressible-client/src/lib.rs @@ -0,0 +1,468 @@ +// pub mod account_fetcher; // Temporarily disabled +#[cfg(feature = "anchor")] +use anchor_lang::{AnchorDeserialize, AnchorSerialize}; +#[cfg(not(feature = "anchor"))] +use borsh::{BorshDeserialize as AnchorDeserialize, BorshSerialize as AnchorSerialize}; +use light_client::indexer::{CompressedAccount, TreeInfo, ValidityProofWithContext}; +pub use light_sdk::compressible::config::CompressibleConfig; +use light_sdk::{ + compressible::{compression_info::CompressedAccountData, Pack}, + constants::C_TOKEN_PROGRAM_ID, + instruction::{ + account_meta::CompressedAccountMetaNoLamportsNoAddress, PackedAccounts, + SystemAccountMetaConfig, ValidityProof, + }, +}; +use solana_account::Account; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +/// Generic instruction data for initialize config +/// Note: Real programs should use their specific instruction format +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct InitializeCompressionConfigData { + pub compression_delay: u32, + pub rent_recipient: Pubkey, + pub address_space: Vec, + pub config_bump: u8, +} + +/// Generic instruction data for update config +/// Note: Real programs should use their specific instruction format +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct UpdateCompressionConfigData { + pub new_compression_delay: Option, + pub new_rent_recipient: Option, + pub new_address_space: Option>, + pub new_update_authority: Option, +} + +/// Instruction data structure for decompress_accounts_idempotent +/// This matches the exact format expected by Anchor programs +/// T is the packed type (result of calling .pack() on the original type) +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct DecompressMultipleAccountsIdempotentData { + pub proof: ValidityProof, + pub compressed_accounts: Vec>, + pub system_accounts_offset: u8, +} + +/// Instruction data structure for compress_accounts_idempotent +/// This matches the exact format expected by Anchor programs +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct CompressAccountsIdempotentData { + pub proof: ValidityProof, + pub compressed_accounts: Vec, + pub signer_seeds: Vec>>, + pub system_accounts_offset: u8, +} + +/// Instruction builders for compressible accounts, following Solana SDK patterns +/// These are generic builders that work with any program implementing the compressible pattern +pub struct CompressibleInstruction; + +impl CompressibleInstruction { + pub const INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR: [u8; 8] = + [133, 228, 12, 169, 56, 76, 222, 61]; + pub const UPDATE_COMPRESSION_CONFIG_DISCRIMINATOR: [u8; 8] = + [135, 215, 243, 81, 163, 146, 33, 70]; + /// Hardcoded discriminator for the standardized decompress_accounts_idempotent instruction + /// This is calculated as SHA256("global:decompress_accounts_idempotent")[..8] (Anchor format) + pub const DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR: [u8; 8] = + [114, 67, 61, 123, 234, 31, 1, 112]; + /// Hardcoded discriminator for compress_token_account_ctoken_signer instruction + /// This is calculated as SHA256("global:compress_token_account_ctoken_signer")[..8] (Anchor format) + pub const COMPRESS_TOKEN_ACCOUNT_CTOKEN_SIGNER_DISCRIMINATOR: [u8; 8] = + [243, 154, 172, 243, 44, 214, 139, 73]; + /// Hardcoded discriminator for the standardized compress_accounts_idempotent instruction + /// This is calculated as SHA256("global:compress_accounts_idempotent")[..8] (Anchor format) + pub const COMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR: [u8; 8] = + [89, 130, 165, 88, 12, 207, 178, 185]; + + /// Creates an initialize_compression_config instruction + /// + /// Following Solana SDK patterns like system_instruction::transfer() + /// Returns Instruction directly - errors surface at execution time + /// + /// # Arguments + /// * `program_id` - The program ID + /// * `discriminator` - The instruction discriminator bytes (flexible length) + /// * `payer` - The payer account + /// * `authority` - The authority account + /// * `compression_delay` - The compression delay + /// * `rent_recipient` - The rent recipient + /// * `address_space` - The address space + /// * `config_bump` - The config bump + #[allow(clippy::too_many_arguments)] + pub fn initialize_compression_config( + program_id: &Pubkey, + discriminator: &[u8], + payer: &Pubkey, + authority: &Pubkey, + compression_delay: u32, + rent_recipient: Pubkey, + address_space: Vec, + config_bump: Option, + ) -> Instruction { + let config_bump = config_bump.unwrap_or(0); + let (config_pda, _) = CompressibleConfig::derive_pda(program_id, config_bump); + + // Get program data account for BPF Loader Upgradeable + let bpf_loader_upgradeable_id = + solana_pubkey::pubkey!("BPFLoaderUpgradeab1e11111111111111111111111"); + let (program_data_pda, _) = + Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable_id); + + let system_program_id = solana_pubkey::pubkey!("11111111111111111111111111111111"); + let accounts = vec![ + AccountMeta::new(*payer, true), // payer + AccountMeta::new(config_pda, false), // config + AccountMeta::new_readonly(program_data_pda, false), // program_data + AccountMeta::new_readonly(*authority, true), // authority + AccountMeta::new_readonly(system_program_id, false), // system_program + ]; + + let instruction_data = InitializeCompressionConfigData { + compression_delay, + rent_recipient, + address_space, + config_bump, + }; + + // Prepend discriminator to serialized data, following Solana SDK pattern + let serialized_data = instruction_data + .try_to_vec() + .expect("Failed to serialize instruction data"); + + let mut data = Vec::new(); + data.extend_from_slice(discriminator); + data.extend_from_slice(&serialized_data); + + Instruction { + program_id: *program_id, + accounts, + data, + } + } + + /// Creates an update config instruction + /// + /// Following Solana SDK patterns - returns Instruction directly + /// + /// # Arguments + /// * `program_id` - The program ID + /// * `discriminator` - The instruction discriminator bytes (flexible length) + /// * `authority` - The authority account + /// * `new_compression_delay` - Optional new compression delay + /// * `new_rent_recipient` - Optional new rent recipient + /// * `new_address_space` - Optional new address space + /// * `new_update_authority` - Optional new update authority + pub fn update_compression_config( + program_id: &Pubkey, + discriminator: &[u8], + authority: &Pubkey, + new_compression_delay: Option, + new_rent_recipient: Option, + new_address_space: Option>, + new_update_authority: Option, + ) -> Instruction { + let (config_pda, _) = CompressibleConfig::derive_pda(program_id, 0); + + let accounts = vec![ + AccountMeta::new(config_pda, false), // config + AccountMeta::new_readonly(*authority, true), // authority + ]; + + let instruction_data = UpdateCompressionConfigData { + new_compression_delay, + new_rent_recipient, + new_address_space, + new_update_authority, + }; + + // Prepend discriminator to serialized data, following Solana SDK pattern + let serialized_data = instruction_data + .try_to_vec() + .expect("Failed to serialize instruction data"); + let mut data = Vec::with_capacity(discriminator.len() + serialized_data.len()); + data.extend_from_slice(discriminator); + data.extend_from_slice(&serialized_data); + + Instruction { + program_id: *program_id, + accounts, + data, + } + } + + /// Build a `decompress_accounts_idempotent` instruction for any program's compressed account variant. + /// + /// # Arguments + /// * `program_id` - Target program + /// * `discriminator` - The instruction discriminator bytes (flexible length) + /// * `decompressed_account_addresses` - addresses of the accounts to decompress into + /// * `compressed_accounts` - Compressed accounts with their data (which implements Pack trait) + /// * `program_account_metas` - Additional accounts required for seed derivation (e.g., amm_config, token_mints) + /// * `validity_proof_with_context` - Validity proof with context + /// * `output_state_tree_info` - Output state tree info + /// + /// Returns `Ok(Instruction)` or error. + #[allow(clippy::too_many_arguments)] + pub fn decompress_accounts_idempotent( + program_id: &Pubkey, + discriminator: &[u8], + decompressed_account_addresses: &[Pubkey], + compressed_accounts: &[(CompressedAccount, T)], + program_account_metas: &[AccountMeta], + validity_proof_with_context: ValidityProofWithContext, + output_state_tree_info: TreeInfo, + ) -> Result> + where + T: Pack + Clone + std::fmt::Debug, + { + let mut remaining_accounts = PackedAccounts::default(); + + // check if pdas/tokens + let mut has_tokens = false; + let mut has_pdas = false; + for (compressed_account, _) in compressed_accounts.iter() { + if compressed_account.owner == C_TOKEN_PROGRAM_ID.into() { + has_tokens = true; + } else { + has_pdas = true; + } + if has_tokens && has_pdas { + break; + } + } + if !has_tokens && !has_pdas { + return Err("No tokens or PDAs found in compressed accounts".into()); + }; + if decompressed_account_addresses.len() != compressed_accounts.len() { + return Err("PDA accounts and compressed accounts must have the same length".into()); + } + + // pack cpi_context_account if required. + if has_pdas && has_tokens { + let cpi_context_of_first_input = + compressed_accounts[0].0.tree_info.cpi_context.unwrap(); + let system_config = SystemAccountMetaConfig::new_with_cpi_context( + *program_id, + cpi_context_of_first_input, + ); + remaining_accounts.add_system_accounts_v2(system_config)?; + } else { + let system_config = SystemAccountMetaConfig::new(*program_id); + remaining_accounts.add_system_accounts_v2(system_config)?; + } + + // pack output queue + let output_state_tree_index = + remaining_accounts.insert_or_get(output_state_tree_info.queue); + + // pack all tree infos + let packed_tree_infos = + validity_proof_with_context.pack_tree_infos(&mut remaining_accounts); + + // Add remaining program accounts + // accounts.extend(remaining_program_accounts); + let mut accounts = program_account_metas.to_vec(); + + // Pack all account data using the Pack trait. This converts types with + // Pubkeys to their packed versions with u8 indices. PDAs must implement + // pack trait. Tokens have a standard implementation. + let typed_compressed_accounts: Vec> = compressed_accounts + .iter() + .map(|(compressed_account, data)| { + let queue_index = + remaining_accounts.insert_or_get(compressed_account.tree_info.queue); + // Create compressed_account_meta + let compressed_meta = CompressedAccountMetaNoLamportsNoAddress { + tree_info: packed_tree_infos + .state_trees + .as_ref() + .unwrap() + .packed_tree_infos + .iter() + .find(|pti| { + pti.queue_pubkey_index == queue_index + && pti.leaf_index == compressed_account.leaf_index + }) + .copied() + .ok_or( + "Matching PackedStateTreeInfo (queue_pubkey_index + leaf_index) not found", + )?, + output_state_tree_index, + }; + // Pack data. Is standardized for TokenData and user-implemented for other types. + let packed_data = data.pack(&mut remaining_accounts); + Ok(CompressedAccountData { + meta: compressed_meta, + data: packed_data, + }) + }) + .collect::, Box>>()?; + + // add all packed systemaccounts to anchor metas. + let (system_accounts, system_accounts_offset, _) = remaining_accounts.to_account_metas(); + accounts.extend(system_accounts); + + // decompressed account addresses must be the last metas. + for account in decompressed_account_addresses { + accounts.push(AccountMeta::new(*account, false)); + } + + let instruction_data = DecompressMultipleAccountsIdempotentData { + proof: validity_proof_with_context.proof, + compressed_accounts: typed_compressed_accounts, + system_accounts_offset: system_accounts_offset as u8, + }; + + // Serialize instruction data with discriminator + let serialized_data = instruction_data.try_to_vec()?; + let mut data = Vec::new(); + data.extend_from_slice(discriminator); + data.extend_from_slice(&serialized_data); + + Ok(Instruction { + program_id: *program_id, + accounts, + data, + }) + } + + /// Build a `compress_accounts_idempotent` instruction for compressing multiple accounts (PDAs and token accounts). + /// + /// # Arguments + /// * `program_id` - Target program + /// * `discriminator` - The instruction discriminator bytes (flexible length) + /// * `account_pubkeys` - Accounts to compress (PDAs and token accounts) + /// * `accounts_to_compress` - Account data to compress + /// * `program_account_metas` - Program-specific accounts (assembled from Anchor accounts struct) + /// * `signer_seeds` - Signer seeds for each account (empty vec if no seeds needed) + /// * `validity_proof_with_context` - Validity proof with context + /// * `output_state_tree_info` - Output state tree info + /// + /// Returns `Ok(Instruction)` or error. + #[allow(clippy::too_many_arguments)] + pub fn compress_accounts_idempotent( + program_id: &Pubkey, + discriminator: &[u8], + account_pubkeys: &[Pubkey], + accounts_to_compress: &[Account], + program_account_metas: &[AccountMeta], + signer_seeds: Vec>>, + validity_proof_with_context: ValidityProofWithContext, + output_state_tree_info: TreeInfo, + ) -> Result> { + if account_pubkeys.len() != accounts_to_compress.len() { + return Err("Accounts pubkeys length must match accounts length".into()); + } + // Sanity checks. + if !signer_seeds.is_empty() && signer_seeds.len() != accounts_to_compress.len() { + return Err("Signer seeds length must match accounts length or be empty".into()); + } + + // Sanity check for better error messages. + for (i, account) in account_pubkeys.iter().enumerate() { + if !signer_seeds.is_empty() { + let seeds = &signer_seeds[i]; + if !seeds.is_empty() { + let derived = Pubkey::create_program_address( + &seeds.iter().map(|v| v.as_slice()).collect::>(), + program_id, + ); + if accounts_to_compress[i].owner != C_TOKEN_PROGRAM_ID.into() { + match derived { + Ok(derived_pubkey) => { + if derived_pubkey != *account { + return Err(format!( + "Derived PDA does not match account_to_compress at index {}: expected {}, got {:?}", + i, + account, + derived_pubkey + ).into()); + } + } + Err(e) => { + return Err(format!( + "Failed to derive PDA for account_to_compress at index {}: {}", + i, e + ) + .into()); + } + } + } + } + } + } + + let mut remaining_accounts = PackedAccounts::default(); + + let system_config = SystemAccountMetaConfig::new(*program_id); + remaining_accounts.add_system_accounts_v2(system_config)?; + + let output_state_tree_index = + remaining_accounts.insert_or_get(output_state_tree_info.queue); + + let packed_tree_infos = + validity_proof_with_context.pack_tree_infos(&mut remaining_accounts); + + let mut compressed_account_metas_no_lamports_no_address = Vec::new(); + + for packed_tree_info in packed_tree_infos + .state_trees + .as_ref() + .unwrap() + .packed_tree_infos + .iter() + { + compressed_account_metas_no_lamports_no_address.push( + CompressedAccountMetaNoLamportsNoAddress { + tree_info: *packed_tree_info, + output_state_tree_index, + }, + ); + } + + // Use program-provided account metas (from Anchor accounts struct) + let mut accounts = program_account_metas.to_vec(); + + for account in accounts_to_compress.iter() { + if account.owner == C_TOKEN_PROGRAM_ID.into() { + let mint = Pubkey::new_from_array(account.data[0..32].try_into().unwrap()); + remaining_accounts.insert_or_get_read_only(mint); + } + } + + let (system_accounts, system_accounts_offset, _) = remaining_accounts.to_account_metas(); + accounts.extend(system_accounts); + + // Accounts to compress must be at the end. + for account in account_pubkeys { + accounts.push(AccountMeta::new(*account, false)); + } + + let instruction_data = CompressAccountsIdempotentData { + proof: validity_proof_with_context.proof, + compressed_accounts: compressed_account_metas_no_lamports_no_address, + signer_seeds, + system_accounts_offset: system_accounts_offset as u8, + }; + + let serialized_data = instruction_data.try_to_vec()?; + let mut data = Vec::new(); + data.extend_from_slice(discriminator); + data.extend_from_slice(&serialized_data); + + Ok(Instruction { + program_id: *program_id, + accounts, + data, + }) + } +} + +/// Generic instruction data for decompress multiple PDAs +// Re-export for easy access following Solana SDK patterns +pub use CompressibleInstruction as compressible_instruction; diff --git a/sdk-libs/compressible-client/tests/pack_test.rs b/sdk-libs/compressible-client/tests/pack_test.rs new file mode 100644 index 0000000000..dd7a2ec377 --- /dev/null +++ b/sdk-libs/compressible-client/tests/pack_test.rs @@ -0,0 +1,119 @@ +#[cfg(test)] +mod tests { + use light_sdk::{ + compressible::Pack, token::PackedCTokenDataWithVariant, token::TokenDataWithVariant, + }; + use light_sdk::{instruction::PackedAccounts, token::TokenData}; + use solana_pubkey::Pubkey; + + #[test] + fn test_token_data_packing() { + let mut remaining_accounts = PackedAccounts::default(); + + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + + let token_data = TokenData { + owner, + mint, + amount: 1000, + delegate: Some(delegate), + state: Default::default(), + tlv: None, + }; + + // Pack the token data + let packed = token_data.pack(&mut remaining_accounts); + + // Verify the packed data + assert_eq!(packed.owner, 0); // First pubkey gets index 0 + assert_eq!(packed.mint, 1); // Second pubkey gets index 1 + assert_eq!(packed.delegate, 2); // Third pubkey gets index 2 + assert_eq!(packed.amount, 1000); + assert!(packed.has_delegate); + assert_eq!(packed.version, 2); + + // Verify remaining_accounts contains the pubkeys + let pubkeys = remaining_accounts.packed_pubkeys(); + assert_eq!(pubkeys[0], owner); + assert_eq!(pubkeys[1], mint); + assert_eq!(pubkeys[2], delegate); + } + + #[test] + fn test_token_data_with_variant_packing() { + use anchor_lang::{AnchorDeserialize, AnchorSerialize}; + + #[derive(Debug, Clone, Copy, AnchorSerialize, AnchorDeserialize)] + enum MyVariant { + TypeA = 0, + TypeB = 1, + } + + let mut remaining_accounts = PackedAccounts::default(); + + let token_with_variant = TokenDataWithVariant { + variant: MyVariant::TypeA, + token_data: TokenData { + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + amount: 500, + delegate: None, + state: Default::default(), + tlv: None, + }, + }; + + // Pack the wrapper + let packed: PackedCTokenDataWithVariant = + token_with_variant.pack(&mut remaining_accounts); + + // Verify variant is unchanged + assert!(matches!(packed.variant, MyVariant::TypeA)); + + // Verify token data is packed + assert_eq!(packed.token_data.owner, 0); + assert_eq!(packed.token_data.mint, 1); + assert_eq!(packed.token_data.amount, 500); + assert!(!packed.token_data.has_delegate); + } + + #[test] + fn test_deduplication_in_packing() { + let mut remaining_accounts = PackedAccounts::default(); + + let shared_owner = Pubkey::new_unique(); + let shared_mint = Pubkey::new_unique(); + + let token1 = TokenData { + owner: shared_owner, + mint: shared_mint, + amount: 100, + delegate: None, + state: Default::default(), + tlv: None, + }; + + let token2 = TokenData { + owner: shared_owner, // Same owner + mint: shared_mint, // Same mint + amount: 200, + delegate: None, + state: Default::default(), + tlv: None, + }; + + // Pack both tokens + let packed1 = token1.pack(&mut remaining_accounts); + let packed2 = token2.pack(&mut remaining_accounts); + + // Both should reference the same indices + assert_eq!(packed1.owner, packed2.owner); + assert_eq!(packed1.mint, packed2.mint); + + // Only 2 unique pubkeys should be stored + let pubkeys = remaining_accounts.packed_pubkeys(); + assert_eq!(pubkeys.len(), 2); + } +} diff --git a/sdk-libs/macros/src/compress_as.rs b/sdk-libs/macros/src/compress_as.rs new file mode 100644 index 0000000000..2e0e82e5f9 --- /dev/null +++ b/sdk-libs/macros/src/compress_as.rs @@ -0,0 +1,206 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + Expr, Ident, ItemStruct, Result, Token, +}; + +/// Parse the compress_as attribute content +struct CompressAsFields { + fields: Punctuated, +} + +struct CompressAsField { + name: Ident, + value: Expr, +} + +impl Parse for CompressAsField { + fn parse(input: ParseStream) -> Result { + let name: Ident = input.parse()?; + input.parse::()?; + let value: Expr = input.parse()?; + Ok(CompressAsField { name, value }) + } +} + +impl Parse for CompressAsFields { + fn parse(input: ParseStream) -> Result { + Ok(CompressAsFields { + fields: Punctuated::parse_terminated(input)?, + }) + } +} + +/// Generates CompressAs trait implementation for a struct with optional compress_as attribute +pub fn derive_compress_as(input: ItemStruct) -> Result { + let struct_name = &input.ident; + + // Find the compress_as attribute (optional) + let compress_as_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("compress_as")); + + // Parse the attribute content if it exists + let compress_as_fields = if let Some(attr) = compress_as_attr { + Some(attr.parse_args::()?) + } else { + None + }; + + // Get all struct fields + let struct_fields = match &input.fields { + syn::Fields::Named(fields) => &fields.named, + _ => { + return Err(syn::Error::new_spanned( + &input, + "CompressAs derive only supports structs with named fields", + )); + } + }; + + // Create field assignments for the compress_as method + let field_assignments = struct_fields.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap(); + + // ALWAYS set compression_info to None - this is required for compressed storage + if field_name == "compression_info" { + return quote! { #field_name: None }; + } + + // Check if this field is overridden in the compress_as attribute + let override_field = compress_as_fields + .as_ref() + .and_then(|fields| fields.fields.iter().find(|f| f.name == *field_name)); + + if let Some(override_field) = override_field { + let override_value = &override_field.value; + quote! { #field_name: #override_value } + } else { + // Keep the original value - determine how to clone/copy based on field type + let field_type = &field.ty; + if is_copy_type(field_type) { + quote! { #field_name: self.#field_name } + } else { + quote! { #field_name: self.#field_name.clone() } + } + } + }); + + // Determine if we need custom compression (any fields specified in compress_as attribute) + let has_custom_fields = compress_as_fields.is_some(); + + let compress_as_impl = if has_custom_fields { + // Custom compression - return Cow::Owned with modified fields + quote! { + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + #(#field_assignments,)* + }) + } + } + } else { + // Simple case - return Cow::Owned with compression_info = None + // We can't return Cow::Borrowed because compression_info must be None + quote! { + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + #(#field_assignments,)* + }) + } + } + }; + + // Generate HasCompressionInfo implementation (automatically included with Compressible) + let has_compression_info_impl = quote! { + impl light_sdk::compressible::HasCompressionInfo for #struct_name { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + self.compression_info + .as_ref() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + self.compression_info + .as_mut() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } + } + }; + + let expanded = quote! { + impl light_sdk::compressible::CompressAs for #struct_name { + type Output = Self; + + #compress_as_impl + } + + impl light_sdk::Size for #struct_name { + fn size(&self) -> usize { + Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE + } + } + + // Automatically derive HasCompressionInfo when using Compressible + #has_compression_info_impl + }; + + Ok(expanded) +} + +/// Determines if a type is likely to be Copy (simple heuristic) +fn is_copy_type(ty: &syn::Type) -> bool { + match ty { + syn::Type::Path(type_path) => { + if let Some(segment) = type_path.path.segments.last() { + let type_name = segment.ident.to_string(); + matches!( + type_name.as_str(), + "u8" | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "bool" + | "char" + | "Pubkey" + ) || (type_name == "Option" && has_copy_inner_type(&segment.arguments)) + } else { + false + } + } + _ => false, + } +} + +/// Check if Option where T is Copy +fn has_copy_inner_type(args: &syn::PathArguments) -> bool { + match args { + syn::PathArguments::AngleBracketed(args) => args.args.iter().any(|arg| { + if let syn::GenericArgument::Type(ty) = arg { + is_copy_type(ty) + } else { + false + } + }), + _ => false, + } +} diff --git a/sdk-libs/macros/src/compressible.rs b/sdk-libs/macros/src/compressible.rs new file mode 100644 index 0000000000..37cf02cbb0 --- /dev/null +++ b/sdk-libs/macros/src/compressible.rs @@ -0,0 +1,85 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Result; + +// TODO: remove or add. +// /// Parse a comma-separated list of identifiers +// #[derive(Clone)] +// enum CompressibleType { +// Regular(Ident), +// } + +// struct CompressibleTypeList { +// types: Punctuated, +// } + +// impl Parse for CompressibleType { +// fn parse(input: ParseStream) -> Result { +// let ident: Ident = input.parse()?; +// Ok(CompressibleType::Regular(ident)) +// } +// } + +// impl Parse for CompressibleTypeList { +// fn parse(input: ParseStream) -> Result { +// Ok(CompressibleTypeList { +// types: Punctuated::parse_terminated(input)?, +// }) +// } +// } +/// Generates HasCompressionInfo trait implementation for a struct with compression_info field +pub fn derive_has_compression_info(input: syn::ItemStruct) -> Result { + let struct_name = input.ident.clone(); + + // Find the compression_info field + let compression_info_field = match &input.fields { + syn::Fields::Named(fields) => fields.named.iter().find(|field| { + field + .ident + .as_ref() + .map(|ident| ident == "compression_info") + .unwrap_or(false) + }), + _ => { + return Err(syn::Error::new_spanned( + &struct_name, + "HasCompressionInfo can only be derived for structs with named fields", + )) + } + }; + + let _compression_info_field = compression_info_field.ok_or_else(|| { + syn::Error::new_spanned( + &struct_name, + "HasCompressionInfo requires a field named 'compression_info' of type Option" + ) + })?; + + // Validate that the field is Option. For now, we'll assume + // it's correct and let the compiler catch type errors + let has_compression_info_impl = quote! { + impl light_sdk::compressible::HasCompressionInfo for #struct_name { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + self.compression_info + .as_ref() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + self.compression_info + .as_mut() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } + } + }; + + Ok(has_compression_info_impl) +} diff --git a/sdk-libs/macros/src/compressible_derive.rs b/sdk-libs/macros/src/compressible_derive.rs new file mode 100644 index 0000000000..dfe9add731 --- /dev/null +++ b/sdk-libs/macros/src/compressible_derive.rs @@ -0,0 +1,354 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + punctuated::Punctuated, + Data, DeriveInput, Expr, Fields, Ident, Result, Token, +}; + +/// Parse the compress_as attribute content +struct CompressAsFields { + fields: Punctuated, +} + +struct CompressAsField { + name: Ident, + value: Expr, +} + +impl Parse for CompressAsField { + fn parse(input: ParseStream) -> Result { + let name: Ident = input.parse()?; + input.parse::()?; + let value: Expr = input.parse()?; + Ok(CompressAsField { name, value }) + } +} + +impl Parse for CompressAsFields { + fn parse(input: ParseStream) -> Result { + Ok(CompressAsFields { + fields: Punctuated::parse_terminated(input)?, + }) + } +} + +/// Generates HasCompressionInfo, Size, and CompressAs trait implementations for compressible account types +/// +/// Supports optional compress_as attribute for custom compression behavior: +/// #[derive(Compressible)] +/// #[compress_as(start_time = 0, end_time = None)] +/// pub struct GameSession { ... } +/// +/// Usage: #[derive(Compressible)] +pub fn derive_compressible(input: DeriveInput) -> Result { + let struct_name = &input.ident; + + // Validate struct has compression_info field + let fields = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => &fields.named, + _ => { + return Err(syn::Error::new_spanned( + &input, + "Compressible only supports structs with named fields", + )); + } + }, + _ => { + return Err(syn::Error::new_spanned( + &input, + "Compressible only supports structs", + )); + } + }; + + // Find the compression_info field + let compression_info_field = fields.iter().find(|field| { + field + .ident + .as_ref() + .map(|ident| ident == "compression_info") + .unwrap_or(false) + }); + + if compression_info_field.is_none() { + return Err(syn::Error::new_spanned( + &struct_name, + "Compressible requires a field named 'compression_info' of type Option" + )); + } + + // Parse the compress_as attribute (optional) + let compress_as_attr = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("compress_as")); + + let compress_as_fields = if let Some(attr) = compress_as_attr { + Some(attr.parse_args::()?) + } else { + None + }; + + // Generate HasCompressionInfo implementation + let has_compression_info_impl = quote! { + impl light_sdk::compressible::HasCompressionInfo for #struct_name { + fn compression_info(&self) -> &light_sdk::compressible::CompressionInfo { + self.compression_info + .as_ref() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut(&mut self) -> &mut light_sdk::compressible::CompressionInfo { + self.compression_info + .as_mut() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } + } + }; + + // Generate Size implementation + let size_impl = quote! { + impl light_sdk::account::Size for #struct_name { + fn size(&self) -> usize { + Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE + } + } + }; + + // Generate CompressAs implementation + let field_assignments = fields.iter().map(|field| { + let field_name = field.ident.as_ref().unwrap(); + + // ALWAYS set compression_info to None - this is required for compressed storage + if field_name == "compression_info" { + return quote! { #field_name: None }; + } + + // Check if this field is overridden in the compress_as attribute + let override_field = compress_as_fields + .as_ref() + .and_then(|fields| fields.fields.iter().find(|f| f.name == *field_name)); + + if let Some(override_field) = override_field { + let override_value = &override_field.value; + quote! { #field_name: #override_value } + } else { + // Keep the original value - determine how to clone/copy based on field type + let field_type = &field.ty; + if is_copy_type(field_type) { + quote! { #field_name: self.#field_name } + } else { + quote! { #field_name: self.#field_name.clone() } + } + } + }); + + let compress_as_impl = quote! { + impl light_sdk::compressible::CompressAs for #struct_name { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + #(#field_assignments,)* + }) + } + } + }; + + // Compute a conservative compile-time compressed INIT_SPACE that accounts for fields overridden to None + // Specifically, for fields of type Option that are set to None via #[compress_as(field = None)] + // (and for compression_info which is always set to None), we subtract the inner T's INIT_SPACE. + // For inner types, we try to use known primitive sizes, arrays, or ::INIT_SPACE when available. + fn inner_type_size_tokens(ty: &syn::Type) -> proc_macro2::TokenStream { + use quote::quote; + match ty { + syn::Type::Path(type_path) => { + if let Some(seg) = type_path.path.segments.last() { + let ident_str = seg.ident.to_string(); + // Known primitives and common types + let primitive = match ident_str.as_str() { + "u8" => Some(quote! { 1 }), + "i8" => Some(quote! { 1 }), + "bool" => Some(quote! { 1 }), + "u16" => Some(quote! { 2 }), + "i16" => Some(quote! { 2 }), + "u32" => Some(quote! { 4 }), + "i32" => Some(quote! { 4 }), + "u64" => Some(quote! { 8 }), + "i64" => Some(quote! { 8 }), + "u128" => Some(quote! { 16 }), + "i128" => Some(quote! { 16 }), + "Pubkey" => Some(quote! { 32 }), + _ => None, + }; + if let Some(sz) = primitive { + return sz; + } + // Fall back to type-level INIT_SPACE if present + let ty_ts = quote! { #type_path }; + return quote! { <#ty_ts>::INIT_SPACE }; + } + quote! { 0 } + } + syn::Type::Array(arr) => { + let elem = &arr.elem; + let len = &arr.len; + let elem_sz = inner_type_size_tokens(elem); + quote! { (#len as usize) * (#elem_sz) } + } + _ => { + // Unknown/unsupported types: assume 0 saving to avoid compile errors + quote! { 0 } + } + } + } + + // Build tokens for total savings from fields explicitly set to None + let mut savings_tokens: Vec = Vec::new(); + for field in fields.iter() { + let field_name = field.ident.as_ref().unwrap(); + + // Determine whether this field is overridden to None via #[compress_as] or is compression_info + let mut overridden_to_none = field_name == "compression_info"; + if !overridden_to_none { + if let Some(attrs) = &compress_as_fields { + if let Some(over_attr) = attrs.fields.iter().find(|f| f.name == *field_name) { + if let syn::Expr::Path(ref p) = over_attr.value { + if let Some(last) = p.path.segments.last() { + if last.ident == "None" { + overridden_to_none = true; + } + } + } + } + } + } + + if overridden_to_none { + // Check that the field type is Option and subtract Inner's INIT_SPACE + if let syn::Type::Path(type_path) = &field.ty { + if let Some(seg) = type_path.path.segments.last() { + if seg.ident == "Option" { + if let syn::PathArguments::AngleBracketed(args) = &seg.arguments { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + let inner_sz = inner_type_size_tokens(inner_ty); + savings_tokens.push(quote! { #inner_sz }); + } + } + } + } + } + } + } + + let compressed_init_space_impl = { + if savings_tokens.is_empty() { + quote! { + impl light_sdk::compressible::compression_info::CompressedInitSpace for #struct_name { const COMPRESSED_INIT_SPACE: usize = Self::INIT_SPACE; } + } + } else { + quote! { + impl light_sdk::compressible::compression_info::CompressedInitSpace for #struct_name { const COMPRESSED_INIT_SPACE: usize = Self::INIT_SPACE - (0 #( + #savings_tokens )*); } + } + } + }; + + let expanded = quote! { + #has_compression_info_impl + #size_impl + #compress_as_impl + #compressed_init_space_impl + }; + + Ok(expanded) +} + +/// Determines if a type is likely to be Copy (simple heuristic) +fn is_copy_type(ty: &syn::Type) -> bool { + match ty { + syn::Type::Path(type_path) => { + if let Some(segment) = type_path.path.segments.last() { + let type_name = segment.ident.to_string(); + matches!( + type_name.as_str(), + "u8" | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "bool" + | "char" + | "Pubkey" + ) || (type_name == "Option" && has_copy_inner_type(&segment.arguments)) + } else { + false + } + } + _ => false, + } +} + +/// Check if Option where T is Copy +fn has_copy_inner_type(args: &syn::PathArguments) -> bool { + match args { + syn::PathArguments::AngleBracketed(args) => args.args.iter().any(|arg| { + if let syn::GenericArgument::Type(ty) = arg { + is_copy_type(ty) + } else { + false + } + }), + _ => false, + } +} + +#[allow(dead_code)] +fn generate_identity_pack_unpack(struct_name: &syn::Ident) -> Result { + let pack_impl = quote! { + impl light_sdk::compressible::Pack for #struct_name { + type Packed = Self; + + fn pack(&self, _remaining_accounts: &mut light_sdk::instruction::PackedAccounts) -> Self::Packed { + self.clone() + } + } + }; + + let unpack_impl = quote! { + impl light_sdk::compressible::Unpack for #struct_name { + type Unpacked = Self; + + fn unpack( + &self, + _remaining_accounts: &[solana_account_info::AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } + } + }; + + let expanded = quote! { + #pack_impl + #unpack_impl + }; + + Ok(expanded) +} diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 45bdf382d1..6c04b1c985 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -7,6 +7,9 @@ use traits::process_light_traits; mod account; mod accounts; +mod compress_as; +mod compressible; +mod compressible_derive; mod discriminator; mod hasher; mod program; @@ -280,3 +283,116 @@ pub fn light_program(_: TokenStream, input: TokenStream) -> TokenStream { .unwrap_or_else(|err| err.to_compile_error()) .into() } + +/// Automatically implements the HasCompressionInfo trait for structs that have a +/// `compression_info: Option` field. +/// +/// This derive macro generates the required trait methods for managing compression +/// information in compressible account structs. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::compressible::{CompressionInfo, HasCompressionInfo}; +/// +/// #[derive(HasCompressionInfo)] +/// pub struct UserRecord { +/// pub compression_info: Option, +/// pub owner: Pubkey, +/// pub name: String, +/// pub score: u64, +/// } +/// ``` +/// +/// ## Requirements +/// +/// The struct must have exactly one field named `compression_info` of type +/// `Option`. +#[proc_macro_derive(HasCompressionInfo)] +pub fn has_compression_info(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemStruct); + compressible::derive_has_compression_info(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Implements CompressAs trait for custom compression behavior. +/// +/// This derive macro allows you to specify which fields should be reset/overridden +/// during compression while keeping other fields as-is. Only the specified fields +/// are modified; all others retain their current values. +/// +/// ## Example +/// +/// ```ignore +/// use light_sdk::compressible::{CompressAs, CompressionInfo}; +/// +/// #[derive(CompressAs)] +/// #[compress_as( +/// start_time = 0, +/// end_time = None, +/// score = 0 +/// )] +/// pub struct GameSession { +/// pub compression_info: Option, +/// pub session_id: u64, +/// pub player: Pubkey, +/// pub game_type: String, +/// pub start_time: u64, +/// pub end_time: Option, +/// pub score: u64, +/// } +/// ``` +/// +/// ## Note +/// +/// Use the `Compressible` derive for complete functionality - it includes this plus more. +#[proc_macro_derive(CompressAs, attributes(compress_as))] +pub fn compress_as_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as ItemStruct); + compress_as::derive_compress_as(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Automatically implements all required traits for compressible accounts. +/// +/// This derive macro generates HasCompressionInfo, Size, and CompressAs trait implementations. +/// It supports optional compress_as attribute for custom compression behavior. +/// +/// ## Example - Basic Usage +/// +/// ```ignore +/// use light_sdk::compressible::CompressionInfo; +/// +/// #[derive(Compressible)] +/// pub struct UserRecord { +/// pub compression_info: Option, +/// pub owner: Pubkey, +/// pub name: String, +/// pub score: u64, +/// } +/// ``` +/// +/// ## Example - Custom Compression +/// +/// ```ignore +/// #[derive(Compressible)] +/// #[compress_as(start_time = 0, end_time = None, score = 0)] +/// pub struct GameSession { +/// pub compression_info: Option, +/// pub session_id: u64, // KEPT +/// pub player: Pubkey, // KEPT +/// pub game_type: String, // KEPT +/// pub start_time: u64, // RESET to 0 +/// pub end_time: Option, // RESET to None +/// pub score: u64, // RESET to 0 +/// } +/// ``` +#[proc_macro_derive(Compressible, attributes(compress_as))] +pub fn compressible_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + compressible_derive::derive_compressible(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} diff --git a/sdk-libs/program-test/Cargo.toml b/sdk-libs/program-test/Cargo.toml index 52ff204f02..2c4c68dcbb 100644 --- a/sdk-libs/program-test/Cargo.toml +++ b/sdk-libs/program-test/Cargo.toml @@ -30,6 +30,8 @@ light-prover-client = { workspace = true } light-zero-copy = { workspace = true } litesvm = { workspace = true } spl-token-2022 = { workspace = true } +light-compressible-client = { workspace = true, features = ["anchor"] } + light-registry = { workspace = true, features = ["cpi"], optional = true } light-compressed-token = { workspace = true, features = ["cpi"], optional = true } diff --git a/sdk-libs/program-test/src/program_test/compressible_setup.rs b/sdk-libs/program-test/src/program_test/compressible_setup.rs new file mode 100644 index 0000000000..37b1493dab --- /dev/null +++ b/sdk-libs/program-test/src/program_test/compressible_setup.rs @@ -0,0 +1,159 @@ +//! Test helpers for compressible account operations +//! +//! This module provides common functionality for testing compressible accounts, +//! including mock program data setup and configuration management. + +use light_client::rpc::{Rpc, RpcError}; +use light_compressible_client::CompressibleInstruction; +use solana_sdk::{ + bpf_loader_upgradeable, + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use crate::program_test::TestRpc; + +/// Create mock program data account for testing +/// +/// This creates a minimal program data account structure that mimics +/// what the BPF loader would create for deployed programs. +pub fn create_mock_program_data(authority: Pubkey) -> Vec { + let mut data = vec![0u8; 1024]; + data[0..4].copy_from_slice(&3u32.to_le_bytes()); // Program data discriminator + data[4..12].copy_from_slice(&0u64.to_le_bytes()); // Slot + data[12] = 1; // Option Some(authority) + data[13..45].copy_from_slice(authority.as_ref()); // Authority pubkey + data +} + +/// Setup mock program data account for testing +/// +/// For testing without ledger, LiteSVM does not create program data accounts, +/// so we need to create them manually. This is required for programs that +/// check their upgrade authority. +/// +/// # Arguments +/// * `rpc` - The test RPC client +/// * `payer` - The payer keypair (used as authority) +/// * `program_id` - The program ID to create data account for +/// +/// # Returns +/// The pubkey of the created program data account +pub fn setup_mock_program_data( + rpc: &mut T, + payer: &Keypair, + program_id: &Pubkey, +) -> Pubkey { + let (program_data_pda, _) = + Pubkey::find_program_address(&[program_id.as_ref()], &bpf_loader_upgradeable::ID); + let mock_data = create_mock_program_data(payer.pubkey()); + let mock_account = solana_sdk::account::Account { + lamports: 1_000_000, + data: mock_data, + owner: bpf_loader_upgradeable::ID, + executable: false, + rent_epoch: 0, + }; + rpc.set_account(program_data_pda, mock_account); + program_data_pda +} + +/// Initialize compression config for a program +/// +/// This is a high-level helper that handles the complete flow of initializing +/// a compression configuration for a program, including proper signer management. +/// +/// # Arguments +/// * `rpc` - The test RPC client +/// * `payer` - The transaction fee payer +/// * `program_id` - The program to initialize config for +/// * `authority` - The config authority (can be same as payer) +/// * `compression_delay` - Number of slots to wait before compression +/// * `rent_recipient` - Where to send rent from compressed accounts +/// * `address_space` - List of address trees for this program +/// +/// # Returns +/// Transaction signature on success +#[allow(clippy::too_many_arguments)] +pub async fn initialize_compression_config( + rpc: &mut T, + payer: &Keypair, + program_id: &Pubkey, + authority: &Keypair, + compression_delay: u32, + rent_recipient: Pubkey, + address_space: Vec, + discriminator: &[u8], + config_bump: Option, +) -> Result { + if address_space.is_empty() { + return Err(RpcError::CustomError( + "At least one address space must be provided".to_string(), + )); + } + + // Use the mid-level instruction builder + let instruction = CompressibleInstruction::initialize_compression_config( + program_id, + discriminator, + &payer.pubkey(), + &authority.pubkey(), + compression_delay, + rent_recipient, + address_space, + config_bump, + ); + + let signers = if payer.pubkey() == authority.pubkey() { + vec![payer] + } else { + vec![payer, authority] + }; + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &signers) + .await +} + +/// Update compression config for a program +/// +/// This is a high-level helper for updating an existing compression configuration. +/// All parameters except the required ones are optional - pass None to keep existing values. +/// +/// # Arguments +/// * `rpc` - The test RPC client +/// * `payer` - The transaction fee payer +/// * `program_id` - The program to update config for +/// * `authority` - The current config authority +/// * `new_compression_delay` - New compression delay (optional) +/// * `new_rent_recipient` - New rent recipient (optional) +/// * `new_address_space` - New address space list (optional) +/// * `new_update_authority` - New authority (optional) +/// +/// # Returns +/// Transaction signature on success +#[allow(clippy::too_many_arguments)] +pub async fn update_compression_config( + rpc: &mut T, + payer: &Keypair, + program_id: &Pubkey, + authority: &Keypair, + new_compression_delay: Option, + new_rent_recipient: Option, + new_address_space: Option>, + new_update_authority: Option, + discriminator: &[u8], +) -> Result { + // Use the mid-level instruction builder + let instruction = CompressibleInstruction::update_compression_config( + program_id, + discriminator, + &authority.pubkey(), + new_compression_delay, + new_rent_recipient, + new_address_space, + new_update_authority, + ); + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, authority]) + .await +} diff --git a/sdk-libs/program-test/src/program_test/mod.rs b/sdk-libs/program-test/src/program_test/mod.rs index c9eee711e3..a6b565f6ae 100644 --- a/sdk-libs/program-test/src/program_test/mod.rs +++ b/sdk-libs/program-test/src/program_test/mod.rs @@ -8,3 +8,6 @@ pub mod test_rpc; pub use light_program_test::LightProgramTest; pub mod indexer; pub use test_rpc::TestRpc; + +pub mod compressible_setup; +pub use compressible_setup::*; diff --git a/sdk-libs/program-test/src/utils/mod.rs b/sdk-libs/program-test/src/utils/mod.rs index ccd3e02457..2309bd1055 100644 --- a/sdk-libs/program-test/src/utils/mod.rs +++ b/sdk-libs/program-test/src/utils/mod.rs @@ -6,3 +6,6 @@ pub mod load_accounts; pub mod register_test_forester; pub mod setup_light_programs; pub mod tree_accounts; + +pub mod simulation; +pub use simulation::simulate_cu; diff --git a/sdk-libs/program-test/src/utils/simulation.rs b/sdk-libs/program-test/src/utils/simulation.rs new file mode 100644 index 0000000000..78987c6c18 --- /dev/null +++ b/sdk-libs/program-test/src/utils/simulation.rs @@ -0,0 +1,36 @@ +use solana_sdk::{ + instruction::Instruction, + signature::{Keypair, Signer}, + transaction::{Transaction, VersionedTransaction}, +}; + +use crate::{program_test::LightProgramTest, Rpc}; + +/// Simulate a transaction and return the compute units consumed. +/// +/// This is a test utility function for measuring transaction costs. +pub async fn simulate_cu( + rpc: &mut LightProgramTest, + payer: &Keypair, + instruction: &Instruction, +) -> u64 { + let blockhash = rpc + .get_latest_blockhash() + .await + .expect("Failed to get latest blockhash") + .0; + let tx = Transaction::new_signed_with_payer( + &[instruction.clone()], + Some(&payer.pubkey()), + &[payer], + blockhash, + ); + let simulate_tx = VersionedTransaction::from(tx); + + let simulate_result = rpc + .context + .simulate_transaction(simulate_tx) + .unwrap_or_else(|err| panic!("Transaction simulation failed: {:?}", err)); + + simulate_result.meta.compute_units_consumed +} diff --git a/sdk-libs/sdk/Cargo.toml b/sdk-libs/sdk/Cargo.toml index 6a53f45446..58ef6401ad 100644 --- a/sdk-libs/sdk/Cargo.toml +++ b/sdk-libs/sdk/Cargo.toml @@ -25,7 +25,7 @@ poseidon = ["light-hasher/poseidon", "light-compressed-account/poseidon"] keccak = ["light-hasher/keccak", "light-compressed-account/keccak"] sha256 = ["light-hasher/sha256", "light-compressed-account/sha256"] merkle-tree = ["light-concurrent-merkle-tree/solana"] - +anchor-discriminator = ["light-sdk-macros/anchor-discriminator"] [dependencies] solana-pubkey = { workspace = true, features = ["borsh", "sha2", "curve25519"] } @@ -34,12 +34,17 @@ solana-msg = { workspace = true } solana-cpi = { workspace = true } solana-program-error = { workspace = true } solana-instruction = { workspace = true } +solana-clock = { workspace = true } +solana-sysvar = { workspace = true } +solana-system-interface = { workspace = true } +solana-program = { workspace = true } anchor-lang = { workspace = true, optional = true } num-bigint = { workspace = true } borsh = { workspace = true } thiserror = { workspace = true } +bincode = "1" light-sdk-macros = { workspace = true } light-sdk-types = { workspace = true, features = ["std"] } diff --git a/sdk-libs/sdk/src/account.rs b/sdk-libs/sdk/src/account.rs index 8c49c28982..ed84cdba57 100644 --- a/sdk-libs/sdk/src/account.rs +++ b/sdk-libs/sdk/src/account.rs @@ -302,6 +302,20 @@ pub mod __internal { pub fn owner(&self) -> &Pubkey { &self.owner } + /// Get the byte size of the account type. + + pub fn size(&self) -> usize + where + A: Size, + { + self.account.size() + } + + /// Remove the data from this account by setting it to default. + /// This is used when decompressing to ensure the compressed account is properly zeroed. + pub fn remove_data(&mut self) { + self.should_remove_data = true; + } pub fn in_account_info(&self) -> &Option { &self.account_info.input @@ -771,7 +785,7 @@ pub mod __internal { prove_by_index: tree_info.prove_by_index, }, root_index: input_account_meta.get_root_index().unwrap_or_default(), - discriminator: [0u8; 8], + discriminator: [0u8; 8], // TODO: consider 0 (need adapt client etc) } }; let output_account_info = { diff --git a/sdk-libs/sdk/src/address.rs b/sdk-libs/sdk/src/address.rs index c3d89f1105..d7e7f78707 100644 --- a/sdk-libs/sdk/src/address.rs +++ b/sdk-libs/sdk/src/address.rs @@ -134,6 +134,20 @@ pub mod v2 { ) } + /// Derive address from PDA using Pubkey types. + pub fn derive_compressed_address( + account_address: &Pubkey, + address_tree_pubkey: &Pubkey, + program_id: &Pubkey, + ) -> [u8; 32] { + derive_address( + &[account_address.to_bytes().as_ref()], + address_tree_pubkey, + program_id, + ) + .0 + } + /// Derives an address from provided seeds. Returns that address and a singular /// seed. /// diff --git a/sdk-libs/sdk/src/compressible/compress_account.rs b/sdk-libs/sdk/src/compressible/compress_account.rs new file mode 100644 index 0000000000..11205fc6ab --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compress_account.rs @@ -0,0 +1,321 @@ +#[cfg(feature = "anchor")] +use anchor_lang::{prelude::Account, AccountDeserialize, AccountSerialize}; +#[cfg(feature = "anchor")] +use light_compressed_account::instruction_data::with_account_info::CompressedAccountInfo; +use light_hasher::DataHasher; +#[cfg(feature = "anchor")] +use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; +use solana_account_info::AccountInfo; +use solana_clock::Clock; +use solana_msg::msg; +#[cfg(feature = "anchor")] +use solana_pubkey::Pubkey; +use solana_sysvar::Sysvar; + +#[cfg(feature = "anchor")] +use crate::compressible::compression_info::CompressAs; +use crate::{ + account::sha::LightAccount, + compressible::{compress_account_on_init_native::close, compression_info::HasCompressionInfo}, + cpi::{InvokeLightSystemProgram, LightCpiInstruction}, + error::LightSdkError, + instruction::{account_meta::CompressedAccountMeta, ValidityProof}, + AnchorDeserialize, AnchorSerialize, LightDiscriminator, +}; + +#[cfg(feature = "v2")] +use crate::cpi::v2::{CpiAccounts, LightSystemProgramCpi}; + +/// Helper function to compress a PDA and reclaim rent. +/// +/// This function uses the CompressAs trait to determine what data should be +/// stored in the compressed state. For simple cases where you want to store the +/// exact same data, implement CompressAs with `type Output = Self` and return +/// `self.clone()`. For custom compression, you can specify different field +/// values or even a different type entirely. +/// +/// This requires the compressed PDA that is tied to the onchain PDA to already +/// exist, and the account type must implement CompressAs. +/// +/// +/// 1. updates the empty compressed PDA with data from CompressAs::compress_as() +/// 2. transfers PDA lamports to rent_recipient +/// 1. closes onchain PDA +/// +/// +/// # Arguments +/// * `solana_account` - The PDA account to compress (will be closed) +/// * `compressed_account_meta` - Metadata for the compressed account (must be +/// empty but have an address) +/// * `proof` - Validity proof +/// * `cpi_accounts` - Accounts needed for CPI +/// * `rent_recipient` - The account to receive the PDA's rent +/// * `compression_delay` - The number of slots to wait before compression is +/// allowed +#[cfg(feature = "anchor")] +pub fn compress_account<'info, A>( + solana_account: &mut Account<'info, A>, + compressed_account_meta: &CompressedAccountMeta, + proof: ValidityProof, + cpi_accounts: CpiAccounts<'_, 'info>, + _rent_recipient: &AccountInfo<'info>, + compression_delay: &u32, +) -> Result<(), crate::ProgramError> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + AccountSerialize + + AccountDeserialize + + Default + + Clone + + HasCompressionInfo + + CompressAs, + A::Output: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + HasCompressionInfo + + Default + + crate::compressible::compression_info::CompressedInitSpace, +{ + let current_slot = Clock::get()?.slot; + + let last_written_slot = solana_account.compression_info().last_written_slot(); + + if current_slot < last_written_slot + *compression_delay as u64 { + msg!( + "compress_account failed: Cannot compress yet. {} slots remaining", + (last_written_slot + *compression_delay as u64).saturating_sub(current_slot) + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + // ensure re-init attack is not possible + solana_account.compression_info_mut().set_compressed(); + + let owner_program_id = cpi_accounts.self_program_id(); + let mut compressed_account = + LightAccount::::new_empty(&owner_program_id, compressed_account_meta)?; + + let compressed_data = match solana_account.compress_as() { + std::borrow::Cow::Borrowed(data) => data.clone(), + std::borrow::Cow::Owned(data) => data, + }; + compressed_account.account = compressed_data; + + { + use crate::compressible::compression_info::CompressedInitSpace; + let __lp_size = 8 + ::COMPRESSED_INIT_SPACE; + if __lp_size > 800 { + msg!( + "Compressed account would exceed 800-byte limit ({} bytes)", + __lp_size + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + } + + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_light_account(compressed_account)? + .invoke(cpi_accounts)?; + + Ok(()) +} + +#[cfg(all(feature = "anchor", feature = "v2"))] +pub fn prepare_account_for_compression<'info, A>( + program_id: &Pubkey, + account: &mut Account<'info, A>, + compressed_account_meta: &CompressedAccountMetaNoLamportsNoAddress, + cpi_accounts: &CpiAccounts<'_, 'info>, + compression_delay: &u32, + address_space: &[Pubkey], +) -> Result +where + A: LightDiscriminator + // + DataHasher + + AnchorSerialize + + AnchorDeserialize + + AccountSerialize + + AccountDeserialize + + Default + + Clone + + HasCompressionInfo + + CompressAs, + A::Output: LightDiscriminator + // + DataHasher + + AnchorSerialize + + AnchorDeserialize + + HasCompressionInfo + + Default + + crate::compressible::compression_info::CompressedInitSpace, +{ + use anchor_lang::Key; + use light_compressed_account::address::derive_address; + + let derived_c_pda = derive_address( + &account.key().to_bytes(), + &address_space[0].to_bytes(), + &program_id.to_bytes(), + ); + + let meta_with_address = CompressedAccountMeta { + tree_info: compressed_account_meta.tree_info, + address: derived_c_pda, + output_state_tree_index: compressed_account_meta.output_state_tree_index, + }; + + let current_slot = Clock::get()?.slot; + + let last_written_slot = account.compression_info().last_written_slot(); + + if current_slot < last_written_slot + *compression_delay as u64 { + msg!( + "compress_account failed: Cannot compress yet. {} slots remaining", + (last_written_slot + *compression_delay as u64).saturating_sub(current_slot) + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + // ensure re-init attack is not possible + account.compression_info_mut().set_compressed(); + + let owner_program_id = cpi_accounts.self_program_id(); + let mut compressed_account = + LightAccount::::new_empty(&owner_program_id, &meta_with_address)?; + + msg!( + "compressed_account before compress_as: {:?}", + compressed_account.owner() + ); + + msg!( + "compressed_account in_account_info: {:?}", + compressed_account.in_account_info() + ); + msg!( + "compressed_account discriminator: {:?}", + compressed_account.discriminator() + ); + msg!( + "compressed_account lamports: {:?}", + compressed_account.lamports() + ); + + msg!( + "DEBUG compress_account: derived_c_pda address: {:?}", + meta_with_address.address + ); + msg!( + "DEBUG compress_account: in_account: {:?}", + compressed_account.in_account_info() + ); + + let compressed_data = match account.compress_as() { + std::borrow::Cow::Borrowed(data) => data.clone(), + std::borrow::Cow::Owned(data) => data, + }; + compressed_account.account = compressed_data; + + // CU-cheap runtime safety check using compile-time compressed space + { + use crate::compressible::compression_info::CompressedInitSpace; + let __lp_size = 8 + ::COMPRESSED_INIT_SPACE; + if __lp_size > 800 { + msg!( + "Compressed account would exceed 800-byte limit ({} bytes)", + __lp_size + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + } + + Ok(compressed_account.to_account_info()?) +} + +/// Native Solana variant of compress_account that works with AccountInfo and pre-deserialized data. +/// +/// Helper function to compress a PDA and reclaim rent. +/// +/// 1. updates the empty compressed PDA with onchain PDA data +/// 2. transfers PDA lamports to rent_recipient +/// 3. closes onchain PDA +/// +/// This requires the compressed PDA that is tied to the onchain PDA to already +/// exist. +/// +/// # Arguments +/// * `pda_account_info` - The PDA AccountInfo to compress (will be closed) +/// * `pda_account_data` - The pre-deserialized PDA account data +/// * `compressed_account_meta` - Metadata for the compressed account (must be +/// empty but have an address) +/// * `proof` - Validity proof +/// * `cpi_accounts` - Accounts needed for CPI +/// * `owner_program` - The program that will own the compressed account +/// * `rent_recipient` - The account to receive the PDA's rent +/// * `compression_delay` - The number of slots to wait before compression is +/// allowed +#[cfg(feature = "v2")] +pub fn compress_pda_native<'info, A>( + pda_account_info: &mut AccountInfo<'info>, + pda_account_data: &mut A, + compressed_account_meta: &CompressedAccountMeta, + proof: ValidityProof, + cpi_accounts: CpiAccounts<'_, 'info>, + rent_recipient: &AccountInfo<'info>, + compression_delay: &u32, +) -> Result<(), crate::ProgramError> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + Default + + Clone + + HasCompressionInfo + + crate::compressible::compression_info::CompressedInitSpace, +{ + let current_slot = Clock::get()?.slot; + + let last_written_slot = pda_account_data.compression_info().last_written_slot(); + + if current_slot < last_written_slot + *compression_delay as u64 { + msg!( + "compress_pda_native failed: Cannot compress yet. {} slots remaining", + (last_written_slot + *compression_delay as u64).saturating_sub(current_slot) + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + // ensure re-init attack is not possible + pda_account_data.compression_info_mut().set_compressed(); + + // Create the compressed account with the PDA data + let owner_program_id = cpi_accounts.self_program_id(); + let mut compressed_account = + LightAccount::::new_empty(&owner_program_id, compressed_account_meta)?; + + let mut compressed_data = pda_account_data.clone(); + compressed_data.set_compression_info_none(); + compressed_account.account = compressed_data; + + // CU-cheap runtime safety check using compile-time compressed space + { + let __lp_size = 8 + + ::COMPRESSED_INIT_SPACE; + if __lp_size > 800 { + msg!( + "Compressed account would exceed 800-byte limit ({} bytes)", + __lp_size + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + } + + // Invoke light system program to create the compressed account + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_light_account(compressed_account)? + .invoke(cpi_accounts)?; + // Close PDA account manually + close(pda_account_info, rent_recipient.clone())?; + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/compress_account_on_init.rs b/sdk-libs/sdk/src/compressible/compress_account_on_init.rs new file mode 100644 index 0000000000..3072ac05d1 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compress_account_on_init.rs @@ -0,0 +1,355 @@ +#![allow(clippy::all)] // TODO: Remove. +#[cfg(feature = "anchor")] +use anchor_lang::Key; +#[allow(unused_imports)] // TODO: Remove. +#[cfg(feature = "anchor")] +use anchor_lang::{ + AccountsClose, + {prelude::Account, AccountDeserialize, AccountSerialize}, +}; +#[cfg(feature = "anchor")] +use light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked; +use light_hasher::DataHasher; +use solana_account_info::AccountInfo; +use solana_msg::msg; +use solana_pubkey::Pubkey; +use std::str::FromStr; + +use crate::{ + account::sha::LightAccount, + compressible::HasCompressionInfo, + cpi::{InvokeLightSystemProgram, LightCpiInstruction}, + error::{LightSdkError, Result}, + instruction::ValidityProof, + AnchorDeserialize, AnchorSerialize, LightDiscriminator, +}; + +#[cfg(feature = "v2")] +use crate::cpi::v2::{CpiAccounts, LightSystemProgramCpi}; + +/// Wrapper to init an Anchor account as compressible and directly compress it. +/// Close the source PDA account manually at the end of the caller program's +/// init instruction. +#[cfg(feature = "anchor")] +pub fn compress_account_on_init<'info, A>( + solana_account: &Account<'info, A>, + address: &[u8; 32], + new_address_param: &NewAddressParamsAssignedPacked, + output_state_tree_index: u8, + cpi_accounts: CpiAccounts<'_, 'info>, + proof: ValidityProof, +) -> Result<()> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + AccountSerialize + + AccountDeserialize + + Default + + Clone + + HasCompressionInfo, + A: std::fmt::Debug, +{ + let compressed_infos = prepare_accounts_for_compression_on_init( + std::slice::from_ref(&solana_account), + std::slice::from_ref(address), + std::slice::from_ref(new_address_param), + std::slice::from_ref(&output_state_tree_index), + &cpi_accounts, + )?; + + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_new_addresses(&[*new_address_param]) + .with_account_infos(&compressed_infos) + .invoke(cpi_accounts)?; + + Ok(()) +} + +/// Helper function to initialize a multiple Anchor accounts as compressible. +/// Returns account_infos so that all compressible accounts can be compressed in +/// a single CPI at the end of the caller program's init instruction. +/// +/// # Arguments +/// * `solana_accounts` - The Anchor accounts to compress +/// * `addresses` - The addresses for the compressed accounts +/// * `new_address_params` - Address parameters for the compressed accounts +/// * `output_state_tree_indices` - Output state tree indices for the compressed +/// accounts +/// * `cpi_accounts` - Accounts needed for validation +/// +/// # Returns +/// * `Ok(Vec)` - CompressedAccountInfo for CPI batching +/// * `Err(LightSdkError)` if there was an error +#[cfg(all(feature = "anchor", feature = "v2"))] +pub fn prepare_accounts_for_compression_on_init<'info, A>( + solana_accounts: &[&Account<'info, A>], + addresses: &[[u8; 32]], + new_address_params: &[NewAddressParamsAssignedPacked], + output_state_tree_indices: &[u8], + cpi_accounts: &CpiAccounts<'_, 'info>, +) -> Result> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + AccountSerialize + + AccountDeserialize + + Default + + Clone + + HasCompressionInfo, + A: std::fmt::Debug, +{ + if solana_accounts.len() != addresses.len() + || solana_accounts.len() != new_address_params.len() + || solana_accounts.len() != output_state_tree_indices.len() + { + msg!( + "Array length mismatch in prepare_accounts_for_compression_on_init - solana_accounts: {}, addresses: {}, new_address_params: {}, output_state_tree_indices: {}", + solana_accounts.len(), + addresses.len(), + new_address_params.len(), + output_state_tree_indices.len() + ); + return Err(LightSdkError::ConstraintViolation); + } + + let mut compressed_account_infos = Vec::new(); + + for (((solana_account, &address), &_new_address_param), &output_state_tree_index) in + solana_accounts + .iter() + .zip(addresses.iter()) + .zip(new_address_params.iter()) + .zip(output_state_tree_indices.iter()) + { + // TODO: check security of not setting compressed so we don't need to pass as mut. + // Ensure the account is marked as compressed We need to init first + // because it's none. Setting to compressed prevents lamports funding + // attack. + // *solana_account.compression_info_mut_opt() = + // Some(super::CompressionInfo::new_decompressed()?); + // solana_account.compression_info_mut().set_compressed(); + + let owner_program_id = cpi_accounts.self_program_id(); + + let mut compressed_account = + LightAccount::::new_init(&owner_program_id, Some(address), output_state_tree_index); + + // Clone the PDA data and set compression_info to None. + let mut compressed_data = (***solana_account).clone(); + compressed_data.set_compression_info_none(); + compressed_account.account = compressed_data; + + compressed_account_infos.push(compressed_account.to_account_info()?); + } + + Ok(compressed_account_infos) +} + +/// Wrapper to process a single onchain PDA for creating an empty compressed +/// account. +/// +/// The PDA account is NOT closed. +#[cfg(feature = "anchor")] +#[allow(clippy::too_many_arguments)] +pub fn compress_empty_account_on_init<'info, A>( + solana_account: &mut Account<'info, A>, + address: &[u8; 32], + new_address_param: &NewAddressParamsAssignedPacked, + output_state_tree_index: u8, + cpi_accounts: CpiAccounts<'_, 'info>, + proof: ValidityProof, +) -> Result<()> +where + // A: DataHasher + + A: LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + AccountSerialize + + AccountDeserialize + + Default + + Clone + + HasCompressionInfo, +{ + let compressed_infos = prepare_empty_compressed_accounts_on_init( + &mut [solana_account], + std::slice::from_ref(address), + std::slice::from_ref(new_address_param), + std::slice::from_ref(&output_state_tree_index), + &cpi_accounts, + )?; + msg!("...prepared empty compressed accounts on init"); + + msg!( + "invoking LightSystemProgramCpi, {:?}", + cpi_accounts.config() + ); + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_new_addresses(&[*new_address_param]) + .with_account_infos(&compressed_infos) + .invoke(cpi_accounts)?; + + Ok(()) +} + +/// Helper function to initialize multiple empty compressed PDA based on the +/// Anchor accounts addresses. +/// +/// Use this over `prepare_accounts_for_compression_on_init` if you want to +/// initialize your Anchor accounts as compressible **without** compressing them +/// atomically. +/// +/// # Arguments +/// * `solana_accounts` - The Anchor accounts +/// * `addresses` - The addresses for the compressed accounts +/// * `new_address_params` - Address parameters for the compressed accounts +/// * `output_state_tree_indices` - Output state tree indices for the compressed +/// accounts +/// * `cpi_accounts` - Accounts needed for validation +/// +/// # Returns +/// * `Ok(Vec)` - CompressedAccountInfo for CPI batching +/// * `Err(LightSdkError)` if there was an error +#[cfg(all(feature = "anchor", feature = "v2"))] +pub fn prepare_empty_compressed_accounts_on_init<'info, A>( + solana_accounts: &mut [&mut Account<'info, A>], + addresses: &[[u8; 32]], + new_address_params: &[NewAddressParamsAssignedPacked], + output_state_tree_indices: &[u8], + cpi_accounts: &CpiAccounts<'_, 'info>, +) -> Result> +where + A: LightDiscriminator + // + DataHasher + + AnchorSerialize + + AnchorDeserialize + + AccountSerialize + + AccountDeserialize + + Default + + Clone + + HasCompressionInfo, +{ + if solana_accounts.len() != addresses.len() + || solana_accounts.len() != new_address_params.len() + || solana_accounts.len() != output_state_tree_indices.len() + { + msg!( + "Array length mismatch in prepare_empty_compressed_accounts_on_init - solana_accounts: {}, addresses: {}, new_address_params: {}, output_state_tree_indices: {}", + solana_accounts.len(), + addresses.len(), + new_address_params.len(), + output_state_tree_indices.len() + ); + return Err(LightSdkError::ConstraintViolation); + } + + let mut compressed_account_infos = Vec::new(); + + for (((solana_account, &address), &_new_address_param), &output_state_tree_index) in + solana_accounts + .iter_mut() + .zip(addresses.iter()) + .zip(new_address_params.iter()) + .zip(output_state_tree_indices.iter()) + { + // TODO: check security of not setting compressed so we don't need to pass as mut. + // Ensure the account is marked as compressed We need to init first + // because it's none. Setting to compressed prevents lamports funding + // attack. + *solana_account.compression_info_mut_opt() = + Some(super::CompressionInfo::new_decompressed()?); + + let owner_program_id = cpi_accounts.self_program_id(); + + let out_index = output_state_tree_index.clone() as usize; + // Create an empty compressed account with the specified address + let mut compressed_account = + LightAccount::::new_init(&owner_program_id, Some(address), output_state_tree_index); + + compressed_account.remove_data(); + + msg!( + "compressed_account before to_account_info: {:?}", + compressed_account.owner() + ); + msg!( + "compressed_account before to_account_info: {:?}", + compressed_account.in_account_info() + ); + msg!( + "compressed_account before to_account_info: {:?}", + compressed_account.out_account_info() + ); + msg!( + "compressed_account before to_account_info: {:?}", + compressed_account.discriminator() + ); + + let tree_account_info = cpi_accounts.get_tree_account_info(out_index)?; + msg!("tree_account_info: {:?}", tree_account_info); + let compressed_account_info = compressed_account.to_account_info()?; + msg!("compressed_account - info: {:?}", compressed_account_info); + + // DEBUG: Compute hash manually to verify which owner is used + // { + // use light_compressed_account::compressed_account::hash_with_hashed_values; + // use light_hasher::Hasher; + + // let owner_correct = owner_program_id; + // let owner_system = + // solana_pubkey::Pubkey::from_str("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7") + // .unwrap(); + + // let hashed_owner_correct = + // light_compressed_account::hash_to_bn254_field_size_be(&owner_correct.to_bytes()); + // let hashed_owner_system = + // light_compressed_account::hash_to_bn254_field_size_be(&owner_system.to_bytes()); + // let hashed_tree = light_compressed_account::hash_to_bn254_field_size_be( + // tree_account_info.key.as_ref(), + // ); + + // // Assuming leaf index will be 0 for first insertion + // let leaf_index = 0u32; + + // let hash_with_correct_owner = hash_with_hashed_values( + // &0u64, + // Some(address.as_slice()), + // Some((&[0u8; 8], &[0u8; 32])), + // &hashed_owner_correct, + // &hashed_tree, + // &leaf_index, + // true, // is_batched + // ) + // .unwrap(); + + // let hash_with_system_owner = hash_with_hashed_values( + // &0u64, + // Some(address.as_slice()), + // Some((&[0u8; 8], &[0u8; 32])), + // &hashed_owner_system, + // &hashed_tree, + // &leaf_index, + // true, // is_batched + // ) + // .unwrap(); + + // msg!( + // "DEBUG: Hash with CORRECT owner ({:?}): {:?}", + // owner_correct, + // hash_with_correct_owner + // ); + // msg!( + // "DEBUG: Hash with SYSTEM owner ({:?}): {:?}", + // owner_system, + // hash_with_system_owner + // ); + // } + + compressed_account_infos.push(compressed_account_info); + } + + Ok(compressed_account_infos) +} diff --git a/sdk-libs/sdk/src/compressible/compress_account_on_init_native.rs b/sdk-libs/sdk/src/compressible/compress_account_on_init_native.rs new file mode 100644 index 0000000000..c448c15c7b --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compress_account_on_init_native.rs @@ -0,0 +1,402 @@ +//! Native Solana helpers for compressing accounts on init. Anchor-free. + +#![allow(clippy::all)] // TODO: Remove. +#[allow(unused_imports)] // TODO: Remove. +use light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked; +use light_hasher::DataHasher; +use solana_account_info::AccountInfo; +use solana_msg::msg; +use solana_pubkey::Pubkey; + +use crate::{ + account::sha::LightAccount, + address::PackedNewAddressParams, + compressible::HasCompressionInfo, + cpi::{InvokeLightSystemProgram, LightCpiInstruction}, + error::{LightSdkError, Result}, + instruction::ValidityProof, + light_account_checks::AccountInfoTrait, + AnchorDeserialize, AnchorSerialize, LightDiscriminator, +}; + +#[cfg(feature = "v2")] +use crate::cpi::v2::{CpiAccounts, LightSystemProgramCpi}; + +/// Native Solana variant of compress_account_on_init that works with raw AccountInfo and pre-deserialized data. +/// +/// Wrapper to init an raw PDA as compressible and directly compress it. +/// Calls `prepare_accounts_for_compression_on_init_native` with single-element +/// slices and invokes the CPI. Close the source PDA account manually. +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "v2")] +pub fn compress_account_on_init_native<'info, A>( + pda_account_info: &mut AccountInfo<'info>, + pda_account_data: &mut A, + address: &[u8; 32], + new_address_param: &PackedNewAddressParams, + output_state_tree_index: u8, + cpi_accounts: CpiAccounts<'_, 'info>, + address_space: &[Pubkey], + rent_recipient: &AccountInfo<'info>, + proof: ValidityProof, +) -> Result<()> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + Default + + Clone + + HasCompressionInfo, +{ + // let pda_accounts_info: = &[pda_account_info]; + let mut pda_accounts_data: [&mut A; 1] = [pda_account_data]; + let addresses: [[u8; 32]; 1] = [*address]; + let new_address_params: [PackedNewAddressParams; 1] = [*new_address_param]; + let output_state_tree_indices: [u8; 1] = [output_state_tree_index]; + + let compressed_infos = prepare_accounts_for_compression_on_init_native( + &mut [pda_account_info], + &mut pda_accounts_data, + &addresses, + &new_address_params, + &output_state_tree_indices, + &cpi_accounts, + address_space, + rent_recipient, + )?; + + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_new_addresses(&[ + light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked::new( + *new_address_param, + None, + ), + ]) + .with_account_infos(&compressed_infos) + .invoke(cpi_accounts)?; + + Ok(()) +} + +/// Native Solana variant of prepare_accounts_for_compression_on_init that works +/// with AccountInfo and pre-deserialized data. +/// +/// Helper function to process multiple onchain PDAs for compression into new +/// compressed accounts. +/// +/// This function processes accounts of a single type and returns +/// CompressedAccountInfo for CPI batching. It allows the caller to handle the +/// CPI invocation separately, enabling batching of multiple different account +/// types. +/// +/// # Arguments +/// * `pda_accounts_info` - The PDA AccountInfos to compress +/// * `pda_accounts_data` - The pre-deserialized PDA account data +/// * `addresses` - The addresses for the compressed accounts +/// * `new_address_params` - Address parameters for the compressed accounts +/// * `output_state_tree_indices` - Output state tree indices for the compressed +/// accounts +/// * `cpi_accounts` - Accounts needed for validation +/// * `address_space` - The address space to validate uniqueness against +/// * `rent_recipient` - The account to receive the PDAs' rent +/// +/// # Returns +/// * `Ok(Vec)` - CompressedAccountInfo for CPI batching +/// * `Err(LightSdkError)` if there was an error +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "v2")] +pub fn prepare_accounts_for_compression_on_init_native<'info, A>( + pda_accounts_info: &mut [&mut AccountInfo<'info>], + pda_accounts_data: &mut [&mut A], + addresses: &[[u8; 32]], + new_address_params: &[PackedNewAddressParams], + output_state_tree_indices: &[u8], + cpi_accounts: &CpiAccounts<'_, 'info>, + address_space: &[Pubkey], + rent_recipient: &AccountInfo<'info>, +) -> Result> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + Default + + Clone + + HasCompressionInfo, +{ + if pda_accounts_info.len() != pda_accounts_data.len() + || pda_accounts_info.len() != addresses.len() + || pda_accounts_info.len() != new_address_params.len() + || pda_accounts_info.len() != output_state_tree_indices.len() + { + msg!("pda_accounts_info.len(): {:?}", pda_accounts_info.len()); + msg!("pda_accounts_data.len(): {:?}", pda_accounts_data.len()); + msg!("addresses.len(): {:?}", addresses.len()); + msg!("new_address_params.len(): {:?}", new_address_params.len()); + msg!( + "output_state_tree_indices.len(): {:?}", + output_state_tree_indices.len() + ); + return Err(LightSdkError::ConstraintViolation); + } + + // Address space validation + for params in new_address_params { + let tree = cpi_accounts + .get_tree_account_info(params.address_merkle_tree_account_index as usize) + .map_err(|_| { + msg!( + "Failed to get tree account info at index {} in prepare_accounts_for_compression_on_init_native", + params.address_merkle_tree_account_index + ); + LightSdkError::ConstraintViolation + })? + .pubkey(); + if !address_space.iter().any(|a| a == &tree) { + msg!("address tree: {:?}", tree); + msg!("expected address_space: {:?}", address_space); + msg!("Address tree {} not found in allowed address space in prepare_accounts_for_compression_on_init_native", tree); + return Err(LightSdkError::ConstraintViolation); + } + } + + let mut compressed_account_infos = Vec::new(); + + for ( + (((pda_account_info, pda_account_data), &address), &_new_address_param), + &output_state_tree_index, + ) in pda_accounts_info + .iter_mut() + .zip(pda_accounts_data.iter_mut()) + .zip(addresses.iter()) + .zip(new_address_params.iter()) + .zip(output_state_tree_indices.iter()) + { + // Ensure the account is marked as compressed We need to init first + // because it's none. Setting to compressed prevents lamports funding + // attack. + *pda_account_data.compression_info_mut_opt() = + Some(super::CompressionInfo::new_decompressed()?); + pda_account_data.compression_info_mut().set_compressed(); + + // Create the compressed account with the PDA data + let owner_program_id = cpi_accounts.self_program_id(); + let mut compressed_account = LightAccount::::new_init( + &owner_program_id, + Some(address), + output_state_tree_index, + ); + + // Clone the PDA data and set compression_info to None for compressed + // storage + let mut compressed_data = (*pda_account_data).clone(); + compressed_data.set_compression_info_none(); + compressed_account.account = compressed_data; + + compressed_account_infos.push(compressed_account.to_account_info()?); + + // Close PDA account manually + close(pda_account_info, rent_recipient.clone()).map_err(|err| { + msg!("Failed to close PDA account in prepare_accounts_for_compression_on_init_native: {:?}", err); + err + })?; + } + + Ok(compressed_account_infos) +} + +/// Native Solana variant to create an EMPTY compressed account from a PDA. +/// +/// This creates an empty compressed account without closing the source PDA, +/// similar to decompress_idempotent behavior. The PDA remains intact with its data. +/// +/// # Arguments +/// * `pda_account_info` - The PDA AccountInfo (will NOT be closed) +/// * `pda_account_data` - The pre-deserialized PDA account data +/// * `address` - The address for the compressed account +/// * `new_address_param` - Address parameters for the compressed account +/// * `output_state_tree_index` - Output state tree index for the compressed account +/// * `cpi_accounts` - Accounts needed for validation +/// * `address_space` - The address space to validate uniqueness against +/// * `proof` - Validity proof for the address tree operation +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "v2")] +pub fn compress_empty_account_on_init_native<'info, A>( + pda_account_info: &mut AccountInfo<'info>, + pda_account_data: &mut A, + address: &[u8; 32], + new_address_param: &PackedNewAddressParams, + output_state_tree_index: u8, + cpi_accounts: CpiAccounts<'_, 'info>, + address_space: &[Pubkey], + proof: ValidityProof, +) -> Result<()> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + Default + + Clone + + HasCompressionInfo, +{ + let mut pda_accounts_data: [&mut A; 1] = [pda_account_data]; + let addresses: [[u8; 32]; 1] = [*address]; + let new_address_params: [PackedNewAddressParams; 1] = [*new_address_param]; + let output_state_tree_indices: [u8; 1] = [output_state_tree_index]; + + let compressed_infos = prepare_empty_compressed_accounts_on_init_native( + &mut [pda_account_info], + &mut pda_accounts_data, + &addresses, + &new_address_params, + &output_state_tree_indices, + &cpi_accounts, + address_space, + )?; + + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_new_addresses(&[ + light_compressed_account::instruction_data::data::NewAddressParamsAssignedPacked::new( + *new_address_param, + None, + ), + ]) + .with_account_infos(&compressed_infos) + .invoke(cpi_accounts)?; + + Ok(()) +} + +/// Native Solana variant to create EMPTY compressed accounts from PDAs. +/// +/// This creates empty compressed accounts without closing the source PDAs. +/// The PDAs remain intact with their data, similar to decompress_idempotent behavior. +/// +/// # Arguments +/// * `pda_accounts_info` - The PDA AccountInfos (will NOT be closed) +/// * `pda_accounts_data` - The pre-deserialized PDA account data +/// * `addresses` - The addresses for the compressed accounts +/// * `new_address_params` - Address parameters for the compressed accounts +/// * `output_state_tree_indices` - Output state tree indices for the compressed accounts +/// * `cpi_accounts` - Accounts needed for validation +/// * `address_space` - The address space to validate uniqueness against +/// +/// # Returns +/// * `Ok(Vec)` - CompressedAccountInfo for CPI batching +/// * `Err(LightSdkError)` if there was an error +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "v2")] +pub fn prepare_empty_compressed_accounts_on_init_native<'info, A>( + _pda_accounts_info: &mut [&mut AccountInfo<'info>], + pda_accounts_data: &mut [&mut A], + addresses: &[[u8; 32]], + new_address_params: &[PackedNewAddressParams], + output_state_tree_indices: &[u8], + cpi_accounts: &CpiAccounts<'_, 'info>, + address_space: &[Pubkey], +) -> Result> +where + A: DataHasher + + LightDiscriminator + + AnchorSerialize + + AnchorDeserialize + + Default + + Clone + + HasCompressionInfo, +{ + if pda_accounts_data.len() != addresses.len() + || pda_accounts_data.len() != new_address_params.len() + || pda_accounts_data.len() != output_state_tree_indices.len() + { + msg!("pda_accounts_data.len(): {:?}", pda_accounts_data.len()); + msg!("addresses.len(): {:?}", addresses.len()); + msg!("new_address_params.len(): {:?}", new_address_params.len()); + msg!( + "output_state_tree_indices.len(): {:?}", + output_state_tree_indices.len() + ); + return Err(LightSdkError::ConstraintViolation); + } + + // Address space validation + for params in new_address_params { + let tree = cpi_accounts + .get_tree_account_info(params.address_merkle_tree_account_index as usize) + .map_err(|_| { + msg!( + "Failed to get tree account info at index {} in prepare_empty_compressed_accounts_on_init_native", + params.address_merkle_tree_account_index + ); + LightSdkError::ConstraintViolation + })? + .pubkey(); + if !address_space.iter().any(|a| a == &tree) { + msg!("address tree: {:?}", tree); + msg!("expected address_space: {:?}", address_space); + return Err(LightSdkError::ConstraintViolation); + } + } + + let mut compressed_account_infos = Vec::new(); + + for (((pda_account_data, &address), &_new_address_param), &output_state_tree_index) in + pda_accounts_data + .iter_mut() + .zip(addresses.iter()) + .zip(new_address_params.iter()) + .zip(output_state_tree_indices.iter()) + { + *pda_account_data.compression_info_mut_opt() = + Some(super::CompressionInfo::new_decompressed()?); + pda_account_data + .compression_info_mut() + .bump_last_written_slot()?; + + let owner_program_id = cpi_accounts.self_program_id(); + let mut light_account = LightAccount::::new_init( + &owner_program_id, + Some(address), + output_state_tree_index, + ); + // Data removal is handled internally by LightAccount + + compressed_account_infos.push(light_account.to_account_info()?); + } + + Ok(compressed_account_infos) +} + +// Proper native Solana account closing implementation +pub fn close<'info>( + info: &mut AccountInfo<'info>, + sol_destination: AccountInfo<'info>, +) -> Result<()> { + // Transfer all lamports from the account to the destination + let lamports_to_transfer = info.lamports(); + + // Use try_borrow_mut_lamports for proper borrow management + **info + .try_borrow_mut_lamports() + .map_err(|_| LightSdkError::ConstraintViolation)? = 0; + + let dest_lamports = sol_destination.lamports(); + **sol_destination + .try_borrow_mut_lamports() + .map_err(|_| LightSdkError::ConstraintViolation)? = + dest_lamports.checked_add(lamports_to_transfer).unwrap(); + + // Assign to system program first + let system_program_id = solana_pubkey::pubkey!("11111111111111111111111111111111"); + + info.assign(&system_program_id); + + // Realloc to 0 size - this should work after assigning to system program + info.realloc(0, false).map_err(|e| { + msg!("Error during realloc: {:?}", e); + LightSdkError::ConstraintViolation + })?; + + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/compression_info.rs b/sdk-libs/sdk/src/compressible/compression_info.rs new file mode 100644 index 0000000000..eefffd94e3 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compression_info.rs @@ -0,0 +1,214 @@ +use std::borrow::Cow; + +use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress; +use solana_account_info::AccountInfo; +use solana_clock::Clock; +use solana_sysvar::Sysvar; + +use crate::{instruction::PackedAccounts, AnchorDeserialize, AnchorSerialize}; + +/// Trait for types that can be packed for compression. +/// +/// Packing is a space optimization technique where 32-byte `Pubkey` fields are replaced +/// with 1-byte indices that reference positions in a `remaining_accounts` array. +/// This significantly reduces instruction data size. +/// +/// For types without Pubkeys, implement identity packing (return self). +pub trait Pack { + /// The packed version of this type + type Packed: AnchorSerialize + Clone + std::fmt::Debug; + + /// Pack this type, replacing Pubkeys with indices + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed; +} + +/// Trait for types that can be unpacked from their compressed form. +/// +/// This is used on-chain to convert packed instruction data back to the original types. +/// The unpacking resolves u8 indices back to Pubkeys using the remaining_accounts array. +/// +/// For identity-packed types, unpack returns a clone of self. +pub trait Unpack { + /// The unpacked version of this type + type Unpacked; + + /// Unpack this type, resolving indices to Pubkeys from remaining_accounts + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> Result; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)] +#[repr(u8)] +pub enum AccountState { + Initialized, + Frozen, +} + +/// Trait for compressible accounts. +pub trait HasCompressionInfo { + fn compression_info(&self) -> &CompressionInfo; + fn compression_info_mut(&mut self) -> &mut CompressionInfo; + fn compression_info_mut_opt(&mut self) -> &mut Option; + fn set_compression_info_none(&mut self); +} + +/// Compile-time constant for the compressed INIT_SPACE of an +/// account type, considering #[compress_as(... = None)] overrides. +pub trait CompressedInitSpace { + const COMPRESSED_INIT_SPACE: usize; +} + +/// Trait for accounts that want to customize how their state gets compressed, +/// instead of just copying the current onchain state. +pub trait CompressAs { + /// The type that will be stored in the compressed state. + /// Can be `Self` or a different type entirely for maximum flexibility. + type Output: crate::AnchorSerialize + + crate::AnchorDeserialize + + crate::LightDiscriminator + + crate::account::Size + + HasCompressionInfo + + Default + + Clone; + + /// Returns the data that should be stored in the compressed state. This + /// allows developers to reset some fields while keeping others, or even + /// return a completely different type during compression. + /// + /// compression_info must ALWAYS be None in the returned data. This + /// eliminates the need for mutation after calling compress_as(). + /// + /// Uses Cow (Clone on Write) for performance - typically returns owned data + /// since compression_info must be None (different from onchain state). + /// + /// # Example - Default. + /// ```rust + /// impl CompressAs for UserRecord { + /// type Output = Self; + /// + /// fn compress_as(&self) -> Cow<'_, Self::Output> { + /// Cow::Owned(Self { + /// compression_info: None, // ALWAYS None + /// owner: self.owner, + /// name: self.name.clone(), + /// score: self.score, + /// }) + /// } + /// } + /// ``` + /// + /// # Example - Custom Compression (reset some values) + /// ```rust + /// impl CompressAs for Oracle { + /// type Output = Self; + /// + /// fn compress_as(&self) -> Cow<'_, Self::Output> { + /// Cow::Owned(Self { + /// compression_info: None, // ALWAYS None + /// initialized: false, // set false + /// observation_index: 0, // set 0 + /// pool_id: self.pool_id, // default + /// observations: None, // set None + /// padding: self.padding, + /// }) + /// } + /// } + /// ``` + /// + /// # Example - Different Type + /// ```rust + /// impl CompressAs for LargeGameState { + /// type Output = CompactGameState; + /// + /// fn compress_as(&self) -> Cow<'_, Self::Output> { + /// Cow::Owned(CompactGameState { + /// compression_info: None, // ALWAYS None + /// player_id: self.player_id, + /// level: self.level, + /// // Skip large arrays, temporary state, etc. + /// }) + /// } + /// } + /// ``` + fn compress_as(&self) -> Cow<'_, Self::Output>; +} + +/// Information for compressible accounts that tracks when the account was last +/// written +#[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize)] +pub struct CompressionInfo { + /// The slot when this account was last written/decompressed + pub last_written_slot: u64, + /// 0 not inited, 1 decompressed, 2 compressed + pub state: CompressionState, +} + +#[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize, PartialEq)] +pub enum CompressionState { + #[default] + Uninitialized, + Decompressed, + Compressed, +} + +// TODO: move to proper rent_func. +impl CompressionInfo { + /// Creates new compression info with the current slot and sets state to + /// decompressed. + pub fn new_decompressed() -> Result { + Ok(Self { + last_written_slot: Clock::get()?.slot, + state: CompressionState::Decompressed, + }) + } + + /// Updates the last written slot to the current slot + pub fn bump_last_written_slot(&mut self) -> Result<(), crate::ProgramError> { + self.last_written_slot = Clock::get()?.slot; + Ok(()) + } + + /// Sets the last written slot to a specific value + pub fn set_last_written_slot(&mut self, slot: u64) { + self.last_written_slot = slot; + } + + /// Gets the last written slot + pub fn last_written_slot(&self) -> u64 { + self.last_written_slot + } + + /// Set compressed + pub fn set_compressed(&mut self) { + self.state = CompressionState::Compressed; + } + + /// Check if the account is compressed + pub fn is_compressed(&self) -> bool { + self.state == CompressionState::Compressed + } +} + +#[cfg(feature = "anchor")] +impl anchor_lang::Space for CompressionInfo { + const INIT_SPACE: usize = 8 + 1; // u64 + state enum +} + +/// Generic compressed account data structure for decompress operations +/// This is generic over the account variant type, allowing programs to use their specific enums +/// +/// # Type Parameters +/// * `T` - The program-specific compressed account variant enum (e.g., CompressedAccountVariant) +/// +/// # Fields +/// * `meta` - The compressed account metadata containing tree info, address, and output index +/// * `data` - The program-specific account variant enum +/// * `seeds` - The PDA seeds (without bump) used to derive the PDA address +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct CompressedAccountData { + pub meta: CompressedAccountMetaNoLamportsNoAddress, + /// Program-specific account variant enum + pub data: T, +} diff --git a/sdk-libs/sdk/src/compressible/config.rs b/sdk-libs/sdk/src/compressible/config.rs new file mode 100644 index 0000000000..a9d036e685 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/config.rs @@ -0,0 +1,483 @@ +use std::collections::HashSet; + +use solana_account_info::AccountInfo; +use solana_cpi::invoke_signed; +use solana_msg::msg; +use solana_program::bpf_loader_upgradeable::UpgradeableLoaderState; +use solana_pubkey::Pubkey; +use solana_system_interface::instruction as system_instruction; +use solana_sysvar::rent::Rent; +use solana_sysvar::Sysvar; + +use crate::{error::LightSdkError, AnchorDeserialize, AnchorSerialize}; + +pub const COMPRESSIBLE_CONFIG_SEED: &[u8] = b"compressible_config"; +pub const MAX_ADDRESS_TREES_PER_SPACE: usize = 1; +const BPF_LOADER_UPGRADEABLE_ID: Pubkey = + Pubkey::from_str_const("BPFLoaderUpgradeab1e11111111111111111111111"); + +// TODO: add rent_authority + rent_func like in ctoken. +/// Global configuration for compressible accounts +#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)] +pub struct CompressibleConfig { + /// Config version for future upgrades + pub version: u8, + /// Number of slots to wait before compression is allowed + pub compression_delay: u32, + /// Authority that can update the config + pub update_authority: Pubkey, + /// Account that receives rent from compressed PDAs + pub rent_recipient: Pubkey, + /// Config bump seed (currently always 0)Ã¥ + pub config_bump: u8, + /// PDA bump seed + pub bump: u8, + /// Address space for compressed accounts (currently 1 address_tree allowed) + pub address_space: Vec, +} + +impl CompressibleConfig { + pub const LEN: usize = 1 + 4 + 32 + 32 + 1 + 4 + (32 * MAX_ADDRESS_TREES_PER_SPACE) + 1; // 107 bytes max + + /// Calculate the exact size needed for a CompressibleConfig with the given + /// number of address spaces + pub fn size_for_address_space(num_address_trees: usize) -> usize { + 1 + 4 + 32 + 32 + 1 + 4 + (32 * num_address_trees) + 1 + } + + /// Derives the config PDA address with config bump + pub fn derive_pda(program_id: &Pubkey, config_bump: u8) -> (Pubkey, u8) { + Pubkey::find_program_address(&[COMPRESSIBLE_CONFIG_SEED, &[config_bump]], program_id) + } + + /// Derives the default config PDA address (config_bump = 0) + pub fn derive_default_pda(program_id: &Pubkey) -> (Pubkey, u8) { + Self::derive_pda(program_id, 0) + } + + /// Checks the config account + pub fn validate(&self) -> Result<(), crate::ProgramError> { + if self.version != 1 { + msg!( + "CompressibleConfig validation failed: Unsupported config version: {}", + self.version + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + if self.address_space.len() != 1 { + msg!( + "CompressibleConfig validation failed: Address space must contain exactly 1 pubkey, found: {}", + self.address_space.len() + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + // For now, only allow config_bump = 0 to keep it simple + if self.config_bump != 0 { + msg!( + "CompressibleConfig validation failed: Config bump must be 0 for now, found: {}", + self.config_bump + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + Ok(()) + } + + /// Loads and validates config from account, checking owner and PDA derivation + #[inline(never)] + pub fn load_checked( + account: &AccountInfo, + program_id: &Pubkey, + ) -> Result { + if account.owner != program_id { + msg!( + "CompressibleConfig::load_checked failed: Config account owner mismatch. Expected: {:?}. Found: {:?}.", + program_id, + account.owner + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + let data = account.try_borrow_data()?; + let config = Self::try_from_slice(&data).map_err(|err| { + msg!( + "CompressibleConfig::load_checked failed: Failed to deserialize config data: {:?}", + err + ); + LightSdkError::Borsh + })?; + config.validate()?; + + // CHECK: PDA derivation + let (expected_pda, _) = Self::derive_pda(program_id, config.config_bump); + if expected_pda != *account.key { + msg!( + "CompressibleConfig::load_checked failed: Config account key mismatch. Expected PDA: {:?}. Found: {:?}.", + expected_pda, + account.key + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + Ok(config) + } +} + +/// Creates a new compressible config PDA +/// +/// # Security - Solana Best Practice +/// This function follows the standard Solana pattern where only the program's +/// upgrade authority can create the initial config. This prevents unauthorized +/// parties from hijacking the config system. +/// +/// # Arguments +/// * `config_account` - The config PDA account to initialize +/// * `update_authority` - Authority that can update the config after creation +/// * `rent_recipient` - Account that receives rent from compressed PDAs +/// * `address_space` - Address space for compressed accounts (currently 1 address_tree allowed) +/// * `compression_delay` - Number of slots to wait before compression +/// * `config_bump` - Config bump seed (must be 0 for now) +/// * `payer` - Account paying for the PDA creation +/// * `system_program` - System program +/// * `program_id` - The program that owns the config +/// +/// # Required Validation (must be done by caller) +/// The caller MUST validate that the signer is the program's upgrade authority +/// by checking against the program data account. This cannot be done in the SDK +/// due to dependency constraints. +/// +/// # Returns +/// * `Ok(())` if config was created successfully +/// * `Err(ProgramError)` if there was an error +#[allow(clippy::too_many_arguments)] +pub fn process_initialize_compression_config_account_info<'info>( + config_account: &AccountInfo<'info>, + update_authority: &AccountInfo<'info>, + rent_recipient: &Pubkey, + address_space: Vec, + compression_delay: u32, + config_bump: u8, + payer: &AccountInfo<'info>, + system_program: &AccountInfo<'info>, + program_id: &Pubkey, +) -> Result<(), crate::ProgramError> { + // CHECK: only 1 address_space + if config_bump != 0 { + msg!("Config bump must be 0 for now, found: {}", config_bump); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // CHECK: not already initialized + if config_account.data_len() > 0 { + msg!("Config account already initialized"); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // CHECK: only 1 address_space + if address_space.len() != 1 { + msg!( + "Address space must contain exactly 1 pubkey, found: {}", + address_space.len() + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // CHECK: unique pubkeys in address_space + validate_address_space_no_duplicates(&address_space)?; + + // CHECK: signer + if !update_authority.is_signer { + msg!("Update authority must be signer for initial config creation"); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // CHECK: pda derivation + let (derived_pda, bump) = CompressibleConfig::derive_pda(program_id, config_bump); + if derived_pda != *config_account.key { + msg!("Invalid config PDA"); + return Err(LightSdkError::ConstraintViolation.into()); + } + + let rent = Rent::get().map_err(LightSdkError::from)?; + let account_size = CompressibleConfig::size_for_address_space(address_space.len()); + let rent_lamports = rent.minimum_balance(account_size); + + let seeds = &[COMPRESSIBLE_CONFIG_SEED, &[config_bump], &[bump]]; + let create_account_ix = system_instruction::create_account( + payer.key, + config_account.key, + rent_lamports, + account_size as u64, + program_id, + ); + + invoke_signed( + &create_account_ix, + &[ + payer.clone(), + config_account.clone(), + system_program.clone(), + ], + &[seeds], + ) + .map_err(LightSdkError::from)?; + + let config = CompressibleConfig { + version: 1, + compression_delay, + update_authority: *update_authority.key, + rent_recipient: *rent_recipient, + config_bump, + address_space, + bump, + }; + + let mut data = config_account + .try_borrow_mut_data() + .map_err(LightSdkError::from)?; + config + .serialize(&mut &mut data[..]) + .map_err(|_| LightSdkError::Borsh)?; + + Ok(()) +} + +/// Updates an existing compressible config +/// +/// # Arguments +/// * `config_account` - The config PDA account to update +/// * `authority` - Current update authority (must match config) +/// * `new_update_authority` - Optional new update authority +/// * `new_rent_recipient` - Optional new rent recipient +/// * `new_address_space` - Optional new address space (currently 1 address_tree allowed) +/// * `new_compression_delay` - Optional new compression delay +/// * `owner_program_id` - The program that owns the config +/// +/// # Returns +/// * `Ok(())` if config was updated successfully +/// * `Err(ProgramError)` if there was an error +pub fn process_update_compression_config<'info>( + config_account: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + new_update_authority: Option<&Pubkey>, + new_rent_recipient: Option<&Pubkey>, + new_address_space: Option>, + new_compression_delay: Option, + owner_program_id: &Pubkey, +) -> Result<(), crate::ProgramError> { + // CHECK: PDA derivation + let mut config = CompressibleConfig::load_checked(config_account, owner_program_id)?; + + // CHECK: signer + if !authority.is_signer { + msg!("Update authority must be signer"); + return Err(LightSdkError::ConstraintViolation.into()); + } + // CHECK: authority + if *authority.key != config.update_authority { + msg!("Invalid update authority"); + return Err(LightSdkError::ConstraintViolation.into()); + } + + if let Some(new_authority) = new_update_authority { + config.update_authority = *new_authority; + } + if let Some(new_recipient) = new_rent_recipient { + config.rent_recipient = *new_recipient; + } + if let Some(new_address_space) = new_address_space { + // CHECK: address space length + if new_address_space.len() != MAX_ADDRESS_TREES_PER_SPACE { + msg!( + "New address space must contain exactly 1 pubkey, found: {}", + new_address_space.len() + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + validate_address_space_no_duplicates(&new_address_space)?; + + validate_address_space_only_adds(&config.address_space, &new_address_space)?; + + config.address_space = new_address_space; + } + if let Some(new_delay) = new_compression_delay { + config.compression_delay = new_delay; + } + + let mut data = config_account.try_borrow_mut_data().map_err(|e| { + msg!("Failed to borrow mut data for config_account: {:?}", e); + LightSdkError::from(e) + })?; + config.serialize(&mut &mut data[..]).map_err(|e| { + msg!("Failed to serialize updated config: {:?}", e); + LightSdkError::Borsh + })?; + + Ok(()) +} + +/// Verifies that the signer is the program's upgrade authority +/// +/// # Arguments +/// * `program_id` - The program to check +/// * `program_data_account` - The program's data account (ProgramData) +/// * `authority` - The authority to verify +/// +/// # Returns +/// * `Ok(())` if authority is valid +/// * `Err(LightSdkError)` if authority is invalid or verification fails +pub fn check_program_upgrade_authority( + program_id: &Pubkey, + program_data_account: &AccountInfo, + authority: &AccountInfo, +) -> Result<(), crate::ProgramError> { + // CHECK: program data PDA + let (expected_program_data, _) = + Pubkey::find_program_address(&[program_id.as_ref()], &BPF_LOADER_UPGRADEABLE_ID); + if program_data_account.key != &expected_program_data { + msg!("Invalid program data account"); + return Err(LightSdkError::ConstraintViolation.into()); + } + + let data = program_data_account.try_borrow_data()?; + let program_state: UpgradeableLoaderState = bincode::deserialize(&data).map_err(|_| { + msg!("Failed to deserialize program data account"); + LightSdkError::ConstraintViolation + })?; + + // Extract upgrade authority + let upgrade_authority = match program_state { + UpgradeableLoaderState::ProgramData { + slot: _, + upgrade_authority_address, + } => { + match upgrade_authority_address { + Some(auth) => { + // Check for invalid zero authority when authority exists + if auth == Pubkey::default() { + msg!("Invalid state: authority is zero pubkey"); + return Err(LightSdkError::ConstraintViolation.into()); + } + auth + } + None => { + msg!("Program has no upgrade authority"); + return Err(LightSdkError::ConstraintViolation.into()); + } + } + } + _ => { + msg!("Account is not ProgramData, found: {:?}", program_state); + return Err(LightSdkError::ConstraintViolation.into()); + } + }; + + // CHECK: upgrade authority is signer + if !authority.is_signer { + msg!("Authority must be signer"); + return Err(LightSdkError::ConstraintViolation.into()); + } + + // CHECK: upgrade authority is program's upgrade authority + if *authority.key != upgrade_authority { + msg!( + "Signer is not the program's upgrade authority. Signer: {:?}, Expected Authority: {:?}", + authority.key, + upgrade_authority + ); + return Err(LightSdkError::ConstraintViolation.into()); + } + + Ok(()) +} + +/// Creates a new compressible config PDA with program upgrade authority +/// validation +/// +/// # Security +/// This function verifies that the signer is the program's upgrade authority +/// before creating the config. This ensures only the program deployer can +/// initialize the configuration. +/// +/// # Arguments +/// * `config_account` - The config PDA account to initialize +/// * `update_authority` - Must be the program's upgrade authority +/// * `program_data_account` - The program's data account for validation +/// * `rent_recipient` - Account that receives rent from compressed PDAs +/// * `address_space` - Address spaces for compressed accounts (exactly 1 +/// allowed) +/// * `compression_delay` - Number of slots to wait before compression +/// * `config_bump` - Config bump seed (must be 0 for now) +/// * `payer` - Account paying for the PDA creation +/// * `system_program` - System program +/// * `program_id` - The program that owns the config +/// +/// # Returns +/// * `Ok(())` if config was created successfully +/// * `Err(ProgramError)` if there was an error or authority validation fails +#[allow(clippy::too_many_arguments)] +pub fn process_initialize_compression_config_checked<'info>( + config_account: &AccountInfo<'info>, + update_authority: &AccountInfo<'info>, + program_data_account: &AccountInfo<'info>, + rent_recipient: &Pubkey, + address_space: Vec, + compression_delay: u32, + config_bump: u8, + payer: &AccountInfo<'info>, + system_program: &AccountInfo<'info>, + program_id: &Pubkey, +) -> Result<(), crate::ProgramError> { + msg!( + "create_compression_config_checked program_data_account: {:?}", + program_data_account.key + ); + msg!( + "create_compression_config_checked program_id: {:?}", + program_id + ); + // Verify the signer is the program's upgrade authority + check_program_upgrade_authority(program_id, program_data_account, update_authority)?; + + // Create the config with validated authority + process_initialize_compression_config_account_info( + config_account, + update_authority, + rent_recipient, + address_space, + compression_delay, + config_bump, + payer, + system_program, + program_id, + ) +} + +/// Validates that address_space contains no duplicate pubkeys +fn validate_address_space_no_duplicates(address_space: &[Pubkey]) -> Result<(), LightSdkError> { + let mut seen = HashSet::new(); + for pubkey in address_space { + if !seen.insert(pubkey) { + msg!("Duplicate pubkey found in address_space: {}", pubkey); + return Err(LightSdkError::ConstraintViolation); + } + } + Ok(()) +} + +/// Validates that new_address_space only adds to existing address_space (no removals) +fn validate_address_space_only_adds( + existing_address_space: &[Pubkey], + new_address_space: &[Pubkey], +) -> Result<(), LightSdkError> { + // Check that all existing pubkeys are still present in new address space + for existing_pubkey in existing_address_space { + if !new_address_space.contains(existing_pubkey) { + msg!( + "Cannot remove existing pubkey from address_space: {}", + existing_pubkey + ); + return Err(LightSdkError::ConstraintViolation); + } + } + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/decompress_idempotent.rs b/sdk-libs/sdk/src/compressible/decompress_idempotent.rs new file mode 100644 index 0000000000..4c4c094035 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/decompress_idempotent.rs @@ -0,0 +1,148 @@ +#![allow(clippy::all)] // TODO: Remove. + +use light_compressed_account::address::derive_address; +use light_hasher::DataHasher; +use light_sdk_types::instruction::account_meta::{ + CompressedAccountMeta, CompressedAccountMetaNoLamportsNoAddress, +}; +use solana_account_info::AccountInfo; +use solana_cpi::invoke_signed; +use solana_msg::msg; +use solana_pubkey::Pubkey; +use solana_system_interface::instruction as system_instruction; +use solana_sysvar::rent::Rent; +use solana_sysvar::Sysvar; + +use crate::{ + account::sha::LightAccount, compressible::compression_info::HasCompressionInfo, + error::LightSdkError, AnchorDeserialize, AnchorSerialize, LightDiscriminator, +}; + +#[cfg(feature = "v2")] +use crate::cpi::v2::CpiAccounts; + +/// Convert a `CompressedAccountMetaNoLamportsNoAddress` to a +/// `CompressedAccountMeta` by deriving the compressed address from the solana +/// account's pubkey. +pub fn into_compressed_meta_with_address<'info>( + compressed_meta_no_lamports_no_address: &CompressedAccountMetaNoLamportsNoAddress, + solana_account: &AccountInfo<'info>, + address_space: Pubkey, + program_id: &Pubkey, +) -> CompressedAccountMeta { + let derived_c_pda = derive_address( + &solana_account.key.to_bytes(), + &address_space.to_bytes(), + &program_id.to_bytes(), + ); + + let meta_with_address = CompressedAccountMeta { + tree_info: compressed_meta_no_lamports_no_address.tree_info, + address: derived_c_pda, + output_state_tree_index: compressed_meta_no_lamports_no_address.output_state_tree_index, + }; + + meta_with_address +} + +// TODO: consider folding into main fn. +/// Helper to invoke create_account on heap. +#[inline(never)] +fn invoke_create_account_with_heap<'info>( + rent_payer: &AccountInfo<'info>, + solana_account: &AccountInfo<'info>, + rent_minimum_balance: u64, + space: u64, + program_id: &Pubkey, + seeds: &[&[u8]], + system_program: &AccountInfo<'info>, +) -> Result<(), LightSdkError> { + let create_account_ix = system_instruction::create_account( + rent_payer.key, + solana_account.key, + rent_minimum_balance, + space, + program_id, + ); + + invoke_signed( + &create_account_ix, + &[ + rent_payer.clone(), + solana_account.clone(), + system_program.clone(), + ], + &[seeds], + ) + .map_err(|e| LightSdkError::ProgramError(e)) +} + +/// Helper function to decompress a compressed account into a PDA +/// idempotently with seeds. Does not invoke the zk compression CPI. +#[inline(never)] +#[cfg(feature = "v2")] +pub fn prepare_account_for_decompression_idempotent<'a, 'info, T>( + program_id: &Pubkey, + data: T, + compressed_meta: CompressedAccountMeta, + solana_account: &AccountInfo<'info>, + rent_payer: &AccountInfo<'info>, + cpi_accounts: &CpiAccounts<'a, 'info>, + signer_seeds: &[&[u8]], +) -> Result< + Option, + LightSdkError, +> +where + T: Clone + + crate::account::Size + // + DataHasher + + LightDiscriminator + + Default + + AnchorSerialize + + AnchorDeserialize + + HasCompressionInfo + + 'info, +{ + if !solana_account.data_is_empty() { + msg!("Account already initialized, skipping"); + return Ok(None); + } + let rent = Rent::get().map_err(|err| { + msg!("Failed to get rent: {:?}", err); + LightSdkError::Borsh + })?; + + let light_account = LightAccount::::new_close(program_id, &compressed_meta, data)?; + + let space = T::size(&light_account.account); + let rent_minimum_balance = rent.minimum_balance(space); + + invoke_create_account_with_heap( + rent_payer, + solana_account, + rent_minimum_balance, + space as u64, + &cpi_accounts.self_program_id(), + signer_seeds, + cpi_accounts.system_program()?, + )?; + + // set compression info + let mut decompressed_pda = light_account.account.clone(); + *decompressed_pda.compression_info_mut_opt() = + Some(super::CompressionInfo::new_decompressed()?); + + // serialize onchain account + let mut account_data = solana_account.try_borrow_mut_data()?; + let discriminator_len = T::LIGHT_DISCRIMINATOR.len(); + account_data[..discriminator_len].copy_from_slice(&T::LIGHT_DISCRIMINATOR); + decompressed_pda + .serialize(&mut &mut account_data[discriminator_len..]) + .map_err(|err| { + msg!("Failed to serialize decompressed PDA: {:?}", err); + LightSdkError::Borsh + })?; + + Ok(Some(light_account.to_account_info()?)) +} diff --git a/sdk-libs/sdk/src/compressible/mod.rs b/sdk-libs/sdk/src/compressible/mod.rs new file mode 100644 index 0000000000..d7122fdea1 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/mod.rs @@ -0,0 +1,33 @@ +//! SDK helpers for compressing and decompressing compressible PDAs accounts. + +pub mod compress_account; +pub mod compress_account_on_init; +pub mod compress_account_on_init_native; +pub mod compression_info; +pub mod config; +pub mod decompress_idempotent; + +#[cfg(feature = "anchor")] +pub use compress_account::compress_account; +#[cfg(feature = "v2")] +pub use compress_account::compress_pda_native; +#[cfg(all(feature = "anchor", feature = "v2"))] +pub use compress_account_on_init::{ + compress_account_on_init, compress_empty_account_on_init, + prepare_accounts_for_compression_on_init, prepare_empty_compressed_accounts_on_init, +}; +#[cfg(feature = "v2")] +pub use compress_account_on_init_native::{ + compress_account_on_init_native, compress_empty_account_on_init_native, + prepare_accounts_for_compression_on_init_native, + prepare_empty_compressed_accounts_on_init_native, +}; +pub use compression_info::{CompressAs, CompressionInfo, HasCompressionInfo, Pack, Unpack}; +pub use config::{ + process_initialize_compression_config_account_info, + process_initialize_compression_config_checked, process_update_compression_config, + CompressibleConfig, COMPRESSIBLE_CONFIG_SEED, MAX_ADDRESS_TREES_PER_SPACE, +}; +pub use decompress_idempotent::into_compressed_meta_with_address; +#[cfg(feature = "v2")] +pub use decompress_idempotent::prepare_account_for_decompression_idempotent; diff --git a/sdk-libs/sdk/src/cpi/invoke.rs b/sdk-libs/sdk/src/cpi/invoke.rs index 91b0bf479d..0ebe09dc08 100644 --- a/sdk-libs/sdk/src/cpi/invoke.rs +++ b/sdk-libs/sdk/src/cpi/invoke.rs @@ -1,5 +1,6 @@ pub use light_compressed_account::LightInstructionData; use light_sdk_types::constants::{CPI_AUTHORITY_PDA_SEED, LIGHT_SYSTEM_PROGRAM_ID}; +use solana_msg::msg; #[cfg(feature = "cpi-context")] use crate::AccountMeta; @@ -74,6 +75,7 @@ where let account_infos = accounts.to_account_infos(); let account_metas = accounts.to_account_metas()?; + msg!("invoking LightSystemProgramCpi, {:?}", account_metas); let instruction = Instruction { program_id: LIGHT_SYSTEM_PROGRAM_ID.into(), accounts: account_metas, diff --git a/sdk-libs/sdk/src/lib.rs b/sdk-libs/sdk/src/lib.rs index a8586aba90..cf35bee295 100644 --- a/sdk-libs/sdk/src/lib.rs +++ b/sdk-libs/sdk/src/lib.rs @@ -152,6 +152,7 @@ pub mod token; pub mod transfer; pub mod utils; +pub mod compressible; #[cfg(feature = "merkle-tree")] pub mod merkle_tree; @@ -159,6 +160,7 @@ pub mod merkle_tree; use anchor_lang::{AnchorDeserialize, AnchorSerialize}; #[cfg(not(feature = "anchor"))] use borsh::{BorshDeserialize as AnchorDeserialize, BorshSerialize as AnchorSerialize}; +pub use compressible::*; pub use light_account_checks::{self, discriminator::Discriminator as LightDiscriminator}; pub use light_hasher; #[cfg(feature = "poseidon")] diff --git a/sdk-libs/sdk/src/token.rs b/sdk-libs/sdk/src/token.rs index 14c0cafb7b..22db434f66 100644 --- a/sdk-libs/sdk/src/token.rs +++ b/sdk-libs/sdk/src/token.rs @@ -1,7 +1,12 @@ use light_compressed_account::compressed_account::CompressedAccountWithMerkleContext; use light_hasher::{sha256::Sha256BE, HasherError}; +use solana_account_info::AccountInfo; -use crate::{AnchorDeserialize, AnchorSerialize, Pubkey}; +use crate::{ + compressible::compression_info::{Pack, Unpack}, + instruction::PackedAccounts, + AnchorDeserialize, AnchorSerialize, Pubkey, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq, AnchorDeserialize, AnchorSerialize, Default)] #[repr(u8)] @@ -57,3 +62,176 @@ impl TokenDataWithMerkleContext { } } } + +/// Implementation for TokenData - packs into InputTokenDataCompressible +impl Pack for TokenData { + type Packed = InputTokenDataCompressible; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + InputTokenDataCompressible { + owner: remaining_accounts.insert_or_get(self.owner), + amount: self.amount, + has_delegate: self.delegate.is_some(), + delegate: if let Some(delegate) = self.delegate { + remaining_accounts.insert_or_get(delegate) + } else { + 0 // Unused when has_delegate is false + }, + mint: remaining_accounts.insert_or_get_read_only(self.mint), + version: 3, // TokenDataVersion::ShaFlat. Default version for compressed token accounts + } + } +} + +impl Unpack for TokenData { + type Unpacked = Self; + + fn unpack( + &self, + _remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } +} + +/// Unpack implementation for InputTokenDataCompressible +impl Unpack for InputTokenDataCompressible { + type Unpacked = TokenData; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(TokenData { + owner: *remaining_accounts + .get(self.owner as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key, + amount: self.amount, + delegate: if self.has_delegate { + Some( + *remaining_accounts + .get(self.delegate as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key, + ) + } else { + None + }, + mint: *remaining_accounts + .get(self.mint as usize) + .ok_or(solana_program_error::ProgramError::InvalidAccountData)? + .key, + state: AccountState::Initialized, // Default state for unpacked + tlv: None, // No TLV data in packed version + }) + } +} + +/// Wrapper for token data with variant information +/// The variant is user-defined and doesn't get altered during packing +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] +pub struct TokenDataWithVariant { + pub variant: V, + pub token_data: TokenData, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] +pub struct PackedCTokenDataWithVariant { + pub variant: V, + pub token_data: InputTokenDataCompressible, +} +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone)] +pub struct CTokenDataWithVariant { + pub variant: V, + pub token_data: TokenData, +} + +/// Pack implementation for CTokenDataWithVariant +impl Pack for CTokenDataWithVariant +where + V: AnchorSerialize + Clone + std::fmt::Debug, +{ + type Packed = PackedCTokenDataWithVariant; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + PackedCTokenDataWithVariant { + variant: self.variant.clone(), + token_data: self.token_data.pack(remaining_accounts), + } + } +} + +/// Unpack implementation for CTokenDataWithVariant +impl Unpack for CTokenDataWithVariant +where + V: Clone, +{ + type Unpacked = TokenDataWithVariant; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(TokenDataWithVariant { + variant: self.variant.clone(), + token_data: self.token_data.unpack(remaining_accounts)?, + }) + } +} + +/// Pack implementation for TokenDataWithVariant +impl Pack for TokenDataWithVariant +where + V: AnchorSerialize + Clone + std::fmt::Debug, +{ + type Packed = PackedCTokenDataWithVariant; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + PackedCTokenDataWithVariant { + variant: self.variant.clone(), + token_data: self.token_data.pack(remaining_accounts), + } + } +} + +/// Unpack implementation for PackedCTokenDataWithVariant +impl Unpack for PackedCTokenDataWithVariant +where + V: Clone, +{ + type Unpacked = TokenDataWithVariant; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(TokenDataWithVariant { + variant: self.variant.clone(), + token_data: self.token_data.unpack(remaining_accounts)?, + }) + } +} + +// custom replacement for MultiInputTokenDataWithContext +// without root_index and without merkle_context +#[derive(Clone, Debug, AnchorDeserialize, AnchorSerialize, Default)] +pub struct InputTokenDataCompressible { + pub owner: u8, + pub amount: u64, + pub has_delegate: bool, // Optional delegate is set + pub delegate: u8, + pub mint: u8, + pub version: u8, +} + +// TODO: remove these and fix renaming after we're done with ci. +#[deprecated(since = "0.2.0", note = "Use `CTokenDataWithVariant` instead")] +pub type CompressibleTokenDataWithVariant = CTokenDataWithVariant; + +#[deprecated(since = "0.2.0", note = "Use `PackedCTokenDataWithVariant` instead")] +pub type PackedCompressibleTokenDataWithVariant = PackedCTokenDataWithVariant; + +// Shorter aliases for convenience +pub type CTokenData = CTokenDataWithVariant; +pub type PackedCTokenData = PackedCTokenDataWithVariant; diff --git a/sdk-libs/token-client/src/actions/create_compressible_token_account.rs b/sdk-libs/token-client/src/actions/create_compressible_token_account.rs index 9ec93b93d5..981e851f04 100644 --- a/sdk-libs/token-client/src/actions/create_compressible_token_account.rs +++ b/sdk-libs/token-client/src/actions/create_compressible_token_account.rs @@ -1,7 +1,6 @@ use light_client::rpc::{Rpc, RpcError}; -use light_compressed_token_sdk::instructions::{ - create_compressible_token_account as create_instruction, CreateCompressibleTokenAccount, -}; +use light_compressed_token_sdk::instructions::create_compressible_token_account_instruction as create_instruction; +use light_compressed_token_sdk::instructions::CreateCompressibleTokenAccount; use light_ctoken_types::state::TokenDataVersion; use solana_keypair::Keypair; use solana_pubkey::Pubkey; @@ -30,6 +29,7 @@ pub async fn create_compressible_token_account( rpc: &mut R, inputs: CreateCompressibleTokenAccountInputs<'_>, ) -> Result { + panic!("create_compressible_token_account not yet implemented with new API"); let CreateCompressibleTokenAccountInputs { owner, mint, diff --git a/sdk-libs/token-client/src/actions/ctoken_transfer.rs b/sdk-libs/token-client/src/actions/ctoken_transfer.rs index 9f4a20cb43..f1d8d6b0fa 100644 --- a/sdk-libs/token-client/src/actions/ctoken_transfer.rs +++ b/sdk-libs/token-client/src/actions/ctoken_transfer.rs @@ -1,3 +1,4 @@ +// TODO: move transfer_ctoken to compressed-token-sdk use light_client::rpc::{Rpc, RpcError}; use solana_instruction::{AccountMeta, Instruction}; use solana_keypair::Keypair; @@ -18,7 +19,7 @@ use solana_signer::Signer; /// /// # Returns /// `Result` - The transaction signature -pub async fn ctoken_transfer( +pub async fn transfer_ctoken( rpc: &mut R, source: Pubkey, destination: Pubkey, @@ -27,7 +28,7 @@ pub async fn ctoken_transfer( payer: &Keypair, ) -> Result { let transfer_instruction = - create_ctoken_transfer_instruction(source, destination, amount, authority.pubkey())?; + create_transfer_ctoken_instruction(source, destination, amount, authority.pubkey())?; let mut signers = vec![payer]; if authority.pubkey() != payer.pubkey() { @@ -38,9 +39,9 @@ pub async fn ctoken_transfer( .await } -/// Create a decompressed token transfer instruction. +/// Create a ctoken transfer instruction. /// This creates an instruction that uses discriminator 3 (CTokenTransfer) to perform -/// SPL token transfers on decompressed compressed token accounts. +/// SPL token transfers on ctoken accounts. /// /// # Arguments /// * `source` - Source token account @@ -51,7 +52,7 @@ pub async fn ctoken_transfer( /// # Returns /// `Result` #[allow(clippy::result_large_err)] -pub fn create_ctoken_transfer_instruction( +pub fn create_transfer_ctoken_instruction( source: Pubkey, destination: Pubkey, amount: u64, @@ -63,6 +64,7 @@ pub fn create_ctoken_transfer_instruction( AccountMeta::new(source, false), // Source token account AccountMeta::new(destination, false), // Destination token account AccountMeta::new(authority, true), // Owner/Authority (signer, writable for lamport transfers) + // TODO: try to remove this AccountMeta::new_readonly(Pubkey::default(), false), // System program for CPI transfers ], data: { diff --git a/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs b/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs index 60c149ff66..a8775e12f1 100644 --- a/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs +++ b/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs @@ -3,7 +3,8 @@ use light_client::{ rpc::{Rpc, RpcError}, }; use light_compressed_token_sdk::{ - account2::create_ctoken_to_spl_transfer_instruction, token_pool::find_token_pool_pda_with_index, + instructions::create_transfer_ctoken_to_spl_instruction, + token_pool::find_token_pool_pda_with_index, SPL_TOKEN_PROGRAM_ID, }; use solana_keypair::Keypair; use solana_pubkey::Pubkey; @@ -11,7 +12,7 @@ use solana_signature::Signature; use solana_signer::Signer; /// Transfer tokens from a compressed token account to an SPL token account -pub async fn ctoken_to_spl_transfer( +pub async fn transfer_ctoken_to_spl( rpc: &mut R, source_ctoken_account: Pubkey, destination_spl_token_account: Pubkey, @@ -24,7 +25,7 @@ pub async fn ctoken_to_spl_transfer( let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint, 0); // Create the transfer instruction - let transfer_ix = create_ctoken_to_spl_transfer_instruction( + let transfer_ix = create_transfer_ctoken_to_spl_instruction( source_ctoken_account, destination_spl_token_account, amount, @@ -33,6 +34,7 @@ pub async fn ctoken_to_spl_transfer( payer.pubkey(), token_pool_pda, token_pool_pda_bump, + Pubkey::new_from_array(SPL_TOKEN_PROGRAM_ID), // TODO: make dynamic ) .map_err(|e| RpcError::AssertRpcError(format!("Failed to create instruction: {:?}", e)))?; diff --git a/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs b/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs index 99607ed3aa..e289ba919d 100644 --- a/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs +++ b/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs @@ -2,8 +2,9 @@ use light_client::{ indexer::Indexer, rpc::{Rpc, RpcError}, }; +use light_compressed_token_sdk::token_pool::find_token_pool_pda_with_index; use light_compressed_token_sdk::{ - account2::create_spl_to_ctoken_transfer_instruction, token_pool::find_token_pool_pda_with_index, + instructions::create_transfer_spl_to_ctoken_instruction, SPL_TOKEN_PROGRAM_ID, }; use solana_keypair::Keypair; use solana_pubkey::Pubkey; @@ -14,7 +15,7 @@ use spl_token_2022::pod::PodAccount; /// Transfer SPL tokens directly to compressed tokens in a single transaction. /// -/// This function wraps `create_spl_to_ctoken_transfer_instruction` to provide +/// This function wraps `create_transfer_spl_to_ctoken_instruction` to provide /// a convenient action for transferring from SPL token accounts to compressed tokens. /// /// # Arguments @@ -50,7 +51,7 @@ pub async fn spl_to_ctoken_transfer( let (token_pool_pda, bump) = find_token_pool_pda_with_index(&mint, 0); // Create the SPL to CToken transfer instruction - let ix = create_spl_to_ctoken_transfer_instruction( + let ix = create_transfer_spl_to_ctoken_instruction( source_spl_token_account, to, amount, @@ -59,6 +60,7 @@ pub async fn spl_to_ctoken_transfer( payer.pubkey(), token_pool_pda, bump, + Pubkey::new_from_array(SPL_TOKEN_PROGRAM_ID), // TODO: make dynamic ) .map_err(|e| RpcError::CustomError(e.to_string()))?; diff --git a/sdk-libs/token-client/src/lib.rs b/sdk-libs/token-client/src/lib.rs index 8f5d67d6dc..aaf3ca7e4e 100644 --- a/sdk-libs/token-client/src/lib.rs +++ b/sdk-libs/token-client/src/lib.rs @@ -1,2 +1,173 @@ pub mod actions; pub mod instructions; + +// Re-export the main utility functions for easy access +use solana_pubkey::{pubkey, Pubkey}; + +pub const CTOKEN_PROGRAM_ID: Pubkey = pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"); + +pub const CTOKEN_CPI_AUTHORITY: Pubkey = pubkey!("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"); + +pub mod ctoken { + use light_compressed_account::address::derive_address; + use light_compressed_token_sdk::POOL_SEED; + use solana_pubkey::Pubkey; + + use super::{CTOKEN_CPI_AUTHORITY, CTOKEN_PROGRAM_ID}; + + pub const ID: Pubkey = CTOKEN_PROGRAM_ID; + + /// Returns the program ID for the Compressed Token Program + pub fn id() -> Pubkey { + ID + } + /// Return the cpi authority pda of the Compressed Token Program. + pub fn cpi_authority() -> Pubkey { + CTOKEN_CPI_AUTHORITY + } + + pub fn get_token_pool_address_and_bump(mint: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[POOL_SEED, mint.as_ref()], &CTOKEN_PROGRAM_ID) + } + /// Returns the associated ctoken address for a given owner and mint. + pub fn get_associated_ctoken_address(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[&owner.to_bytes(), &id().to_bytes(), &mint.to_bytes()], + &id(), + ) + .0 + } + /// Returns the associated ctoken address and bump for a given owner and mint. + pub fn get_associated_ctoken_address_and_bump(owner: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[&owner.to_bytes(), &id().to_bytes(), &mint.to_bytes()], + &id(), + ) + } + + pub const CTOKEN_MINT_SEED: &[u8] = &[ + // b"compressed_mint" + 99, 111, 109, 112, 114, 101, 115, 115, 101, 100, 95, 109, 105, 110, 116, + ]; + + /// Derives the cToken program mint PDA from the provided signer pubkey (keypair or PDA). + /// The signer must sign when creating the SPL mint PDA on-chain. + pub fn find_mint_address(signer: Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[CTOKEN_MINT_SEED, signer.to_bytes().as_ref()], &ID) + } + + pub fn derive_compressed_address(mint: Pubkey, address_tree: &Pubkey) -> [u8; 32] { + derive_address(&mint.to_bytes(), &address_tree.to_bytes(), &ID.to_bytes()) + } + + /// Comprehensive helper that derives all addresses from a signer in one call + /// Returns: (mint_address, mint_bump, compressed_address) + pub fn derive_compressed_address_from_mint_signer( + signer: Pubkey, + address_tree: &Pubkey, + ) -> (Pubkey, u8, [u8; 32]) { + let (mint_address, mint_bump) = find_mint_address(signer); + let compressed_address = derive_compressed_address(mint_address, address_tree); + (mint_address, mint_bump, compressed_address) + } + + pub fn derive_ctoken_program_config(_version: Option) -> (Pubkey, u8) { + let version = 1u16; + let registry_program_id = + solana_pubkey::pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX"); + let (compressible_config_pda, config_bump) = Pubkey::find_program_address( + &[b"compressible_config", &version.to_le_bytes()], + ®istry_program_id, + ); + println!("compressible_config: {:?}", compressible_config_pda); + (compressible_config_pda, config_bump) + } + + pub fn derive_ctoken_rent_sponsor(_version: Option) -> (Pubkey, u8) { + // Derive the rent_recipient PDA + // let version = version.unwrap_or(1); + let version = 1u16; + Pubkey::find_program_address( + &[ + b"rent_sponsor".as_slice(), + (version as u16).to_le_bytes().as_slice(), + ], + &solana_pubkey::pubkey!("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"), + ) + } + + pub fn derive_ctoken_compression_authority(version: Option) -> (Pubkey, u8) { + let registry_program_id = + solana_pubkey::pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX"); + let (compression_authority, compression_authority_bump) = Pubkey::find_program_address( + &[ + b"compression_authority".as_slice(), + version.unwrap_or(1).to_le_bytes().as_slice(), + ], + ®istry_program_id, + ); + (compression_authority, compression_authority_bump) + } + + // /// Derives the SPL mint PDA from a signer keypair + // /// + // /// # Arguments + // /// * `signer` - The signer pubkey used as seed + // /// + // /// # Returns + // /// Tuple of (mint_pda, bump_seed) + // /// Derives the Compressed Token Program mint PDA from a signer pubkey. + // /// + // /// This derives the cToken program mint PDA for a given keypair or PDA; that signer must sign. + // pub fn find_mint_address(signer: &Pubkey) -> (Pubkey, u8) { + // sdk_find_mint_address(signer) + // } + + // /// Derives the compressed address from a mint PDA and address tree + // /// + // /// # Arguments + // /// * `mint` - The mint PDA + // /// * `address_tree` - The address tree pubkey + // /// + // /// # Returns + // /// The compressed address as [u8; 32] + // pub fn derive_compressed_address(mint: &Pubkey, address_tree: &Pubkey) -> [u8; 32] { + // sdk_derive_address( + // &mint.to_bytes(), + // &address_tree.to_bytes(), + // &super::ctoken::ID.to_bytes(), + // ) + // } + + // /// Comprehensive helper that derives all addresses from a signer in one call + // /// + // /// This is the main function you should use for mint derivation. + // /// + // /// # Arguments + // /// * `signer` - The signer keypair pubkey + // /// * `address_tree` - The address tree pubkey + // /// + // /// # Returns + // /// Tuple of (mint_address, mint_bump, compressed_address) + // /// + // /// # Example + // /// ```rust + // /// use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; + // /// use light_token_client::utils::derive_compressed_address_from_signer; + // /// + // /// let signer = Keypair::new(); + // /// let address_tree = Pubkey::new_unique(); + // /// let (mint_pda, mint_bump, compressed_address) = + // /// derive_compressed_address_from_signer(&signer.pubkey(), &address_tree); + // /// + // /// println!("Mint PDA: {}", mint_pda); + // /// println!("Mint Bump: {}", mint_bump); + // /// println!("Compressed Address: {:?}", compressed_address); + // /// ``` + // pub fn derive_compressed_address_from_mint_signer( + // signer: &Pubkey, + // address_tree: &Pubkey, + // ) -> (Pubkey, u8, [u8; 32]) { + // sdk_derive_compressed_address_from_mint_signer(signer, address_tree) + // } +} diff --git a/sdk-tests/csdk-anchor-test/Cargo.toml b/sdk-tests/csdk-anchor-test/Cargo.toml new file mode 100644 index 0000000000..6c2556158d --- /dev/null +++ b/sdk-tests/csdk-anchor-test/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "csdk-anchor-test" +version = "0.1.0" +description = "Simple Anchor program template with user records" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "csdk_anchor_test" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = ["idl-build"] +idl-build = ["anchor-lang/idl-build", "light-sdk/idl-build"] +test-sbf = [] + +[dependencies] +light-sdk = { workspace = true, features = ["anchor", "idl-build", "v2", "anchor-discriminator", "cpi-context"] } +light-sdk-types = { workspace = true, features = ["v2", "cpi-context"] } +light-hasher = { workspace = true, features = ["solana"] } +solana-program = { workspace = true } +light-macros = { workspace = true, features = ["solana"] } +borsh = { workspace = true } +light-compressed-account = { workspace = true, features = ["solana"] } +anchor-lang = { workspace = true, features = ["idl-build"] } +anchor-spl = { version = "=0.31.1", git = "https://github.com/lightprotocol/anchor", rev = "d8a2b3d9", features = ["memo", "metadata", "idl-build"] } +light-ctoken-types = { workspace = true, features = ["anchor"] } +light-compressed-token-sdk = { workspace = true, features = ["anchor"] } +light-compressed-token-types = { workspace = true, features = ["anchor"] } +light-compressible = { workspace = true, features = ["anchor"] } + +[dev-dependencies] +light-token-client = { workspace = true } +light-program-test = { workspace = true, features = ["v2"] } +light-client = { workspace = true, features = ["v2"] } +light-compressible-client = { workspace = true, features = ["anchor"] } +light-test-utils = { workspace = true} +tokio = { workspace = true } +solana-sdk = { workspace = true } +solana-logger = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +solana-signature = { workspace = true } +solana-signer = { workspace = true } +solana-keypair = { workspace = true } +solana-account = { workspace = true } +bincode = "1.3" + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] \ No newline at end of file diff --git a/sdk-tests/csdk-anchor-test/Xargo.toml b/sdk-tests/csdk-anchor-test/Xargo.toml new file mode 100644 index 0000000000..1744f098ae --- /dev/null +++ b/sdk-tests/csdk-anchor-test/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/sdk-tests/csdk-anchor-test/src/lib.rs b/sdk-tests/csdk-anchor-test/src/lib.rs new file mode 100644 index 0000000000..5983a2927f --- /dev/null +++ b/sdk-tests/csdk-anchor-test/src/lib.rs @@ -0,0 +1,2248 @@ +use anchor_lang::{ + prelude::*, + solana_program::{instruction::AccountMeta, program::invoke, pubkey::Pubkey}, +}; +use anchor_spl::token_interface::TokenAccount; +use light_compressed_token_sdk::instructions::{ + create_compressed_mint::find_spl_mint_address, create_mint_action_cpi, MintActionInputs, +}; +use light_ctoken_types::instructions::mint_action::CompressedMintWithContext; +use light_sdk::{ + account::Size, + compressible::{ + compress_account::prepare_account_for_compression, + compress_account_on_init, compress_empty_account_on_init, + decompress_idempotent::{ + into_compressed_meta_with_address, prepare_account_for_decompression_idempotent, + }, + prepare_accounts_for_compression_on_init, process_initialize_compression_config_checked, + process_update_compression_config, CompressAs, CompressibleConfig, CompressionInfo, + HasCompressionInfo, Pack, Unpack, + }, + compression_info::CompressedInitSpace, + derive_light_cpi_signer, + instruction::{ + account_meta::CompressedAccountMetaNoLamportsNoAddress, PackedAccounts, + PackedAddressTreeInfo, ValidityProof, + }, + light_hasher::{DataHasher, Hasher}, + LightDiscriminator, LightHasher, +}; +use light_sdk_types::{cpi_accounts::CpiAccountsConfig, CpiSigner, C_TOKEN_PROGRAM_ID}; + +pub const POOL_VAULT_SEED: &str = "pool_vault"; +pub const USER_RECORD_SEED: &str = "user_record"; +pub const CTOKEN_SIGNER_SEED: &str = "ctoken_signer"; +#[repr(u32)] +pub enum ErrorCode { + InvalidAccountCount, + InvalidRentRecipient, + MintCreationFailed, + MissingCompressedTokenProgram, + MissingCompressedTokenProgramAuthorityPDA, +} +#[automatically_derived] +impl ::core::fmt::Debug for ErrorCode { + #[inline] + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + ::core::fmt::Formatter::write_str( + f, + match self { + ErrorCode::InvalidAccountCount => "InvalidAccountCount", + ErrorCode::InvalidRentRecipient => "InvalidRentRecipient", + ErrorCode::MintCreationFailed => "MintCreationFailed", + ErrorCode::MissingCompressedTokenProgram => "MissingCompressedTokenProgram", + ErrorCode::MissingCompressedTokenProgramAuthorityPDA => { + "MissingCompressedTokenProgramAuthorityPDA" + } + }, + ) + } +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + match self { + ErrorCode::InvalidAccountCount => fmt.write_fmt(format_args!( + "Invalid account count: PDAs and compressed accounts must match", + )), + ErrorCode::InvalidRentRecipient => { + fmt.write_fmt(format_args!("Rent recipient does not match config")) + } + ErrorCode::MintCreationFailed => { + fmt.write_fmt(format_args!("Failed to create compressed mint")) + } + ErrorCode::MissingCompressedTokenProgram => fmt.write_fmt(format_args!( + "Compressed token program account not found in remaining accounts", + )), + ErrorCode::MissingCompressedTokenProgramAuthorityPDA => fmt.write_fmt(format_args!( + "Compressed token program authority PDA account not found in remaining accounts", + )), + } + } +} +// extern crate alloc; +#[repr(u32)] +/// Auto-generated error codes for compressible instructions +/// These are separate from the user's ErrorCode enum to avoid conflicts +pub enum CompressibleInstructionError { + InvalidRentRecipient, + CTokenDecompressionNotImplemented, + PdaDecompressionNotImplemented, + TokenCompressionNotImplemented, + PdaCompressionNotImplemented, +} +// Auto-generated client-side seed function +pub fn get_userrecord_seeds(owner: &Pubkey) -> (Vec>, anchor_lang::prelude::Pubkey) { + let mut seed_values = Vec::with_capacity(2usize + 1); + seed_values.push((USER_RECORD_SEED.as_bytes()).to_vec()); + seed_values.push((owner.as_ref()).to_vec()); + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = anchor_lang::prelude::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(<[_]>::into_vec(Box::new([bump]))); + (seed_values, pda) +} +/// Auto-generated client-side seed function +pub fn get_gamesession_seeds(session_id: u64) -> (Vec>, anchor_lang::prelude::Pubkey) { + let mut seed_values = Vec::with_capacity(2usize + 1); + seed_values.push(("game_session".as_bytes()).to_vec()); + seed_values.push((session_id.to_le_bytes().as_ref()).to_vec()); + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = anchor_lang::prelude::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(<[_]>::into_vec(Box::new([bump]))); + (seed_values, pda) +} +/// Auto-generated client-side seed function +pub fn get_placeholderrecord_seeds( + placeholder_id: u64, +) -> (Vec>, anchor_lang::prelude::Pubkey) { + let mut seed_values = Vec::with_capacity(2usize + 1); + seed_values.push(("placeholder_record".as_bytes()).to_vec()); + seed_values.push((placeholder_id.to_le_bytes().as_ref()).to_vec()); + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = anchor_lang::prelude::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(<[_]>::into_vec(Box::new([bump]))); + (seed_values, pda) +} +/// Auto-generated client-side CToken seed function +pub fn get_ctokensigner_seeds( + fee_payer: &anchor_lang::prelude::Pubkey, + some_mint: &anchor_lang::prelude::Pubkey, +) -> (Vec>, anchor_lang::prelude::Pubkey) { + let mut seed_values = Vec::with_capacity(3usize + 1); + seed_values.push((CTOKEN_SIGNER_SEED.as_bytes()).to_vec()); + seed_values.push((fee_payer.as_ref()).to_vec()); + seed_values.push((some_mint.as_ref()).to_vec()); + let seed_slices: Vec<&[u8]> = seed_values.iter().map(|v| v.as_slice()).collect(); + let (pda, bump) = anchor_lang::prelude::Pubkey::find_program_address(&seed_slices, &crate::ID); + seed_values.push(<[_]>::into_vec(Box::new([bump]))); + (seed_values, pda) +} +/// Trait-based system for generic CToken variant seed handling +/// Users implement this trait for their CTokenAccountVariant enum +pub mod ctoken_seed_system { + use super::*; + /// Context struct providing access to ALL instruction accounts + /// This gives users access to any account in the instruction context + pub struct CTokenSeedContext<'a, 'info> { + pub accounts: &'a DecompressAccountsIdempotent<'info>, + pub remaining_accounts: &'a [anchor_lang::prelude::AccountInfo<'info>], + } + /// Trait that CToken variants implement to provide seed derivation + /// Completely extensible - users can implement ANY seed logic with access to ALL accounts + pub trait CTokenSeedProvider { + fn get_seeds<'a, 'info>( + &self, + ctx: &CTokenSeedContext<'a, 'info>, + ) -> (Vec>, Pubkey); + } +} +/// Auto-generated CTokenSeedProvider implementation +impl ctoken_seed_system::CTokenSeedProvider for CTokenAccountVariant { + fn get_seeds<'a, 'info>( + &self, + ctx: &ctoken_seed_system::CTokenSeedContext<'a, 'info>, + ) -> (Vec>, anchor_lang::prelude::Pubkey) { + match self { + CTokenAccountVariant::CTokenSigner => { + let seed_1 = ctx.accounts.fee_payer.key().to_bytes(); + let seed_2 = ctx.accounts.some_mint.key().to_bytes(); + let seeds: &[&[u8]] = &[CTOKEN_SIGNER_SEED.as_bytes(), &seed_1, &seed_2]; + let (pda, bump) = + anchor_lang::prelude::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(<[_]>::into_vec(Box::new([bump]))); + (seeds_vec, pda) + } + CTokenAccountVariant::CTokenSigner2 => { + let seed_1 = ctx.accounts.fee_payer.key().to_bytes(); + let seeds: &[&[u8]] = &[b"user_vault", &seed_1]; + let (pda, bump) = + anchor_lang::prelude::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(<[_]>::into_vec(Box::new([bump]))); + (seeds_vec, pda) + } + CTokenAccountVariant::CTokenSigner3 => { + let seed_1 = ctx.accounts.fee_payer.key().to_bytes(); + let seeds: &[&[u8]] = &[POOL_VAULT_SEED.as_bytes(), &seed_1, b"liquidity"]; + let (pda, bump) = + anchor_lang::prelude::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(<[_]>::into_vec(Box::new([bump]))); + (seeds_vec, pda) + } + CTokenAccountVariant::CTokenSigner4 => { + let seed_1 = ctx.accounts.fee_payer.key().to_bytes(); + let seed_2 = ctx.accounts.fee_payer.key().to_bytes(); // Use fee_payer as second account + let program_id_bytes = crate::ID.to_bytes(); + let seeds: &[&[u8]] = &[b"multi_account", &seed_1, &seed_2, &program_id_bytes]; + let (pda, bump) = + anchor_lang::prelude::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(<[_]>::into_vec(Box::new([bump]))); + (seeds_vec, pda) + } + CTokenAccountVariant::CTokenSigner5 => { + let seed_1 = ctx.accounts.fee_payer.key().to_bytes(); + let seed_2 = ctx.accounts.some_mint.key().to_bytes(); + let index_bytes = 42u64.to_le_bytes(); // Fixed index for this variant + let seeds: &[&[u8]] = &[b"indexed_vault", &seed_1, &seed_2, &index_bytes, b"final"]; + let (pda, bump) = + anchor_lang::prelude::Pubkey::find_program_address(seeds, &crate::ID); + let mut seeds_vec = Vec::with_capacity(seeds.len() + 1); + seeds_vec.extend(seeds.iter().map(|s| s.to_vec())); + seeds_vec.push(<[_]>::into_vec(Box::new([bump]))); + (seeds_vec, pda) + } + } + } +} + +declare_id!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah"); +pub const LIGHT_CPI_SIGNER: CpiSigner = + derive_light_cpi_signer!("FAMipfVEhN4hjCLpKCvjDXXfzLsoVTqQccXzePz1L1ah"); + +// CToken signer 1: Classic pattern with user + mint +pub fn get_ctoken_signer_seeds<'a>(user: &'a Pubkey, mint: &'a Pubkey) -> (Vec>, Pubkey) { + let mut seeds = vec![ + b"ctoken_signer".to_vec(), + user.to_bytes().to_vec(), + mint.to_bytes().to_vec(), + ]; + let seeds_slice = seeds.iter().map(|s| s.as_slice()).collect::>(); + let (pda, bump) = Pubkey::find_program_address(seeds_slice.as_slice(), &crate::ID); + seeds.push(vec![bump]); + (seeds, pda) +} + +// CToken signer 2: Simple user vault pattern +pub fn get_ctoken_signer2_seeds<'a>(user: &'a Pubkey) -> (Vec>, Pubkey) { + let mut seeds = vec![b"user_vault".to_vec(), user.to_bytes().to_vec()]; + let seeds_slice = seeds.iter().map(|s| s.as_slice()).collect::>(); + let (pda, bump) = Pubkey::find_program_address(seeds_slice.as_slice(), &crate::ID); + seeds.push(vec![bump]); + (seeds, pda) +} + +// CToken signer 3: Pool vault pattern with constant seed +pub fn get_ctoken_signer3_seeds<'a>(user: &'a Pubkey) -> (Vec>, Pubkey) { + let mut seeds = vec![ + POOL_VAULT_SEED.as_bytes().to_vec(), + user.to_bytes().to_vec(), + b"liquidity".to_vec(), + ]; + let seeds_slice = seeds.iter().map(|s| s.as_slice()).collect::>(); + let (pda, bump) = Pubkey::find_program_address(seeds_slice.as_slice(), &crate::ID); + seeds.push(vec![bump]); + (seeds, pda) +} + +// Authority seeds for ctoken operations: Light CPI signer PDA derived from ("cpi_authority", program_id) +pub fn get_ctokensigner_authority_seeds() -> (Vec>, Pubkey) { + let mut seeds = vec![b"cpi_authority".to_vec()]; + let seeds_slice = seeds.iter().map(|s| s.as_slice()).collect::>(); + let (pda, bump) = Pubkey::find_program_address(seeds_slice.as_slice(), &crate::ID); + seeds.push(vec![bump]); + (seeds, pda) +} + +pub fn get_ctokensigner2_authority_seeds() -> (Vec>, Pubkey) { + // Same authority PDA as above; separate helper keeps parity with variant naming + get_ctokensigner_authority_seeds() +} + +pub fn get_ctokensigner3_authority_seeds() -> (Vec>, Pubkey) { + // Same authority PDA as above; separate helper keeps parity with variant naming + get_ctokensigner_authority_seeds() +} + +// CToken signer 4: Multi-account pattern with user + fee_payer + program_id +pub fn get_ctoken_signer4_seeds<'a>( + user: &'a Pubkey, + fee_payer: &'a Pubkey, +) -> (Vec>, Pubkey) { + let mut seeds = vec![ + b"multi_account".to_vec(), + user.to_bytes().to_vec(), + fee_payer.to_bytes().to_vec(), + crate::ID.to_bytes().to_vec(), + ]; + let seeds_slice = seeds.iter().map(|s| s.as_slice()).collect::>(); + let (pda, bump) = Pubkey::find_program_address(seeds_slice.as_slice(), &crate::ID); + seeds.push(vec![bump]); + (seeds, pda) +} + +// CToken signer 5: Complex pattern with user + mint + numeric index + extra seed +pub fn get_ctoken_signer5_seeds<'a>( + user: &'a Pubkey, + mint: &'a Pubkey, + index: u64, +) -> (Vec>, Pubkey) { + let mut seeds = vec![ + b"indexed_vault".to_vec(), + user.to_bytes().to_vec(), + mint.to_bytes().to_vec(), + index.to_le_bytes().to_vec(), + b"final".to_vec(), + ]; + let seeds_slice = seeds.iter().map(|s| s.as_slice()).collect::>(); + let (pda, bump) = Pubkey::find_program_address(seeds_slice.as_slice(), &crate::ID); + seeds.push(vec![bump]); + (seeds, pda) +} + +pub fn get_ctokensigner4_authority_seeds() -> (Vec>, Pubkey) { + // Same authority PDA as above; separate helper keeps parity with variant naming + get_ctokensigner_authority_seeds() +} + +pub fn get_ctokensigner5_authority_seeds() -> (Vec>, Pubkey) { + // Same authority PDA as above; separate helper keeps parity with variant naming + get_ctokensigner_authority_seeds() +} + +#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)] +#[repr(u8)] +pub enum CTokenAccountVariant { + CTokenSigner = 0, + CTokenSigner2 = 1, + CTokenSigner3 = 2, + CTokenSigner4 = 3, + CTokenSigner5 = 4, +} + +// Simple anchor program retrofitted with compressible accounts. +#[program] +pub mod csdk_anchor_test { + + use light_compressed_token_sdk::instructions::{ + compress_and_close::compress_and_close_ctoken_accounts_signed, + create_token_account::create_ctoken_account_signed, find_mint_address, + }; + use light_sdk::cpi::{ + v2::{CpiAccounts, LightSystemProgramCpi}, + InvokeLightSystemProgram, LightCpiInstruction, + }; + use light_sdk_types::cpi_context_write::CpiContextWriteAccounts; + + use super::*; + + // auto-derived via macro. + pub fn initialize_compression_config( + ctx: Context, + compression_delay: u32, + rent_recipient: Pubkey, + address_space: Vec, + ) -> Result<()> { + process_initialize_compression_config_checked( + &ctx.accounts.config.to_account_info(), + &ctx.accounts.authority.to_account_info(), + &ctx.accounts.program_data.to_account_info(), + &rent_recipient, + address_space, + compression_delay, + 0, // one global config for now, so bump is 0. + &ctx.accounts.payer.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &crate::ID, + )?; + + Ok(()) + } + + // auto-derived via macro. + pub fn update_compression_config( + ctx: Context, + new_compression_delay: Option, + new_rent_recipient: Option, + new_address_space: Option>, + new_update_authority: Option, + ) -> Result<()> { + process_update_compression_config( + &ctx.accounts.config.to_account_info(), + &ctx.accounts.authority.to_account_info(), + new_update_authority.as_ref(), + new_rent_recipient.as_ref(), + new_address_space, + new_compression_delay, + &crate::ID, + )?; + + Ok(()) + } + + /// Compress multiple accounts (PDAs and token accounts) in a single instruction. + pub fn compress_accounts_idempotent<'info>( + ctx: Context<'_, '_, 'info, 'info, CompressAccountsIdempotent<'info>>, + proof: ValidityProof, + compressed_accounts: Vec, + signer_seeds: Vec>>, + system_accounts_offset: u8, + ) -> Result<()> { + let compression_config = + CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + if ctx.accounts.rent_recipient.key() != compression_config.rent_recipient { + msg!( + "rent recipient passed: {:?}", + ctx.accounts.rent_recipient.key() + ); + msg!( + "rent recipient config: {:?}", + compression_config.rent_recipient + ); + panic!("Rent recipient does not match config"); + } + + let cpi_accounts = CpiAccounts::new( + ctx.accounts.fee_payer.as_ref(), + &ctx.remaining_accounts[system_accounts_offset as usize..], + LIGHT_CPI_SIGNER, + ); + + let pda_accounts_start = ctx.remaining_accounts.len() - signer_seeds.len(); + let solana_accounts = &ctx.remaining_accounts[pda_accounts_start..]; + + let mut token_accounts_to_compress = Vec::new(); + let mut compressed_pda_infos = Vec::new(); + let mut pda_indices_to_close: Vec = Vec::new(); + + for (i, account_info) in solana_accounts.iter().enumerate() { + if account_info.data_is_empty() { + msg!("No data. Account already compressed or uninitialized. Skipping."); + continue; + } + if account_info.owner == &C_TOKEN_PROGRAM_ID.into() { + if let Ok(token_account) = InterfaceAccount::::try_from(account_info) + { + let account_signer_seeds = signer_seeds[i].clone(); + + token_accounts_to_compress.push( + light_compressed_token_sdk::AccountInfoToCompress { + account_info: token_account.to_account_info(), + signer_seeds: account_signer_seeds, + }, + ); + } + } else if account_info.owner == &crate::ID { + let data = account_info.try_borrow_data()?; + let discriminator = &data[0..8]; + let meta = compressed_accounts[i]; + + // TODO: consider CHECKING seeds. + match discriminator { + d if d == UserRecord::discriminator() => { + let mut anchor_account = Account::::try_from(account_info)?; + + let compressed_info = prepare_account_for_compression::( + &crate::ID, + &mut anchor_account, + &meta, + &cpi_accounts, + &compression_config.compression_delay, + &compression_config.address_space, + )?; + + compressed_pda_infos.push(compressed_info); + pda_indices_to_close.push(i); + } + d if d == GameSession::discriminator() => { + let mut anchor_account = Account::::try_from(account_info)?; + let compressed_info = prepare_account_for_compression::( + &crate::ID, + &mut anchor_account, + &meta, + &cpi_accounts, + &compression_config.compression_delay, + &compression_config.address_space, + )?; + + compressed_pda_infos.push(compressed_info); + pda_indices_to_close.push(i); + } + d if d == PlaceholderRecord::discriminator() => { + let mut anchor_account = + Account::::try_from(account_info)?; + let compressed_info = prepare_account_for_compression::( + &crate::ID, + &mut anchor_account, + &meta, + &cpi_accounts, + &compression_config.compression_delay, + &compression_config.address_space, + )?; + + compressed_pda_infos.push(compressed_info); + pda_indices_to_close.push(i); + } + _ => { + panic!("Trying to compress with invalid account discriminator"); + } + } + } + } + let has_pdas = !compressed_pda_infos.is_empty(); + let has_tokens = !token_accounts_to_compress.is_empty(); + + msg!("has_tokens: {}", has_tokens); + msg!("has_pdas: {}", has_pdas); + // 1. compress and close token accounts in one CPI (no proof). + if has_tokens { + let ctoken_rent_sponsor = ctx.accounts.ctoken_rent_sponsor.to_account_info(); + let ctoken_cpi_authority = ctx.accounts.ctoken_cpi_authority.to_account_info(); + + let system_offset = cpi_accounts.system_accounts_end_offset(); + let fee_payer = cpi_accounts.fee_payer().to_account_info(); + let output_queue = cpi_accounts.tree_accounts().unwrap()[0].to_account_info(); + let cpi_authority = cpi_accounts.authority().unwrap().to_account_info(); + let remaining_accounts = cpi_accounts.to_account_infos(); + let post_system = &remaining_accounts[system_offset..]; + + compress_and_close_ctoken_accounts_signed( + &token_accounts_to_compress, + fee_payer, + output_queue, + ctoken_rent_sponsor, + ctoken_cpi_authority, + cpi_authority, + post_system, + &remaining_accounts, + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + + if has_pdas { + LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, proof) + .with_account_infos(&compressed_pda_infos) + // .write_to_cpi_context_first() + // .invoke_write_to_cpi_context_first( + // light_sdk_types::cpi_context_write::CpiContextWriteAccounts { + // fee_payer: cpi_accounts.fee_payer(), + // authority: cpi_accounts.authority().unwrap(), + // cpi_context: cpi_accounts.cpi_context().unwrap(), + // cpi_signer: LIGHT_CPI_SIGNER, + // }, + // )?; + .invoke(cpi_accounts)?; + + // Close + for idx in pda_indices_to_close.into_iter() { + let mut info = solana_accounts[idx].clone(); + light_sdk::compressible::compress_account_on_init_native::close( + &mut info, + ctx.accounts.rent_recipient.clone(), + ) + .map_err(|e| anchor_lang::prelude::ProgramError::from(e))?; + } + } + Ok(()) + } + + // auto-derived via macro. takes the tagged account structs via + // add_compressible_accounts macro and derives the relevant variant type and + // dispatcher. The instruction can be used with any number of any of the + // tagged account structs. It's idempotent; it will not fail if the accounts + // are already decompressed. + #[inline(never)] + pub fn decompress_accounts_idempotent<'info>( + ctx: Context<'_, '_, '_, 'info, DecompressAccountsIdempotent<'info>>, + proof: light_sdk::instruction::ValidityProof, + compressed_accounts: Vec, + system_accounts_offset: u8, + ) -> Result<()> { + // Helper functions to handle each account type - kept out of main frame + #[inline(never)] + fn handle_user_record<'b, 'info>( + data: UserRecord, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + solana_accounts: &[AccountInfo<'info>], + i: usize, + address_space: Pubkey, + cpi_accounts: &CpiAccounts<'b, 'info>, + rent_payer: &Signer<'info>, + out: &mut Vec, + ) -> Result<()> { + let seeds_vec = { + let seeds: &[&[u8]] = &[USER_RECORD_SEED.as_bytes(), (data.owner).as_ref()]; + let (_pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + vec![seeds[0].to_vec(), seeds[1].to_vec(), vec![bump]] + }; + let seed_refs: Vec<&[u8]> = seeds_vec.iter().map(|v| v.as_slice()).collect(); + let infos = prepare_account_for_decompression_idempotent::( + &crate::ID, + data, + into_compressed_meta_with_address( + meta, + &solana_accounts[i], + address_space, + &crate::ID, + ), + &solana_accounts[i], + rent_payer, + cpi_accounts, + seed_refs.as_slice(), + ) + .map_err(|e| ProgramError::from(e))?; + out.extend(infos); + Ok(()) + } + + #[inline(never)] + fn handle_game_session<'b, 'info>( + data: GameSession, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + solana_accounts: &[AccountInfo<'info>], + i: usize, + address_space: Pubkey, + cpi_accounts: &CpiAccounts<'b, 'info>, + rent_payer: &Signer<'info>, + out: &mut Vec, + ) -> Result<()> { + let seed_binding_1 = data.session_id.to_le_bytes(); + let seeds_vec = { + let seeds: &[&[u8]] = &["game_session".as_bytes(), seed_binding_1.as_ref()]; + let (_pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + vec![seeds[0].to_vec(), seeds[1].to_vec(), vec![bump]] + }; + let seed_refs: Vec<&[u8]> = seeds_vec.iter().map(|v| v.as_slice()).collect(); + let infos = prepare_account_for_decompression_idempotent::( + &crate::ID, + data, + into_compressed_meta_with_address( + meta, + &solana_accounts[i], + address_space, + &crate::ID, + ), + &solana_accounts[i], + rent_payer, + cpi_accounts, + seed_refs.as_slice(), + ) + .map_err(|e| ProgramError::from(e))?; + out.extend(infos); + Ok(()) + } + + #[inline(never)] + fn handle_placeholder_record<'b, 'info>( + data: PlaceholderRecord, + meta: &light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + solana_accounts: &[AccountInfo<'info>], + i: usize, + address_space: Pubkey, + cpi_accounts: &CpiAccounts<'b, 'info>, + rent_payer: &Signer<'info>, + out: &mut Vec, + ) -> Result<()> { + let seed_binding_1 = data.placeholder_id.to_le_bytes(); + let seeds_vec = { + let seeds: &[&[u8]] = &["placeholder_record".as_bytes(), seed_binding_1.as_ref()]; + let (_pda, bump) = Pubkey::find_program_address(seeds, &crate::ID); + vec![seeds[0].to_vec(), seeds[1].to_vec(), vec![bump]] + }; + let seed_refs: Vec<&[u8]> = seeds_vec.iter().map(|v| v.as_slice()).collect(); + let infos = prepare_account_for_decompression_idempotent::( + &crate::ID, + data, + into_compressed_meta_with_address( + meta, + &solana_accounts[i], + address_space, + &crate::ID, + ), + &solana_accounts[i], + rent_payer, + cpi_accounts, + seed_refs.as_slice(), + ) + .map_err(|e| ProgramError::from(e))?; + out.extend(infos); + Ok(()) + } + + #[inline(never)] + fn check_account_types(compressed_accounts: &[CompressedAccountData]) -> (bool, bool) { + let (mut has_tokens, mut has_pdas) = (false, false); + for c in compressed_accounts { + match c.data { + CompressedAccountVariant::PackedCTokenData(_) => { + has_tokens = true; + } + _ => has_pdas = true, + } + if has_tokens && has_pdas { + break; + } + } + (has_tokens, has_pdas) + } + /// Helper function to process token decompression - separated to avoid stack overflow + #[inline(never)] + fn process_tokens<'a, 'b, 'info>( + accounts: &DecompressAccountsIdempotent<'info>, + remaining_accounts: &[anchor_lang::prelude::AccountInfo<'info>], + fee_payer: &anchor_lang::prelude::AccountInfo<'info>, + ctoken_program: &anchor_lang::prelude::UncheckedAccount<'info>, + ctoken_rent_sponsor: &anchor_lang::prelude::AccountInfo<'info>, + ctoken_cpi_authority: &anchor_lang::prelude::UncheckedAccount<'info>, + ctoken_config: &anchor_lang::prelude::AccountInfo<'info>, + config: &anchor_lang::prelude::AccountInfo<'info>, + ctoken_accounts: Vec<( + light_sdk::token::PackedCTokenData, + light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + )>, + proof: light_sdk::instruction::ValidityProof, + cpi_accounts: &CpiAccounts<'b, 'info>, + post_system_accounts: &[anchor_lang::prelude::AccountInfo<'info>], + has_pdas: bool, + ) -> Result<()> { + let mut token_decompress_indices: Box< + Vec, + > = Box::new(Vec::with_capacity(ctoken_accounts.len())); + // Collect per-owner signer seed groups; invoke_signed requires one seed group per PDA signer + let mut token_signers_seed_groups: Vec>> = + Vec::with_capacity(ctoken_accounts.len()); + let packed_accounts = post_system_accounts; + use crate::ctoken_seed_system::{CTokenSeedContext, CTokenSeedProvider}; + let seed_context = CTokenSeedContext { + accounts, + remaining_accounts, + }; + let authority = cpi_accounts.authority().unwrap(); + let cpi_context = cpi_accounts.cpi_context().unwrap(); + + for (token_data, meta) in ctoken_accounts.into_iter() { + let owner_index: u8 = token_data.token_data.owner; + let mint_index: u8 = token_data.token_data.mint; + let mint_info = packed_accounts[mint_index as usize].to_account_info(); + let owner_info = packed_accounts[owner_index as usize].to_account_info(); + let (ctoken_signer_seeds, derived_token_account_address) = + token_data.variant.get_seeds(&seed_context); + { + if derived_token_account_address != *owner_info.key { + msg!( + "derived_token_account_address: {:?}", + derived_token_account_address + ); + msg!("owner_info.key: {:?}", owner_info.key); + panic!("Derived token account address must match owner_info.key"); + } + + // Convert Vec> to &[&[&[u8]]] + let seed_refs: Vec<&[u8]> = + ctoken_signer_seeds.iter().map(|s| s.as_slice()).collect(); + let seeds_slice: &[&[u8]] = &seed_refs; + + create_ctoken_account_signed( + crate::ID, + fee_payer.clone().to_account_info(), + owner_info.clone(), + mint_info.clone(), + *authority.clone().to_account_info().key, + seeds_slice, + ctoken_rent_sponsor.clone().to_account_info(), + ctoken_config.to_account_info(), + Some(2), // TODO: make this configurable + None, // TODO: make this configurable + )?; + } + // let decompress_index = + // light_compressed_token_sdk::instructions::DecompressFullIndices::from(( + // token_data.token_data, + // meta, + // owner_index, + // )); + // Construct MultiInputTokenDataWithContext from token data and meta + let source = + light_ctoken_types::instructions::transfer2::MultiInputTokenDataWithContext { + owner: token_data.token_data.owner, + amount: token_data.token_data.amount, + has_delegate: token_data.token_data.has_delegate, + delegate: token_data.token_data.delegate, + mint: token_data.token_data.mint, + version: token_data.token_data.version, + merkle_context: meta.tree_info.into(), + root_index: meta.tree_info.root_index, + }; + let decompress_index = + light_compressed_token_sdk::instructions::DecompressFullIndices { + source, + destination_index: owner_index, + }; + token_decompress_indices.push(decompress_index); + token_signers_seed_groups.push(ctoken_signer_seeds); + } + + // log each token account to decompress + // for token_account in token_decompress_indices.clone().into_iter() { + // msg!( + // "token_account: {:?}", + // packed_accounts[token_account.destination_index as usize].key() + // ); + // } + let ctoken_ix = light_compressed_token_sdk::instructions::decompress_full_ctoken_accounts_with_indices( + fee_payer.key(), + proof, + if has_pdas { Some(cpi_context.key()) } else { None }, + &token_decompress_indices, + packed_accounts, + ) + .map_err(anchor_lang::prelude::ProgramError::from)?; + { + let mut all_account_infos = + <[_]>::into_vec(Box::new([fee_payer.to_account_info()])); + all_account_infos.extend(ctoken_cpi_authority.to_account_infos()); + all_account_infos.extend(ctoken_program.to_account_infos()); + all_account_infos.extend(ctoken_rent_sponsor.to_account_infos()); + all_account_infos.extend(config.to_account_infos()); + all_account_infos.extend(cpi_accounts.to_account_infos()); + // Build &[&[&[u8]]] where each inner slice is a distinct PDA seed group + let signer_seed_refs: Vec> = token_signers_seed_groups + .iter() + .map(|group| group.iter().map(|s| s.as_slice()).collect()) + .collect(); + let signer_seed_slices: Vec<&[&[u8]]> = + signer_seed_refs.iter().map(|g| g.as_slice()).collect(); + + anchor_lang::solana_program::program::invoke_signed( + &ctoken_ix, + all_account_infos.as_slice(), + signer_seed_slices.as_slice(), + )?; + } + Ok(()) + } + + let compression_config = light_sdk::compressible::CompressibleConfig::load_checked( + &ctx.accounts.config, + &crate::ID, + )?; + let address_space = compression_config.address_space[0]; + + let (has_tokens, has_pdas) = check_account_types(&compressed_accounts); + if !has_tokens && !has_pdas { + return Ok(()); + } + + // Pre-count for exact alloc. + let (mut token_count, mut pda_count) = (0usize, 0usize); + for c in &compressed_accounts { + match c.data { + CompressedAccountVariant::PackedCTokenData(_) => token_count += 1, + _ => pda_count += 1, + } + } + + let mut ctoken_accounts: Vec<( + light_sdk::token::PackedCTokenData, + light_sdk::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress, + )> = Vec::with_capacity(token_count); + let mut compressed_pda_infos = Vec::with_capacity(pda_count); + + let cpi_accounts = if has_tokens && has_pdas { + CpiAccounts::new_with_config( + ctx.accounts.fee_payer.as_ref(), + &ctx.remaining_accounts[system_accounts_offset as usize..], + CpiAccountsConfig::new_with_cpi_context(LIGHT_CPI_SIGNER), + ) + } else { + CpiAccounts::new( + ctx.accounts.fee_payer.as_ref(), + &ctx.remaining_accounts[system_accounts_offset as usize..], + LIGHT_CPI_SIGNER, + ) + }; + + let pda_accounts_start = ctx.remaining_accounts.len() - compressed_accounts.len(); + let solana_accounts = &ctx.remaining_accounts[pda_accounts_start..]; + let post_system_offset = cpi_accounts.system_accounts_end_offset(); + let all_infos = cpi_accounts.account_infos(); + let post_system_accounts = &all_infos[post_system_offset..]; + for (i, compressed_data) in compressed_accounts.into_iter().enumerate() { + let unpacked_data = compressed_data.data.unpack(post_system_accounts)?; + match unpacked_data { + CompressedAccountVariant::UserRecord(data) => { + handle_user_record( + data, + &compressed_data.meta, + solana_accounts, + i, + address_space, + &cpi_accounts, + &ctx.accounts.rent_payer, + &mut compressed_pda_infos, + )?; + } + CompressedAccountVariant::GameSession(data) => { + handle_game_session( + data, + &compressed_data.meta, + solana_accounts, + i, + address_space, + &cpi_accounts, + &ctx.accounts.rent_payer, + &mut compressed_pda_infos, + )?; + } + CompressedAccountVariant::PlaceholderRecord(data) => { + handle_placeholder_record( + data, + &compressed_data.meta, + solana_accounts, + i, + address_space, + &cpi_accounts, + &ctx.accounts.rent_payer, + &mut compressed_pda_infos, + )?; + } + CompressedAccountVariant::PackedCTokenData(data) => { + ctoken_accounts.push((data, compressed_data.meta)); + } + CompressedAccountVariant::PackedUserRecord(_) + | CompressedAccountVariant::PackedGameSession(_) + | CompressedAccountVariant::PackedPlaceholderRecord(_) + | CompressedAccountVariant::CTokenData(_) => { + panic!("internal error: entered unreachable code"); + } + } + } + // return if no uninitialized accounts. + let has_pdas = !compressed_pda_infos.is_empty(); + let has_tokens = !ctoken_accounts.is_empty(); + if !has_pdas && !has_tokens { + return Ok(()); + } + let fee_payer = ctx.accounts.fee_payer.as_ref(); + let authority = cpi_accounts.authority().unwrap(); + let cpi_context = cpi_accounts.cpi_context().unwrap(); + + // init PDAs. + if has_pdas && has_tokens { + let system_cpi_accounts = light_sdk_types::cpi_context_write::CpiContextWriteAccounts { + fee_payer, + authority, + cpi_context, + cpi_signer: LIGHT_CPI_SIGNER, + }; + LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, proof) + .with_account_infos(&compressed_pda_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(system_cpi_accounts)?; + } else if has_pdas { + LightSystemProgramCpi::new_cpi(cpi_accounts.config().cpi_signer, proof) + .with_account_infos(&compressed_pda_infos) + .invoke(cpi_accounts.clone())?; + } + + // init tokens. + if has_tokens { + process_tokens( + &ctx.accounts, + &ctx.remaining_accounts, + &fee_payer, + &ctx.accounts.ctoken_program, + &ctx.accounts.ctoken_rent_sponsor, + &ctx.accounts.ctoken_cpi_authority, + &ctx.accounts.ctoken_config, + &ctx.accounts.config, + ctoken_accounts, + proof, + &cpi_accounts, + post_system_accounts, + has_pdas, + )?; + } + Ok(()) + } + + pub fn create_record<'info>( + ctx: Context<'_, '_, '_, 'info, CreateRecord<'info>>, + name: String, + proof: ValidityProof, + compressed_address: [u8; 32], + address_tree_info: PackedAddressTreeInfo, + output_state_tree_index: u8, + ) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + + // 1. Load config from the config account + let config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + + user_record.owner = ctx.accounts.user.key(); + user_record.name = name; + user_record.score = 11; + + // 2. Verify rent recipient matches config + if ctx.accounts.rent_recipient.key() != config.rent_recipient { + panic!("Rent recipient does not match config"); + // return err!(ErrorCode::InvalidRentRecipient); + } + + // 3. Create CPI accounts + let user_account_info = ctx.accounts.user.to_account_info(); + let cpi_accounts = + CpiAccounts::new(&user_account_info, ctx.remaining_accounts, LIGHT_CPI_SIGNER); + + let new_address_params = address_tree_info + .into_new_address_params_assigned_packed(user_record.key().to_bytes().into(), Some(0)); + + compress_account_on_init::( + user_record, + &compressed_address, + &new_address_params, + output_state_tree_index, + cpi_accounts, + proof, + ) + .map_err(|e| ProgramError::from(e))?; + + // at the end of the instruction we always clean up all onchain pdas that we compressed + user_record.close(ctx.accounts.rent_recipient.to_account_info())?; + + Ok(()) + } + + // Must be manually implemented. + pub fn create_game_session<'info>( + ctx: Context<'_, '_, '_, 'info, CreateGameSession<'info>>, + session_id: u64, + game_type: String, + proof: ValidityProof, + compressed_address: [u8; 32], + address_tree_info: PackedAddressTreeInfo, + output_state_tree_index: u8, + ) -> Result<()> { + let game_session = &mut ctx.accounts.game_session; + + // Load config from the config account + let config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + + // Set your account data. + game_session.session_id = session_id; + game_session.player = ctx.accounts.player.key(); + game_session.game_type = game_type; + game_session.start_time = Clock::get()?.unix_timestamp as u64; + game_session.end_time = None; + game_session.score = 0; + + // Check that rent recipient matches your config. + if ctx.accounts.rent_recipient.key() != config.rent_recipient { + panic!("Rent recipient does not match config"); + } + + // Create CPI accounts. + let player_account_info = ctx.accounts.player.to_account_info(); + let cpi_accounts = CpiAccounts::new( + &player_account_info, + ctx.remaining_accounts, + LIGHT_CPI_SIGNER, + ); + + // Prepare new address params. The cpda takes the address of the + // compressible pda account as seed. + let new_address_params = address_tree_info + .into_new_address_params_assigned_packed(game_session.key().to_bytes().into(), Some(0)); + + // Call at the end of your init instruction to compress the pda account + // safely. This also closes the pda account. The account can then be + // decompressed by anyone at any time via the + // decompress_accounts_idempotent instruction. Creates a unique cPDA to + // ensure that the account cannot be re-inited only decompressed. + compress_account_on_init::( + game_session, + &compressed_address, + &new_address_params, + output_state_tree_index, + cpi_accounts, + proof, + ) + .map_err(|e| ProgramError::from(e))?; + + game_session.close(ctx.accounts.rent_recipient.to_account_info())?; + + Ok(()) + } + + // Must be manually implemented. + pub fn create_user_record_and_game_session<'info>( + ctx: Context<'_, '_, '_, 'info, CreateUserRecordAndGameSession<'info>>, + account_data: AccountCreationData, + compression_params: CompressionParams, + ) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + let game_session = &mut ctx.accounts.game_session; + + // Load your config checked. + let config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + + // Check that rent recipient matches your config. + if ctx.accounts.rent_recipient.key() != config.rent_recipient { + panic!("Rent recipient does not match config"); + } + + // Set your account data. + user_record.owner = ctx.accounts.user.key(); + user_record.name = account_data.user_name.clone(); + user_record.score = 11; + + game_session.session_id = account_data.session_id; + game_session.player = ctx.accounts.user.key(); + game_session.game_type = account_data.game_type.clone(); + game_session.start_time = Clock::get()?.unix_timestamp as u64; + game_session.end_time = None; + game_session.score = 0; + + // Create CPI accounts from remaining accounts + let cpi_accounts = CpiAccounts::new_with_config( + ctx.accounts.user.as_ref(), + ctx.remaining_accounts, + CpiAccountsConfig::new_with_cpi_context(LIGHT_CPI_SIGNER), + ); + let cpi_context_pubkey = cpi_accounts.cpi_context().unwrap().key(); + let cpi_context_account = cpi_accounts.cpi_context().unwrap(); + + // Prepare new address params. One per pda account. + let user_new_address_params = compression_params + .user_address_tree_info + .into_new_address_params_assigned_packed(user_record.key().to_bytes().into(), Some(0)); + let game_new_address_params = compression_params + .game_address_tree_info + .into_new_address_params_assigned_packed(game_session.key().to_bytes().into(), Some(1)); + + let mut all_compressed_infos = Vec::new(); + + // Prepares the firstpda account for compression. compress the pda + // account safely. This also closes the pda account. safely. This also + // closes the pda account. The account can then be decompressed by + // anyone at any time via the decompress_accounts_idempotent + // instruction. Creates a unique cPDA to ensure that the account cannot + // be re-inited only decompressed. + let user_compressed_infos = prepare_accounts_for_compression_on_init::( + &[user_record], + &[compression_params.user_compressed_address], + &[user_new_address_params], + &[compression_params.user_output_state_tree_index], + &cpi_accounts, + ) + .map_err(|e| ProgramError::from(e))?; + + all_compressed_infos.extend(user_compressed_infos); + + // Process GameSession for compression. compress the pda account safely. + // This also closes the pda account. The account can then be + // decompressed by anyone at any time via the + // decompress_accounts_idempotent instruction. Creates a unique cPDA to + // ensure that the account cannot be re-inited only decompressed. + let game_compressed_infos = prepare_accounts_for_compression_on_init::( + &[game_session], + &[compression_params.game_compressed_address], + &[game_new_address_params], + &[compression_params.game_output_state_tree_index], + &cpi_accounts, + ) + .map_err(|e| ProgramError::from(e))?; + all_compressed_infos.extend(game_compressed_infos); + + let cpi_context_accounts = CpiContextWriteAccounts { + fee_payer: cpi_accounts.fee_payer(), + authority: cpi_accounts.authority().unwrap(), + cpi_context: cpi_context_account, + cpi_signer: LIGHT_CPI_SIGNER, + }; + LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, compression_params.proof.clone()) + .with_new_addresses(&[user_new_address_params, game_new_address_params]) + .with_account_infos(&all_compressed_infos) + .write_to_cpi_context_first() + .invoke_write_to_cpi_context_first(cpi_context_accounts)?; + + // these are custom seeds of the caller program that are used to derive the program owned onchain tokenb account PDA. + // dual use: as owner of the compressed token account. + let mint = find_mint_address(&ctx.accounts.mint_signer.key()).0; + let (_, token_account_address) = get_ctoken_signer_seeds(&ctx.accounts.user.key(), &mint); + + let actions = vec![ + light_compressed_token_sdk::instructions::mint_action::MintActionType::MintTo { + recipients: vec![ + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: token_account_address, // TRY: THE DECOMPRESS TOKEN ACCOUNT ADDRES IS THE OWNER OF ITS COMPRESSIBLED VERSION. + amount: 1000, // Mint the full supply to the user + }, + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: get_ctoken_signer2_seeds(&ctx.accounts.user.key()).1, + amount: 1000, + }, + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: get_ctoken_signer3_seeds(&ctx.accounts.user.key()).1, + amount: 1000, + }, + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: get_ctoken_signer4_seeds( + &ctx.accounts.user.key(), + &ctx.accounts.user.key(), + ) + .1, // user as fee_payer + amount: 1000, + }, + light_compressed_token_sdk::instructions::mint_action::MintToRecipient { + recipient: get_ctoken_signer5_seeds(&ctx.accounts.user.key(), &mint, 42).1, // Fixed index 42 + amount: 1000, + }, + ], + token_account_version: 3, + }, + ]; + + let output_queue = *cpi_accounts.tree_accounts().unwrap()[0].key; // Same tree as PDA + let address_tree_pubkey = *cpi_accounts.tree_accounts().unwrap()[1].key; // Same tree as PDA + + let mint_action_inputs = MintActionInputs { + compressed_mint_inputs: compression_params.mint_with_context.clone(), + mint_seed: ctx.accounts.mint_signer.key(), + mint_bump: Some(compression_params.mint_bump), + create_mint: true, + authority: ctx.accounts.mint_authority.key(), + payer: ctx.accounts.user.key(), + proof: compression_params.proof.into(), + actions, + input_queue: None, // Not needed for create_mint: true + output_queue, + tokens_out_queue: Some(output_queue), // For MintTo actions + address_tree_pubkey, + token_pool: None, // Not needed for simple compressed mint creation + }; + + let mint_action_instruction = create_mint_action_cpi( + mint_action_inputs, + Some(light_ctoken_types::instructions::mint_action::CpiContext { + set_context: false, + first_set_context: false, + in_tree_index: 1, // address tree + in_queue_index: 0, + out_queue_index: 0, + token_out_queue_index: 0, + assigned_account_index: 2, + read_only_address_trees: [0; 4], + }), + Some(cpi_context_pubkey), + ) + .unwrap(); + + // Get all account infos needed for the mint action + let mut account_infos = cpi_accounts.to_account_infos(); + account_infos.push( + ctx.accounts + .compress_token_program_cpi_authority + .to_account_info(), + ); + account_infos.push(ctx.accounts.ctoken_program.to_account_info()); + account_infos.push(ctx.accounts.mint_authority.to_account_info()); + account_infos.push(ctx.accounts.mint_signer.to_account_info()); + account_infos.push(ctx.accounts.user.to_account_info()); + + // Invoke the mint action instruction directly + invoke(&mint_action_instruction, &account_infos)?; + + // at the end of the instruction we always clean up all onchain pdas that we compressed + user_record.close(ctx.accounts.rent_recipient.to_account_info())?; + game_session.close(ctx.accounts.rent_recipient.to_account_info())?; + + Ok(()) + } + + /// Creates an empty compressed account while keeping the PDA intact. + /// This demonstrates the compress_empty_account_on_init functionality. + pub fn create_placeholder_record<'info>( + ctx: Context<'_, '_, '_, 'info, CreatePlaceholderRecord<'info>>, + placeholder_id: u64, + name: String, + proof: ValidityProof, + compressed_address: [u8; 32], + address_tree_info: PackedAddressTreeInfo, + output_state_tree_index: u8, + ) -> Result<()> { + let placeholder_record = &mut ctx.accounts.placeholder_record; + + let config = CompressibleConfig::load_checked(&ctx.accounts.config, &crate::ID)?; + + placeholder_record.owner = ctx.accounts.user.key(); + placeholder_record.name = name; + placeholder_record.placeholder_id = placeholder_id; + + // Initialize compression_info for the PDA + *placeholder_record.compression_info_mut_opt() = + Some(super::CompressionInfo::new_decompressed()?); + placeholder_record + .compression_info_mut() + .bump_last_written_slot()?; + + // Verify rent recipient matches config + if ctx.accounts.rent_recipient.key() != config.rent_recipient { + panic!("Rent recipient does not match config"); + } + + // Create CPI accounts + let user_account_info = ctx.accounts.user.to_account_info(); + let cpi_accounts = + CpiAccounts::new(&user_account_info, ctx.remaining_accounts, LIGHT_CPI_SIGNER); + + let new_address_params = address_tree_info.into_new_address_params_assigned_packed( + placeholder_record.key().to_bytes().into(), + Some(0), + ); + + msg!("compressing empty account on init"); + // Use the new compress_empty_account_on_init function + // This creates an empty compressed account but does NOT close the PDA + compress_empty_account_on_init::( + placeholder_record, + &compressed_address, + &new_address_params, + output_state_tree_index, + cpi_accounts, + proof, + ) + .map_err(|e| ProgramError::from(e))?; + + msg!("...compressed empty account on init"); + + // Note we do not actually close this account yet because in this + // example we only create _empty_ compressed account without fully + // compressing it yet. + Ok(()) + } + + pub fn update_record(ctx: Context, name: String, score: u64) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + + user_record.name = name; + user_record.score = score; + + // 1. Must manually set compression info + user_record + .compression_info_mut() + .bump_last_written_slot()?; + + Ok(()) + } + + pub fn update_game_session( + ctx: Context, + _session_id: u64, + new_score: u64, + ) -> Result<()> { + let game_session = &mut ctx.accounts.game_session; + + game_session.score = new_score; + game_session.end_time = Some(Clock::get()?.unix_timestamp as u64); + + // Must manually set compression info + game_session + .compression_info_mut() + .bump_last_written_slot()?; + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct CreateRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + init, + payer = user, + // discriminator + owner + string len + name + score + + // option. Note that in the onchain space + // CompressionInfo is always Some. + space = 8 + 32 + 4 + 32 + 8 + 10, + seeds = [b"user_record", user.key().as_ref()], + bump, + )] + pub user_record: Account<'info, UserRecord>, + /// Needs to be here for the init anchor macro to work. + pub system_program: Program<'info, System>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +#[instruction(placeholder_id: u64)] +pub struct CreatePlaceholderRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + init, + payer = user, + // discriminator + compression_info + owner + string len + name + placeholder_id + space = 8 + 10 + 32 + 4 + 32 + 8, + seeds = [b"placeholder_record", placeholder_id.to_le_bytes().as_ref()], + bump, + )] + pub placeholder_record: Account<'info, PlaceholderRecord>, + /// Needs to be here for the init anchor macro to work. + pub system_program: Program<'info, System>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +#[instruction(account_data: AccountCreationData)] +pub struct CreateUserRecordAndGameSession<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + init, + payer = user, + // discriminator + owner + string len + name + score + + // option. Note that in the onchain space + // CompressionInfo is always Some. + space = 8 + 32 + 4 + 32 + 8 + 10, + seeds = [b"user_record", user.key().as_ref()], + bump, + )] + pub user_record: Account<'info, UserRecord>, + #[account( + init, + payer = user, + // discriminator + option + session_id + player + + // string len + game_type + start_time + end_time(Option) + score + space = 8 + 10 + 8 + 32 + 4 + 32 + 8 + 9 + 8, + seeds = [b"game_session", account_data.session_id.to_le_bytes().as_ref()], + bump, + )] + pub game_session: Account<'info, GameSession>, + + // Compressed mint creation accounts - only token-specific ones needed + /// The mint signer used for PDA derivation + pub mint_signer: Signer<'info>, + + /// The mint authority used for PDA derivation + pub mint_authority: Signer<'info>, + + /// Compressed token program + /// CHECK: Program ID validated using C_TOKEN_PROGRAM_ID constant + pub ctoken_program: UncheckedAccount<'info>, + + /// CHECK: CPI authority of the compressed token program + pub compress_token_program_cpi_authority: UncheckedAccount<'info>, + + /// Needs to be here for the init anchor macro to work. + pub system_program: Program<'info, System>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +#[instruction(session_id: u64)] +pub struct CreateGameSession<'info> { + #[account(mut)] + pub player: Signer<'info>, + #[account( + init, + payer = player, + space = 8 + 9 + 8 + 32 + 4 + 32 + 8 + 9 + 8, // discriminator + compression_info + session_id + player + string len + game_type + start_time + end_time(Option) + score + seeds = [b"game_session", session_id.to_le_bytes().as_ref()], + bump, + )] + pub game_session: Account<'info, GameSession>, + pub system_program: Program<'info, System>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct UpdateRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + mut, + seeds = [b"user_record", user.key().as_ref()], + bump, + constraint = user_record.owner == user.key() + )] + pub user_record: Account<'info, UserRecord>, +} + +#[derive(Accounts)] +#[instruction(session_id: u64)] +pub struct UpdateGameSession<'info> { + #[account(mut)] + pub player: Signer<'info>, + #[account( + mut, + seeds = [b"game_session", session_id.to_le_bytes().as_ref()], + bump, + constraint = game_session.player == player.key() + )] + pub game_session: Account<'info, GameSession>, +} + +#[derive(Accounts)] +pub struct CompressRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + mut, + seeds = [b"user_record", user.key().as_ref()], + bump, + constraint = pda_to_compress.owner == user.key() + )] + pub pda_to_compress: Account<'info, UserRecord>, + // pub system_program: Program<'info, System>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +#[instruction(session_id: u64)] +pub struct CompressGameSession<'info> { + #[account(mut)] + pub player: Signer<'info>, + #[account( + mut, + seeds = [b"game_session", session_id.to_le_bytes().as_ref()], + bump, + constraint = pda_to_compress.player == player.key() + )] + pub pda_to_compress: Account<'info, GameSession>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CompressPlaceholderRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + mut, + constraint = pda_to_compress.owner == user.key() + )] + pub pda_to_compress: Account<'info, PlaceholderRecord>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CompressTokenAccountCtokenSigner<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + pub rent_authority: Signer<'info>, + /// CHECK: todo + pub user: UncheckedAccount<'info>, + /// CHECK: todo + ctoken_cpi_authority: UncheckedAccount<'info>, + /// CHECK: todo + ctoken_program: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"ctoken_signer", user.key().as_ref(), token_account_to_compress.mint.as_ref()], + bump, + )] + pub token_account_to_compress: InterfaceAccount<'info, TokenAccount>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CompressAccountsIdempotent<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, + /// ctoken rent recipient when token accounts are present + /// CHECK: Checked by Protocol. + #[account(mut)] + pub ctoken_rent_sponsor: UncheckedAccount<'info>, + // Required token-specific accounts (always needed for mixed compression) + /// CHECK: Checked by Protocol. + pub ctoken_program: UncheckedAccount<'info>, + /// CPI authority PDA of the ctoken program + /// CHECK: Checked by Protocol. + pub ctoken_cpi_authority: UncheckedAccount<'info>, +} + +#[derive(Accounts)] +pub struct CompressMultipleTokenAccounts<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// The authority that owns all token accounts being compressed + /// CHECK: Validated by the SDK + pub authority: AccountInfo<'info>, + /// CHECK: CPI authority of the compressed token program + pub ctoken_cpi_authority: UncheckedAccount<'info>, + /// CHECK: Compressed token program + pub ctoken_program: UncheckedAccount<'info>, + /// The global config account + /// CHECK: Config is validated by the SDK's load_checked method + pub config: AccountInfo<'info>, + /// Rent recipient - must match config + /// CHECK: Rent recipient is validated against the config + #[account(mut)] + pub rent_recipient: AccountInfo<'info>, +} + +// TODO: split into one ix with ctoken and one without. +#[derive(Accounts)] +pub struct DecompressAccountsIdempotent<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + /// The global config account + /// CHECK: load_checked. + pub config: AccountInfo<'info>, + /// UNCHECKED: Anyone can pay to init PDAs. + #[account(mut)] + pub rent_payer: Signer<'info>, + /// CHECK: Checked in protocol. + #[account(mut)] + pub ctoken_rent_sponsor: UncheckedAccount<'info>, + /// CHECK: Checked in protocol. + pub ctoken_config: UncheckedAccount<'info>, + /// ctoken program (always required in mixed variant) + /// CHECK: Checked by Protocol. + pub ctoken_program: UncheckedAccount<'info>, + /// CPI authority PDA of the compressed token program (always required in mixed variant) + /// CHECK: Checked by Protocol. + pub ctoken_cpi_authority: UncheckedAccount<'info>, + /// CHECK: unchecked. + pub some_mint: UncheckedAccount<'info>, +} + +#[derive(Accounts)] +pub struct InitializeCompressionConfig<'info> { + #[account(mut)] + pub payer: Signer<'info>, + /// CHECK: Config PDA is created and validated by the SDK + #[account(mut)] + pub config: AccountInfo<'info>, + /// The program's data account + /// CHECK: Program data account is validated by the SDK + pub program_data: AccountInfo<'info>, + /// The program's upgrade authority (must sign) + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct UpdateCompressionConfig<'info> { + /// CHECK: Config PDA is created and validated by the SDK + #[account(mut)] + pub config: AccountInfo<'info>, + /// Must match the update authority stored in config + pub authority: Signer<'info>, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct PackedGameSession { + pub compression_info: Option, + pub session_id: u64, + pub player: u8, + pub game_type: String, + pub start_time: u64, + pub end_time: Option, + pub score: u64, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct PackedPlaceholderRecord { + pub compression_info: Option, + pub owner: u8, + pub name: String, + pub placeholder_id: u64, +} + +/// Auto-derived via macro. Unified enum that can hold any account type. Crucial +/// for dispatching multiple compressed accounts of different types in +/// decompress_accounts_idempotent. + +#[derive(Clone, Debug, AnchorSerialize, AnchorDeserialize)] +pub enum CompressedAccountVariant { + UserRecord(UserRecord), + PackedUserRecord(PackedUserRecord), + GameSession(GameSession), + PackedGameSession(PackedGameSession), + PlaceholderRecord(PlaceholderRecord), + PackedPlaceholderRecord(PackedPlaceholderRecord), + PackedCTokenData(light_sdk::token::PackedCTokenData), + CTokenData(light_sdk::token::CTokenData), +} + +impl Default for CompressedAccountVariant { + fn default() -> Self { + Self::UserRecord(UserRecord::default()) + } +} + +// impl DataHasher for CompressedAccountVariant { +// fn hash(&self) -> std::result::Result<[u8; 32], light_hasher::HasherError> { +// match self { +// Self::UserRecord(data) => data.hash::(), +// Self::PackedUserRecord(_) => unreachable!(), +// Self::GameSession(data) => data.hash::(), +// Self::PlaceholderRecord(data) => data.hash::(), +// Self::PackedCTokenData(_) => unreachable!(), +// Self::CTokenData(_) => unreachable!(), +// Self::PackedGameSession(_) => unreachable!(), +// Self::PackedPlaceholderRecord(_) => unreachable!(), +// } +// } +// } + +impl LightDiscriminator for CompressedAccountVariant { + const LIGHT_DISCRIMINATOR: [u8; 8] = [0; 8]; // This won't be used directly + const LIGHT_DISCRIMINATOR_SLICE: &'static [u8] = &Self::LIGHT_DISCRIMINATOR; +} + +impl HasCompressionInfo for CompressedAccountVariant { + fn compression_info(&self) -> &CompressionInfo { + match self { + Self::UserRecord(data) => data.compression_info(), + Self::PackedUserRecord(_) => unreachable!(), + Self::GameSession(data) => data.compression_info(), + Self::PlaceholderRecord(data) => data.compression_info(), + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + Self::PackedGameSession(_) => unreachable!(), + Self::PackedPlaceholderRecord(_) => unreachable!(), + } + } + + fn compression_info_mut(&mut self) -> &mut CompressionInfo { + match self { + Self::UserRecord(data) => data.compression_info_mut(), + Self::PackedUserRecord(_) => unreachable!(), + Self::GameSession(data) => data.compression_info_mut(), + Self::PlaceholderRecord(data) => data.compression_info_mut(), + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + Self::PackedGameSession(_) => unreachable!(), + Self::PackedPlaceholderRecord(_) => unreachable!(), + } + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + match self { + Self::UserRecord(data) => data.compression_info_mut_opt(), + Self::PackedUserRecord(_) => unreachable!(), + Self::GameSession(data) => data.compression_info_mut_opt(), + Self::PlaceholderRecord(data) => data.compression_info_mut_opt(), + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + Self::PackedGameSession(_) => unreachable!(), + Self::PackedPlaceholderRecord(_) => unreachable!(), + } + } + + fn set_compression_info_none(&mut self) { + match self { + Self::UserRecord(data) => data.set_compression_info_none(), + Self::PackedUserRecord(_) => unreachable!(), + Self::GameSession(data) => data.set_compression_info_none(), + Self::PlaceholderRecord(data) => data.set_compression_info_none(), + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + Self::PackedGameSession(_) => unreachable!(), + Self::PackedPlaceholderRecord(_) => unreachable!(), + } + } +} + +impl Size for CompressedAccountVariant { + fn size(&self) -> usize { + match self { + Self::UserRecord(data) => data.size(), + Self::PackedUserRecord(_) => unreachable!(), + Self::GameSession(data) => data.size(), + Self::PlaceholderRecord(data) => data.size(), + Self::PackedCTokenData(_) => unreachable!(), + Self::CTokenData(_) => unreachable!(), + Self::PackedGameSession(_) => unreachable!(), + Self::PackedPlaceholderRecord(_) => unreachable!(), + } + } +} + +// Pack implementation for CompressedAccountVariant +// This delegates to the underlying type's Pack implementation +impl Pack for CompressedAccountVariant { + type Packed = Self; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + match self { + Self::PackedUserRecord(_) => unreachable!(), + Self::UserRecord(data) => Self::PackedUserRecord(data.pack(remaining_accounts)), + Self::GameSession(data) => Self::GameSession(data.pack(remaining_accounts)), + Self::PlaceholderRecord(data) => Self::PlaceholderRecord(data.pack(remaining_accounts)), + Self::PackedCTokenData(_) => { + unreachable!() + } + Self::CTokenData(data) => Self::PackedCTokenData(data.pack(remaining_accounts)), + Self::PackedGameSession(_) => unreachable!(), + Self::PackedPlaceholderRecord(_) => unreachable!(), + } + } +} + +// Unpack implementation for CompressedAccountVariant +// This delegates to the underlying type's Unpack implementation +impl Unpack for CompressedAccountVariant { + type Unpacked = Self; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + match self { + Self::PackedUserRecord(data) => Ok(Self::UserRecord(data.unpack(remaining_accounts)?)), + Self::UserRecord(_) => unreachable!(), + Self::GameSession(data) => Ok(Self::GameSession(data.unpack(remaining_accounts)?)), + Self::PlaceholderRecord(data) => { + Ok(Self::PlaceholderRecord(data.unpack(remaining_accounts)?)) + } + Self::PackedCTokenData(_data) => Ok(self.clone()), // as-is + Self::CTokenData(_data) => unreachable!(), // as-is + Self::PackedGameSession(_data) => unreachable!(), + Self::PackedPlaceholderRecord(_data) => unreachable!(), + } + } +} + +// Auto-derived via macro. Ix data implemented for Variant. +#[derive(Clone, Debug, AnchorDeserialize, AnchorSerialize)] +pub struct CompressedAccountData { + pub meta: CompressedAccountMetaNoLamportsNoAddress, + pub data: CompressedAccountVariant, +} + +#[derive(Default, Debug, LightHasher, LightDiscriminator, InitSpace)] +#[account] +pub struct UserRecord { + #[skip] + pub compression_info: Option, + #[hash] + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub score: u64, +} + +// Auto-derived via macro. +impl HasCompressionInfo for UserRecord { + fn compression_info(&self) -> &CompressionInfo { + self.compression_info + .as_ref() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut(&mut self) -> &mut CompressionInfo { + self.compression_info + .as_mut() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } +} + +impl CompressedInitSpace for UserRecord { + const COMPRESSED_INIT_SPACE: usize = Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE; +} + +impl CompressedInitSpace for GameSession { + const COMPRESSED_INIT_SPACE: usize = Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE; +} + +impl CompressedInitSpace for PlaceholderRecord { + const COMPRESSED_INIT_SPACE: usize = Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE; +} + +impl Size for UserRecord { + fn size(&self) -> usize { + Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE + } +} + +impl CompressAs for UserRecord { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + // Simple case: return owned data with compression_info = None + // We can't return Cow::Borrowed because compression_info must always be None for compressed storage + std::borrow::Cow::Owned(Self { + compression_info: None, // ALWAYS None for compressed storage + owner: self.owner, + name: self.name.clone(), + score: self.score, + }) + } +} + +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize)] +pub struct PackedUserRecord { + pub compression_info: Option, + pub owner: u8, + pub name: String, + pub score: u64, +} + +// Identity Pack implementation - no custom packing needed for PDA types +impl Pack for UserRecord { + type Packed = PackedUserRecord; + + fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed { + PackedUserRecord { + compression_info: None, + owner: remaining_accounts.insert_or_get(self.owner), + name: self.name.clone(), + score: self.score, + } + } +} + +// Identity Unpack implementation - PDA types are sent unpacked +impl Unpack for UserRecord { + type Unpacked = Self; + + fn unpack( + &self, + _remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } +} + +// Identity Pack implementation - no custom packing needed for PDA types +impl Pack for PackedUserRecord { + type Packed = Self; + + fn pack(&self, _remaining_accounts: &mut PackedAccounts) -> Self::Packed { + self.clone() + } +} + +// Identity Unpack implementation - PDA types are sent unpacked +impl Unpack for PackedUserRecord { + type Unpacked = UserRecord; + + fn unpack( + &self, + remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(UserRecord { + compression_info: None, + owner: *remaining_accounts[self.owner as usize].key, + name: self.name.clone(), + score: self.score, + }) + } +} + +// Your existing account structs must be manually extended: +// 1. Add compression_info field to the struct, with type +// Option. +// 2. add a #[skip] field for the compression_info field. +// 3. Add LightHasher, LightDiscriminator. +// 4. Add #[hash] attribute to ALL fields that can be >31 bytes. (eg Pubkeys, +// Strings) +#[derive(Default, Debug, LightHasher, LightDiscriminator, InitSpace)] +#[account] +pub struct GameSession { + #[skip] + pub compression_info: Option, + pub session_id: u64, + #[hash] + pub player: Pubkey, + #[max_len(32)] + pub game_type: String, + pub start_time: u64, + pub end_time: Option, + pub score: u64, +} + +// Auto-derived via macro. +impl HasCompressionInfo for GameSession { + fn compression_info(&self) -> &CompressionInfo { + self.compression_info + .as_ref() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut(&mut self) -> &mut CompressionInfo { + self.compression_info + .as_mut() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } +} + +impl Size for GameSession { + fn size(&self) -> usize { + Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE + } +} + +impl CompressAs for GameSession { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + // Custom compression: return owned data with modified fields + std::borrow::Cow::Owned(Self { + compression_info: None, // ALWAYS None for compressed storage + session_id: self.session_id, // KEEP - identifier + player: self.player, // KEEP - identifier + game_type: self.game_type.clone(), // KEEP - core property + start_time: 0, // RESET - clear timing + end_time: None, // RESET - clear timing + score: 0, // RESET - clear progress + }) + } +} + +// Identity Pack implementation - no custom packing needed for PDA types +impl Pack for GameSession { + type Packed = Self; + + fn pack(&self, _remaining_accounts: &mut PackedAccounts) -> Self::Packed { + self.clone() + } +} + +// Identity Unpack implementation - PDA types are sent unpacked +impl Unpack for GameSession { + type Unpacked = Self; + + fn unpack( + &self, + _remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } +} + +// PlaceholderRecord - demonstrates empty compressed account creation +// The PDA remains intact while an empty compressed account is created +#[derive(Default, Debug, LightDiscriminator, InitSpace)] +#[account] +pub struct PlaceholderRecord { + // #[skip] + pub compression_info: Option, + // #[hash] + pub owner: Pubkey, + #[max_len(32)] + pub name: String, + pub placeholder_id: u64, +} + +impl HasCompressionInfo for PlaceholderRecord { + fn compression_info(&self) -> &CompressionInfo { + self.compression_info + .as_ref() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut(&mut self) -> &mut CompressionInfo { + self.compression_info + .as_mut() + .expect("CompressionInfo must be Some on-chain") + } + + fn compression_info_mut_opt(&mut self) -> &mut Option { + &mut self.compression_info + } + + fn set_compression_info_none(&mut self) { + self.compression_info = None; + } +} + +impl Size for PlaceholderRecord { + fn size(&self) -> usize { + Self::LIGHT_DISCRIMINATOR.len() + Self::INIT_SPACE + } +} + +impl CompressAs for PlaceholderRecord { + type Output = Self; + + fn compress_as(&self) -> std::borrow::Cow<'_, Self::Output> { + std::borrow::Cow::Owned(Self { + compression_info: None, + owner: self.owner, + name: self.name.clone(), + placeholder_id: self.placeholder_id, + }) + } +} + +// Identity Pack implementation - no custom packing needed for PDA types +impl Pack for PlaceholderRecord { + type Packed = Self; + + fn pack(&self, _remaining_accounts: &mut PackedAccounts) -> Self::Packed { + self.clone() + } +} + +// Identity Unpack implementation - PDA types are sent unpacked +impl Unpack for PlaceholderRecord { + type Unpacked = Self; + + fn unpack( + &self, + _remaining_accounts: &[AccountInfo], + ) -> std::result::Result { + Ok(self.clone()) + } +} + +// #[error_code] +// pub enum CompressibleInstructionError { +// #[msg("Invalid account count: PDAs and compressed accounts must match")] +// InvalidAccountCount, +// #[msg("Rent recipient does not match config")] +// InvalidRentRecipient, +// #[msg("Failed to create compressed mint")] +// MintCreationFailed, +// #[msg("Compressed token program account not found in remaining accounts")] +// MissingCompressedTokenProgram, +// #[msg("Compressed token program authority PDA account not found in remaining accounts")] +// MissingCompressedTokenProgramAuthorityPDA, + +// #[msg("CToken decompression not yet implemented")] +// CTokenDecompressionNotImplemented, +// } + +// Add these struct definitions before the program module +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct AccountCreationData { + pub user_name: String, + pub session_id: u64, + pub game_type: String, + // TODO: Add mint metadata fields when implementing mint functionality + pub mint_name: String, + pub mint_symbol: String, + pub mint_uri: String, + pub mint_decimals: u8, + pub mint_supply: u64, + pub mint_update_authority: Option, + pub mint_freeze_authority: Option, + pub additional_metadata: Option>, +} + +/// Information about a token account to compress +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct TokenAccountInfo { + pub user: Pubkey, + pub mint: Pubkey, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct CompressionParams { + pub proof: ValidityProof, + pub user_compressed_address: [u8; 32], + pub user_address_tree_info: PackedAddressTreeInfo, + pub user_output_state_tree_index: u8, + pub game_compressed_address: [u8; 32], + pub game_address_tree_info: PackedAddressTreeInfo, + pub game_output_state_tree_index: u8, + // TODO: Add mint compression parameters when implementing mint functionality + // pub mint_compressed_address: [u8; 32], + // pub mint_address_tree_info: PackedAddressTreeInfo, + // pub mint_output_state_tree_index: u8, + pub mint_bump: u8, + pub mint_with_context: CompressedMintWithContext, +} + +#[inline] +pub fn account_meta_from_account_info(account_info: &AccountInfo) -> AccountMeta { + AccountMeta { + pubkey: *account_info.key, + is_signer: account_info.is_signer, + is_writable: account_info.is_writable, + } +} diff --git a/sdk-tests/csdk-anchor-test/tests/test.rs b/sdk-tests/csdk-anchor-test/tests/test.rs new file mode 100644 index 0000000000..b06355b016 --- /dev/null +++ b/sdk-tests/csdk-anchor-test/tests/test.rs @@ -0,0 +1,3147 @@ +use anchor_lang::{ + AccountDeserialize, AnchorDeserialize, Discriminator, InstructionData, ToAccountMetas, +}; +use csdk_anchor_test::{ + get_ctoken_signer2_seeds, get_ctoken_signer3_seeds, get_ctoken_signer4_seeds, + get_ctoken_signer5_seeds, get_ctoken_signer_seeds, CTokenAccountVariant, + CompressedAccountVariant, GameSession, UserRecord, +}; +use light_client::indexer::CompressedAccount; +use light_compressed_account::address::derive_address; +use light_compressed_token_sdk::instructions::create_compressed_mint::{ + derive_compressed_mint_address, derive_ctoken_mint_address, find_spl_mint_address, +}; +use light_compressed_token_types::CPI_AUTHORITY_PDA; +use light_compressible_client::CompressibleInstruction; +use light_ctoken_types::{ + instructions::mint_action::{CompressedMintInstructionData, CompressedMintWithContext}, + state::CompressedMintMetadata, +}; +use light_macros::pubkey; +use light_program_test::{ + program_test::{ + initialize_compression_config, setup_mock_program_data, LightProgramTest, TestRpc, + }, + utils::simulation::simulate_cu, + AddressWithTree, Indexer, ProgramTestConfig, Rpc, RpcError, +}; +use light_sdk::{ + compressible::{CompressAs, CompressibleConfig}, + instruction::{PackedAccounts, SystemAccountMetaConfig}, + token::CTokenDataWithVariant, +}; +use light_sdk_types::C_TOKEN_PROGRAM_ID; +use light_token_client::ctoken; +// use light_token_client::ctoken; +use solana_account::Account; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +pub const ADDRESS_SPACE: [Pubkey; 1] = [pubkey!("amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx")]; +pub const RENT_RECIPIENT: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); +pub const TOKEN_PROGRAM_ID: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +pub const CTOKEN_RENT_SPONSOR: Pubkey = pubkey!("r18WwUxfG8kQ69bQPAB2jV6zGNKy3GosFGctjQoV4ti"); // derive_ctoken_rent_sponsor(None).0; +pub const CTOKEN_RENT_AUTHORITY: Pubkey = pubkey!("8r3QmazwoLHYppYWysXPgUxYJ3Khn7vh3e313jYDcCKy"); +#[tokio::test] +async fn test_create_and_decompress_two_accounts() { + let program_id = csdk_anchor_test::ID; + let mut config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + println!("config_pda ANCHOR COMPRESSIBLE: {:?}", config_pda); + println!( + "config_pda CTOKEN: {:?}", + CompressibleConfig::derive_pda( + &solana_pubkey::pubkey!("Lighton6oQpVkeewmo2mcPTQQp7kYHr4fWpAgJyEmDX"), + 1 + ) + .0 + ); + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + // Save the CompressibleConfig account + // let config_pubkey = + // solana_pubkey::Pubkey::from_str_const("ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg"); + // rpc.save_account_to_cli(&config_pubkey) + // .await + // .expect("Failed to save config account"); + + // Save/refresh another account + // let refresh_pubkey_str = "7Hp52chxaew8bW1ApR4fck2bh6Y8qA1pu3qwH6N9zaLj"; + // let refresh_pubkey = Pubkey::from_str(refresh_pubkey_str).unwrap(); + // rpc.save_account_to_cli(&refresh_pubkey).await.unwrap(); + + let combined_user = Keypair::new(); + let fund_user_ix = solana_sdk::system_instruction::transfer( + &payer.pubkey(), + &combined_user.pubkey(), + 1e9 as u64, + ); + let fund_result = rpc + .create_and_send_transaction(&[fund_user_ix], &payer.pubkey(), &[&payer]) + .await; + assert!(fund_result.is_ok(), "Funding combined user should succeed"); + let combined_session_id = 99999u64; + let (combined_user_record_pda, _combined_user_record_bump) = Pubkey::find_program_address( + &[b"user_record", combined_user.pubkey().as_ref()], + &program_id, + ); + let (combined_game_session_pda, _combined_game_bump) = Pubkey::find_program_address( + &[b"game_session", combined_session_id.to_le_bytes().as_ref()], + &program_id, + ); + + let ( + ctoken_account, + _mint_signer, + ctoken_account_2, + ctoken_account_3, + ctoken_account_4, + ctoken_account_5, + ) = create_user_record_and_game_session( + &mut rpc, + &combined_user, + &program_id, + &config_pda, + &combined_user_record_pda, + &combined_game_session_pda, + combined_session_id, + ) + .await; + + rpc.warp_to_slot(200).unwrap(); + + let (_, ctoken_account_address) = csdk_anchor_test::get_ctoken_signer_seeds( + &combined_user.pubkey(), + &ctoken_account.token.mint, + ); + + let (_, ctoken_account_address_2) = + csdk_anchor_test::get_ctoken_signer2_seeds(&combined_user.pubkey()); + + let (_, ctoken_account_address_3) = + csdk_anchor_test::get_ctoken_signer3_seeds(&combined_user.pubkey()); + + let (_, ctoken_account_address_4) = csdk_anchor_test::get_ctoken_signer4_seeds( + &combined_user.pubkey(), + &combined_user.pubkey(), + ); // user as fee_payer + + let (_, ctoken_account_address_5) = csdk_anchor_test::get_ctoken_signer5_seeds( + &combined_user.pubkey(), + &ctoken_account.token.mint, + 42, + ); // Fixed index 42 + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let compressed_user_record_address = derive_address( + &combined_user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let compressed_game_session_address = derive_address( + &combined_game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let user_record_before_decompression: CompressedAccount = rpc + .get_compressed_account(compressed_user_record_address, None) + .await + .unwrap() + .value + .unwrap(); + let game_session_before_decompression: CompressedAccount = rpc + .get_compressed_account(compressed_game_session_address, None) + .await + .unwrap() + .value + .unwrap(); + + decompress_multiple_pdas_with_ctoken( + &mut rpc, + &combined_user, + &program_id, + &combined_user_record_pda, + &combined_game_session_pda, + combined_session_id, + "Combined User", + "Combined Game", + 200, + ctoken_account.clone(), + ctoken_account_address, // also the owner of the compressed token account! + ctoken_account_2.clone(), + ctoken_account_address_2, + ctoken_account_3.clone(), + ctoken_account_address_3, + ctoken_account_4.clone(), + ctoken_account_address_4, + ctoken_account_5.clone(), + ctoken_account_address_5, + ) + .await; + + // Now compress the decompressed token account back to compressed + rpc.warp_to_slot(300).unwrap(); + + compress_token_account_after_decompress( + &mut rpc, + &combined_user, + &program_id, + &config_pda, + ctoken_account_address, + ctoken_account_address_2, + ctoken_account_address_3, + ctoken_account_address_4, + ctoken_account_address_5, + ctoken_account.token.mint, + ctoken_account.token.amount, + &combined_user_record_pda, + &combined_game_session_pda, + combined_session_id, + user_record_before_decompression.hash, + game_session_before_decompression.hash, + ) + .await; +} + +#[tokio::test] +async fn test_create_decompress_compress_single_account() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + 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 result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + let (user_record_pda, user_record_bump) = + Pubkey::find_program_address(&[b"user_record", payer.pubkey().as_ref()], &program_id); + + create_record(&mut rpc, &payer, &program_id, &user_record_pda, None).await; + + rpc.warp_to_slot(100).unwrap(); + + decompress_single_user_record( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &user_record_bump, + "Test User", + 100, + ) + .await; + + rpc.warp_to_slot(101).unwrap(); + + println!("compressing record..."); + let result = compress_record(&mut rpc, &payer, &program_id, &user_record_pda, true).await; + assert!(result.is_err(), "Compression should fail due to slot delay"); + if let Err(err) = result { + let err_msg = format!("{:?}", err); + assert!( + err_msg.contains("Custom(16001)"), + "Expected error message about slot delay, got: {}", + err_msg + ); + } + rpc.warp_to_slot(200).unwrap(); + let _result = compress_record(&mut rpc, &payer, &program_id, &user_record_pda, false).await; +} + +#[tokio::test] +async fn test_double_decompression_attack() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + 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 result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + let (user_record_pda, user_record_bump) = + Pubkey::find_program_address(&[b"user_record", payer.pubkey().as_ref()], &program_id); + + // Create and compress the account + create_record(&mut rpc, &payer, &program_id, &user_record_pda, None).await; + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let compressed_user_record = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + let c_user_record = + UserRecord::deserialize(&mut &compressed_user_record.data.unwrap().data[..]).unwrap(); + + rpc.warp_to_slot(100).unwrap(); + + // First decompression - should succeed + decompress_single_user_record( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &user_record_bump, + "Test User", + 100, + ) + .await; + + // Verify account is now decompressed + let user_pda_account = rpc.get_account(user_record_pda).await.unwrap(); + assert!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "User PDA should be decompressed after first operation" + ); + + // Second decompression attempt - should be idempotent (skip already initialized account) + + let c_user_pda = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let rpc_result = rpc + .get_validity_proof(vec![c_user_pda.hash], vec![], None) + .await + .unwrap() + .value; + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + // Second decompression instruction - should still work (idempotent) + let instruction = + light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + &program_id, + &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &[user_record_pda], + &[( + c_user_pda, + CompressedAccountVariant::UserRecord(c_user_record), + )], + &csdk_anchor_test::accounts::DecompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_payer: payer.pubkey(), + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + ctoken_config: ctoken::derive_ctoken_program_config(None).0, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + some_mint: payer.pubkey(), + } + .to_account_metas(None), + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + + // Should succeed due to idempotent behavior (skips already initialized accounts) + assert!( + result.is_ok(), + "Second decompression should succeed idempotently" + ); + + // Verify account state is still correct and not corrupted + let user_pda_account = rpc.get_account(user_record_pda).await.unwrap(); + let user_pda_data = user_pda_account.unwrap().data; + let decompressed_user_record = UserRecord::try_deserialize(&mut &user_pda_data[..]).unwrap(); + + assert_eq!(decompressed_user_record.name, "Test User"); + assert_eq!(decompressed_user_record.score, 11); + assert_eq!(decompressed_user_record.owner, payer.pubkey()); + assert!(!decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .is_compressed()); +} + +#[tokio::test] +async fn test_create_and_decompress_accounts_with_different_state_trees() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + let result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + let (user_record_pda, _user_record_bump) = + Pubkey::find_program_address(&[b"user_record", payer.pubkey().as_ref()], &program_id); + + let session_id = 54321u64; + let (game_session_pda, _game_bump) = Pubkey::find_program_address( + &[b"game_session", session_id.to_le_bytes().as_ref()], + &program_id, + ); + + // Get two different state trees + let first_state_tree_info = rpc.get_state_tree_infos()[0]; + let second_state_tree_info = rpc.get_state_tree_infos()[1]; + + // Create user record using first state tree + create_record( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + Some(first_state_tree_info.queue), + ) + .await; + + // Create game session using second state tree + create_game_session( + &mut rpc, + &payer, + &program_id, + &config_pda, + &game_session_pda, + session_id, + Some(second_state_tree_info.queue), + ) + .await; + + rpc.warp_to_slot(100).unwrap(); + + // Now decompress both accounts together - they come from different state trees + // This should succeed and validate that our decompression can handle mixed state tree sources + decompress_multiple_pdas( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &game_session_pda, + session_id, + "Test User", + "Battle Royale", + 100, + ) + .await; +} + +#[tokio::test] +async fn test_update_record_compression_info() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + 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 result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + let (user_record_pda, user_record_bump) = + Pubkey::find_program_address(&[b"user_record", payer.pubkey().as_ref()], &program_id); + + // Create and compress the account + create_record(&mut rpc, &payer, &program_id, &user_record_pda, None).await; + + // Warp to slot 100 and decompress + rpc.warp_to_slot(100).unwrap(); + decompress_single_user_record( + &mut rpc, + &payer, + &program_id, + &user_record_pda, + &user_record_bump, + "Test User", + 100, + ) + .await; + + // Warp to slot 150 for the update + rpc.warp_to_slot(150).unwrap(); + + // Create update instruction + let accounts = csdk_anchor_test::accounts::UpdateRecord { + user: payer.pubkey(), + user_record: user_record_pda, + }; + + let instruction_data = csdk_anchor_test::instruction::UpdateRecord { + name: "Updated User".to_string(), + score: 42, + }; + + let instruction = Instruction { + program_id, + accounts: accounts.to_account_metas(None), + data: instruction_data.data(), + }; + + // Execute the update + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + assert!(result.is_ok(), "Update record transaction should succeed"); + + // Warp to slot 200 to ensure we're past the update + rpc.warp_to_slot(200).unwrap(); + + // Fetch the account and verify compression_info.last_written_slot + let user_pda_account = rpc.get_account(user_record_pda).await.unwrap(); + assert!( + user_pda_account.is_some(), + "User record account should exist after update" + ); + + let account_data = user_pda_account.unwrap().data; + let updated_user_record = UserRecord::try_deserialize(&mut &account_data[..]).unwrap(); + + // Verify the data was updated + assert_eq!(updated_user_record.name, "Updated User"); + assert_eq!(updated_user_record.score, 42); + assert_eq!(updated_user_record.owner, payer.pubkey()); + + // Verify compression_info.last_written_slot was updated to slot 150 + assert_eq!( + updated_user_record + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + 150 + ); + assert!(!updated_user_record + .compression_info + .as_ref() + .unwrap() + .is_compressed()); +} + +#[tokio::test] +async fn test_custom_compression_game_session() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + // Initialize config + let result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, // compression delay + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + // Create a game session + let session_id = 42424u64; + let (game_session_pda, _game_bump) = Pubkey::find_program_address( + &[b"game_session", session_id.to_le_bytes().as_ref()], + &program_id, + ); + + create_game_session( + &mut rpc, + &payer, + &program_id, + &config_pda, + &game_session_pda, + session_id, + None, + ) + .await; + + // Warp forward to allow decompression + rpc.warp_to_slot(100).unwrap(); + + // Decompress the game session first to verify original state + decompress_single_game_session( + &mut rpc, + &payer, + &program_id, + &game_session_pda, + &_game_bump, + session_id, + "Battle Royale", + 100, + 0, // original score should be 0 + ) + .await; + + // Warp forward past compression delay to allow compression + rpc.warp_to_slot(250).unwrap(); + + // Test the custom compression trait - this demonstrates the core functionality + compress_game_session_with_custom_data( + &mut rpc, + &payer, + &program_id, + &game_session_pda, + session_id, + ) + .await; +} + +#[tokio::test] +async fn test_create_empty_compressed_account() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + // Initialize compression config + let result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + // Create placeholder record using empty compressed account functionality + let placeholder_id = 54321u64; + let (placeholder_record_pda, placeholder_record_bump) = Pubkey::find_program_address( + &[b"placeholder_record", placeholder_id.to_le_bytes().as_ref()], + &program_id, + ); + + create_placeholder_record( + &mut rpc, + &payer, + &program_id, + &config_pda, + &placeholder_record_pda, + placeholder_id, + "Test Placeholder", + ) + .await; + + println!("...create_placeholder_record done"); + + // Verify the PDA still exists and has data + let placeholder_pda_account = rpc.get_account(placeholder_record_pda).await.unwrap(); + assert!( + placeholder_pda_account.is_some(), + "Placeholder PDA should exist after empty compression" + ); + let account = placeholder_pda_account.unwrap(); + assert!( + account.lamports > 0, + "Placeholder PDA should have lamports (not closed)" + ); + assert!( + !account.data.is_empty(), + "Placeholder PDA should have data (not closed)" + ); + + // Verify we can read the PDA data + let placeholder_data = account.data; + let decompressed_placeholder_record = + csdk_anchor_test::PlaceholderRecord::try_deserialize(&mut &placeholder_data[..]).unwrap(); + assert_eq!(decompressed_placeholder_record.name, "Test Placeholder"); + assert_eq!( + decompressed_placeholder_record.placeholder_id, + placeholder_id + ); + assert_eq!(decompressed_placeholder_record.owner, payer.pubkey()); + + // Verify empty compressed account was created + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + let compressed_address = derive_address( + &placeholder_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let compressed_placeholder = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_placeholder.address, + Some(compressed_address), + "Compressed account should exist with correct address" + ); + assert!( + compressed_placeholder.data.is_some(), + "Compressed account should have data field" + ); + + // Verify the compressed account is empty (length 0) + let compressed_data = compressed_placeholder.data.unwrap(); + assert_eq!( + compressed_data.data.len(), + 0, + "Compressed account data should be empty" + ); + + // This demonstrates the key difference from regular compression: + // The PDA still exists with data, and an empty compressed account was created + + // Step 2: Now compress the PDA (this will close the PDA and put data into the compressed account) + rpc.warp_to_slot(200).unwrap(); // Wait past compression delay + + println!("...compressing placeholder record"); + + compress_placeholder_record( + &mut rpc, + &payer, + &program_id, + &config_pda, + &placeholder_record_pda, + &placeholder_record_bump, + placeholder_id, + ) + .await; +} + +async fn create_record( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + state_tree_queue: Option, +) { + let config_pda = CompressibleConfig::derive_pda(program_id, 0).0; + + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new(*program_id); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + let accounts = csdk_anchor_test::accounts::CreateRecord { + user: payer.pubkey(), + user_record: *user_record_pda, + system_program: solana_sdk::system_program::ID, + config: config_pda, + rent_recipient: RENT_RECIPIENT, + }; + + let compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Get validity proof from RPC + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compressed_address, + tree: address_tree_pubkey, + }], + None, + ) + .await + .unwrap() + .value; + + // Pack tree infos into remaining accounts + let packed_tree_infos = rpc_result.pack_tree_infos(&mut remaining_accounts); + + // Get the packed address tree info + let address_tree_info = packed_tree_infos.address_trees[0]; + + // Get output state tree index + let output_state_tree_index = remaining_accounts.insert_or_get( + state_tree_queue.unwrap_or_else(|| rpc.get_random_state_tree_info().unwrap().queue), + ); + + // Get system accounts for the instruction + let (system_accounts, _, _) = remaining_accounts.to_account_metas(); + + // Create instruction data + let instruction_data = csdk_anchor_test::instruction::CreateRecord { + name: "Test User".to_string(), + proof: rpc_result.proof, + compressed_address, + address_tree_info, + output_state_tree_index, + }; + + // Build the instruction + let instruction = Instruction { + program_id: *program_id, + accounts: [accounts.to_account_metas(None), system_accounts].concat(), + data: instruction_data.data(), + }; + + let cu = simulate_cu(rpc, payer, &instruction).await; + println!("CreateRecord CU consumed: {}", cu); + + // Create and send transaction + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + + assert!(result.is_ok(), "Transaction should succeed"); + + // should be empty + let user_record_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_record_account.is_none(), + "Account should not exist after compression" + ); +} + +async fn create_game_session( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + config_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, + state_tree_queue: Option, +) { + // Setup remaining accounts for Light Protocol + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new(*program_id); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + // Get address tree info + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Create the instruction + let accounts = csdk_anchor_test::accounts::CreateGameSession { + player: payer.pubkey(), + game_session: *game_session_pda, + system_program: solana_sdk::system_program::ID, + config: *config_pda, + rent_recipient: RENT_RECIPIENT, + }; + + // Derive a new address for the compressed account + let compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Get validity proof from RPC + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compressed_address, + tree: address_tree_pubkey, + }], + None, + ) + .await + .unwrap() + .value; + + // Pack tree infos into remaining accounts + let packed_tree_infos = rpc_result.pack_tree_infos(&mut remaining_accounts); + + // Get the packed address tree info + let address_tree_info = packed_tree_infos.address_trees[0]; + + // Get output state tree index + let output_state_tree_index = remaining_accounts.insert_or_get( + state_tree_queue.unwrap_or_else(|| rpc.get_random_state_tree_info().unwrap().queue), + ); + + // Get system accounts for the instruction + let (system_accounts, _, _) = remaining_accounts.to_account_metas(); + + // Create instruction data + let instruction_data = csdk_anchor_test::instruction::CreateGameSession { + session_id, + game_type: "Battle Royale".to_string(), + proof: rpc_result.proof, + compressed_address, + address_tree_info, + output_state_tree_index, + }; + + // Build the instruction + let instruction = Instruction { + program_id: *program_id, + accounts: [accounts.to_account_metas(None), system_accounts].concat(), + data: instruction_data.data(), + }; + + // Create and send transaction + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + + assert!(result.is_ok(), "Transaction should succeed"); + + // Verify the account is closed after compression + let game_session_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_session_account.is_none(), + "Account should not exist after compression" + ); + + let compressed_game_session = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!(compressed_game_session.address, Some(compressed_address)); + assert!(compressed_game_session.data.is_some()); + + let buf = compressed_game_session.data.as_ref().unwrap().data.clone(); + + let game_session = GameSession::deserialize(&mut &buf[..]).unwrap(); + + assert_eq!(game_session.session_id, session_id); + assert_eq!(game_session.game_type, "Battle Royale"); + assert_eq!(game_session.player, payer.pubkey()); + assert_eq!(game_session.score, 0); + assert!(game_session.compression_info.is_none()); +} + +#[allow(clippy::too_many_arguments)] +async fn decompress_multiple_pdas_with_ctoken( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, + expected_user_name: &str, + expected_game_type: &str, + expected_slot: u64, + ctoken_account: light_client::indexer::CompressedTokenAccount, + native_token_account: Pubkey, + ctoken_account_2: light_client::indexer::CompressedTokenAccount, + native_token_account_2: Pubkey, + ctoken_account_3: light_client::indexer::CompressedTokenAccount, + native_token_account_3: Pubkey, + ctoken_account_4: light_client::indexer::CompressedTokenAccount, + native_token_account_4: Pubkey, + ctoken_account_5: light_client::indexer::CompressedTokenAccount, + native_token_account_5: Pubkey, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // c pda USER_RECORD + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let c_user_pda = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let user_account_data = c_user_pda.data.as_ref().unwrap(); + let c_user_record = UserRecord::deserialize(&mut &user_account_data.data[..]).unwrap(); + + // c pda GAME_SESSION + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let c_game_pda = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + let game_account_data = c_game_pda.data.as_ref().unwrap(); + let c_game_session = GameSession::deserialize(&mut &game_account_data.data[..]).unwrap(); + + // Get validity proof for all seven compressed accounts (2 PDAs + 5 tokens) + println!("DEBUG hashes passed to get_validity_proof:"); + println!(" c_user_pda.hash: {:?}", c_user_pda.hash); + println!(" c_game_pda.hash: {:?}", c_game_pda.hash); + println!( + " ctoken_account.hash: {:?}", + ctoken_account.clone().account.hash + ); + println!( + " ctoken_account_2.hash: {:?}", + ctoken_account_2.clone().account.hash + ); + println!( + " ctoken_account_3.hash: {:?}", + ctoken_account_3.clone().account.hash + ); + println!( + " ctoken_account_4.hash: {:?}", + ctoken_account_4.clone().account.hash + ); + println!( + " ctoken_account_5.hash: {:?}", + ctoken_account_5.clone().account.hash + ); + + let rpc_result = rpc + .get_validity_proof( + vec![ + c_user_pda.hash, + c_game_pda.hash, + ctoken_account.clone().account.hash.clone(), + ctoken_account_2.clone().account.hash.clone(), + ctoken_account_3.clone().account.hash.clone(), + ctoken_account_4.clone().account.hash.clone(), + ctoken_account_5.clone().account.hash.clone(), + ], + vec![], + None, + ) + .await + .unwrap() + .value; + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let ctoken_config = ctoken::derive_ctoken_program_config(None).0; + println!("AAA ctoken_config: {:?}", ctoken_config); + println!("AAA ctoken_account: {:?}", ctoken_account); + let instruction = + light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + program_id, + &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + // must be same order as the compressed_accounts! + // &[*user_record_pda, *game_session_pda], + // &[native_token_account], + &[ + *user_record_pda, + *game_session_pda, + native_token_account, + native_token_account_2, + native_token_account_3, + native_token_account_4, + native_token_account_5, + ], + &[ + // gets packed internally and never unpacked onchain: + ( + c_user_pda.clone(), + CompressedAccountVariant::UserRecord(c_user_record), + ), + ( + c_game_pda.clone(), + CompressedAccountVariant::GameSession(c_game_session), + ), + ( + { + let acc = ctoken_account.clone().account; + let token = ctoken_account.clone().token; + println!("DEBUG CLIENT ctoken_account - owner: {:?}", token.owner); + println!("DEBUG CLIENT ctoken_account - mint: {:?}", token.mint); + println!("DEBUG CLIENT ctoken_account - amount: {:?}", token.amount); + println!( + "DEBUG CLIENT ctoken_account - delegate: {:?}", + token.delegate + ); + println!("DEBUG CLIENT ctoken_account - account.hash: {:?}", acc.hash); + acc + }, + CompressedAccountVariant::CTokenData(CTokenDataWithVariant::< + CTokenAccountVariant, + > { + variant: CTokenAccountVariant::CTokenSigner, + token_data: ctoken_account.clone().token, + }), + ), + ( + ctoken_account_2.clone().account, + CompressedAccountVariant::CTokenData(CTokenDataWithVariant::< + CTokenAccountVariant, + > { + variant: CTokenAccountVariant::CTokenSigner2, + token_data: ctoken_account_2.clone().token, + }), + ), + ( + ctoken_account_3.clone().account, + CompressedAccountVariant::CTokenData(CTokenDataWithVariant::< + CTokenAccountVariant, + > { + variant: CTokenAccountVariant::CTokenSigner3, + token_data: ctoken_account_3.clone().token, + }), + ), + ( + ctoken_account_4.clone().account, + CompressedAccountVariant::CTokenData(CTokenDataWithVariant::< + CTokenAccountVariant, + > { + variant: CTokenAccountVariant::CTokenSigner4, + token_data: ctoken_account_4.clone().token, + }), + ), + ( + ctoken_account_5.clone().account, + CompressedAccountVariant::CTokenData(CTokenDataWithVariant::< + CTokenAccountVariant, + > { + variant: CTokenAccountVariant::CTokenSigner5, + token_data: ctoken_account_5.clone().token, + }), + ), + ], + &csdk_anchor_test::accounts::DecompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_payer: payer.pubkey(), + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + ctoken_config, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + some_mint: ctoken_account.token.mint, + } + .to_account_metas(None), + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + println!("user_record_pda: {:?}", user_record_pda.to_string()); + println!("game_session_pda: {:?}", game_session_pda.to_string()); + println!( + "native_token_account: {:?}", + native_token_account.to_string() + ); + + // Verify PDAs are uninitialized before decompression + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert_eq!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0), + 0, + "User PDA account data len must be 0 before decompression" + ); + + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert_eq!( + game_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0), + 0, + "Game PDA account data len must be 0 before decompression" + ); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + assert!(result.is_ok(), "Decompress transaction should succeed"); + + // Verify UserRecord PDA is decompressed + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "User PDA account data len must be > 0 after decompression" + ); + + let user_pda_data = user_pda_account.unwrap().data; + assert_eq!( + &user_pda_data[0..8], + UserRecord::DISCRIMINATOR, + "User account anchor discriminator mismatch" + ); + + let decompressed_user_record = UserRecord::try_deserialize(&mut &user_pda_data[..]).unwrap(); + assert_eq!(decompressed_user_record.name, expected_user_name); + assert_eq!(decompressed_user_record.score, 11); + assert_eq!(decompressed_user_record.owner, payer.pubkey()); + assert!(!decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + expected_slot + ); + + // Verify GameSession PDA is decompressed + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "Game PDA account data len must be > 0 after decompression" + ); + + let game_pda_data = game_pda_account.unwrap().data; + assert_eq!( + &game_pda_data[0..8], + csdk_anchor_test::GameSession::DISCRIMINATOR, + "Game account anchor discriminator mismatch" + ); + + let decompressed_game_session = + csdk_anchor_test::GameSession::try_deserialize(&mut &game_pda_data[..]).unwrap(); + assert_eq!(decompressed_game_session.session_id, session_id); + assert_eq!(decompressed_game_session.game_type, expected_game_type); + assert_eq!(decompressed_game_session.player, payer.pubkey()); + assert_eq!(decompressed_game_session.score, 0); + assert!(!decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + expected_slot + ); + + // Verify the native token account has the decompressed tokens + let token_account_data = rpc + .get_account(native_token_account) + .await + .unwrap() + .unwrap(); + // For now, just verify the account exists and has data + assert!( + !token_account_data.data.is_empty(), + "Token account should have data" + ); + assert_eq!(token_account_data.owner, C_TOKEN_PROGRAM_ID.into()); + + // Ensure all compressed accounts are now empty (closed) + let compressed_user_record_data = rpc + .get_compressed_account(c_user_pda.clone().address.clone().unwrap(), None) + .await + .unwrap() + .value + .unwrap(); + let compressed_game_session_data = rpc + .get_compressed_account(c_game_pda.clone().address.clone().unwrap(), None) + .await + .unwrap() + .value + .unwrap(); + for ctoken in [ + &ctoken_account, + &ctoken_account_2, + &ctoken_account_3, + &ctoken_account_4, + &ctoken_account_5, + ] { + let response = rpc + .get_compressed_account_by_hash(ctoken.clone().account.hash.clone(), None) + .await + .unwrap(); + assert!( + response.value.is_none(), + "Compressed token account should have value == None after being closed" + ); + } + + assert!( + compressed_user_record_data.data.unwrap().data.is_empty(), + "Compressed user record should be closed/empty after decompression" + ); + assert!( + compressed_game_session_data.data.unwrap().data.is_empty(), + "Compressed game session should be closed/empty after decompression" + ); +} + +#[allow(clippy::too_many_arguments)] +async fn decompress_multiple_pdas( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, + expected_user_name: &str, + expected_game_type: &str, + expected_slot: u64, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // c pda USER_RECORD + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let c_user_pda = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let user_account_data = c_user_pda.data.as_ref().unwrap(); + + let c_user_record = UserRecord::deserialize(&mut &user_account_data.data[..]).unwrap(); + + // c pda GAME_SESSION + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let c_game_pda = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + let game_account_data = c_game_pda.data.as_ref().unwrap(); + + let c_game_session = GameSession::deserialize(&mut &game_account_data.data[..]).unwrap(); + + // Get validity proof for both compressed accounts + let rpc_result = rpc + .get_validity_proof(vec![c_user_pda.hash, c_game_pda.hash], vec![], None) + .await + .unwrap() + .value; + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + // Use the new SDK helper function with typed data + let instruction = + light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + program_id, + &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &[*user_record_pda, *game_session_pda], + &[ + ( + c_user_pda, + CompressedAccountVariant::UserRecord(c_user_record), + ), + ( + c_game_pda, + CompressedAccountVariant::GameSession(c_game_session), + ), + ], + &csdk_anchor_test::accounts::DecompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_payer: payer.pubkey(), + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + ctoken_config: ctoken::derive_ctoken_program_config(None).0, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + some_mint: payer.pubkey(), + } + .to_account_metas(None), + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + // Verify PDAs are uninitialized before decompression + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert_eq!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0), + 0, + "User PDA account data len must be 0 before decompression" + ); + + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert_eq!( + game_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0), + 0, + "Game PDA account data len must be 0 before decompression" + ); + + let cu = simulate_cu(rpc, payer, &instruction).await; + println!("decompress_multiple_pdas CU consumed: {}", cu); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + assert!(result.is_ok(), "Decompress transaction should succeed"); + + // Verify UserRecord PDA is decompressed + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "User PDA account data len must be > 0 after decompression" + ); + + let user_pda_data = user_pda_account.unwrap().data; + assert_eq!( + &user_pda_data[0..8], + UserRecord::DISCRIMINATOR, + "User account anchor discriminator mismatch" + ); + + let decompressed_user_record = UserRecord::try_deserialize(&mut &user_pda_data[..]).unwrap(); + assert_eq!(decompressed_user_record.name, expected_user_name); + assert_eq!(decompressed_user_record.score, 11); + assert_eq!(decompressed_user_record.owner, payer.pubkey()); + assert!(!decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + expected_slot + ); + + // Verify GameSession PDA is decompressed + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "Game PDA account data len must be > 0 after decompression" + ); + + let game_pda_data = game_pda_account.unwrap().data; + assert_eq!( + &game_pda_data[0..8], + csdk_anchor_test::GameSession::DISCRIMINATOR, + "Game account anchor discriminator mismatch" + ); + + let decompressed_game_session = + csdk_anchor_test::GameSession::try_deserialize(&mut &game_pda_data[..]).unwrap(); + assert_eq!(decompressed_game_session.session_id, session_id); + assert_eq!(decompressed_game_session.game_type, expected_game_type); + assert_eq!(decompressed_game_session.player, payer.pubkey()); + assert_eq!(decompressed_game_session.score, 0); + assert!(!decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + expected_slot + ); + + // Verify compressed accounts exist and have correct data + let c_game_pda = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert!(c_game_pda.data.is_some()); + assert_eq!(c_game_pda.data.unwrap().data.len(), 0); +} + +async fn create_user_record_and_game_session( + rpc: &mut LightProgramTest, + user: &Keypair, + program_id: &Pubkey, + config_pda: &Pubkey, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, +) -> ( + light_client::indexer::CompressedTokenAccount, + Pubkey, + light_client::indexer::CompressedTokenAccount, + light_client::indexer::CompressedTokenAccount, + light_client::indexer::CompressedTokenAccount, + light_client::indexer::CompressedTokenAccount, +) { + let state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + // Setup remaining accounts for Light Protocol + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new_with_cpi_context( + *program_id, + state_tree_info.cpi_context.unwrap(), + ); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + // Get address tree info + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Create a mint signer for the compressed mint + let decimals = 6u8; + let mint_authority_keypair = Keypair::new(); + let mint_authority = mint_authority_keypair.pubkey(); + let freeze_authority = mint_authority; // Same as mint authority for this example + let mint_signer = Keypair::new(); + let compressed_mint_address = + derive_ctoken_mint_address(&mint_signer.pubkey(), &address_tree_pubkey); + + // Find mint bump for the instruction + let (spl_mint, mint_bump) = find_spl_mint_address(&mint_signer.pubkey()); + // Create the instruction + let accounts = csdk_anchor_test::accounts::CreateUserRecordAndGameSession { + user: user.pubkey(), + user_record: *user_record_pda, + game_session: *game_session_pda, + mint_signer: mint_signer.pubkey(), + ctoken_program: light_sdk_types::constants::C_TOKEN_PROGRAM_ID.into(), + system_program: solana_sdk::system_program::ID, + config: *config_pda, + rent_recipient: RENT_RECIPIENT, + mint_authority, + compress_token_program_cpi_authority: Pubkey::new_from_array(CPI_AUTHORITY_PDA), + }; + // Derive addresses for both compressed accounts + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Get validity proof from RPC including mint address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![ + AddressWithTree { + address: user_compressed_address, + tree: address_tree_pubkey, + }, + AddressWithTree { + address: game_compressed_address, + tree: address_tree_pubkey, + }, + AddressWithTree { + address: compressed_mint_address, + tree: address_tree_pubkey, + }, + ], + None, + ) + .await + .unwrap() + .value; + + let user_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + let game_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + let _mint_output_state_tree_index = remaining_accounts.insert_or_get(state_tree_info.queue); + + // Pack tree infos into remaining accounts + let packed_tree_infos = rpc_result.pack_tree_infos(&mut remaining_accounts); + + // Get the packed address tree info (all should use the same tree) + let user_address_tree_info = packed_tree_infos.address_trees[0]; + let game_address_tree_info = packed_tree_infos.address_trees[1]; + let mint_address_tree_info = packed_tree_infos.address_trees[2]; + + // Get system accounts for the instruction + let (system_accounts, _, _) = remaining_accounts.to_account_metas(); + + // Create instruction data + let instruction_data = csdk_anchor_test::instruction::CreateUserRecordAndGameSession { + account_data: csdk_anchor_test::AccountCreationData { + user_name: "Combined User".to_string(), + session_id, + game_type: "Combined Game".to_string(), + // Add mint metadata + mint_name: "Test Game Token".to_string(), + mint_symbol: "TGT".to_string(), + mint_uri: "https://example.com/token.json".to_string(), + mint_decimals: 9, + mint_supply: 1_000_000_000, + mint_update_authority: Some(mint_authority), + mint_freeze_authority: Some(freeze_authority), + additional_metadata: None, + }, + compression_params: csdk_anchor_test::CompressionParams { + proof: rpc_result.proof, + user_compressed_address, + user_address_tree_info, + user_output_state_tree_index, + game_compressed_address, + game_address_tree_info, + game_output_state_tree_index, + // Add mint compression parameters + mint_bump, + mint_with_context: CompressedMintWithContext { + leaf_index: 0, + prove_by_index: false, + root_index: mint_address_tree_info.root_index, + address: compressed_mint_address, + mint: CompressedMintInstructionData { + supply: 0, + decimals, + metadata: CompressedMintMetadata { + version: 3, + mint: spl_mint.into(), + spl_mint_initialized: false, + }, + mint_authority: Some(mint_authority.into()), + freeze_authority: Some(freeze_authority.into()), + extensions: None, + }, + }, + }, + }; + + // Build the instruction + let instruction = Instruction { + program_id: *program_id, + accounts: [accounts.to_account_metas(None), system_accounts].concat(), + data: instruction_data.data(), + }; + + // Create and send transaction + let result = rpc + .create_and_send_transaction( + &[instruction], + &user.pubkey(), + &[user, &mint_signer, &mint_authority_keypair], + ) + .await; + + assert!( + result.is_ok(), + "Combined creation transaction should succeed" + ); + + // Verify both accounts are closed after compression + let user_record_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_record_account.is_none(), + "User record account should not exist after compression" + ); + + let game_session_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_session_account.is_none(), + "Game session account should not exist after compression" + ); + + // Verify compressed accounts exist and have correct data + let compressed_user_record = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_user_record.address, + Some(user_compressed_address) + ); + assert!(compressed_user_record.data.is_some()); + + let user_buf = compressed_user_record.data.unwrap().data; + + let user_record = UserRecord::deserialize(&mut &user_buf[..]).unwrap(); + + assert_eq!(user_record.name, "Combined User"); + assert_eq!(user_record.score, 11); + assert_eq!(user_record.owner, user.pubkey()); + + let compressed_game_session = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_game_session.address, + Some(game_compressed_address) + ); + assert!(compressed_game_session.data.is_some()); + + let game_buf = compressed_game_session.data.unwrap().data; + let game_session = GameSession::deserialize(&mut &game_buf[..]).unwrap(); + assert_eq!(game_session.session_id, session_id); + assert_eq!(game_session.game_type, "Combined Game"); + assert_eq!(game_session.player, user.pubkey()); + assert_eq!(game_session.score, 0); + + // SAME AS OWNER + let token_account_address = get_ctoken_signer_seeds( + &user.pubkey(), + &find_spl_mint_address(&mint_signer.pubkey()).0, + ) + .1; + + let mint = find_spl_mint_address(&mint_signer.pubkey()).0; + let token_account_address_2 = get_ctoken_signer2_seeds(&user.pubkey()).1; + let token_account_address_3 = get_ctoken_signer3_seeds(&user.pubkey()).1; + let token_account_address_4 = get_ctoken_signer4_seeds(&user.pubkey(), &user.pubkey()).1; // user as fee_payer + let token_account_address_5 = get_ctoken_signer5_seeds(&user.pubkey(), &mint, 42).1; // Fixed index 42 + + // Fetch the compressed token accounts that were created during the mint action + let ctoken_accounts = rpc + .get_compressed_token_accounts_by_owner(&token_account_address, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_2 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_2, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_3 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_3, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_4 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_4, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_5 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_5, None, None) + .await + .unwrap() + .value; + + assert!( + !ctoken_accounts.items.is_empty(), + "Should have at least one compressed token account" + ); + assert!( + !ctoken_accounts_2.items.is_empty(), + "Should have at least one compressed token account 2" + ); + assert!( + !ctoken_accounts_3.items.is_empty(), + "Should have at least one compressed token account 3" + ); + assert!( + !ctoken_accounts_4.items.is_empty(), + "Should have at least one compressed token account 4" + ); + assert!( + !ctoken_accounts_5.items.is_empty(), + "Should have at least one compressed token account 5" + ); + + let ctoken_account = ctoken_accounts.items[0].clone(); + let ctoken_account_2 = ctoken_accounts_2.items[0].clone(); + let ctoken_account_3 = ctoken_accounts_3.items[0].clone(); + let ctoken_account_4 = ctoken_accounts_4.items[0].clone(); + let ctoken_account_5 = ctoken_accounts_5.items[0].clone(); + + // DEBUG: Print the owner of each ctoken account from indexer + println!( + "DEBUG ctoken_account owner: {:?}", + ctoken_account.token.owner + ); + println!( + "DEBUG ctoken_account_2 owner: {:?}", + ctoken_account_2.token.owner + ); + println!( + "DEBUG ctoken_account_3 owner: {:?}", + ctoken_account_3.token.owner + ); + println!( + "DEBUG ctoken_account_4 owner: {:?}", + ctoken_account_4.token.owner + ); + println!( + "DEBUG ctoken_account_5 owner: {:?}", + ctoken_account_5.token.owner + ); + + println!( + "DEBUG ctoken_account hash: {:?}", + ctoken_account.account.hash + ); + println!( + "DEBUG ctoken_account_2 hash: {:?}", + ctoken_account_2.account.hash + ); + println!( + "DEBUG ctoken_account_3 hash: {:?}", + ctoken_account_3.account.hash + ); + println!( + "DEBUG ctoken_account_4 hash: {:?}", + ctoken_account_4.account.hash + ); + println!( + "DEBUG ctoken_account_5 hash: {:?}", + ctoken_account_5.account.hash + ); + + ( + ctoken_account, + mint_signer.pubkey(), + ctoken_account_2, + ctoken_account_3, + ctoken_account_4, + ctoken_account_5, + ) +} + +async fn compress_record( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + should_fail: bool, +) -> Result { + // Get the current decompressed user record data + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_pda_account.is_some(), + "User PDA account should exist before compression" + ); + let account = user_pda_account.unwrap(); + assert!( + account.lamports > 0, + "Account should have lamports before compression" + ); + assert!( + !account.data.is_empty(), + "Account data should not be empty before compression" + ); + + // Setup remaining accounts for Light Protocol + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new(*program_id); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + // Get address tree info + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + println!("CLIENT: address_tree_pubkey: {:?}", address_tree_pubkey); + + let address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + println!("CLIENT: address: {:?}", address); + + let compressed_account = rpc + .get_compressed_account(address, None) + .await + .unwrap() + .value + .unwrap(); + let compressed_address = compressed_account.address.unwrap(); + + println!("CLIENT: compressed account: {:?}", compressed_account); + + // Get validity proof from RPC + let rpc_result = rpc + .get_validity_proof(vec![compressed_account.hash], vec![], None) + .await + .unwrap() + .value; + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let instruction = CompressibleInstruction::compress_accounts_idempotent( + program_id, + csdk_anchor_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, + &[*user_record_pda], + &[account], + &csdk_anchor_test::accounts::CompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_recipient: RENT_RECIPIENT, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + ctoken_rent_sponsor: payer.pubkey(), + } + .to_account_metas(None), + vec![csdk_anchor_test::get_userrecord_seeds(&payer.pubkey()).0], // signer_seeds + rpc_result, // validity_proof_with_context + output_state_tree_info, // output_state_tree_info + ) + .unwrap(); + + // if !should_fail { + // let cu = simulate_cu(rpc, payer, &instruction).await; + // println!("CompressRecord CU consumed: {}", cu); + // } + + // Create and send transaction + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + + if should_fail { + assert!(result.is_err(), "Compress transaction should fail"); + return result; + } else { + assert!(result.is_ok(), "Compress transaction should succeed"); + } + + // Verify the PDA account is now empty (compressed) + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert!( + user_pda_account.is_none(), + "Account should not exist after compression" + ); + + // Verify the compressed account exists + let compressed_user_record = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!(compressed_user_record.address, Some(compressed_address)); + assert!(compressed_user_record.data.is_some()); + + let buf = compressed_user_record.data.unwrap().data; + let user_record: UserRecord = UserRecord::deserialize(&mut &buf[..]).unwrap(); + + assert_eq!(user_record.name, "Test User"); + assert_eq!(user_record.score, 11); + assert_eq!(user_record.owner, payer.pubkey()); + assert!(user_record.compression_info.is_none()); + Ok(result.unwrap()) +} + +async fn decompress_single_user_record( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + user_record_pda: &Pubkey, + _user_record_bump: &u8, + expected_user_name: &str, + expected_slot: u64, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Get compressed user record + let user_compressed_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let c_user_pda = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let user_account_data = c_user_pda.data.as_ref().unwrap(); + let c_user_record = UserRecord::deserialize(&mut &user_account_data.data[..]).unwrap(); + + // Get validity proof for the compressed account + let rpc_result = rpc + .get_validity_proof(vec![c_user_pda.hash], vec![], None) + .await + .unwrap() + .value; + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + // Use the new SDK helper function with typed data + let instruction = + light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + program_id, + &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &[*user_record_pda], + &[( + c_user_pda, + CompressedAccountVariant::UserRecord(c_user_record), + )], + &csdk_anchor_test::accounts::DecompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_payer: payer.pubkey(), + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + ctoken_config: ctoken::derive_ctoken_program_config(None).0, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + some_mint: payer.pubkey(), + } + .to_account_metas(None), + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + // Verify PDA is uninitialized before decompression + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + assert_eq!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0), + 0, + "User PDA account data len must be 0 before decompression" + ); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + assert!(result.is_ok(), "Decompress transaction should succeed"); + + // Verify UserRecord PDA is decompressed + let user_pda_account = rpc.get_account(*user_record_pda).await.unwrap(); + + println!("DECOMPRESS DONE"); + println!("CLIENT: user_pda_account: {:?}", user_pda_account); + + // the compressed acocunt should be empty now again after decompress ! + let compressed_account = rpc + .get_compressed_account(user_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + println!("CLIENT: compressed account: {:?}", compressed_account); + assert!(compressed_account.data.unwrap().data.is_empty()); + + assert!( + user_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "User PDA account data len must be > 0 after decompression" + ); + + let user_pda_data = user_pda_account.unwrap().data; + assert_eq!( + &user_pda_data[0..8], + UserRecord::DISCRIMINATOR, + "User account anchor discriminator mismatch" + ); + + let decompressed_user_record = UserRecord::try_deserialize(&mut &user_pda_data[..]).unwrap(); + assert_eq!(decompressed_user_record.name, expected_user_name); + assert_eq!(decompressed_user_record.score, 11); + assert_eq!(decompressed_user_record.owner, payer.pubkey()); + assert!(!decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_user_record + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + expected_slot + ); +} + +async fn create_placeholder_record( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + config_pda: &Pubkey, + placeholder_record_pda: &Pubkey, + placeholder_id: u64, + name: &str, +) { + // Setup remaining accounts for Light Protocol + let mut remaining_accounts = PackedAccounts::default(); + let system_config = SystemAccountMetaConfig::new(*program_id); + let _ = remaining_accounts.add_system_accounts_v2(system_config); + + // Get address tree info + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Create the instruction + let accounts = csdk_anchor_test::accounts::CreatePlaceholderRecord { + user: payer.pubkey(), + placeholder_record: *placeholder_record_pda, + system_program: solana_sdk::system_program::ID, + config: *config_pda, + rent_recipient: RENT_RECIPIENT, + }; + + // Derive a new address for the compressed account + let compressed_address = derive_address( + &placeholder_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Get validity proof from RPC + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compressed_address, + tree: address_tree_pubkey, + }], + None, + ) + .await + .unwrap() + .value; + + // Pack tree infos into remaining accounts + let packed_tree_infos = rpc_result.pack_tree_infos(&mut remaining_accounts); + + // Get the packed address tree info + let address_tree_info = packed_tree_infos.address_trees[0]; + + // Get output state tree index + let output_state_tree_index = + remaining_accounts.insert_or_get(rpc.get_random_state_tree_info().unwrap().queue); + + // Get system accounts for the instruction + let (system_accounts, _, _) = remaining_accounts.to_account_metas(); + + // Create instruction data + let instruction_data = csdk_anchor_test::instruction::CreatePlaceholderRecord { + placeholder_id, + name: name.to_string(), + proof: rpc_result.proof, + compressed_address, + address_tree_info, + output_state_tree_index, + }; + + // Build the instruction + let instruction = Instruction { + program_id: *program_id, + accounts: [accounts.to_account_metas(None), system_accounts].concat(), + data: instruction_data.data(), + }; + + // let cu = simulate_cu(rpc, payer, &instruction).await; + // println!("CreatePlaceholderRecord CU consumed: {}", cu); + println!("sending CreatePlaceholderRecord instruction"); + + // Create and send transaction + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + + assert!( + result.is_ok(), + "CreatePlaceholderRecord transaction should succeed" + ); +} + +async fn compress_placeholder_record( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + _config_pda: &Pubkey, + placeholder_record_pda: &Pubkey, + _placeholder_record_bump: &u8, + placeholder_id: u64, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Get compressed placeholder record address + let placeholder_compressed_address = derive_address( + &placeholder_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Get the compressed account that already exists (empty) + let compressed_placeholder = rpc + .get_compressed_account(placeholder_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + println!("compressed_placeholder: {:?}", compressed_placeholder); + + // Get validity proof from RPC + let rpc_result = rpc + .get_validity_proof(vec![compressed_placeholder.hash], vec![], None) + .await + .unwrap() + .value; + + let placeholder_seeds = csdk_anchor_test::get_placeholderrecord_seeds(placeholder_id); + + let account = rpc + .get_account(*placeholder_record_pda) + .await + .unwrap() + .unwrap(); + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let instruction = + light_compressible_client::CompressibleInstruction::compress_accounts_idempotent( + program_id, + &csdk_anchor_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, + &[*placeholder_record_pda], + &[account], + &csdk_anchor_test::accounts::CompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_recipient: RENT_RECIPIENT, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + ctoken_rent_sponsor: payer.pubkey(), + } + .to_account_metas(None), + vec![placeholder_seeds.0], + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + // let cu = simulate_cu(rpc, payer, &instruction).await; + // println!("CompressPlaceholderRecord CU consumed: {}", cu); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + + assert!( + result.is_ok(), + "CompressPlaceholderRecord transaction should succeed: {:?}", + result + ); + + // Check if PDA account is closed (it may or may not be depending on the compression behavior) + let _account = rpc.get_account(*placeholder_record_pda).await.unwrap(); + + // Verify compressed account now has the data + let compressed_placeholder_after = rpc + .get_compressed_account(placeholder_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert!( + compressed_placeholder_after.data.is_some(), + "Compressed account should have data after compression" + ); + + let compressed_data_after = compressed_placeholder_after.data.unwrap(); + + assert!( + compressed_data_after.data.len() > 0, + "Compressed account should contain the PDA data" + ); +} + +async fn compress_placeholder_record_for_double_test( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + placeholder_record_pda: &Pubkey, + placeholder_id: u64, + previous_account: Option, +) -> Result { + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Get compressed placeholder record address + let placeholder_compressed_address = derive_address( + &placeholder_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + // Get the compressed account that exists (initially empty, later with data) + let compressed_placeholder = rpc + .get_compressed_account(placeholder_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + // Get validity proof from RPC + let rpc_result = rpc + .get_validity_proof(vec![compressed_placeholder.hash], vec![], None) + .await + .unwrap() + .value; + + let placeholder_seeds = csdk_anchor_test::get_placeholderrecord_seeds(placeholder_id); + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + let accounts_to_compress = if let Some(account) = previous_account { + vec![account] + } else { + panic!("Previous account should be provided"); + }; + let instruction = + light_compressible_client::CompressibleInstruction::compress_accounts_idempotent( + program_id, + &csdk_anchor_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, + &[*placeholder_record_pda], + &accounts_to_compress, + &csdk_anchor_test::accounts::CompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_recipient: RENT_RECIPIENT, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + ctoken_rent_sponsor: payer.pubkey(), + } + .to_account_metas(None), + vec![placeholder_seeds.0], + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + // Create and send transaction + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await +} + +async fn decompress_single_game_session( + rpc: &mut LightProgramTest, + payer: &Keypair, + program_id: &Pubkey, + game_session_pda: &Pubkey, + _game_bump: &u8, + session_id: u64, + expected_game_type: &str, + expected_slot: u64, + expected_score: u64, +) { + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + + // Get compressed game session + let game_compressed_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let c_game_pda = rpc + .get_compressed_account(game_compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let game_account_data = c_game_pda.data.as_ref().unwrap(); + let c_game_session = + csdk_anchor_test::GameSession::deserialize(&mut &game_account_data.data[..]).unwrap(); + + // Get validity proof for the compressed account + let rpc_result = rpc + .get_validity_proof(vec![c_game_pda.hash], vec![], None) + .await + .unwrap() + .value; + + let output_state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + // Use the SDK helper function with typed data + let instruction = + light_compressible_client::CompressibleInstruction::decompress_accounts_idempotent( + program_id, + &CompressibleInstruction::DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &[*game_session_pda], + &[( + c_game_pda, + csdk_anchor_test::CompressedAccountVariant::GameSession(c_game_session), + )], + &csdk_anchor_test::accounts::DecompressAccountsIdempotent { + fee_payer: payer.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_payer: payer.pubkey(), + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + ctoken_config: ctoken::derive_ctoken_program_config(None).0, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + some_mint: payer.pubkey(), + } + .to_account_metas(None), + rpc_result, + output_state_tree_info, + ) + .unwrap(); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer]) + .await; + assert!(result.is_ok(), "Decompress transaction should succeed"); + + // Verify GameSession PDA is decompressed + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap(); + assert!( + game_pda_account.as_ref().map(|a| a.data.len()).unwrap_or(0) > 0, + "Game PDA account data len must be > 0 after decompression" + ); + + let game_pda_data = game_pda_account.unwrap().data; + assert_eq!( + &game_pda_data[0..8], + csdk_anchor_test::GameSession::DISCRIMINATOR, + "Game account anchor discriminator mismatch" + ); + + let decompressed_game_session = + csdk_anchor_test::GameSession::try_deserialize(&mut &game_pda_data[..]).unwrap(); + assert_eq!(decompressed_game_session.session_id, session_id); + assert_eq!(decompressed_game_session.game_type, expected_game_type); + assert_eq!(decompressed_game_session.player, payer.pubkey()); + assert_eq!(decompressed_game_session.score, expected_score); + assert!(!decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .is_compressed()); + assert_eq!( + decompressed_game_session + .compression_info + .as_ref() + .unwrap() + .last_written_slot(), + expected_slot + ); +} + +async fn compress_game_session_with_custom_data( + rpc: &mut LightProgramTest, + _payer: &Keypair, + _program_id: &Pubkey, + game_session_pda: &Pubkey, + _session_id: u64, +) { + let game_pda_account = rpc.get_account(*game_session_pda).await.unwrap().unwrap(); + let game_pda_data = game_pda_account.data; + let original_game_session = + csdk_anchor_test::GameSession::try_deserialize(&mut &game_pda_data[..]).unwrap(); + + // Test the custom compression trait directly + let custom_compressed_data = match original_game_session.compress_as() { + std::borrow::Cow::Borrowed(data) => data.clone(), // Should never happen since compression_info must be None + std::borrow::Cow::Owned(data) => data, // Use owned data directly + }; + + // Verify that the custom compression works as expected + assert_eq!( + custom_compressed_data.session_id, original_game_session.session_id, + "Session ID should be kept" + ); + assert_eq!( + custom_compressed_data.player, original_game_session.player, + "Player should be kept" + ); + assert_eq!( + custom_compressed_data.game_type, original_game_session.game_type, + "Game type should be kept" + ); + assert_eq!( + custom_compressed_data.start_time, 0, + "Start time should be RESET to 0" + ); + assert_eq!( + custom_compressed_data.end_time, None, + "End time should be RESET to None" + ); + assert_eq!( + custom_compressed_data.score, 0, + "Score should be RESET to 0" + ); +} + +#[tokio::test] +async fn test_double_compression_attack() { + let program_id = csdk_anchor_test::ID; + let config = ProgramTestConfig::new_v2(true, Some(vec![("csdk_anchor_test", program_id)])); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let config_pda = CompressibleConfig::derive_pda(&program_id, 0).0; + let _program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + // Initialize compression config + let result = initialize_compression_config( + &mut rpc, + &payer, + &program_id, + &payer, + 100, + RENT_RECIPIENT, + vec![ADDRESS_SPACE[0]], + &CompressibleInstruction::INITIALIZE_COMPRESSION_CONFIG_DISCRIMINATOR, + None, + ) + .await; + assert!(result.is_ok(), "Initialize config should succeed"); + + // Create placeholder record + let placeholder_id = 99999u64; + let (placeholder_record_pda, _placeholder_record_bump) = Pubkey::find_program_address( + &[b"placeholder_record", placeholder_id.to_le_bytes().as_ref()], + &program_id, + ); + + create_placeholder_record( + &mut rpc, + &payer, + &program_id, + &config_pda, + &placeholder_record_pda, + placeholder_id, + "Double Compression Test", + ) + .await; + + // Verify the PDA exists and has data before first compression + let placeholder_pda_account = rpc.get_account(placeholder_record_pda).await.unwrap(); + assert!( + placeholder_pda_account.is_some(), + "Placeholder PDA should exist before compression" + ); + let account_before = placeholder_pda_account.unwrap(); + assert!( + account_before.lamports > 0, + "Placeholder PDA should have lamports before compression" + ); + assert!( + !account_before.data.is_empty(), + "Placeholder PDA should have data before compression" + ); + + // Verify empty compressed account was created + let address_tree_pubkey = rpc.get_address_tree_v2().queue; + let compressed_address = derive_address( + &placeholder_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + + let compressed_placeholder_before = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + assert_eq!( + compressed_placeholder_before.address, + Some(compressed_address), + "Empty compressed account should exist" + ); + assert_eq!( + compressed_placeholder_before + .data + .as_ref() + .unwrap() + .data + .len(), + 0, + "Compressed account should be empty initially" + ); + + // Wait past compression delay + rpc.warp_to_slot(200).unwrap(); + + // First compression - should succeed and move data from PDA to compressed account + let first_compression_result = compress_placeholder_record_for_double_test( + &mut rpc, + &payer, + &program_id, + &placeholder_record_pda, + placeholder_id, + Some(account_before.clone()), + ) + .await; + assert!( + first_compression_result.is_ok(), + "First compression should succeed: {:?}", + first_compression_result + ); + + // Verify PDA is now closed after first compression + let placeholder_pda_after_first = rpc.get_account(placeholder_record_pda).await.unwrap(); + assert!( + placeholder_pda_after_first.is_none(), + "PDA should not exist after first compression" + ); + + // Verify compressed account now has the data + let compressed_placeholder_after_first = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + let first_data_len = compressed_placeholder_after_first + .data + .as_ref() + .unwrap() + .data + .len(); + assert!( + first_data_len > 0, + "Compressed account should contain data after first compression" + ); + + // Second compression attempt - should succeed idempotently (skip already compressed account) + let second_compression_result = compress_placeholder_record_for_double_test( + &mut rpc, + &payer, + &program_id, + &placeholder_record_pda, + placeholder_id, + Some(account_before), + ) + .await; + + // This should succeed because the instruction is idempotent + assert!( + second_compression_result.is_ok(), + "Second compression should succeed idempotently: {:?}", + second_compression_result + ); + + // Verify state hasn't changed after second compression attempt + let placeholder_pda_after_second = rpc.get_account(placeholder_record_pda).await.unwrap(); + assert!( + placeholder_pda_after_second.is_none(), + "PDA should still not exist after second compression" + ); + + let compressed_placeholder_after_second = rpc + .get_compressed_account(compressed_address, None) + .await + .unwrap() + .value + .unwrap(); + + // Verify compressed account data is unchanged + assert_eq!( + compressed_placeholder_after_first.hash, compressed_placeholder_after_second.hash, + "Compressed account hash should be unchanged after second compression" + ); + assert_eq!( + compressed_placeholder_after_first + .data + .as_ref() + .unwrap() + .data, + compressed_placeholder_after_second + .data + .as_ref() + .unwrap() + .data, + "Compressed account data should be unchanged after second compression" + ); +} + +async fn compress_token_account_after_decompress( + rpc: &mut LightProgramTest, + user: &Keypair, + program_id: &Pubkey, + _config_pda: &Pubkey, + token_account_address: Pubkey, + token_account_address_2: Pubkey, + token_account_address_3: Pubkey, + token_account_address_4: Pubkey, + token_account_address_5: Pubkey, + mint: Pubkey, + amount: u64, + user_record_pda: &Pubkey, + game_session_pda: &Pubkey, + session_id: u64, + user_record_hash_before_decompression: [u8; 32], + game_session_hash_before_decompression: [u8; 32], +) { + // Verify the token account exists and has the expected data + let token_account_data = rpc.get_account(token_account_address).await.unwrap(); + assert!( + token_account_data.is_some(), + "Token account should exist before compression" + ); + + let account = token_account_data.unwrap(); + + assert!( + account.lamports > 0, + "Token account should have lamports before compression" + ); + assert!( + !account.data.is_empty(), + "Token account should have data before compression" + ); + + let (user_record_seeds, user_record_pubkey) = + csdk_anchor_test::get_userrecord_seeds(&user.pubkey()); + let (game_session_seeds, game_session_pubkey) = + csdk_anchor_test::get_gamesession_seeds(session_id); + let (_, token_account_address) = get_ctoken_signer_seeds(&user.pubkey(), &mint); + + let (_, token_account_address_2) = get_ctoken_signer2_seeds(&user.pubkey()); + let (_, token_account_address_3) = get_ctoken_signer3_seeds(&user.pubkey()); + let (_, token_account_address_4) = get_ctoken_signer4_seeds(&user.pubkey(), &user.pubkey()); // user as fee_payer + let (_, token_account_address_5) = get_ctoken_signer5_seeds(&user.pubkey(), &mint, 42); // Fixed index 42 + // Use program-provided helper: authority for all token owner variants is Light CPI signer PDA + let (token_signer_seeds, ctoken_1_authority_pda) = + csdk_anchor_test::get_ctokensigner_authority_seeds(); + + let (token_signer_seeds_2, ctoken_2_authority_pda) = + csdk_anchor_test::get_ctokensigner2_authority_seeds(); + + let (token_signer_seeds_3, ctoken_3_authority_pda) = + csdk_anchor_test::get_ctokensigner3_authority_seeds(); + + let (token_signer_seeds_4, ctoken_4_authority_pda) = + csdk_anchor_test::get_ctokensigner4_authority_seeds(); + + let (token_signer_seeds_5, ctoken_5_authority_pda) = + csdk_anchor_test::get_ctokensigner5_authority_seeds(); + + println!("ctoken_1_authority_pda: {:?}", ctoken_1_authority_pda); + println!("ctoken_2_authority_pda: {:?}", ctoken_2_authority_pda); + println!("ctoken_3_authority_pda: {:?}", ctoken_3_authority_pda); + println!("ctoken_4_authority_pda: {:?}", ctoken_4_authority_pda); + println!("ctoken_5_authority_pda: {:?}", ctoken_5_authority_pda); + println!("token_account_address: {:?}", token_account_address); + println!("token_account_address_2: {:?}", token_account_address_2); + println!("token_account_address_3: {:?}", token_account_address_3); + println!("token_account_address_4: {:?}", token_account_address_4); + println!("token_account_address_5: {:?}", token_account_address_5); + + let cpisigner = Pubkey::new_from_array(csdk_anchor_test::LIGHT_CPI_SIGNER.cpi_signer); + println!("cpisigner: {:?}", cpisigner); + + let mut accounts: Vec = vec![]; + + let user_record_account = rpc.get_account(*user_record_pda).await.unwrap().unwrap(); + let game_session_account = rpc.get_account(*game_session_pda).await.unwrap().unwrap(); + let token_account = rpc + .get_account(token_account_address) + .await + .unwrap() + .unwrap(); + let token_account_2 = rpc + .get_account(token_account_address_2) + .await + .unwrap() + .unwrap(); + let token_account_3 = rpc + .get_account(token_account_address_3) + .await + .unwrap() + .unwrap(); + let token_account_4 = rpc + .get_account(token_account_address_4) + .await + .unwrap() + .unwrap(); + let token_account_5 = rpc + .get_account(token_account_address_5) + .await + .unwrap() + .unwrap(); + + accounts.push(user_record_account); + accounts.push(game_session_account); + accounts.push(token_account); // first token account + accounts.push(token_account_2); // second token account + accounts.push(token_account_3); // third token account + accounts.push(token_account_4); // fourth token account + accounts.push(token_account_5); // fifth token account must come last + + assert_eq!(*user_record_pda, user_record_pubkey); + assert_eq!(*game_session_pda, game_session_pubkey); + assert_eq!(token_account_address, token_account_address); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + + let compressed_user_record_address = derive_address( + &user_record_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let compressed_game_session_address = derive_address( + &game_session_pda.to_bytes(), + &address_tree_pubkey.to_bytes(), + &program_id.to_bytes(), + ); + let user_record: CompressedAccount = rpc + .get_compressed_account(compressed_user_record_address, None) + .await + .unwrap() + .value + .unwrap(); + let game_session: CompressedAccount = rpc + .get_compressed_account(compressed_game_session_address, None) + .await + .unwrap() + .value + .unwrap(); + + let user_record_hash = user_record.hash; + let game_session_hash = game_session.hash; + + assert_ne!( + user_record_hash, user_record_hash_before_decompression, + "User record hash NOT_EQUAL before and after compression" + ); + assert_ne!( + game_session_hash, game_session_hash_before_decompression, + "Game session hash NOT_EQUAL before and after compression" + ); + + let proof_with_context = rpc + .get_validity_proof(vec![user_record_hash, game_session_hash], vec![], None) + .await + .unwrap() + .value; + + let random_tree_info = rpc.get_random_state_tree_info().unwrap(); + let instruction = + light_compressible_client::CompressibleInstruction::compress_accounts_idempotent( + program_id, + &csdk_anchor_test::instruction::CompressAccountsIdempotent::DISCRIMINATOR, + &[ + user_record_pubkey, + game_session_pubkey, + token_account_address, + token_account_address_2, + token_account_address_3, + token_account_address_4, + token_account_address_5, + ], + &accounts, + &csdk_anchor_test::accounts::CompressAccountsIdempotent { + fee_payer: user.pubkey(), + config: CompressibleConfig::derive_pda(&program_id, 0).0, + rent_recipient: RENT_RECIPIENT, + ctoken_program: ctoken::id(), + ctoken_cpi_authority: ctoken::cpi_authority(), + ctoken_rent_sponsor: CTOKEN_RENT_SPONSOR, + } + .to_account_metas(None), + vec![ + user_record_seeds, + game_session_seeds, + token_signer_seeds.clone(), + token_signer_seeds_2, + token_signer_seeds_3, + token_signer_seeds_4, + token_signer_seeds_5, + ], + proof_with_context, + random_tree_info, + ) + .unwrap(); + + // Send the transaction + let result = rpc + .create_and_send_transaction(&[instruction], &user.pubkey(), &[user]) + .await; + + assert!( + result.is_ok(), + "Compress token account transaction should succeed: {:?}", + result + ); + + println!("ctoken program id bytes {:?}", ctoken::ID); + // Verify the token accounts are now closed + let token_account_after = rpc.get_account(token_account_address).await.unwrap(); + assert!( + token_account_after.is_none(), + "Token account should not exist after compression" + ); + let token_account_after_2 = rpc.get_account(token_account_address_2).await.unwrap(); + assert!( + token_account_after_2.is_none(), + "Token account 2 should not exist after compression" + ); + let token_account_after_3 = rpc.get_account(token_account_address_3).await.unwrap(); + assert!( + token_account_after_3.is_none(), + "Token account 3 should not exist after compression" + ); + let token_account_after_4 = rpc.get_account(token_account_address_4).await.unwrap(); + assert!( + token_account_after_4.is_none(), + "Token account 4 should not exist after compression" + ); + let token_account_after_5 = rpc.get_account(token_account_address_5).await.unwrap(); + assert!( + token_account_after_5.is_none(), + "Token account 5 should not exist after compression" + ); + + // Verify the compressed token account exists + let ctoken_accounts = rpc + .get_compressed_token_accounts_by_owner(&token_account_address, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_2 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_2, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_3 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_3, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_4 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_4, None, None) + .await + .unwrap() + .value; + let ctoken_accounts_5 = rpc + .get_compressed_token_accounts_by_owner(&token_account_address_5, None, None) + .await + .unwrap() + .value; + + println!("otoken account address / owner{:?}", token_account_address); + println!( + "otoken account address / owner bytes{:?}", + token_account_address.to_bytes() + ); + println!("ctoken_accounts {:?}", ctoken_accounts); + assert!( + !ctoken_accounts.items.is_empty(), + "Should have at least one compressed token account after compression" + ); + assert!( + !ctoken_accounts_2.items.is_empty(), + "Should have at least one compressed token account 2 after compression" + ); + assert!( + !ctoken_accounts_3.items.is_empty(), + "Should have at least one compressed token account 3 after compression" + ); + assert!( + !ctoken_accounts_4.items.is_empty(), + "Should have at least one compressed token account 4 after compression" + ); + assert!( + !ctoken_accounts_5.items.is_empty(), + "Should have at least one compressed token account 5 after compression" + ); + + let ctoken = &ctoken_accounts.items[0]; + assert_eq!( + ctoken.token.mint, mint, + "Compressed token should have the same mint" + ); + assert_eq!( + ctoken.token.owner, token_account_address, + "Compressed token owner should be the token account address" + ); + assert_eq!( + ctoken.token.amount, amount, + "Compressed token should have the same amount" + ); + // Second token assertions + let ctoken2 = &ctoken_accounts_2.items[0]; + assert_eq!( + ctoken2.token.mint, mint, + "Compressed token 2 should have the same mint" + ); + assert_eq!( + ctoken2.token.owner, token_account_address_2, + "Compressed token 2 owner should be the token account address" + ); + assert_eq!( + ctoken2.token.amount, amount, + "Compressed token 2 should have the same amount" + ); + // Third token assertions + let ctoken3 = &ctoken_accounts_3.items[0]; + assert_eq!( + ctoken3.token.mint, mint, + "Compressed token 3 should have the same mint" + ); + assert_eq!( + ctoken3.token.owner, token_account_address_3, + "Compressed token 3 owner should be the token account address" + ); + assert_eq!( + ctoken3.token.amount, amount, + "Compressed token 3 should have the same amount" + ); + // Fourth token assertions + let ctoken4 = &ctoken_accounts_4.items[0]; + assert_eq!( + ctoken4.token.mint, mint, + "Compressed token 4 should have the same mint" + ); + assert_eq!( + ctoken4.token.owner, token_account_address_4, + "Compressed token 4 owner should be the token account address" + ); + assert_eq!( + ctoken4.token.amount, amount, + "Compressed token 4 should have the same amount" + ); + // Fifth token assertions + let ctoken5 = &ctoken_accounts_5.items[0]; + assert_eq!( + ctoken5.token.mint, mint, + "Compressed token 5 should have the same mint" + ); + assert_eq!( + ctoken5.token.owner, token_account_address_5, + "Compressed token 5 owner should be the token account address" + ); + assert_eq!( + ctoken5.token.amount, amount, + "Compressed token 5 should have the same amount" + ); + let user_record_account = rpc.get_account(*user_record_pda).await.unwrap().unwrap(); + let game_session_account = rpc.get_account(*game_session_pda).await.unwrap().unwrap(); + let token_account = rpc + .get_account(token_account_address) + .await + .unwrap() + .unwrap(); + let token_account_3 = rpc + .get_account(token_account_address_3) + .await + .unwrap() + .unwrap(); + let token_account_4 = rpc + .get_account(token_account_address_4) + .await + .unwrap() + .unwrap(); + let token_account_5 = rpc + .get_account(token_account_address_5) + .await + .unwrap() + .unwrap(); + + assert_eq!( + user_record_account.lamports, 0, + "User record account should be None" + ); + assert_eq!( + game_session_account.lamports, 0, + "Game session account should be None" + ); + assert_eq!(token_account.lamports, 0, "Token account should be None"); + assert!( + user_record_account.data.is_empty(), + "User record account should be empty" + ); + assert!( + game_session_account.data.is_empty(), + "Game session account should be empty" + ); + assert!( + token_account.data.is_empty(), + "Token account should be empty" + ); + assert!( + token_account_3.data.is_empty(), + "Token account 3 should be empty" + ); + assert!( + token_account_4.data.is_empty(), + "Token account 4 should be empty" + ); + assert!( + token_account_5.data.is_empty(), + "Token account 5 should be empty" + ); +} diff --git a/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs b/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs index ee57ab658e..2eedefe23d 100644 --- a/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs +++ b/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs @@ -1,6 +1,6 @@ use anchor_lang::{prelude::*, solana_program::program::invoke_signed}; use light_compressed_token_sdk::instructions::create_token_account::{ - create_compressible_token_account, CreateCompressibleTokenAccount, + create_compressible_token_account_instruction, CreateCompressibleTokenAccount, }; use light_ctoken_types::instructions::extensions::compressible::CompressToPubkey; @@ -38,8 +38,8 @@ pub fn process_create_ctoken_with_compress_to_pubkey<'info>( token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, }; - let instruction = - create_compressible_token_account(create_account_inputs).map_err(ProgramError::from)?; + let instruction = create_compressible_token_account_instruction(create_account_inputs) + .map_err(ProgramError::from)?; let seeds = [seeds[0], seeds[1], &[bump]]; diff --git a/sparse-merkle-tree/src/merkle_tree.rs b/sparse-merkle-tree/src/merkle_tree.rs index 6983a12096..a930582659 100644 --- a/sparse-merkle-tree/src/merkle_tree.rs +++ b/sparse-merkle-tree/src/merkle_tree.rs @@ -46,7 +46,7 @@ where .zip(H::zero_bytes().iter()) .enumerate() { - if current_index.is_multiple_of(2) { + if current_index % 2 == 0 { left = current_level_hash; right = *zero_byte; *subtree = current_level_hash;