diff --git a/Cargo.lock b/Cargo.lock index a107e70b97..246f03d9b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -948,6 +948,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -4067,7 +4082,9 @@ dependencies = [ "light-sdk-types", "prettyplease", "proc-macro2", + "proptest", "quote", + "rand 0.8.5", "solana-pubkey 2.4.0", "syn 2.0.111", ] @@ -5205,6 +5222,25 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "protobuf" version = "3.7.2" @@ -5260,6 +5296,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -5438,6 +5480,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -5877,6 +5928,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" @@ -11020,6 +11083,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.8.1" @@ -11178,6 +11247,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/sdk-libs/macros/Cargo.toml b/sdk-libs/macros/Cargo.toml index c61472a235..a030be3876 100644 --- a/sdk-libs/macros/Cargo.toml +++ b/sdk-libs/macros/Cargo.toml @@ -30,6 +30,8 @@ light-macros = { workspace = true } light-account-checks = { workspace = true } light-hasher = { workspace = true , features = ["poseidon", "keccak"]} light-poseidon = { workspace = true } +rand = "0.8" +proptest = "1.4" [lib] proc-macro = true diff --git a/sdk-libs/macros/fuzz/.gitignore b/sdk-libs/macros/fuzz/.gitignore deleted file mode 100644 index 1a45eee776..0000000000 --- a/sdk-libs/macros/fuzz/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -target -corpus -artifacts -coverage diff --git a/sdk-libs/macros/fuzz/Cargo.lock b/sdk-libs/macros/fuzz/Cargo.lock deleted file mode 100644 index ce11c230e3..0000000000 --- a/sdk-libs/macros/fuzz/Cargo.lock +++ /dev/null @@ -1,1130 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy 0.7.35", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "arbitrary" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" - -[[package]] -name = "ark-bn254" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" -dependencies = [ - "ark-ec", - "ark-ff", - "ark-std", -] - -[[package]] -name = "ark-ec" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" -dependencies = [ - "ahash", - "ark-ff", - "ark-poly", - "ark-serialize", - "ark-std", - "educe", - "fnv", - "hashbrown 0.15.2", - "itertools", - "num-bigint", - "num-integer", - "num-traits", - "zeroize", -] - -[[package]] -name = "ark-ff" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" -dependencies = [ - "ark-ff-asm", - "ark-ff-macros", - "ark-serialize", - "ark-std", - "arrayvec", - "digest", - "educe", - "itertools", - "num-bigint", - "num-traits", - "paste", - "zeroize", -] - -[[package]] -name = "ark-ff-asm" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" -dependencies = [ - "quote", - "syn 2.0.100", -] - -[[package]] -name = "ark-ff-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "ark-poly" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" -dependencies = [ - "ahash", - "ark-ff", - "ark-serialize", - "ark-std", - "educe", - "fnv", - "hashbrown 0.15.2", -] - -[[package]] -name = "ark-serialize" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" -dependencies = [ - "ark-serialize-derive", - "ark-std", - "arrayvec", - "digest", - "num-bigint", -] - -[[package]] -name = "ark-serialize-derive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "ark-std" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" -dependencies = [ - "num-traits", - "rand", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "bitflags" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "borsh" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" -dependencies = [ - "borsh-derive", - "hashbrown 0.13.2", -] - -[[package]] -name = "borsh-derive" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" -dependencies = [ - "borsh-derive-internal", - "borsh-schema-derive-internal", - "proc-macro-crate", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "borsh-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "borsh-schema-derive-internal" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" - -[[package]] -name = "bumpalo" -version = "3.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" - -[[package]] -name = "cc" -version = "1.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" -dependencies = [ - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "educe" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "enum-ordinalize" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "five8_const" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26dec3da8bc3ef08f2c04f61eab298c3ab334523e55f076354d6d6f613799a7b" -dependencies = [ - "five8_core", -] - -[[package]] -name = "five8_core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" -dependencies = [ - "allocator-api2", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "jobserver" -version = "0.1.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" -dependencies = [ - "getrandom 0.3.2", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" -dependencies = [ - "cpufeatures", -] - -[[package]] -name = "libc" -version = "0.2.171" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "light-hasher" -version = "3.0.0" -dependencies = [ - "ark-bn254", - "ark-ff", - "arrayvec", - "borsh", - "light-poseidon", - "num-bigint", - "sha2", - "sha3", - "solana-program-error", - "solana-pubkey", - "thiserror 2.0.12", -] - -[[package]] -name = "light-poseidon" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3d87542063daaccbfecd78b60f988079b6ec4e089249658b9455075c78d42" -dependencies = [ - "ark-bn254", - "ark-ff", - "num-bigint", - "thiserror 1.0.69", -] - -[[package]] -name = "light-sdk-macros" -version = "0.6.0" -dependencies = [ - "ark-bn254", - "light-hasher", - "light-poseidon", - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "light-sdk-macros-fuzz" -version = "0.0.0" -dependencies = [ - "libfuzzer-sys", - "light-hasher", - "light-sdk-macros", - "proc-macro2", - "quote", - "rand", - "syn 2.0.100", -] - -[[package]] -name = "lock_api" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy 0.8.24", -] - -[[package]] -name = "proc-macro-crate" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" -dependencies = [ - "toml", -] - -[[package]] -name = "proc-macro2" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.15", -] - -[[package]] -name = "redox_syscall" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "sha2" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha3" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" -dependencies = [ - "digest", - "keccak", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "smallvec" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" - -[[package]] -name = "solana-atomic-u64" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52e52720efe60465b052b9e7445a01c17550666beec855cce66f44766697bc2" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "solana-decode-error" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a6a6383af236708048f8bd8d03db8ca4ff7baf4a48e5d580f4cce545925470" -dependencies = [ - "num-traits", -] - -[[package]] -name = "solana-define-syscall" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf784bb2cb3e02cac9801813c30187344228d2ae952534902108f6150573a33d" - -[[package]] -name = "solana-hash" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf7bcb14392900fe02e4e34e90234fbf0c673d4e327888410ba99fa2ba0f4e99" -dependencies = [ - "bs58", - "js-sys", - "solana-atomic-u64", - "solana-sanitize", - "wasm-bindgen", -] - -[[package]] -name = "solana-instruction" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce496a475e5062ba5de97215ab39d9c358f9c9df4bb7f3a45a1f1a8bd9065ed" -dependencies = [ - "getrandom 0.2.15", - "js-sys", - "num-traits", - "solana-define-syscall", - "solana-pubkey", - "wasm-bindgen", -] - -[[package]] -name = "solana-msg" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092" -dependencies = [ - "solana-define-syscall", -] - -[[package]] -name = "solana-program-error" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8ae2c1a8d0d4ae865882d5770a7ebca92bab9c685e43f0461682c6c05a35bfa" -dependencies = [ - "num-traits", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-pubkey", -] - -[[package]] -name = "solana-pubkey" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad77cf9f30b971a1eec48dde6a863dcac60ba005a34dfde23736afa5c7ac667" -dependencies = [ - "bs58", - "five8_const", - "getrandom 0.2.15", - "js-sys", - "num-traits", - "solana-atomic-u64", - "solana-decode-error", - "solana-define-syscall", - "solana-sanitize", - "solana-sha256-hasher", - "wasm-bindgen", -] - -[[package]] -name = "solana-sanitize" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf" - -[[package]] -name = "solana-sha256-hasher" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0037386961c0d633421f53560ad7c80675c0447cba4d1bb66d60974dd486c7ea" -dependencies = [ - "sha2", - "solana-define-syscall", - "solana-hash", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" -dependencies = [ - "thiserror-impl 2.0.12", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.100", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - -[[package]] -name = "zeroize" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] diff --git a/sdk-libs/macros/fuzz/Cargo.toml b/sdk-libs/macros/fuzz/Cargo.toml deleted file mode 100644 index ebb82bf196..0000000000 --- a/sdk-libs/macros/fuzz/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "light-sdk-macros-fuzz" -version = "0.0.0" -publish = false -edition = "2021" - -[workspace] - -[package.metadata] -cargo-fuzz = true - -[dependencies] -libfuzzer-sys = "0.4" -proc-macro2 = "1.0" -quote = "1.0" -syn = { version = "2.0", features = ["full", "extra-traits"] } -rand = "0.8.5" - -[dependencies.light-sdk-macros] -path = ".." -[dependencies.light-hasher] -path = "../../../program-libs/hasher" - -[[bin]] -name = "macro_input" -path = "fuzz_targets/macro_input.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "struct_generation" -path = "fuzz_targets/struct_generation.rs" -test = false -doc = false -bench = false diff --git a/sdk-libs/macros/fuzz/README.md b/sdk-libs/macros/fuzz/README.md deleted file mode 100644 index 7651c50455..0000000000 --- a/sdk-libs/macros/fuzz/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Fuzzing for LightHasher Derive Macro - -This directory contains fuzzing tests for the LightHasher derive macro. - -## Setup - -1. Install cargo-fuzz: -```bash -cargo install cargo-fuzz -``` - -2. Note that cargo-fuzz requires nightly Rust: -```bash -rustup default nightly # Switch default to nightly -# OR -rustup install nightly # Just install nightly -``` - -## Running the Fuzzers - -The repository includes multiple fuzz targets: - -### 1. `macro_input` - Tests the macro's input processing directly - -This fuzzer generates random struct definitions and passes them to the derive macro's internal implementation. - -```bash -# Run the fuzzer -cargo +nightly fuzz run macro_input - -# Run with a time limit (e.g., 5 minutes) -cargo +nightly fuzz run macro_input -- -max_total_time=300 -``` - -### 2. `struct_generation` - Tests runtime behavior of generated code - -This fuzzer creates properly typed structs with random data and verifies that hashing works correctly. - -```bash -# Run the fuzzer -cargo +nightly fuzz run struct_generation - -# Run with a time limit and address sanitizer -RUSTFLAGS="-Zsanitizer=address" cargo +nightly fuzz run struct_generation -- -max_total_time=600 -``` - -## Fuzzing Strategy - -The fuzzing approach implements a multi-layered strategy: - -1. **Property-Based Testing**: Using random input generation to verify invariants -2. **Structural Testing**: Testing struct definitions with various field types and attributes -3. **Runtime Testing**: Ensuring generated code correctly handles various inputs -4. **Edge Case Testing**: Intentionally testing with invalid inputs - -## Adding New Fuzz Targets - -To add a new fuzz target: - -1. Create a new file in `fuzz_targets/` -2. Add a `[[bin]]` entry in `fuzz/Cargo.toml` - -## CI Integration - -Add the following to your CI workflow: - -```yaml -- name: Run fuzzers - run: | - cargo install cargo-fuzz - cd sdk-libs/macros - cargo +nightly fuzz run macro_input -- -max_total_time=300 -max_len=1232 - cargo +nightly fuzz run struct_generation -- -max_total_time=300 -max_len=1232 -``` diff --git a/sdk-libs/macros/fuzz/fuzz_targets/macro_input.rs b/sdk-libs/macros/fuzz/fuzz_targets/macro_input.rs deleted file mode 100644 index 32ddd9f1d9..0000000000 --- a/sdk-libs/macros/fuzz/fuzz_targets/macro_input.rs +++ /dev/null @@ -1,301 +0,0 @@ -#![no_main] - -use libfuzzer_sys::fuzz_target; -use light_sdk_macros::LightHasher; -use syn::{parse_str, ItemStruct}; -use rand::{rngs::StdRng, Rng, SeedableRng}; - -// Define a nested struct that we'll use in our random struct generation -// We need to define this outside because we reference SimpleNested in the struct generation -#[derive(LightHasher, Clone)] -pub struct SimpleNested { - pub a: u32, - pub b: i32, - #[hash] - pub c: String, -} - -// Fuzz target that generates random struct definitions -// and feeds them to the LightHasher derive macro -fuzz_target!(|data: &[u8]| { - if data.len() < 8 { - return; // Need at least a seed for the RNG - } - - // Use the first 8 bytes as a seed - let mut seed = [0u8; 8]; - seed.copy_from_slice(&data[0..8]); - let seed = u64::from_le_bytes(seed); - let mut rng = StdRng::seed_from_u64(seed); - - // Generate a random struct with various field types and attributes - let struct_def = generate_random_struct(&mut rng); - - // Try to parse the struct definition - if let Ok(_item_struct) = parse_str::(&struct_def) { - // Since we can't directly call the internal hasher function, - // we'll just validate that the struct definition is parseable. - // The real testing happens in the struct_generation target - // where we test the runtime behavior. - } -}); - -/// Field generation strategies for testing different aspects of the LightHasher macro -enum FieldStrategy { - Primitive, // u8, u32, i32, etc. - Array, // Random sized arrays - ArrayBoundary, // Arrays at boundary conditions (31, 32, 33 bytes) - ExactArraySize(usize), // Arrays of exact size (for DataHasher impl testing) - Option, // Option - NestedStruct, // Nested structs - HashableString, // String with #[hash] - HashableArray, // [u8; N] with #[hash], should work for all sizes -} - -/// Struct generation strategies for testing different macro behaviors -enum StructStrategy { - Random, // Random fields and attributes - MaxFieldCount, // Test with exactly 12-13 fields (limit) - AllArraySizes, // Test specifically with arrays of sizes 1-12 - AttributeCombinations, // Test combinations of #[hash] and #[skip] - NestedDepth(usize), // Test nested struct depth -} - -// Generate a random struct definition with various field types and attributes -fn generate_random_struct(rng: &mut StdRng) -> String { - // Choose a strategy - let strategy = match rng.gen_range(0..=4) { - 0 => StructStrategy::Random, - 1 => StructStrategy::MaxFieldCount, - 2 => StructStrategy::AllArraySizes, - 3 => StructStrategy::AttributeCombinations, - _ => StructStrategy::NestedDepth(rng.gen_range(1..=3)), - }; - - // Generate struct based on strategy - match strategy { - StructStrategy::Random => { - let field_count = rng.gen_range(1..=15); - generate_struct_with_random_fields(rng, field_count) - } - StructStrategy::MaxFieldCount => { - // Test at and around the field count limit - let field_count = if rng.gen_bool(0.7) { 12 } else { 13 }; - generate_struct_with_random_fields(rng, field_count) - } - StructStrategy::AllArraySizes => { - // Test with arrays of sizes that have specific DataHasher impls - let array_size = rng.gen_range(1..=12); - generate_struct_with_specific_field(rng, FieldStrategy::ExactArraySize(array_size)) - } - StructStrategy::AttributeCombinations => { - // Test various combinations of hash and skip attributes - generate_struct_with_attribute_combinations(rng) - } - StructStrategy::NestedDepth(depth) => { - // Test deeply nested structs - generate_nested_struct(rng, depth) - } - } -} - -// Generate a struct with random fields -fn generate_struct_with_random_fields(rng: &mut StdRng, field_count: usize) -> String { - let struct_name = format!("TestStruct{}", rng.gen::()); - let mut fields = Vec::new(); - - for i in 0..field_count { - let field_type = generate_random_type(rng); - let field_name = format!("field_{}", i); - let has_attr = rng.gen_bool(0.3); - - let attr = if has_attr { - match rng.gen_range(0..=2) { - 0 => "#[hash]", - 1 => "#[skip]", - _ => "#[flatten]", // Intentionally test unsupported attribute - } - } else { - "" - }; - - fields.push(format!(" {}\n pub {}: {}", attr, field_name, field_type)); - } - - format!( - "#[derive(LightHasher)]\npub struct {} {{\n{}\n}}", - struct_name, - fields.join(",\n") - ) -} - -// Generate a struct with a specific field type of interest -fn generate_struct_with_specific_field(rng: &mut StdRng, field_strategy: FieldStrategy) -> String { - let struct_name = format!("TestStruct{}", rng.gen::()); - let mut fields = Vec::new(); - - // Add 1-3 random fields - for i in 0..rng.gen_range(1..=3) { - let field_name = format!("random_field_{}", i); - fields.push(format!(" pub {}: {}", field_name, generate_random_type(rng))); - } - - // Add the specific field of interest - let specific_field = match field_strategy { - FieldStrategy::ExactArraySize(size) => { - // 50% chance to add #[hash] attribute, which should allow any array size - let attr = if rng.gen_bool(0.5) { "#[hash]\n " } else { "" }; - format!(" {}pub array_field: [u8; {}]", attr, size) - } - FieldStrategy::ArrayBoundary => { - let sizes = [31, 32, 33, 64]; // Boundary sizes - let size = sizes[rng.gen_range(0..sizes.len())]; - // Arrays >= 32 should have #[hash] to work properly - let needs_hash = size >= 32; - let attr = if needs_hash || rng.gen_bool(0.5) { "#[hash]\n " } else { "" }; - format!(" {}pub array_field: [u8; {}]", attr, size) - } - FieldStrategy::HashableArray => { - // Any array size should work with #[hash] - let size = rng.gen_range(1..=100); - format!(" #[hash]\n pub array_field: [u8; {}]", size) - } - _ => format!(" pub special_field: {}", generate_type_for_strategy(rng, field_strategy)), - }; - - fields.push(specific_field); - - format!( - "#[derive(LightHasher)]\npub struct {} {{\n{}\n}}", - struct_name, - fields.join(",\n") - ) -} - -// Generate a struct with various attribute combinations -fn generate_struct_with_attribute_combinations(rng: &mut StdRng) -> String { - let struct_name = format!("TestStruct{}", rng.gen::()); - let field_count = rng.gen_range(3..=8); - let mut fields = Vec::new(); - - // Distribution of attributes: 1/3 regular, 1/3 #[hash], 1/3 #[skip] - for i in 0..field_count { - let field_name = format!("field_{}", i); - let field_type = generate_random_type(rng); - - // Choose attribute - let attr = match i % 3 { - 0 => "", // No attribute - 1 => "#[hash]", // hash attribute - _ => "#[skip]", // skip attribute - }; - - fields.push(format!(" {}\n pub {}: {}", attr, field_name, field_type)); - } - - // Add specific test case: large array with hash - fields.push(format!(" #[hash]\n pub large_array: [u8; {}]", rng.gen_range(32..=100))); - - format!( - "#[derive(LightHasher)]\npub struct {} {{\n{}\n}}", - struct_name, - fields.join(",\n") - ) -} - -// Generate a nested struct with specified depth -fn generate_nested_struct(rng: &mut StdRng, depth: usize) -> String { - if depth == 0 { - return "SimpleNested".to_string(); - } - - let struct_name = format!("NestedStruct{}", rng.gen::()); - let mut fields = Vec::new(); - - // Add 1-3 regular fields - for i in 0..rng.gen_range(1..=3) { - fields.push(format!(" pub field_{}: {}", i, generate_random_type(rng))); - } - - // Add nested field - let nested_field_type = if depth == 1 { - "SimpleNested".to_string() - } else { - format!("NestedStruct{}", rng.gen::()) - }; - - fields.push(format!(" pub nested: {}", nested_field_type)); - - // If depth > 1, add definition for the nested type first - let nested_def = if depth > 1 { - format!("{}\n\n", generate_nested_struct(rng, depth - 1)) - } else { - "".to_string() - }; - - format!( - "{}#[derive(LightHasher)]\npub struct {} {{\n{}\n}}", - nested_def, - struct_name, - fields.join(",\n") - ) -} - -// Generate a type based on a specific strategy -fn generate_type_for_strategy(rng: &mut StdRng, strategy: FieldStrategy) -> String { - match strategy { - FieldStrategy::Primitive => { - match rng.gen_range(0..=5) { - 0 => "u8".to_string(), - 1 => "u32".to_string(), - 2 => "u64".to_string(), - 3 => "i32".to_string(), - 4 => "i64".to_string(), - _ => "bool".to_string(), - } - } - FieldStrategy::Array => { - format!("[u8; {}]", rng.gen_range(1..=64)) - } - FieldStrategy::ArrayBoundary => { - let sizes = [31, 32, 33, 64]; // Boundary sizes - format!("[u8; {}]", sizes[rng.gen_range(0..sizes.len())]) - } - FieldStrategy::ExactArraySize(size) => { - format!("[u8; {}]", size) - } - FieldStrategy::Option => { - let inner_type = match rng.gen_range(0..=3) { - 0 => "u32".to_string(), - 1 => "String".to_string(), - 2 => "SimpleNested".to_string(), - _ => format!("[u8; {}]", rng.gen_range(1..=64)), - }; - format!("Option<{}>", inner_type) - } - FieldStrategy::NestedStruct => { - "SimpleNested".to_string() - } - FieldStrategy::HashableString => { - "String".to_string() // Will be marked with #[hash] - } - FieldStrategy::HashableArray => { - format!("[u8; {}]", rng.gen_range(1..=100)) // Will be marked with #[hash] - } - } -} - -// Generate a random type for a field -fn generate_random_type(rng: &mut StdRng) -> String { - // Choose a field strategy - let strategy = match rng.gen_range(0..=9) { - 0..=2 => FieldStrategy::Primitive, - 3..=4 => FieldStrategy::Array, - 5 => FieldStrategy::ArrayBoundary, - 6..=7 => FieldStrategy::Option, - 8 => FieldStrategy::NestedStruct, - _ => if rng.gen_bool(0.5) { FieldStrategy::HashableString } else { FieldStrategy::HashableArray }, - }; - - generate_type_for_strategy(rng, strategy) -} \ No newline at end of file diff --git a/sdk-libs/macros/fuzz/fuzz_targets/struct_generation.rs b/sdk-libs/macros/fuzz/fuzz_targets/struct_generation.rs deleted file mode 100644 index 6d4e0b9ede..0000000000 --- a/sdk-libs/macros/fuzz/fuzz_targets/struct_generation.rs +++ /dev/null @@ -1,523 +0,0 @@ -#![no_main] - -use libfuzzer_sys::fuzz_target; -use light_hasher::{DataHasher, Poseidon}; -use light_sdk_macros::LightHasher; -use rand::{rngs::StdRng, Rng, SeedableRng}; - -// Define helper structs for testing -#[derive(LightHasher, Clone)] -pub struct SimpleNested { - pub a: u32, - pub b: i32, - #[hash] - pub c: String, -} - -/// Test strategies to verify runtime behavior of the hash implementation -enum TestStrategy { - // Basic hash consistency and correctness - BasicConsistency, - // Test arrays of specific sizes (1-12) which have special DataHasher impls - ArraySizeSpecific, - // Test arrays at boundary conditions (31, 32, 33, 64) - ArrayBoundary, - // Test behavior of #[hash] attribute (esp. with arrays) - HashAttribute, - // Test #[skip] attribute behavior - SkipAttribute, - // Test behavior with Option types - OptionHandling, - // Test behavior with nested structs - NestedStructs, - // Test specifically that u8 arrays out of bounds succeed when marked with #[hash] - OutOfBoundsArrayHash, -} - -// Fuzz target that generates random structs and tests hashing behavior -fuzz_target!(|data: &[u8]| { - if data.len() < 16 { - return; // Need enough bytes for the test - } - - // Use the first 8 bytes as a seed - let mut seed = [0u8; 8]; - seed.copy_from_slice(&data[0..8]); - let seed = u64::from_le_bytes(seed); - let mut rng = StdRng::seed_from_u64(seed); - - // Select test strategy - let strategy = match rng.gen_range(0..=7) { - 0 => TestStrategy::BasicConsistency, - 1 => TestStrategy::ArraySizeSpecific, - 2 => TestStrategy::ArrayBoundary, - 3 => TestStrategy::HashAttribute, - 4 => TestStrategy::SkipAttribute, - 5 => TestStrategy::OptionHandling, - 6 => TestStrategy::NestedStructs, - _ => TestStrategy::OutOfBoundsArrayHash, - }; - - // Execute the selected test strategy - match strategy { - TestStrategy::BasicConsistency => { - // Standard test for hash consistency - let test_struct = generate_test_struct(&mut rng, &data[8..]); - - // Verify hashing works and is consistent - let hash1 = test_struct.hash::(); - if hash1.is_ok() { - let hash2 = test_struct.hash::(); - assert_eq!(hash1.unwrap(), hash2.unwrap(), "Hash should be deterministic"); - } - }, - TestStrategy::ArraySizeSpecific => { - // Test with array sizes that have specific DataHasher implementations (1-12) - if data.len() < 20 { return; } // Need more data - - // Create a struct with a specific array size - let array_size = rng.gen_range(1..=12); - let test_struct = generate_array_test_struct(&mut rng, array_size, &data[8..]); - - // Verify hash works correctly - assert!(test_struct.hash::().is_ok(), - "Hash failed for array size {}", array_size); - }, - TestStrategy::ArrayBoundary => { - // Test array sizes around boundaries - if data.len() < 20 { return; } // Need more data - - // Test array sizes at boundaries and additional large sizes - // Include some very large array sizes to test the #[hash] behavior - let boundary_sizes = [31, 32, 33, 64, 100, 256, 512, 1024]; - let size = boundary_sizes[rng.gen_range(0..boundary_sizes.len())]; - - // Arrays ≥ 32 must have #[hash] to work correctly - if size >= 32 { - // Always use #[hash] for large arrays - user noted that any u8 array - // out of bounds should succeed if marked with #[hash] - let hash_struct = generate_hash_array_struct(&mut rng, size, &data[8..]); - - // Verify hash works correctly with #[hash] - let hash_result = hash_struct.hash::(); - assert!(hash_result.is_ok(), "Hash failed for boundary array size {} with #[hash]", size); - } else { - // For smaller arrays, randomly decide whether to add #[hash] - if rng.gen_bool(0.5) { - // Use #[hash] attribute - let hash_struct = generate_hash_array_struct(&mut rng, size, &data[8..]); - let hash_result = hash_struct.hash::(); - assert!(hash_result.is_ok(), "Hash failed for boundary array size {} with #[hash]", size); - } else { - // Use regular array (no #[hash]) - let array_struct = generate_array_test_struct(&mut rng, size, &data[8..]); - let hash_result = array_struct.hash::(); - assert!(hash_result.is_ok(), "Hash failed for array size {} without #[hash]", size); - } - } - }, - TestStrategy::HashAttribute => { - // Test #[hash] attribute behavior with various types - if data.len() < 20 { return; } // Need more data - - // Create struct with hashable types - let test_struct = generate_hash_attribute_struct(&mut rng, &data[8..]); - - // Verify hash works - let result = test_struct.hash::(); - assert!(result.is_ok(), "Hash failed for #[hash] attribute test"); - }, - TestStrategy::SkipAttribute => { - // Test #[skip] attribute behavior - if data.len() < 20 { return; } // Need more data - - // Create struct with skipped fields - let test_struct = generate_skip_attribute_struct(&mut rng, &data[8..]); - - // Verify hash works - let result = test_struct.hash::(); - assert!(result.is_ok(), "Hash failed for #[skip] attribute test"); - }, - TestStrategy::OptionHandling => { - // Test Option handling - if data.len() < 20 { return; } // Need more data - - // Create struct with Option fields - let test_struct = generate_option_struct(&mut rng, &data[8..]); - - // Verify hash works - let result = test_struct.hash::(); - assert!(result.is_ok(), "Hash failed for Option test"); - }, - TestStrategy::NestedStructs => { - // Test nested struct handling - if data.len() < 20 { return; } // Need more data - - // Create struct with nested fields - let test_struct = generate_nested_struct(&mut rng, &data[8..]); - - // Verify hash works - let result = test_struct.hash::(); - assert!(result.is_ok(), "Hash failed for nested struct test"); - }, - TestStrategy::OutOfBoundsArrayHash => { - // Test specifically that u8 arrays out of bounds succeed when marked with #[hash] - if data.len() < 20 { return; } // Need more data - - // Select an array size that exceeds the standard limit (32 bytes) - // Test with increasingly large sizes to ensure the #[hash] attribute works - let large_sizes = [33, 64, 128, 256, 512, 1024, 2048, 4096]; - let size = large_sizes[rng.gen_range(0..large_sizes.len())]; - - // Create a test struct for out-of-bounds hash testing - let test_struct = TestStruct { - a: false, // Mark this as a special test - b: size as u64, // Store the intended size - c: Some(1234), // Use a fixed value - d: format!("outofbounds-hash-test-{}", size), - e: SimpleNested { - a: 0, - b: 0, - c: format!("hash-array-size-{}", size), - }, - f: 0, // Not used in hash - g: Some(format!("array-size-{}", size)), - array_marker: format!("hash-array-{}bytes", size), // Marker for #[hash] array simulation - }; - - // Verify hash works with #[hash] attribute - let result = test_struct.hash::(); - assert!(result.is_ok(), "Hash failed for out-of-bounds array with #[hash] attribute, size {}", size); - - // Additional assertions to verify the hashing behavior (optional) - if result.is_ok() { - let hash1 = test_struct.hash::().unwrap(); - let hash2 = test_struct.hash::().unwrap(); - assert_eq!(hash1, hash2, "Hash for out-of-bounds array should be deterministic"); - } - - // For actual testing, create a test struct with the real OutOfBoundsArrayTest - // This simulates a hash array of the desired size with the #[hash] attribute - // This is for validating the proper behavior of #[hash] arrays - #[derive(LightHasher, Clone)] - struct OutOfBoundsArrayTest { - pub size: u64, - #[hash] - pub array_marker: String, // Marker for array hash behavior testing - } - - let oob_test = OutOfBoundsArrayTest { - size: size as u64, - array_marker: format!("array-hash-test-{}", size), - }; - - // Verify hash works - let result = oob_test.hash::(); - assert!(result.is_ok(), "Hash with #[hash] attribute failed for simulated array size {}", size); - } - } -}); - -// Random struct with explicit types instead of string generation -#[derive(LightHasher, Clone)] -pub struct TestStruct { - pub a: bool, - pub b: u64, - pub c: Option, - #[hash] - pub d: String, - pub e: SimpleNested, - #[skip] - pub f: u64, - #[hash] - pub g: Option, - #[hash] - pub array_marker: String, // Marker to indicate hash behavior for arrays -} - -// Generate structs for different test scenarios - -// Basic test struct with mixed types and attributes -fn generate_test_struct(rng: &mut StdRng, data: &[u8]) -> TestStruct { - // Create a string from some of the input data - let string_len = std::cmp::min(data.len(), 64); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - // Create a fixed-size array from some of the input data - let mut array = [0u8; 32]; - if data.len() >= 32 { - array.copy_from_slice(&data[..32]); - } - - TestStruct { - a: rng.gen(), - b: rng.gen(), - c: if rng.gen_bool(0.5) { - Some(rng.gen()) - } else { - None - }, - d: random_string, - e: SimpleNested { - a: rng.gen(), - b: rng.gen(), - c: format!("nested-{}", rng.gen::()), - }, - f: rng.gen(), // Should be skipped in hash - g: if rng.gen_bool(0.5) { - Some(format!("option-{}", rng.gen::())) - } else { - None - }, - array_marker: format!("array-test-{}", rng.gen::()), - } -} - -// Custom structs for array size testing (1-12) -#[derive(LightHasher, Clone)] -pub struct ArraySizeStruct { - pub a: u32, - pub b: i64, - pub array_size: usize, // Store array size as a normal field - #[hash] - pub array_data: String, // Simulate array data with a string -} - -fn generate_array_test_struct(rng: &mut StdRng, size: usize, data: &[u8]) -> ArraySizeStruct { - // Create a string representation of array data - let string_len = std::cmp::min(data.len(), 64); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - ArraySizeStruct { - a: rng.gen(), - b: rng.gen(), - array_size: size, - array_data: format!("array-data-{}-{}", size, random_string), - } -} - -#[allow(dead_code)] -// Helper function to determine a reasonable string length for test data -fn string_len() -> usize { - 64 -} - -#[allow(dead_code)] -// Helper function to create a TestStruct for hash testing of large arrays -fn create_test_struct_with_hash(rng: &mut StdRng, size: usize, _array: &[u8], data: &[u8]) -> TestStruct { - // Create a string from data - let string_len = std::cmp::min(data.len(), 64); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - // Create a TestStruct with a #[hash] marker for array - TestStruct { - a: rng.gen(), - b: rng.gen(), - c: Some(rng.gen()), - d: format!("hash-array-test-{}", size), // Include size in the string - e: SimpleNested { - a: rng.gen(), - b: rng.gen(), - c: random_string.clone(), - }, - f: size as u64, // Store the intended size - g: Some(random_string), - array_marker: format!("array-hash-marker-{}-{}", size, rng.gen::()), - } -} - -// Test struct with hash attribute for large arrays -#[derive(LightHasher, Clone)] -pub struct HashArrayStruct { - pub a: u32, - pub b: i64, - pub size: usize, // Store the intended array size - #[hash] - pub array_data: String, // Simulate array with #[hash] attribute using a string -} - -// Generate struct with #[hash] attribute for array of any size -fn generate_hash_array_struct(rng: &mut StdRng, size: usize, data: &[u8]) -> HashArrayStruct { - // Create a string representation of array data - let string_len = std::cmp::min(data.len(), 100); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - HashArrayStruct { - a: rng.gen(), - b: rng.gen(), - size: size, - array_data: format!("hash-array-{}-{}", size, random_string), - } -} - -// Test struct with various hashable types -#[derive(LightHasher, Clone)] -pub struct HashAttributeStruct { - pub a: u32, - #[hash] - pub string: String, - #[hash] - pub option_string: Option, - #[hash] - pub large_array_marker: String, // Marker for large array with #[hash] -} - -// Generate struct specifically to test #[hash] attribute behavior -fn generate_hash_attribute_struct(rng: &mut StdRng, data: &[u8]) -> HashAttributeStruct { - // Create string from data - let string_len = std::cmp::min(data.len(), 100); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - // Create array from data - let mut array = [0u8; 64]; - if data.len() >= 64 { - array.copy_from_slice(&data[..64]); - } - - HashAttributeStruct { - a: rng.gen(), - string: random_string.clone(), - option_string: if rng.gen_bool(0.7) { - Some(random_string) - } else { - None - }, - large_array_marker: format!("array-64-data-{}", rng.gen::()), - } -} - -// Test struct with skipped fields -#[derive(LightHasher, Clone)] -pub struct SkipAttributeStruct { - pub a: u32, - #[skip] - pub skip_primitive: u64, - pub b: i32, - #[skip] - pub skip_string: String, - #[skip] - pub skip_array_marker: String, // Marker for skipped array - pub c: Option, -} - -// Generate struct specifically to test #[skip] attribute behavior -fn generate_skip_attribute_struct(rng: &mut StdRng, data: &[u8]) -> SkipAttributeStruct { - // Create string and array from data - let string_len = std::cmp::min(data.len(), 32); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - let mut array = [0u8; 32]; - if data.len() >= 32 { - array.copy_from_slice(&data[..32]); - } - - SkipAttributeStruct { - a: rng.gen(), - skip_primitive: rng.gen(), // Should be ignored - b: rng.gen(), - skip_string: random_string, // Should be ignored - skip_array_marker: format!("skip-array-marker-{}", rng.gen::()), // Should be ignored - c: if rng.gen_bool(0.5) { - Some(rng.gen()) - } else { - None - }, - } -} - -// Test struct with Option fields -#[derive(LightHasher, Clone)] -pub struct OptionStruct { - pub a: Option, - pub b: Option, - pub c: Option, - #[hash] - pub d: Option, - #[hash] - pub e: Option, // Option for array marker -} - -// Generate struct specifically to test Option handling -fn generate_option_struct(rng: &mut StdRng, data: &[u8]) -> OptionStruct { - // Create string and array from data - let string_len = std::cmp::min(data.len(), 32); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - let mut array = [0u8; 32]; - if data.len() >= 32 { - array.copy_from_slice(&data[..32]); - } - - // For each field, randomly determine if it's None or Some - OptionStruct { - a: if rng.gen_bool(0.5) { Some(rng.gen()) } else { None }, - b: if rng.gen_bool(0.5) { Some(rng.gen()) } else { None }, - c: if rng.gen_bool(0.5) { - Some(SimpleNested { - a: rng.gen(), - b: rng.gen(), - c: format!("nested-{}", rng.gen::()), - }) - } else { - None - }, - d: if rng.gen_bool(0.5) { Some(random_string) } else { None }, - e: if rng.gen_bool(0.5) { Some(format!("option-array-marker-{}", rng.gen::())) } else { None }, - } -} - -// Test struct with nested structs -#[derive(LightHasher, Clone)] -pub struct OuterStruct { - pub a: u32, - pub b: SimpleNested, - pub c: Option, -} - -// Generate struct specifically to test nested struct handling -fn generate_nested_struct(rng: &mut StdRng, data: &[u8]) -> OuterStruct { - let string_len = std::cmp::min(data.len(), 20); - let random_string: String = data[..string_len] - .iter() - .map(|&b| (b % 26 + b'a') as char) - .collect(); - - OuterStruct { - a: rng.gen(), - b: SimpleNested { - a: rng.gen(), - b: rng.gen(), - c: format!("nested-{}", rng.gen::()), - }, - c: if rng.gen_bool(0.7) { - Some(SimpleNested { - a: rng.gen(), - b: rng.gen(), - c: random_string, - }) - } else { - None - }, - } -} diff --git a/sdk-libs/macros/proptest-regressions/light_pdas/accounts/e2e_prop_tests.txt b/sdk-libs/macros/proptest-regressions/light_pdas/accounts/e2e_prop_tests.txt new file mode 100644 index 0000000000..f73ee97adf --- /dev/null +++ b/sdk-libs/macros/proptest-regressions/light_pdas/accounts/e2e_prop_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 72eea5db9f506b8887f49083f3e220f19b045449e263b6462c64e3a3b4b97da6 # shrinks to struct_name = "Self" diff --git a/sdk-libs/macros/proptest-regressions/light_pdas/shared_utils_prop_tests.txt b/sdk-libs/macros/proptest-regressions/light_pdas/shared_utils_prop_tests.txt new file mode 100644 index 0000000000..ac824b5052 --- /dev/null +++ b/sdk-libs/macros/proptest-regressions/light_pdas/shared_utils_prop_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc f292f61ba0a0a376d8731826b7a7a20a111b9bc09c82b9acc170713d8b15de25 # shrinks to base = "true" diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 427b8dc614..992ff3b382 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -12,6 +12,9 @@ mod light_pdas; mod rent_sponsor; mod utils; +#[cfg(test)] +mod light_pdas_tests; + #[proc_macro_derive(LightDiscriminator)] pub fn light_discriminator(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as ItemStruct); diff --git a/sdk-libs/macros/src/light_pdas/account/light_compressible.rs b/sdk-libs/macros/src/light_pdas/account/light_compressible.rs index a98bb36d64..13cd796cf0 100644 --- a/sdk-libs/macros/src/light_pdas/account/light_compressible.rs +++ b/sdk-libs/macros/src/light_pdas/account/light_compressible.rs @@ -109,150 +109,3 @@ fn derive_input_to_item_struct(input: &DeriveInput) -> Result { semi_token: data.semi_token, }) } - -#[cfg(test)] -mod tests { - use syn::parse_quote; - - use super::*; - - #[test] - fn test_light_compressible_basic() { - // No #[hash] or #[skip] needed - SHA256 hashes entire struct, compression_info auto-skipped - let input: DeriveInput = parse_quote! { - pub struct UserRecord { - pub owner: Pubkey, - pub name: String, - pub score: u64, - pub compression_info: Option, - } - }; - - let result = derive_light_account(input); - assert!(result.is_ok(), "LightCompressible should succeed"); - - let output = result.unwrap().to_string(); - - // Should contain LightHasherSha output - assert!(output.contains("DataHasher"), "Should implement DataHasher"); - assert!( - output.contains("ToByteArray"), - "Should implement ToByteArray" - ); - - // Should contain LightDiscriminator output - assert!( - output.contains("LightDiscriminator"), - "Should implement LightDiscriminator" - ); - assert!( - output.contains("LIGHT_DISCRIMINATOR"), - "Should have discriminator constant" - ); - - // Should contain Compressible output (HasCompressionInfo, CompressAs, Size) - assert!( - output.contains("HasCompressionInfo"), - "Should implement HasCompressionInfo" - ); - assert!(output.contains("CompressAs"), "Should implement CompressAs"); - assert!(output.contains("Size"), "Should implement Size"); - - // Should contain CompressiblePack output (Pack, Unpack, Packed struct) - assert!(output.contains("Pack"), "Should implement Pack"); - assert!(output.contains("Unpack"), "Should implement Unpack"); - assert!( - output.contains("PackedUserRecord"), - "Should generate Packed struct" - ); - } - - #[test] - fn test_light_compressible_with_compress_as() { - // compress_as still works - no #[hash] or #[skip] needed - let input: DeriveInput = parse_quote! { - #[compress_as(start_time = 0, score = 0)] - pub struct GameSession { - pub session_id: u64, - pub player: Pubkey, - pub start_time: u64, - pub score: u64, - pub compression_info: Option, - } - }; - - let result = derive_light_account(input); - assert!( - result.is_ok(), - "LightCompressible with compress_as should succeed" - ); - - let output = result.unwrap().to_string(); - - // compress_as attribute should be processed - assert!(output.contains("CompressAs"), "Should implement CompressAs"); - } - - #[test] - fn test_light_compressible_no_pubkey_fields() { - let input: DeriveInput = parse_quote! { - pub struct SimpleRecord { - pub id: u64, - pub value: u32, - pub compression_info: Option, - } - }; - - let result = derive_light_account(input); - assert!( - result.is_ok(), - "LightCompressible without Pubkey fields should succeed" - ); - - let output = result.unwrap().to_string(); - - // Should still generate everything - assert!(output.contains("DataHasher"), "Should implement DataHasher"); - assert!( - output.contains("LightDiscriminator"), - "Should implement LightDiscriminator" - ); - assert!( - output.contains("HasCompressionInfo"), - "Should implement HasCompressionInfo" - ); - - // For structs without Pubkey fields, PackedSimpleRecord should be a type alias - // (implementation detail of CompressiblePack) - } - - #[test] - fn test_light_compressible_enum_fails() { - let input: DeriveInput = parse_quote! { - pub enum NotAStruct { - A, - B, - } - }; - - let result = derive_light_account(input); - assert!(result.is_err(), "LightCompressible should fail for enums"); - } - - #[test] - fn test_light_compressible_missing_compression_info() { - let input: DeriveInput = parse_quote! { - pub struct MissingCompressionInfo { - pub id: u64, - pub value: u32, - } - }; - - let result = derive_light_account(input); - // Compressible derive validates compression_info field - assert!( - result.is_err(), - "Should fail without compression_info field" - ); - } -} diff --git a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs index c3052bb4f2..b87105fad2 100644 --- a/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs +++ b/sdk-libs/macros/src/light_pdas/account/seed_extraction.rs @@ -296,7 +296,7 @@ pub fn extract_from_accounts_struct( /// - Mint: `#[light_account(init, mint::...)]` /// - Token: `#[light_account(init, token::...)]` or `#[light_account(token::...)]` /// - ATA: `#[light_account(init, associated_token::...)]` or `#[light_account(associated_token::...)]` -fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool, bool) { +pub(crate) fn check_light_account_type(attrs: &[syn::Attribute]) -> (bool, bool, bool) { for attr in attrs { if attr.path().is_ident("light_account") { // Parse the content to determine if it's init-only (PDA) or init+mint (Mint) @@ -1013,241 +1013,3 @@ fn extract_data_field_from_expr(expr: &syn::Expr) -> Option<(Ident, bool)> { _ => None, } } - -#[cfg(test)] -mod tests { - use syn::parse_quote; - - use super::*; - - fn make_instruction_args(names: &[&str]) -> InstructionArgSet { - InstructionArgSet::from_names(names.iter().map(|s| s.to_string())) - } - - #[test] - fn test_bare_pubkey_instruction_arg() { - let args = make_instruction_args(&["owner", "amount"]); - let expr: syn::Expr = parse_quote!(owner); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!( - matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") - ); - } - - #[test] - fn test_bare_primitive_with_to_le_bytes() { - let args = make_instruction_args(&["amount"]); - let expr: syn::Expr = parse_quote!(amount.to_le_bytes().as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!(matches!( - result, - ClassifiedSeed::DataField { - field_name, - conversion: Some(conv) - } if field_name == "amount" && conv == "to_le_bytes" - )); - } - - #[test] - fn test_custom_struct_param_name() { - let args = make_instruction_args(&["input"]); - let expr: syn::Expr = parse_quote!(input.owner.as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!( - matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") - ); - } - - #[test] - fn test_nested_field_access() { - let args = make_instruction_args(&["data"]); - let expr: syn::Expr = parse_quote!(data.inner.key.as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!( - matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "key") - ); - } - - #[test] - fn test_context_account_not_confused_with_arg() { - let args = make_instruction_args(&["owner"]); // "authority" is NOT an arg - let expr: syn::Expr = parse_quote!(authority.key().as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!(matches!( - result, - ClassifiedSeed::CtxAccount(ident) if ident == "authority" - )); - } - - #[test] - fn test_empty_instruction_args() { - let args = InstructionArgSet::empty(); - let expr: syn::Expr = parse_quote!(owner); - let result = classify_seed_expr(&expr, &args).unwrap(); - // Without instruction args, bare ident treated as ctx account - assert!(matches!(result, ClassifiedSeed::CtxAccount(_))); - } - - #[test] - fn test_literal_seed() { - let args = InstructionArgSet::empty(); - let expr: syn::Expr = parse_quote!(b"seed"); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!(matches!(result, ClassifiedSeed::Literal(bytes) if bytes == b"seed")); - } - - #[test] - fn test_constant_seed() { - let args = InstructionArgSet::empty(); - let expr: syn::Expr = parse_quote!(SEED_PREFIX); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!(matches!(result, ClassifiedSeed::Constant(_))); - } - - #[test] - fn test_standard_params_field_access() { - // Traditional format: #[instruction(params: CreateParams)] - let args = make_instruction_args(&["params"]); - let expr: syn::Expr = parse_quote!(params.owner.as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!( - matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") - ); - } - - #[test] - fn test_args_naming_format() { - // Alternative naming: #[instruction(args: MyArgs)] - let args = make_instruction_args(&["args"]); - let expr: syn::Expr = parse_quote!(args.key.as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!( - matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "key") - ); - } - - #[test] - fn test_data_naming_format() { - // Alternative naming: #[instruction(data: DataInput)] - let args = make_instruction_args(&["data"]); - let expr: syn::Expr = parse_quote!(data.value.to_le_bytes().as_ref()); - let result = classify_seed_expr(&expr, &args).unwrap(); - assert!(matches!( - result, - ClassifiedSeed::DataField { - field_name, - conversion: Some(conv) - } if field_name == "value" && conv == "to_le_bytes" - )); - } - - #[test] - fn test_format2_multiple_params() { - // Format 2: #[instruction(owner: Pubkey, amount: u64)] - let args = make_instruction_args(&["owner", "amount"]); - - let expr1: syn::Expr = parse_quote!(owner.as_ref()); - let result1 = classify_seed_expr(&expr1, &args).unwrap(); - assert!( - matches!(result1, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") - ); - - let expr2: syn::Expr = parse_quote!(amount.to_le_bytes().as_ref()); - let result2 = classify_seed_expr(&expr2, &args).unwrap(); - assert!(matches!( - result2, - ClassifiedSeed::DataField { - field_name, - conversion: Some(_) - } if field_name == "amount" - )); - } - - #[test] - fn test_parse_instruction_arg_names() { - // Test that we can parse instruction attributes - let attrs: Vec = vec![parse_quote!(#[instruction(owner: Pubkey)])]; - let args = parse_instruction_arg_names(&attrs).unwrap(); - assert!(args.contains("owner")); - } - - #[test] - fn test_parse_instruction_arg_names_multiple() { - let attrs: Vec = - vec![parse_quote!(#[instruction(owner: Pubkey, amount: u64, flag: bool)])]; - let args = parse_instruction_arg_names(&attrs).unwrap(); - assert!(args.contains("owner")); - assert!(args.contains("amount")); - assert!(args.contains("flag")); - } - - #[test] - fn test_check_light_account_type_mint_namespace() { - // Test that mint:: namespace is detected correctly - let attrs: Vec = vec![parse_quote!( - #[light_account(init, - mint::signer = mint_signer, - mint::authority = fee_payer, - mint::decimals = 6 - )] - )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); - assert!(!has_pda, "Should NOT be detected as PDA"); - assert!(has_mint, "Should be detected as mint"); - assert!(!has_ata, "Should NOT be detected as ATA"); - } - - #[test] - fn test_check_light_account_type_pda_only() { - // Test that plain init (no mint::) is detected as PDA - let attrs: Vec = vec![parse_quote!( - #[light_account(init)] - )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); - assert!(has_pda, "Should be detected as PDA"); - assert!(!has_mint, "Should NOT be detected as mint"); - assert!(!has_ata, "Should NOT be detected as ATA"); - } - - #[test] - fn test_check_light_account_type_token_namespace() { - // Test that token:: namespace is not detected as mint (it's neither PDA nor mint nor ATA) - let attrs: Vec = vec![parse_quote!( - #[light_account(token::authority = [b"auth"])] - )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); - assert!(!has_pda, "Should NOT be detected as PDA (no init)"); - assert!(!has_mint, "Should NOT be detected as mint"); - assert!(!has_ata, "Should NOT be detected as ATA"); - } - - #[test] - fn test_check_light_account_type_associated_token_init() { - // Test that associated_token:: with init is detected as ATA - let attrs: Vec = vec![parse_quote!( - #[light_account(init, - associated_token::authority = owner, - associated_token::mint = mint - )] - )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); - assert!(!has_pda, "Should NOT be detected as PDA"); - assert!(!has_mint, "Should NOT be detected as mint"); - assert!(has_ata, "Should be detected as ATA"); - } - - #[test] - fn test_check_light_account_type_token_init() { - // Test that token:: with init is NOT detected as PDA - let attrs: Vec = vec![parse_quote!( - #[light_account(init, - token::authority = [b"vault_auth"], - token::mint = mint - )] - )]; - let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); - assert!(!has_pda, "Should NOT be detected as PDA"); - assert!(!has_mint, "Should NOT be detected as mint"); - assert!(!has_ata, "Should NOT be detected as ATA"); - } -} diff --git a/sdk-libs/macros/src/light_pdas/accounts/derive.rs b/sdk-libs/macros/src/light_pdas/accounts/derive.rs index ad5bbbe5f9..28cc2b9a4c 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/derive.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/derive.rs @@ -30,7 +30,7 @@ use syn::DeriveInput; use super::builder::LightAccountsBuilder; /// Main orchestration - shows the high-level flow clearly. -pub(super) fn derive_light_accounts(input: &DeriveInput) -> Result { +pub(crate) fn derive_light_accounts(input: &DeriveInput) -> Result { let builder = LightAccountsBuilder::parse(input)?; builder.validate()?; @@ -55,199 +55,3 @@ pub(super) fn derive_light_accounts(input: &DeriveInput) -> Result { - #[account(mut)] - pub fee_payer: Signer<'info>, - - #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] - pub vault: Account<'info, CToken>, - - pub light_token_compressible_config: Account<'info, CompressibleConfig>, - pub light_token_rent_sponsor: Account<'info, RentSponsor>, - pub light_token_cpi_authority: AccountInfo<'info>, - } - }; - - let result = derive_light_accounts(&input); - assert!(result.is_ok(), "Token account derive should succeed"); - - let output = result.unwrap().to_string(); - - // Verify pre_init generates token account creation - assert!( - output.contains("LightPreInit"), - "Should generate LightPreInit impl" - ); - assert!( - output.contains("CreateTokenAccountCpi"), - "Should generate CreateTokenAccountCpi call" - ); - assert!( - output.contains("rent_free"), - "Should call rent_free on CreateTokenAccountCpi" - ); - assert!( - output.contains("invoke_signed"), - "Should call invoke_signed with seeds" - ); - } - - #[test] - fn test_ata_with_init_generates_create_cpi() { - // ATA with init should generate CreateTokenAtaCpi in pre_init - let input: DeriveInput = parse_quote! { - #[instruction(params: CreateAtaParams)] - pub struct CreateAta<'info> { - #[account(mut)] - pub fee_payer: Signer<'info>, - - #[light_account(init, associated_token::authority = wallet, associated_token::mint = my_mint)] - pub user_ata: Account<'info, CToken>, - - pub wallet: AccountInfo<'info>, - pub my_mint: AccountInfo<'info>, - pub light_token_compressible_config: Account<'info, CompressibleConfig>, - pub light_token_rent_sponsor: Account<'info, RentSponsor>, - } - }; - - let result = derive_light_accounts(&input); - assert!(result.is_ok(), "ATA derive should succeed"); - - let output = result.unwrap().to_string(); - - // Verify pre_init generates ATA creation - assert!( - output.contains("LightPreInit"), - "Should generate LightPreInit impl" - ); - assert!( - output.contains("CreateTokenAtaCpi"), - "Should generate CreateTokenAtaCpi call" - ); - } - - #[test] - fn test_token_mark_only_generates_no_creation() { - // Token without init should NOT generate creation code (mark-only mode) - // Mark-only returns None from parsing, so token_account_fields is empty - let input: DeriveInput = parse_quote! { - #[instruction(params: UseVaultParams)] - pub struct UseVault<'info> { - #[account(mut)] - pub fee_payer: Signer<'info>, - - // Mark-only: no init keyword, type inferred from namespace - #[light_account(token::authority = [b"authority"])] - pub vault: Account<'info, CToken>, - } - }; - - let result = derive_light_accounts(&input); - assert!(result.is_ok(), "Mark-only token derive should succeed"); - - let output = result.unwrap().to_string(); - - // Mark-only should NOT have token account creation - assert!( - !output.contains("CreateTokenAccountCpi"), - "Mark-only should NOT generate CreateTokenAccountCpi" - ); - - // Should still generate both trait impls - assert!( - output.contains("LightPreInit"), - "Should generate LightPreInit impl" - ); - assert!( - output.contains("LightFinalize"), - "Should generate LightFinalize impl (no-op)" - ); - } - - #[test] - fn test_mixed_token_and_ata_generates_both() { - // Mixed token account + ATA should generate both creation codes in pre_init - let input: DeriveInput = parse_quote! { - #[instruction(params: CreateBothParams)] - pub struct CreateBoth<'info> { - #[account(mut)] - pub fee_payer: Signer<'info>, - - #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] - pub vault: Account<'info, CToken>, - - #[light_account(init, associated_token::authority = wallet, associated_token::mint = my_mint)] - pub user_ata: Account<'info, CToken>, - - pub wallet: AccountInfo<'info>, - pub my_mint: AccountInfo<'info>, - pub light_token_compressible_config: Account<'info, CompressibleConfig>, - pub light_token_rent_sponsor: Account<'info, RentSponsor>, - pub light_token_cpi_authority: AccountInfo<'info>, - } - }; - - let result = derive_light_accounts(&input); - assert!(result.is_ok(), "Mixed token+ATA derive should succeed"); - - let output = result.unwrap().to_string(); - - // Should have both creation types in pre_init - assert!( - output.contains("LightPreInit"), - "Should generate LightPreInit impl" - ); - assert!( - output.contains("CreateTokenAccountCpi"), - "Should generate CreateTokenAccountCpi for vault" - ); - assert!( - output.contains("CreateTokenAtaCpi"), - "Should generate CreateTokenAtaCpi for ATA" - ); - } - - #[test] - fn test_no_instruction_args_generates_noop() { - // No #[instruction] attribute should generate no-op impls - let input: DeriveInput = parse_quote! { - pub struct NoInstruction<'info> { - #[account(mut)] - pub fee_payer: Signer<'info>, - } - }; - - let result = derive_light_accounts(&input); - assert!(result.is_ok(), "No instruction args should succeed"); - - let output = result.unwrap().to_string(); - - // Should generate no-op impls with () param type - assert!( - output.contains("LightPreInit"), - "Should generate LightPreInit impl" - ); - assert!( - output.contains("LightFinalize"), - "Should generate LightFinalize impl" - ); - // No-op returns Ok(false) in pre_init and Ok(()) in finalize - assert!( - output.contains("Ok (false)") || output.contains("Ok(false)"), - "Should return Ok(false) in pre_init" - ); - } -} diff --git a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs index da291c6fb9..b5fd47145c 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/light_account.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/light_account.rs @@ -476,7 +476,7 @@ fn parse_namespaced_key_values( /// * `field_ident` - The field identifier /// * `direct_proof_arg` - If `Some`, CreateAccountsProof is passed directly as an instruction arg /// with this name, so defaults should use `.field` instead of `params.create_accounts_proof.field` -pub(super) fn parse_light_account_attr( +pub(crate) fn parse_light_account_attr( field: &Field, field_ident: &Ident, direct_proof_arg: &Option, @@ -1006,1006 +1006,3 @@ impl From for super::parse::ParsedPdaField { } } } - -#[cfg(test)] -mod tests { - use syn::parse_quote; - - use super::*; - - #[test] - fn test_parse_light_account_pda_bare() { - let field: syn::Field = parse_quote! { - #[light_account(init)] - pub record: Account<'info, MyRecord> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Pda(pda) => { - assert_eq!(pda.ident.to_string(), "record"); - assert!(!pda.is_boxed); - } - _ => panic!("Expected PDA field"), - } - } - - #[test] - fn test_parse_pda_tree_keywords_rejected() { - // Tree keywords are no longer allowed - they're auto-fetched from CreateAccountsProof - let field: syn::Field = parse_quote! { - #[light_account(init, pda::address_tree_info = custom_tree)] - pub record: Account<'info, MyRecord> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - } - - #[test] - fn test_parse_light_account_mint() { - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"test"] - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Mint(mint) => { - assert_eq!(mint.field_ident.to_string(), "cmint"); - } - _ => panic!("Expected Mint field"), - } - } - - #[test] - fn test_parse_light_account_mint_with_metadata() { - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"test"], - mint::name = params.name.clone(), - mint::symbol = params.symbol.clone(), - mint::uri = params.uri.clone() - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Mint(mint) => { - assert!(mint.name.is_some()); - assert!(mint.symbol.is_some()); - assert!(mint.uri.is_some()); - } - _ => panic!("Expected Mint field"), - } - } - - #[test] - fn test_parse_light_account_missing_init() { - let field: syn::Field = parse_quote! { - #[light_account(mint, mint::decimals = 9)] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - } - - #[test] - fn test_parse_light_account_mint_missing_required() { - let field: syn::Field = parse_quote! { - #[light_account(init, mint, mint::decimals = 9)] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - } - - #[test] - fn test_parse_light_account_partial_metadata_fails() { - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"test"], - mint::name = params.name.clone() - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - } - - #[test] - fn test_no_light_account_attr_returns_none() { - let field: syn::Field = parse_quote! { - pub record: Account<'info, MyRecord> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - // ======================================================================== - // Token Account Tests - // ======================================================================== - - #[test] - fn test_parse_token_mark_only_returns_none() { - // Mark-only mode (no init) should return None for LightAccounts derive - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [b"authority"])] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn test_parse_token_init_creates_field() { - let field: syn::Field = parse_quote! { - #[light_account(init, token, token::authority = [b"authority"], token::mint = token_mint, token::owner = vault_authority)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::TokenAccount(token) => { - assert_eq!(token.field_ident.to_string(), "vault"); - assert!(token.has_init); - assert!(!token.authority_seeds.is_empty()); - assert!(token.mint.is_some()); - assert!(token.owner.is_some()); - } - _ => panic!("Expected TokenAccount field"), - } - } - - #[test] - fn test_parse_token_init_missing_authority_fails() { - let field: syn::Field = parse_quote! { - #[light_account(init, token)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!(err.contains("authority")); - } - - #[test] - fn test_parse_token_mark_only_missing_authority_fails() { - // Mark-only token now requires authority - let field: syn::Field = parse_quote! { - #[light_account(token)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("authority"), - "Expected error about missing authority, got: {}", - err - ); - } - - #[test] - fn test_parse_token_mark_only_rejects_mint() { - // Mark-only token should not allow mint parameter - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [b"auth"], token::mint = token_mint)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("mint") && err.contains("only allowed with `init`"), - "Expected error about mint only for init, got: {}", - err - ); - } - - #[test] - fn test_parse_token_mark_only_rejects_owner() { - // Mark-only token should not allow owner parameter - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [b"auth"], token::owner = vault_authority)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("owner") && err.contains("only allowed with `init`"), - "Expected error about owner only for init, got: {}", - err - ); - } - - #[test] - fn test_parse_token_init_missing_mint_fails() { - // Token init requires mint parameter - let field: syn::Field = parse_quote! { - #[light_account(init, token, token::authority = [b"authority"], token::owner = vault_authority)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("mint"), - "Expected error about missing mint, got: {}", - err - ); - } - - #[test] - fn test_parse_token_init_missing_owner_fails() { - // Token init requires owner parameter - let field: syn::Field = parse_quote! { - #[light_account(init, token, token::authority = [b"authority"], token::mint = token_mint)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("owner"), - "Expected error about missing owner, got: {}", - err - ); - } - - // ======================================================================== - // Associated Token Tests - // ======================================================================== - - #[test] - fn test_parse_associated_token_mark_only_returns_none() { - // Mark-only mode (no init) should return None for LightAccounts derive - let field: syn::Field = parse_quote! { - #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn test_parse_associated_token_init_creates_field() { - let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, associated_token::authority = owner, associated_token::mint = mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::AssociatedToken(ata) => { - assert_eq!(ata.field_ident.to_string(), "user_ata"); - assert!(ata.has_init); - } - _ => panic!("Expected AssociatedToken field"), - } - } - - #[test] - fn test_parse_associated_token_init_missing_authority_fails() { - let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, associated_token::mint = mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!(err.contains("authority")); - } - - #[test] - fn test_parse_associated_token_init_missing_mint_fails() { - let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, associated_token::authority = owner)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!(err.contains("mint")); - } - - #[test] - fn test_parse_token_unknown_argument_fails() { - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [b"auth"], token::unknown = foo)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!(err.contains("unknown")); - } - - #[test] - fn test_parse_associated_token_unknown_argument_fails() { - let field: syn::Field = parse_quote! { - #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint, associated_token::unknown = foo)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!(err.contains("unknown")); - } - - #[test] - fn test_parse_associated_token_shorthand_syntax() { - // Test shorthand syntax: mint, authority, bump without = value - let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, associated_token::authority, associated_token::mint, associated_token::bump)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::AssociatedToken(ata) => { - assert_eq!(ata.field_ident.to_string(), "user_ata"); - assert!(ata.has_init); - assert!(ata.bump.is_some()); - } - _ => panic!("Expected AssociatedToken field"), - } - } - - #[test] - fn test_parse_token_duplicate_key_fails() { - // Duplicate keys should be rejected - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [b"auth1"], token::authority = [b"auth2"])] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("Duplicate key"), - "Expected error about duplicate key, got: {}", - err - ); - } - - #[test] - fn test_parse_associated_token_duplicate_key_fails() { - // Duplicate keys in associated_token should also be rejected - let field: syn::Field = parse_quote! { - #[light_account(init, associated_token, associated_token::authority = foo, associated_token::authority = bar, associated_token::mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("Duplicate key"), - "Expected error about duplicate key, got: {}", - err - ); - } - - #[test] - fn test_parse_token_init_empty_authority_fails() { - // Empty authority seeds with init should be rejected - let field: syn::Field = parse_quote! { - #[light_account(init, token, token::authority = [], token::mint = token_mint, token::owner = vault_authority)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("Empty authority seeds"), - "Expected error about empty authority seeds, got: {}", - err - ); - } - - #[test] - fn test_parse_token_non_init_empty_authority_allowed() { - // Empty authority seeds without init should be allowed (mark-only mode) - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [])] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - // Mark-only mode returns Ok(None) - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn test_parse_pda_with_direct_proof_arg_uses_proof_ident_for_defaults() { - // When CreateAccountsProof is passed as a direct instruction arg (not nested in params), - // the default address_tree_info and output_tree should reference the proof arg directly. - let field: syn::Field = parse_quote! { - #[light_account(init)] - pub record: Account<'info, MyRecord> - }; - let field_ident = field.ident.clone().unwrap(); - - // Simulate passing CreateAccountsProof as direct arg named "proof" - let proof_ident: Ident = parse_quote!(proof); - let direct_proof_arg = Some(proof_ident.clone()); - - let result = parse_light_account_attr(&field, &field_ident, &direct_proof_arg); - assert!( - result.is_ok(), - "Should parse successfully with direct proof arg" - ); - let result = result.unwrap(); - assert!(result.is_some(), "Should return Some for init PDA"); - - match result.unwrap() { - LightAccountField::Pda(pda) => { - assert_eq!(pda.ident.to_string(), "record"); - - // Verify defaults use the direct proof identifier - // address_tree_info should be: proof.address_tree_info - let addr_tree_info = &pda.address_tree_info; - let addr_tree_str = quote::quote!(#addr_tree_info).to_string(); - assert!( - addr_tree_str.contains("proof"), - "address_tree_info should reference 'proof', got: {}", - addr_tree_str - ); - assert!( - addr_tree_str.contains("address_tree_info"), - "address_tree_info should access .address_tree_info field, got: {}", - addr_tree_str - ); - - // output_tree should be: proof.output_state_tree_index - let output_tree = &pda.output_tree; - let output_tree_str = quote::quote!(#output_tree).to_string(); - assert!( - output_tree_str.contains("proof"), - "output_tree should reference 'proof', got: {}", - output_tree_str - ); - assert!( - output_tree_str.contains("output_state_tree_index"), - "output_tree should access .output_state_tree_index field, got: {}", - output_tree_str - ); - } - _ => panic!("Expected PDA field"), - } - } - - #[test] - fn test_parse_mint_with_direct_proof_arg_uses_proof_ident_for_defaults() { - // When CreateAccountsProof is passed as a direct instruction arg, - // the default address_tree_info should reference the proof arg directly. - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"test"] - )] - pub cmint: UncheckedAccount<'info> - }; - let field_ident = field.ident.clone().unwrap(); - - // Simulate passing CreateAccountsProof as direct arg named "create_proof" - let proof_ident: Ident = parse_quote!(create_proof); - let direct_proof_arg = Some(proof_ident.clone()); - - let result = parse_light_account_attr(&field, &field_ident, &direct_proof_arg); - assert!( - result.is_ok(), - "Should parse successfully with direct proof arg" - ); - let result = result.unwrap(); - assert!(result.is_some(), "Should return Some for init mint"); - - match result.unwrap() { - LightAccountField::Mint(mint) => { - assert_eq!(mint.field_ident.to_string(), "cmint"); - - // Verify default address_tree_info uses the direct proof identifier - // Should be: create_proof.address_tree_info - let addr_tree_info = &mint.address_tree_info; - let addr_tree_str = quote::quote!(#addr_tree_info).to_string(); - assert!( - addr_tree_str.contains("create_proof"), - "address_tree_info should reference 'create_proof', got: {}", - addr_tree_str - ); - assert!( - addr_tree_str.contains("address_tree_info"), - "address_tree_info should access .address_tree_info field, got: {}", - addr_tree_str - ); - - // Verify default output_tree uses the direct proof identifier - // Should be: create_proof.output_state_tree_index - let output_tree = &mint.output_tree; - let output_tree_str = quote::quote!(#output_tree).to_string(); - assert!( - output_tree_str.contains("create_proof"), - "output_tree should reference 'create_proof', got: {}", - output_tree_str - ); - assert!( - output_tree_str.contains("output_state_tree_index"), - "output_tree should access .output_state_tree_index field, got: {}", - output_tree_str - ); - } - _ => panic!("Expected Mint field"), - } - } - - // ======================================================================== - // Bump Parameter Tests - // ======================================================================== - - #[test] - fn test_parse_token_with_bump_parameter() { - // Test token with explicit bump parameter - let field: syn::Field = parse_quote! { - #[light_account(init, token, - token::authority = [b"vault", self.offer.key()], - token::mint = token_mint, - token::owner = vault_authority, - token::bump = params.vault_bump - )] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!( - result.is_ok(), - "Should parse successfully with bump parameter" - ); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::TokenAccount(token) => { - assert_eq!(token.field_ident.to_string(), "vault"); - assert!(token.has_init); - assert!(!token.authority_seeds.is_empty()); - assert!(token.bump.is_some(), "bump should be Some when provided"); - } - _ => panic!("Expected TokenAccount field"), - } - } - - #[test] - fn test_parse_token_without_bump_backwards_compatible() { - // Test token without bump (backwards compatible - bump will be auto-derived) - let field: syn::Field = parse_quote! { - #[light_account(init, token, - token::authority = [b"vault", self.offer.key()], - token::mint = token_mint, - token::owner = vault_authority - )] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!( - result.is_ok(), - "Should parse successfully without bump parameter" - ); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::TokenAccount(token) => { - assert_eq!(token.field_ident.to_string(), "vault"); - assert!(token.has_init); - assert!(!token.authority_seeds.is_empty()); - assert!( - token.bump.is_none(), - "bump should be None when not provided" - ); - } - _ => panic!("Expected TokenAccount field"), - } - } - - #[test] - fn test_parse_mint_with_mint_bump() { - // Test mint with explicit mint::bump parameter - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"mint"], - mint::bump = params.mint_bump - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!( - result.is_ok(), - "Should parse successfully with mint::bump parameter" - ); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Mint(mint) => { - assert_eq!(mint.field_ident.to_string(), "cmint"); - assert!( - mint.mint_bump.is_some(), - "mint_bump should be Some when provided" - ); - } - _ => panic!("Expected Mint field"), - } - } - - #[test] - fn test_parse_mint_with_authority_bump() { - // Test mint with authority_seeds and authority_bump - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"mint"], - mint::authority_seeds = &[b"auth"], - mint::authority_bump = params.auth_bump - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!( - result.is_ok(), - "Should parse successfully with authority_bump parameter" - ); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Mint(mint) => { - assert_eq!(mint.field_ident.to_string(), "cmint"); - assert!( - mint.authority_seeds.is_some(), - "authority_seeds should be Some" - ); - assert!( - mint.authority_bump.is_some(), - "authority_bump should be Some when provided" - ); - } - _ => panic!("Expected Mint field"), - } - } - - #[test] - fn test_parse_mint_without_bumps_backwards_compatible() { - // Test mint without bump parameters (backwards compatible - bumps will be auto-derived) - let field: syn::Field = parse_quote! { - #[light_account(init, mint, - mint::signer = mint_signer, - mint::authority = authority, - mint::decimals = 9, - mint::seeds = &[b"mint"], - mint::authority_seeds = &[b"auth"] - )] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!( - result.is_ok(), - "Should parse successfully without bump parameters" - ); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::Mint(mint) => { - assert_eq!(mint.field_ident.to_string(), "cmint"); - assert!( - mint.mint_bump.is_none(), - "mint_bump should be None when not provided" - ); - assert!( - mint.authority_seeds.is_some(), - "authority_seeds should be Some" - ); - assert!( - mint.authority_bump.is_none(), - "authority_bump should be None when not provided" - ); - } - _ => panic!("Expected Mint field"), - } - } - - #[test] - fn test_parse_token_bump_shorthand_syntax() { - // Test token with bump shorthand syntax (token::bump = bump) - let field: syn::Field = parse_quote! { - #[light_account(init, token, - token::authority = [b"vault"], - token::mint = token_mint, - token::owner = vault_authority, - token::bump - )] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!( - result.is_ok(), - "Should parse successfully with bump shorthand" - ); - let result = result.unwrap(); - assert!(result.is_some()); - - match result.unwrap() { - LightAccountField::TokenAccount(token) => { - assert!( - token.bump.is_some(), - "bump should be Some with shorthand syntax" - ); - } - _ => panic!("Expected TokenAccount field"), - } - } - - // ======================================================================== - // Namespace Validation Tests - // ======================================================================== - - #[test] - fn test_parse_wrong_namespace_fails() { - // Using mint:: namespace with token account type should fail - let field: syn::Field = parse_quote! { - #[light_account(token, mint::authority = [b"auth"])] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("doesn't match account type"), - "Expected namespace mismatch error, got: {}", - err - ); - } - - #[test] - fn test_old_syntax_gives_helpful_error() { - // Old syntax without namespace should give helpful migration error - let field: syn::Field = parse_quote! { - #[light_account(init, mint, authority = some_authority)] - pub cmint: UncheckedAccount<'info> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("Missing namespace prefix") || err.contains("mint::authority"), - "Expected helpful migration error, got: {}", - err - ); - } - - // ======================================================================== - // Mark-Only Associated Token Validation Tests - // ======================================================================== - - #[test] - fn test_parse_associated_token_mark_only_missing_authority_fails() { - // Mark-only associated_token requires authority - let field: syn::Field = parse_quote! { - #[light_account(associated_token, associated_token::mint = mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("authority"), - "Expected error about missing authority, got: {}", - err - ); - } - - #[test] - fn test_parse_associated_token_mark_only_missing_mint_fails() { - // Mark-only associated_token requires mint - let field: syn::Field = parse_quote! { - #[light_account(associated_token, associated_token::authority = owner)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("mint"), - "Expected error about missing mint, got: {}", - err - ); - } - - #[test] - fn test_parse_associated_token_mark_only_with_both_params_succeeds() { - // Mark-only associated_token with both authority and mint should succeed (returns None) - let field: syn::Field = parse_quote! { - #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); // Mark-only returns None - } - - // ======================================================================== - // Mixed Namespace Prefix Tests - // ======================================================================== - - #[test] - fn test_parse_mixed_token_and_associated_token_prefix_fails() { - // Mixing token:: with associated_token type should fail - let field: syn::Field = parse_quote! { - #[light_account(associated_token, associated_token::authority = owner, token::mint = mint)] - pub user_ata: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("doesn't match account type"), - "Expected namespace mismatch error, got: {}", - err - ); - } - - #[test] - fn test_parse_mixed_associated_token_and_token_prefix_fails() { - // Mixing associated_token:: with token type should fail - let field: syn::Field = parse_quote! { - #[light_account(token, token::authority = [b"auth"], associated_token::mint = mint)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("doesn't match account type"), - "Expected namespace mismatch error, got: {}", - err - ); - } - - #[test] - fn test_parse_init_mixed_token_and_mint_prefix_fails() { - // Mixing token:: with mint:: in init mode should fail - let field: syn::Field = parse_quote! { - #[light_account(init, token, token::authority = [b"auth"], mint::decimals = 9)] - pub vault: Account<'info, CToken> - }; - let ident = field.ident.clone().unwrap(); - - let result = parse_light_account_attr(&field, &ident, &None); - assert!(result.is_err()); - let err = result.err().unwrap().to_string(); - assert!( - err.contains("doesn't match account type"), - "Expected namespace mismatch error, got: {}", - err - ); - } -} diff --git a/sdk-libs/macros/src/light_pdas/accounts/mint.rs b/sdk-libs/macros/src/light_pdas/accounts/mint.rs index 1e75421e44..3b2170bd6b 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/mint.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/mint.rs @@ -23,7 +23,7 @@ use super::parse::InfraFields; /// A field marked with #[light_account(init, mint, ...)] #[derive(Debug)] -pub(super) struct LightMintField { +pub(crate) struct LightMintField { /// The field name where #[light_account(init, mint, ...)] is attached (Mint account) pub field_ident: Ident, /// The mint_signer field (AccountInfo that seeds the mint PDA) diff --git a/sdk-libs/macros/src/light_pdas/accounts/mod.rs b/sdk-libs/macros/src/light_pdas/accounts/mod.rs index 53931ae443..1872d5fec0 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/mod.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/mod.rs @@ -13,13 +13,15 @@ //! - `derive.rs` - Orchestration layer that wires everything together mod builder; -mod derive; -mod light_account; -mod mint; -mod parse; mod pda; mod token; +// Made pub(crate) for testing in light_pdas_tests module +pub(crate) mod derive; +pub(crate) mod light_account; +pub(crate) mod mint; +pub(crate) mod parse; + use proc_macro2::TokenStream; use syn::DeriveInput; diff --git a/sdk-libs/macros/src/light_pdas/accounts/parse.rs b/sdk-libs/macros/src/light_pdas/accounts/parse.rs index 8f30e347a5..ab39065dd5 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/parse.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/parse.rs @@ -22,7 +22,7 @@ pub(super) use super::mint::LightMintField; /// Classification of infrastructure fields by naming convention. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum InfraFieldType { +pub(crate) enum InfraFieldType { FeePayer, CompressionConfig, LightTokenConfig, @@ -58,7 +58,7 @@ impl InfraFieldType { } /// Classifier for infrastructure fields by naming convention. -pub(super) struct InfraFieldClassifier; +pub(crate) struct InfraFieldClassifier; impl InfraFieldClassifier { /// Classify a field name into its infrastructure type, if any. @@ -80,7 +80,7 @@ impl InfraFieldClassifier { /// Collected infrastructure field identifiers. #[derive(Default)] -pub(super) struct InfraFields { +pub(crate) struct InfraFields { pub fee_payer: Option, pub compression_config: Option, pub light_token_config: Option, diff --git a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs index 59185b9af1..3bf240cbda 100644 --- a/sdk-libs/macros/src/light_pdas/light_account_keywords.rs +++ b/sdk-libs/macros/src/light_pdas/light_account_keywords.rs @@ -166,117 +166,3 @@ pub fn missing_namespace_error(key: &str, account_type: &str) -> String { key, account_type, key, key ) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_token_namespace_keys() { - assert!(TOKEN_NAMESPACE_KEYS.contains(&"authority")); - assert!(TOKEN_NAMESPACE_KEYS.contains(&"mint")); - assert!(TOKEN_NAMESPACE_KEYS.contains(&"owner")); - assert!(TOKEN_NAMESPACE_KEYS.contains(&"bump")); - assert!(!TOKEN_NAMESPACE_KEYS.contains(&"unknown")); - } - - #[test] - fn test_associated_token_namespace_keys() { - assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"authority")); - assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"mint")); - assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"bump")); - assert!(!ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"owner")); // renamed to authority - assert!(!ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"unknown")); - } - - #[test] - fn test_mint_namespace_keys() { - assert!(MINT_NAMESPACE_KEYS.contains(&"signer")); // renamed from mint_signer - assert!(MINT_NAMESPACE_KEYS.contains(&"authority")); - assert!(MINT_NAMESPACE_KEYS.contains(&"decimals")); - assert!(MINT_NAMESPACE_KEYS.contains(&"seeds")); // renamed from mint_seeds - assert!(MINT_NAMESPACE_KEYS.contains(&"bump")); // renamed from mint_bump - assert!(MINT_NAMESPACE_KEYS.contains(&"freeze_authority")); - assert!(MINT_NAMESPACE_KEYS.contains(&"authority_seeds")); - assert!(MINT_NAMESPACE_KEYS.contains(&"authority_bump")); - assert!(MINT_NAMESPACE_KEYS.contains(&"name")); - assert!(MINT_NAMESPACE_KEYS.contains(&"symbol")); - assert!(MINT_NAMESPACE_KEYS.contains(&"uri")); - assert!(MINT_NAMESPACE_KEYS.contains(&"update_authority")); - assert!(MINT_NAMESPACE_KEYS.contains(&"additional_metadata")); - } - - #[test] - fn test_standalone_keywords() { - assert!(is_standalone_keyword("init")); - assert!(is_standalone_keyword("token")); - assert!(is_standalone_keyword("associated_token")); - assert!(is_standalone_keyword("mint")); - assert!(!is_standalone_keyword("authority")); - } - - #[test] - fn test_shorthand_keys() { - // token namespace - assert!(is_shorthand_key("token", "mint")); - assert!(is_shorthand_key("token", "owner")); - assert!(is_shorthand_key("token", "bump")); - assert!(!is_shorthand_key("token", "authority")); // authority requires seeds array - - // associated_token namespace - assert!(is_shorthand_key("associated_token", "authority")); - assert!(is_shorthand_key("associated_token", "mint")); - assert!(is_shorthand_key("associated_token", "bump")); - - // mint namespace - no shorthand - assert!(!is_shorthand_key("mint", "signer")); - assert!(!is_shorthand_key("mint", "authority")); - } - - #[test] - fn test_valid_keys_for_namespace() { - let token_kw = valid_keys_for_namespace("token"); - assert_eq!(token_kw, TOKEN_NAMESPACE_KEYS); - - let ata_kw = valid_keys_for_namespace("associated_token"); - assert_eq!(ata_kw, ASSOCIATED_TOKEN_NAMESPACE_KEYS); - - let mint_kw = valid_keys_for_namespace("mint"); - assert_eq!(mint_kw, MINT_NAMESPACE_KEYS); - - let unknown_kw = valid_keys_for_namespace("unknown"); - assert!(unknown_kw.is_empty()); - } - - #[test] - fn test_validate_namespaced_key() { - // Valid keys - assert!(validate_namespaced_key("token", "authority").is_ok()); - assert!(validate_namespaced_key("token", "mint").is_ok()); - assert!(validate_namespaced_key("associated_token", "authority").is_ok()); - assert!(validate_namespaced_key("mint", "signer").is_ok()); - assert!(validate_namespaced_key("mint", "decimals").is_ok()); - - // Invalid keys - assert!(validate_namespaced_key("token", "invalid").is_err()); - assert!(validate_namespaced_key("unknown_namespace", "key").is_err()); - } - - #[test] - fn test_unknown_key_error() { - let error = unknown_key_error("token", "invalid"); - assert!(error.contains("invalid")); - assert!(error.contains("token")); - assert!(error.contains("authority")); - - let error = unknown_key_error("unknown", "key"); - assert!(error.contains("Unknown namespace")); - } - - #[test] - fn test_missing_namespace_error() { - let error = missing_namespace_error("authority", "token"); - assert!(error.contains("token::authority")); - assert!(error.contains("Missing namespace prefix")); - } -} diff --git a/sdk-libs/macros/src/light_pdas/program/crate_context.rs b/sdk-libs/macros/src/light_pdas/program/crate_context.rs index d01572d0dc..a4df268b65 100644 --- a/sdk-libs/macros/src/light_pdas/program/crate_context.rs +++ b/sdk-libs/macros/src/light_pdas/program/crate_context.rs @@ -226,7 +226,7 @@ fn find_module_file(parent_dir: &Path, parent_name: &str, mod_name: &str) -> Opt } /// Check if a struct has a specific derive attribute. -fn has_derive_attribute(attrs: &[syn::Attribute], derive_name: &str) -> bool { +pub(crate) fn has_derive_attribute(attrs: &[syn::Attribute], derive_name: &str) -> bool { for attr in attrs { if !attr.path().is_ident("derive") { continue; @@ -254,34 +254,3 @@ fn has_derive_attribute(attrs: &[syn::Attribute], derive_name: &str) -> bool { } false } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_has_derive_attribute() { - let code = quote::quote! { - #[derive(Accounts, LightAccounts)] - pub struct CreateUser<'info> { - pub fee_payer: Signer<'info>, - } - }; - let item: ItemStruct = syn::parse2(code).unwrap(); - assert!(has_derive_attribute(&item.attrs, "LightAccounts")); - assert!(has_derive_attribute(&item.attrs, "Accounts")); - assert!(!has_derive_attribute(&item.attrs, "Clone")); - } - - #[test] - fn test_has_derive_attribute_qualified() { - let code = quote::quote! { - #[derive(light_sdk::LightAccounts)] - pub struct CreateUser<'info> { - pub fee_payer: Signer<'info>, - } - }; - let item: ItemStruct = syn::parse2(code).unwrap(); - assert!(has_derive_attribute(&item.attrs, "LightAccounts")); - } -} diff --git a/sdk-libs/macros/src/light_pdas/program/mod.rs b/sdk-libs/macros/src/light_pdas/program/mod.rs index 2ff7064f4d..ad09e206b0 100644 --- a/sdk-libs/macros/src/light_pdas/program/mod.rs +++ b/sdk-libs/macros/src/light_pdas/program/mod.rs @@ -6,14 +6,16 @@ //! - Generates all necessary types, enums, and instruction handlers mod compress; -pub mod crate_context; mod decompress; pub mod expr_traversal; pub mod instructions; -mod parsing; pub mod seed_codegen; pub mod seed_utils; pub mod variant_enum; -pub mod visitors; + +// Made pub(crate) for testing in light_pdas_tests module +pub(crate) mod crate_context; +pub(crate) mod parsing; +pub(crate) mod visitors; pub use instructions::light_program_impl; diff --git a/sdk-libs/macros/src/light_pdas/program/parsing.rs b/sdk-libs/macros/src/light_pdas/program/parsing.rs index d6f12c8231..9d29f594d3 100644 --- a/sdk-libs/macros/src/light_pdas/program/parsing.rs +++ b/sdk-libs/macros/src/light_pdas/program/parsing.rs @@ -464,7 +464,7 @@ fn is_delegation_body(block: &syn::Block, ctx_name: &str) -> bool { /// Check if any argument in the call is the context param (moving the context). /// Detects: ctx, &ctx, &mut ctx, ctx.clone(), ctx.into(), etc. /// `ctx_name` is the context parameter name to look for (e.g., "ctx", "context"). -fn call_has_ctx_arg( +pub(crate) fn call_has_ctx_arg( args: &syn::punctuated::Punctuated, ctx_name: &str, ) -> bool { @@ -555,219 +555,3 @@ pub fn wrap_function_with_light( } } } - -#[cfg(test)] -mod tests { - use syn::punctuated::Punctuated; - - use super::*; - - fn parse_args(code: &str) -> Punctuated { - let call: syn::ExprCall = syn::parse_str(&format!("f({})", code)).unwrap(); - call.args - } - - #[test] - fn test_call_has_ctx_arg_direct() { - // F001: Direct ctx identifier - let args = parse_args("ctx"); - assert!(call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_reference() { - // F001: Reference pattern &ctx - let args = parse_args("&ctx"); - assert!(call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_mut_reference() { - // F001: Mutable reference pattern &mut ctx - let args = parse_args("&mut ctx"); - assert!(call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_clone() { - // F001: Method call ctx.clone() - let args = parse_args("ctx.clone()"); - assert!(call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_into() { - // F001: Method call ctx.into() - let args = parse_args("ctx.into()"); - assert!(call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_other_name() { - // Non-ctx identifier should return false when looking for "ctx" - let args = parse_args("context"); - assert!(!call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_method_on_other() { - // Method call on non-ctx receiver - let args = parse_args("other.clone()"); - assert!(!call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_multiple_args() { - // F001: ctx among multiple arguments - let args = parse_args("foo, ctx.clone(), bar"); - assert!(call_has_ctx_arg(&args, "ctx")); - } - - #[test] - fn test_call_has_ctx_arg_empty() { - // Empty args should return false - let args = parse_args(""); - assert!(!call_has_ctx_arg(&args, "ctx")); - } - - // Tests for dynamic context name detection - #[test] - fn test_call_has_ctx_arg_custom_name_context() { - // Direct identifier with custom name "context" - let args = parse_args("context"); - assert!(call_has_ctx_arg(&args, "context")); - } - - #[test] - fn test_call_has_ctx_arg_custom_name_anchor_ctx() { - // Direct identifier with custom name "anchor_ctx" - let args = parse_args("anchor_ctx"); - assert!(call_has_ctx_arg(&args, "anchor_ctx")); - } - - #[test] - fn test_call_has_ctx_arg_custom_name_reference() { - // Reference pattern with custom name - let args = parse_args("&my_context"); - assert!(call_has_ctx_arg(&args, "my_context")); - } - - #[test] - fn test_call_has_ctx_arg_custom_name_method_call() { - // Method call with custom name - let args = parse_args("c.clone()"); - assert!(call_has_ctx_arg(&args, "c")); - } - - #[test] - fn test_call_has_ctx_arg_wrong_custom_name() { - // Looking for wrong name should return false - let args = parse_args("ctx"); - assert!(!call_has_ctx_arg(&args, "context")); - } - - #[test] - fn test_extract_context_and_params_standard() { - let fn_item: syn::ItemFn = syn::parse_quote! { - pub fn handler(ctx: Context, params: Params) -> Result<()> { - Ok(()) - } - }; - match extract_context_and_params(&fn_item) { - ExtractResult::Success { - context_type, - params_ident, - ctx_ident, - } => { - assert_eq!(context_type, "MyAccounts"); - assert_eq!(params_ident.to_string(), "params"); - assert_eq!(ctx_ident.to_string(), "ctx"); - } - _ => panic!("Expected ExtractResult::Success"), - } - } - - #[test] - fn test_extract_context_and_params_custom_context_name() { - let fn_item: syn::ItemFn = syn::parse_quote! { - pub fn handler(context: Context, params: Params) -> Result<()> { - Ok(()) - } - }; - match extract_context_and_params(&fn_item) { - ExtractResult::Success { - context_type, - params_ident, - ctx_ident, - } => { - assert_eq!(context_type, "MyAccounts"); - assert_eq!(params_ident.to_string(), "params"); - assert_eq!(ctx_ident.to_string(), "context"); - } - _ => panic!("Expected ExtractResult::Success"), - } - } - - #[test] - fn test_extract_context_and_params_anchor_ctx_name() { - let fn_item: syn::ItemFn = syn::parse_quote! { - pub fn handler(anchor_ctx: Context, data: Data) -> Result<()> { - Ok(()) - } - }; - match extract_context_and_params(&fn_item) { - ExtractResult::Success { - context_type, - params_ident, - ctx_ident, - } => { - assert_eq!(context_type, "MyAccounts"); - assert_eq!(params_ident.to_string(), "data"); - assert_eq!(ctx_ident.to_string(), "anchor_ctx"); - } - _ => panic!("Expected ExtractResult::Success"), - } - } - - #[test] - fn test_extract_context_and_params_single_letter_name() { - let fn_item: syn::ItemFn = syn::parse_quote! { - pub fn handler(c: Context, p: Params) -> Result<()> { - Ok(()) - } - }; - match extract_context_and_params(&fn_item) { - ExtractResult::Success { - context_type, - params_ident, - ctx_ident, - } => { - assert_eq!(context_type, "MyAccounts"); - assert_eq!(params_ident.to_string(), "p"); - assert_eq!(ctx_ident.to_string(), "c"); - } - _ => panic!("Expected ExtractResult::Success"), - } - } - - #[test] - fn test_extract_context_and_params_multiple_args_detected() { - // Format-2 case: multiple instruction arguments should be detected - let fn_item: syn::ItemFn = syn::parse_quote! { - pub fn handler(ctx: Context, amount: u64, owner: Pubkey) -> Result<()> { - Ok(()) - } - }; - match extract_context_and_params(&fn_item) { - ExtractResult::MultipleParams { - context_type, - param_names, - } => { - assert_eq!(context_type, "MyAccounts"); - assert!(param_names.contains(&"amount".to_string())); - assert!(param_names.contains(&"owner".to_string())); - } - _ => panic!("Expected ExtractResult::MultipleParams"), - } - } -} diff --git a/sdk-libs/macros/src/light_pdas/program/visitors.rs b/sdk-libs/macros/src/light_pdas/program/visitors.rs index 192e77c04a..f2dcabfad3 100644 --- a/sdk-libs/macros/src/light_pdas/program/visitors.rs +++ b/sdk-libs/macros/src/light_pdas/program/visitors.rs @@ -537,88 +537,3 @@ pub fn generate_client_seed_code( } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_ctx_accounts_field() { - let expr: Expr = syn::parse_quote!(ctx.accounts.user); - let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "user"); - } - - #[test] - fn test_extract_ctx_direct_field() { - let expr: Expr = syn::parse_quote!(ctx.program_id); - let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "program_id"); - } - - #[test] - fn test_extract_data_field() { - let expr: Expr = syn::parse_quote!(data.owner); - let fields = FieldExtractor::data_fields().extract(&expr); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "owner"); - } - - #[test] - fn test_extract_nested_in_method_call() { - let expr: Expr = syn::parse_quote!(ctx.accounts.user.key()); - let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "user"); - } - - #[test] - fn test_extract_nested_in_reference() { - let expr: Expr = syn::parse_quote!(&ctx.accounts.user.key()); - let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "user"); - } - - #[test] - fn test_excludes_fields() { - let expr: Expr = syn::parse_quote!(ctx.accounts.fee_payer); - let fields = FieldExtractor::ctx_fields(&["fee_payer"]).extract(&expr); - assert!(fields.is_empty()); - } - - #[test] - fn test_deduplicates_fields() { - let expr: Expr = syn::parse_quote!({ - ctx.accounts.user.key(); - ctx.accounts.user.owner(); - }); - let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].to_string(), "user"); - } - - #[test] - fn test_extract_from_call_args() { - let expr: Expr = syn::parse_quote!(some_fn(&ctx.accounts.user, data.amount)); - let ctx_fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - let data_fields = FieldExtractor::data_fields().extract(&expr); - assert_eq!(ctx_fields.len(), 1); - assert_eq!(ctx_fields[0].to_string(), "user"); - assert_eq!(data_fields.len(), 1); - assert_eq!(data_fields[0].to_string(), "amount"); - } - - #[test] - fn test_separate_extractors_for_ctx_and_data() { - let expr: Expr = syn::parse_quote!((ctx.accounts.user, data.amount)); - let ctx_fields = FieldExtractor::ctx_fields(&[]).extract(&expr); - let data_fields = FieldExtractor::data_fields().extract(&expr); - assert_eq!(ctx_fields.len(), 1); - assert_eq!(ctx_fields[0].to_string(), "user"); - assert_eq!(data_fields.len(), 1); - assert_eq!(data_fields[0].to_string(), "amount"); - } -} diff --git a/sdk-libs/macros/src/light_pdas/shared_utils.rs b/sdk-libs/macros/src/light_pdas/shared_utils.rs index a3e079b522..6d7a57ba3f 100644 --- a/sdk-libs/macros/src/light_pdas/shared_utils.rs +++ b/sdk-libs/macros/src/light_pdas/shared_utils.rs @@ -103,22 +103,29 @@ impl From for Expr { /// Check if an identifier string is a constant (SCREAMING_SNAKE_CASE). /// -/// Returns true if the string is non-empty and all characters are uppercase letters, -/// underscores, or ASCII digits. +/// Returns true if the string: +/// - Is non-empty +/// - Starts with an uppercase letter +/// - All subsequent characters are uppercase letters, underscores, or ASCII digits /// /// # Examples /// ```ignore /// assert!(is_constant_identifier("MY_CONSTANT")); /// assert!(is_constant_identifier("SEED_123")); /// assert!(!is_constant_identifier("myVariable")); +/// assert!(!is_constant_identifier("123_INVALID")); // must start with letter /// assert!(!is_constant_identifier("")); /// ``` #[inline] pub fn is_constant_identifier(ident: &str) -> bool { - !ident.is_empty() - && ident - .chars() - .all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) + let mut chars = ident.chars(); + // Must start with uppercase letter + match chars.next() { + Some(first) if first.is_ascii_uppercase() => {} + _ => return false, + } + // Rest must be uppercase, underscore, or digit + chars.all(|c| c.is_uppercase() || c == '_' || c.is_ascii_digit()) } /// Extract the terminal identifier from an expression. @@ -161,20 +168,3 @@ pub fn extract_terminal_ident(expr: &Expr, key_method_only: bool) -> Option bool { matches!(expr, Expr::Path(p) if p.path.segments.first().is_some_and(|s| s.ident == base)) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_constant_identifier() { - assert!(is_constant_identifier("MY_CONSTANT")); - assert!(is_constant_identifier("SEED")); - assert!(is_constant_identifier("SEED_123")); - assert!(is_constant_identifier("A")); - assert!(!is_constant_identifier("myVariable")); - assert!(!is_constant_identifier("my_variable")); - assert!(!is_constant_identifier("MyConstant")); - assert!(!is_constant_identifier("")); - } -} diff --git a/sdk-libs/macros/src/light_pdas_tests/crate_context_tests.rs b/sdk-libs/macros/src/light_pdas_tests/crate_context_tests.rs new file mode 100644 index 0000000000..9e1f6b8503 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/crate_context_tests.rs @@ -0,0 +1,33 @@ +//! Unit tests for crate context parsing utilities. +//! +//! Extracted from `light_pdas/program/crate_context.rs`. + +use syn::ItemStruct; + +use crate::light_pdas::program::crate_context::has_derive_attribute; + +#[test] +fn test_has_derive_attribute() { + let code = quote::quote! { + #[derive(Accounts, LightAccounts)] + pub struct CreateUser<'info> { + pub fee_payer: Signer<'info>, + } + }; + let item: ItemStruct = syn::parse2(code).unwrap(); + assert!(has_derive_attribute(&item.attrs, "LightAccounts")); + assert!(has_derive_attribute(&item.attrs, "Accounts")); + assert!(!has_derive_attribute(&item.attrs, "Clone")); +} + +#[test] +fn test_has_derive_attribute_qualified() { + let code = quote::quote! { + #[derive(light_sdk::LightAccounts)] + pub struct CreateUser<'info> { + pub fee_payer: Signer<'info>, + } + }; + let item: ItemStruct = syn::parse2(code).unwrap(); + assert!(has_derive_attribute(&item.attrs, "LightAccounts")); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/derive_tests.rs b/sdk-libs/macros/src/light_pdas_tests/derive_tests.rs new file mode 100644 index 0000000000..d525924f8d --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/derive_tests.rs @@ -0,0 +1,196 @@ +//! Unit tests for LightAccounts derive macro. +//! +//! Extracted from `light_pdas/accounts/derive.rs`. + +use syn::{parse_quote, DeriveInput}; + +use crate::light_pdas::accounts::derive::derive_light_accounts; + +#[test] +fn test_token_account_with_init_generates_create_cpi() { + // Token account with init should generate CreateTokenAccountCpi in pre_init + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateVaultParams)] + pub struct CreateVault<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] + pub vault: Account<'info, CToken>, + + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + pub light_token_cpi_authority: AccountInfo<'info>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "Token account derive should succeed"); + + let output = result.unwrap().to_string(); + + // Verify pre_init generates token account creation + assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + assert!( + output.contains("CreateTokenAccountCpi"), + "Should generate CreateTokenAccountCpi call" + ); + assert!( + output.contains("rent_free"), + "Should call rent_free on CreateTokenAccountCpi" + ); + assert!( + output.contains("invoke_signed"), + "Should call invoke_signed with seeds" + ); +} + +#[test] +fn test_ata_with_init_generates_create_cpi() { + // ATA with init should generate CreateTokenAtaCpi in pre_init + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateAtaParams)] + pub struct CreateAta<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, associated_token::authority = wallet, associated_token::mint = my_mint)] + pub user_ata: Account<'info, CToken>, + + pub wallet: AccountInfo<'info>, + pub my_mint: AccountInfo<'info>, + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "ATA derive should succeed"); + + let output = result.unwrap().to_string(); + + // Verify pre_init generates ATA creation + assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + assert!( + output.contains("CreateTokenAtaCpi"), + "Should generate CreateTokenAtaCpi call" + ); +} + +#[test] +fn test_token_mark_only_generates_no_creation() { + // Token without init should NOT generate creation code (mark-only mode) + // Mark-only returns None from parsing, so token_account_fields is empty + let input: DeriveInput = parse_quote! { + #[instruction(params: UseVaultParams)] + pub struct UseVault<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + // Mark-only: no init keyword, type inferred from namespace + #[light_account(token::authority = [b"authority"])] + pub vault: Account<'info, CToken>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "Mark-only token derive should succeed"); + + let output = result.unwrap().to_string(); + + // Mark-only should NOT have token account creation + assert!( + !output.contains("CreateTokenAccountCpi"), + "Mark-only should NOT generate CreateTokenAccountCpi" + ); + + // Should still generate both trait impls + assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + assert!( + output.contains("LightFinalize"), + "Should generate LightFinalize impl (no-op)" + ); +} + +#[test] +fn test_mixed_token_and_ata_generates_both() { + // Mixed token account + ATA should generate both creation codes in pre_init + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateBothParams)] + pub struct CreateBoth<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] + pub vault: Account<'info, CToken>, + + #[light_account(init, associated_token::authority = wallet, associated_token::mint = my_mint)] + pub user_ata: Account<'info, CToken>, + + pub wallet: AccountInfo<'info>, + pub my_mint: AccountInfo<'info>, + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + pub light_token_cpi_authority: AccountInfo<'info>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "Mixed token+ATA derive should succeed"); + + let output = result.unwrap().to_string(); + + // Should have both creation types in pre_init + assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + assert!( + output.contains("CreateTokenAccountCpi"), + "Should generate CreateTokenAccountCpi for vault" + ); + assert!( + output.contains("CreateTokenAtaCpi"), + "Should generate CreateTokenAtaCpi for ATA" + ); +} + +#[test] +fn test_no_instruction_args_generates_noop() { + // No #[instruction] attribute should generate no-op impls + let input: DeriveInput = parse_quote! { + pub struct NoInstruction<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + } + }; + + let result = derive_light_accounts(&input); + assert!(result.is_ok(), "No instruction args should succeed"); + + let output = result.unwrap().to_string(); + + // Should generate no-op impls with () param type + assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + assert!( + output.contains("LightFinalize"), + "Should generate LightFinalize impl" + ); + // No-op returns Ok(false) in pre_init and Ok(()) in finalize + assert!( + output.contains("Ok (false)") || output.contains("Ok(false)"), + "Should return Ok(false) in pre_init" + ); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/e2e_prop_tests.rs b/sdk-libs/macros/src/light_pdas_tests/e2e_prop_tests.rs new file mode 100644 index 0000000000..38aa92d457 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/e2e_prop_tests.rs @@ -0,0 +1,379 @@ +//! End-to-end property-based tests for derive_light_accounts macro. +//! +//! These tests verify correctness properties of the full macro pipeline: +//! - Never panics on syntactically valid input +//! - Output contains expected trait implementations +//! - Deterministic code generation + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use syn::{parse_quote, DeriveInput}; + + // Access derive module from parent (accounts module) + use crate::light_pdas::accounts::derive::derive_light_accounts; + + // ======================================================================== + // Constants + // ======================================================================== + + /// Rust keywords that are capitalized and could match PascalCase patterns. + /// These should be excluded from struct/type name generation. + const RUST_TYPE_KEYWORDS: &[&str] = &["Self"]; + + // ======================================================================== + // Strategies for generating test inputs + // ======================================================================== + + /// Strategy for generating struct names (PascalCase) + /// Excludes Rust keywords like "Self" that would fail parsing. + fn arb_struct_name() -> impl Strategy { + "[A-Z][a-z]{2,10}".prop_filter("not a Rust keyword", |s| { + !RUST_TYPE_KEYWORDS.contains(&s.as_str()) + }) + } + + /// Strategy for generating field names + fn arb_field_name() -> impl Strategy { + "[a-z][a-z0-9_]{0,10}" + } + + /// Strategy for generating param type names (PascalCase) + /// Excludes Rust keywords like "Self" that would fail parsing. + fn arb_type_name() -> impl Strategy { + "[A-Z][a-z]{2,10}Params".prop_filter("not a Rust keyword", |s| !s.starts_with("Self")) + } + + // ======================================================================== + // Property Tests: Basic Macro Behavior + // ======================================================================== + + proptest! { + /// Empty struct without instruction should not panic and generate noop impls. + #[test] + fn prop_empty_struct_no_panic(struct_name in arb_struct_name()) { + let input: DeriveInput = syn::parse_str(&format!( + "pub struct {}<'info> {{ pub fee_payer: Signer<'info> }}", + struct_name + )).unwrap(); + + let result = derive_light_accounts(&input); + prop_assert!( + result.is_ok(), + "Empty struct '{}' should not cause macro to panic", + struct_name + ); + } + + /// Struct with instruction attribute should generate non-noop impls. + #[test] + fn prop_with_instruction_generates_impls( + struct_name in arb_struct_name(), + param_type in arb_type_name() + ) { + let input: DeriveInput = syn::parse_str(&format!( + r#"#[instruction(params: {})] + pub struct {}<'info> {{ + pub fee_payer: Signer<'info> + }}"#, + param_type, struct_name + )).unwrap(); + + let result = derive_light_accounts(&input); + prop_assert!( + result.is_ok(), + "Struct '{}' with instruction should generate impls", + struct_name + ); + + let output = result.unwrap().to_string(); + prop_assert!( + output.contains("LightPreInit"), + "Output should contain LightPreInit trait impl" + ); + prop_assert!( + output.contains("LightFinalize"), + "Output should contain LightFinalize trait impl" + ); + } + + /// derive_light_accounts should be deterministic. + #[test] + fn prop_deterministic(struct_name in arb_struct_name()) { + let input: DeriveInput = syn::parse_str(&format!( + "pub struct {}<'info> {{ pub fee_payer: Signer<'info> }}", + struct_name + )).unwrap(); + + let result1 = derive_light_accounts(&input); + let result2 = derive_light_accounts(&input); + + prop_assert_eq!( + result1.is_ok(), + result2.is_ok(), + "Macro should consistently succeed or fail" + ); + + if let (Ok(output1), Ok(output2)) = (result1, result2) { + prop_assert_eq!( + output1.to_string(), + output2.to_string(), + "Macro output should be deterministic" + ); + } + } + + /// Without instruction attribute, should generate noop impls. + #[test] + fn prop_without_instruction_noop(struct_name in arb_struct_name()) { + let input: DeriveInput = syn::parse_str(&format!( + "pub struct {}<'info> {{ pub fee_payer: Signer<'info> }}", + struct_name + )).unwrap(); + + let result = derive_light_accounts(&input); + if let Ok(output) = result { + let output_str = output.to_string(); + // Noop impls have Ok(false) for pre_init + prop_assert!( + output_str.contains("Ok (false)") || output_str.contains("Ok(false)"), + "Without instruction, pre_init should return Ok(false)" + ); + } + } + } + + // ======================================================================== + // Property Tests: Light Account Field Parsing + // ======================================================================== + + proptest! { + /// Struct with light_account(init) field should generate PDA code. + /// Uses parse_quote for more reliable struct generation. + #[test] + fn prop_light_account_init_generates_code( + struct_name in arb_struct_name(), + _field_name in arb_field_name(), + _param_type in arb_type_name() + ) { + // Use parse_quote with fixed structure - property test varies struct name only + // to avoid complex string formatting issues. + // Includes required infrastructure fields: fee_payer, compression_config + let struct_ident = syn::Ident::new(&struct_name, proc_macro2::Span::call_site()); + let input: DeriveInput = parse_quote! { + #[instruction(params: TestParams)] + pub struct #struct_ident<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + #[account( + init, + payer = fee_payer, + space = 8 + 100, + seeds = [b"test"], + bump + )] + #[light_account(init)] + pub user_record: Account<'info, TestRecord>, + // Required infrastructure field for PDA fields + pub compression_config: Account<'info, CompressionConfig> + } + }; + + let result = derive_light_accounts(&input); + prop_assert!( + result.is_ok(), + "Struct with light_account(init) should parse successfully: {:?}", + result.err() + ); + + let output = result.unwrap().to_string(); + // Should generate pre_init code for PDA + prop_assert!( + output.contains("LightPreInit"), + "Should generate LightPreInit impl" + ); + } + + /// Token account with init should generate CreateTokenAccountCpi. + #[test] + fn prop_token_account_generates_cpi( + _struct_name in arb_struct_name(), + _param_type in arb_type_name() + ) { + // Use parse_quote which is more reliable for complex structs + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateParams)] + pub struct TestStruct<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, token::authority = [b"authority"], token::mint = my_mint, token::owner = fee_payer)] + pub vault: Account<'info, CToken>, + + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + pub light_token_cpi_authority: AccountInfo<'info>, + } + }; + + let result = derive_light_accounts(&input); + prop_assert!( + result.is_ok(), + "Token account struct should parse successfully" + ); + + let output = result.unwrap().to_string(); + prop_assert!( + output.contains("CreateTokenAccountCpi"), + "Token account with init should generate CreateTokenAccountCpi" + ); + } + + /// ATA with init should generate CreateTokenAtaCpi. + #[test] + fn prop_ata_generates_cpi( + _struct_name in arb_struct_name(), + _param_type in arb_type_name() + ) { + let input: DeriveInput = parse_quote! { + #[instruction(params: CreateParams)] + pub struct TestAta<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[light_account(init, associated_token::authority = wallet, associated_token::mint = my_mint)] + pub user_ata: Account<'info, CToken>, + + pub wallet: AccountInfo<'info>, + pub my_mint: AccountInfo<'info>, + pub light_token_compressible_config: Account<'info, CompressibleConfig>, + pub light_token_rent_sponsor: Account<'info, RentSponsor>, + } + }; + + let result = derive_light_accounts(&input); + prop_assert!( + result.is_ok(), + "ATA struct should parse successfully" + ); + + let output = result.unwrap().to_string(); + prop_assert!( + output.contains("CreateTokenAtaCpi"), + "ATA with init should generate CreateTokenAtaCpi" + ); + } + } + + // ======================================================================== + // Property Tests: Error Handling + // ======================================================================== + + proptest! { + /// light_account without instruction attribute should fail. + #[test] + fn prop_light_account_requires_instruction( + struct_name in arb_struct_name(), + field_name in arb_field_name() + ) { + let input_str = format!( + r#"pub struct {}<'info> {{ + #[account(mut)] + pub fee_payer: Signer<'info>, + #[account( + init, + payer = fee_payer, + space = 8 + 100, + seeds = [b"test"], + bump + )] + #[light_account(init)] + pub {}: Account<'info, TestRecord> + }}"#, + struct_name, field_name + ); + + if let Ok(input) = syn::parse_str::(&input_str) { + let result = derive_light_accounts(&input); + // Should fail because light_account fields require instruction attribute + prop_assert!( + result.is_err(), + "light_account without instruction should fail" + ); + } + } + + /// Invalid struct (not a struct) should fail gracefully. + #[test] + fn prop_non_struct_fails_gracefully(_seed in 0u32..1000) { + // Try to derive on an enum (should fail) + let input: DeriveInput = parse_quote! { + pub enum NotAStruct { + VariantA, + VariantB, + } + }; + + let result = derive_light_accounts(&input); + prop_assert!( + result.is_err(), + "Enum should fail gracefully" + ); + } + } + + // ======================================================================== + // Property Tests: Output Structure + // ======================================================================== + + proptest! { + /// Output should always contain both trait implementations. + #[test] + fn prop_always_produces_both_traits(struct_name in arb_struct_name()) { + let input: DeriveInput = syn::parse_str(&format!( + "pub struct {}<'info> {{ pub fee_payer: Signer<'info> }}", + struct_name + )).unwrap(); + + let result = derive_light_accounts(&input); + if let Ok(output) = result { + let output_str = output.to_string(); + prop_assert!( + output_str.contains("LightPreInit"), + "Output should always contain LightPreInit" + ); + prop_assert!( + output_str.contains("LightFinalize"), + "Output should always contain LightFinalize" + ); + } + } + + /// Generated code should compile as valid Rust tokens. + #[test] + fn prop_output_is_valid_tokens(struct_name in arb_struct_name()) { + let input: DeriveInput = syn::parse_str(&format!( + "pub struct {}<'info> {{ pub fee_payer: Signer<'info> }}", + struct_name + )).unwrap(); + + let result = derive_light_accounts(&input); + if let Ok(output) = result { + // The output should be parseable as valid token stream + // (it already is a TokenStream, so this is a sanity check) + let output_str = output.to_string(); + prop_assert!( + !output_str.is_empty(), + "Output should not be empty" + ); + // Check it's balanced braces (basic syntax check) + let open_braces = output_str.matches('{').count(); + let close_braces = output_str.matches('}').count(); + prop_assert_eq!( + open_braces, close_braces, + "Braces should be balanced in output" + ); + } + } + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/fuzz_tests.rs b/sdk-libs/macros/src/light_pdas_tests/fuzz_tests.rs new file mode 100644 index 0000000000..d28b9f70e5 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/fuzz_tests.rs @@ -0,0 +1,260 @@ +//! Randomized fuzz-style tests for seed parsing. +//! +//! These tests run as part of `cargo test -p light-sdk-macros` and exercise +//! the actual parsing functions with random inputs. + +#[cfg(test)] +mod tests { + use rand::{rngs::StdRng, Rng, SeedableRng}; + use syn::parse_str; + + use crate::light_pdas::account::seed_extraction::{ + classify_seed_expr, extract_anchor_seeds, InstructionArgSet, + }; + + /// Generate a random seed expression string + fn generate_random_seed_expr(rng: &mut StdRng) -> String { + match rng.gen_range(0..=20) { + // Byte string literals + 0 => "b\"seed\"".to_string(), + 1 => "b\"user\"".to_string(), + 2 => format!("b\"{}\"", random_string(rng, 1, 20)), + + // Byte string with slice + 3 => "b\"seed\"[..]".to_string(), + 4 => format!("b\"{}\"[..]", random_string(rng, 1, 10)), + + // Constants (uppercase) + 5 => "SEED_CONSTANT".to_string(), + 6 => "VAULT_PREFIX".to_string(), + 7 => "crate::SEED_PREFIX".to_string(), + 8 => "module::nested::CONSTANT".to_string(), + + // Account key access + 9 => "fee_payer.key().as_ref()".to_string(), + 10 => "authority.key().as_ref()".to_string(), + 11 => format!("field_{}.key().as_ref()", rng.gen_range(0..10)), + + // Instruction arg field access + 12 => "params.owner.as_ref()".to_string(), + 13 => "data.owner.as_ref()".to_string(), + 14 => "args.value.as_ref()".to_string(), + + // Nested field access + 15 => "params.nested.field.as_ref()".to_string(), + 16 => "data.inner.key.as_ref()".to_string(), + 17 => "params.deep.nested.value.as_ref()".to_string(), + + // to_le_bytes conversion + 18 => "params.amount.to_le_bytes().as_ref()".to_string(), + 19 => "amount.to_le_bytes().as_ref()".to_string(), + + // Array indexing + _ => format!("params.arrays[{}]", rng.gen_range(0..10)), + } + } + + /// Generate random instruction args + fn generate_random_instruction_args(rng: &mut StdRng) -> InstructionArgSet { + let possible_args = ["params", "data", "args", "input", "owner", "amount", "bump"]; + let count = rng.gen_range(0..=3); + let names: Vec = (0..count) + .map(|_| possible_args[rng.gen_range(0..possible_args.len())].to_string()) + .collect(); + InstructionArgSet::from_names(names) + } + + /// Generate random string + fn random_string(rng: &mut StdRng, min_len: usize, max_len: usize) -> String { + let len = rng.gen_range(min_len..=max_len); + let chars: Vec = "abcdefghijklmnopqrstuvwxyz_0123456789".chars().collect(); + (0..len) + .map(|_| chars[rng.gen_range(0..chars.len())]) + .collect() + } + + /// Fuzz test for classify_seed_expr - runs many random inputs + #[test] + fn fuzz_classify_seed_expr() { + let mut rng = StdRng::seed_from_u64(0xDEADBEEF); + + for iteration in 0..10_000 { + let expr_str = generate_random_seed_expr(&mut rng); + let args = generate_random_instruction_args(&mut rng); + + // Try to parse as expression + if let Ok(expr) = parse_str::(&expr_str) { + // This should not panic - errors are fine, panics are not + let result = classify_seed_expr(&expr, &args); + + // Verify result is consistent + if result.is_ok() { + let result2 = classify_seed_expr(&expr, &args); + assert!( + result2.is_ok(), + "classify_seed_expr not deterministic at iteration {}", + iteration + ); + } + } + } + } + + /// Fuzz test with malformed/edge-case expressions + #[test] + fn fuzz_classify_seed_expr_edge_cases() { + let mut rng = StdRng::seed_from_u64(0xCAFEBABE); + + let edge_cases = vec![ + // Empty and minimal + "", + "a", + "_", + // Deeply nested + "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p", + "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.as_ref()", + // Method chains + "x.key().as_ref().as_bytes()", + "x.to_le_bytes().to_be_bytes()", + // References + "&x", + "&&x", + "&x.key()", + "¶ms.owner.as_ref()", + // Indexing edge cases + "arr[0]", + "arr[999999]", + "params.arr[0][1][2]", + "b\"seed\"[0..2]", + "b\"seed\"[..]", + "b\"seed\"[1..]", + "b\"seed\"[..1]", + // Function calls + "max_key(&a.key(), &b.key())", + "some_fn()", + "some_fn(a, b, c, d, e)", + // Mixed case identifiers (constant detection) + "CONSTANT", + "constant", + "Constant", + "CONSTANT_WITH_UNDERSCORE", + "NOT_A_constant", + "_UNDERSCORE_START", + // Unicode (should fail gracefully) + // Numeric literals (unsupported) + "123", + "0x1234", + // Tuples (unsupported) + "(a, b)", + // Closures (unsupported) + "|x| x", + // Blocks (unsupported) + "{ x }", + ]; + + for expr_str in &edge_cases { + if let Ok(expr) = parse_str::(expr_str) { + let args = generate_random_instruction_args(&mut rng); + // Should not panic + let _ = classify_seed_expr(&expr, &args); + } + } + } + + /// Fuzz test for extract_anchor_seeds with random attributes + #[test] + fn fuzz_extract_anchor_seeds() { + let mut rng = StdRng::seed_from_u64(0xBEEFCAFE); + + for _ in 0..10_000 { + let seeds: Vec = (0..rng.gen_range(1..=5)) + .map(|_| generate_random_seed_expr(&mut rng)) + .collect(); + let seeds_str = seeds.join(", "); + + // Create a struct with the attribute to parse + let struct_str = format!( + "struct Test {{ #[account(seeds = [{}], bump)] field: u8 }}", + seeds_str + ); + + // Parse the struct and extract the attribute + if let Ok(item) = syn::parse_str::(&struct_str) { + if let Some(field) = item.fields.iter().next() { + let args = generate_random_instruction_args(&mut rng); + // Should not panic + let _ = extract_anchor_seeds(&field.attrs, &args); + } + } + } + } + + /// Fuzz test with truly random byte strings - chaos monkey style + #[test] + fn fuzz_classify_seed_expr_random_bytes() { + let mut rng = StdRng::seed_from_u64(0xCA0505); + + for _ in 0..10_000 { + // Generate random length (1-100 bytes) + let len = rng.gen_range(1..=100); + + // Generate completely random bytes + let random_bytes: Vec = (0..len).map(|_| rng.gen::()).collect(); + + // Try to interpret as UTF-8 string + if let Ok(random_str) = String::from_utf8(random_bytes.clone()) { + // Try to parse as expression + if let Ok(expr) = parse_str::(&random_str) { + let args = generate_random_instruction_args(&mut rng); + // Should not panic - errors are fine + let _ = classify_seed_expr(&expr, &args); + } + } + + // Also try with printable ASCII subset for higher parse success rate + let printable_bytes: Vec = (0..len) + .map(|_| { + // ASCII printable range: 32-126, plus some Rust-relevant chars + rng.gen_range(32..=126) as u8 + }) + .collect(); + + if let Ok(printable_str) = String::from_utf8(printable_bytes) { + if let Ok(expr) = parse_str::(&printable_str) { + let args = generate_random_instruction_args(&mut rng); + let _ = classify_seed_expr(&expr, &args); + } + } + } + } + + /// Property test: valid expressions should produce consistent results + #[test] + fn property_classify_seed_expr_deterministic() { + let valid_exprs = [ + "b\"seed\"", + "CONSTANT", + "params.owner.as_ref()", + "authority.key().as_ref()", + "params.amount.to_le_bytes().as_ref()", + "b\"test\"[..]", + ]; + + let args = InstructionArgSet::from_names(vec!["params".to_string()]); + + for expr_str in &valid_exprs { + let expr: syn::Expr = syn::parse_str(expr_str).unwrap(); + + let result1 = classify_seed_expr(&expr, &args); + let result2 = classify_seed_expr(&expr, &args); + + // Both should succeed or both should fail + assert_eq!( + result1.is_ok(), + result2.is_ok(), + "Non-deterministic for: {}", + expr_str + ); + } + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/keywords_prop_tests.rs b/sdk-libs/macros/src/light_pdas_tests/keywords_prop_tests.rs new file mode 100644 index 0000000000..1c12f19be7 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/keywords_prop_tests.rs @@ -0,0 +1,351 @@ +//! Property-based tests for light_account_keywords functions. +//! +//! These tests verify correctness properties of: +//! - `is_standalone_keyword` - Standalone flag detection +//! - `is_shorthand_key` - Shorthand syntax eligibility +//! - `valid_keys_for_namespace` - Namespace key lookup +//! - `validate_namespaced_key` - Key validation with error messages + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use crate::light_pdas::light_account_keywords::{ + is_shorthand_key, is_standalone_keyword, valid_keys_for_namespace, validate_namespaced_key, + ASSOCIATED_TOKEN_NAMESPACE_KEYS, MINT_NAMESPACE_KEYS, SHORTHAND_KEYS_BY_NAMESPACE, + STANDALONE_KEYWORDS, TOKEN_NAMESPACE_KEYS, + }; + + // ======================================================================== + // Strategies for generating test inputs + // ======================================================================== + + /// Strategy for generating known standalone keywords + fn arb_standalone_keyword() -> impl Strategy { + prop::sample::select(STANDALONE_KEYWORDS) + } + + /// Strategy for generating known token namespace keys + fn arb_token_key() -> impl Strategy { + prop::sample::select(TOKEN_NAMESPACE_KEYS) + } + + /// Strategy for generating known ATA namespace keys + fn arb_ata_key() -> impl Strategy { + prop::sample::select(ASSOCIATED_TOKEN_NAMESPACE_KEYS) + } + + /// Strategy for generating known mint namespace keys + fn arb_mint_key() -> impl Strategy { + prop::sample::select(MINT_NAMESPACE_KEYS) + } + + /// Strategy for generating random lowercase strings (likely invalid keys) + fn arb_random_key() -> impl Strategy { + "[a-z_]{1,20}" + } + + /// Strategy for generating known valid namespaces + fn arb_valid_namespace() -> impl Strategy { + prop::sample::select(vec!["token", "associated_token", "mint"]) + } + + // ======================================================================== + // Property Tests: is_standalone_keyword + // ======================================================================== + + proptest! { + /// Known standalone keywords should be accepted. + #[test] + fn prop_known_keywords_accepted(keyword in arb_standalone_keyword()) { + let result = is_standalone_keyword(keyword); + prop_assert!( + result, + "Known standalone keyword '{}' should be accepted", + keyword + ); + } + + /// Random strings should (almost certainly) be rejected. + #[test] + fn prop_random_keywords_rejected(keyword in arb_random_key()) { + // Skip if randomly generated one of the known keywords + prop_assume!(!STANDALONE_KEYWORDS.contains(&keyword.as_str())); + + let result = is_standalone_keyword(&keyword); + prop_assert!( + !result, + "Random string '{}' should be rejected as standalone keyword", + keyword + ); + } + + /// is_standalone_keyword should be deterministic. + #[test] + fn prop_standalone_deterministic(keyword in arb_random_key()) { + let result1 = is_standalone_keyword(&keyword); + let result2 = is_standalone_keyword(&keyword); + prop_assert_eq!( + result1, result2, + "is_standalone_keyword should be deterministic for '{}'", + keyword + ); + } + + /// Keywords should be case-sensitive (uppercase should be rejected). + #[test] + fn prop_case_sensitive_uppercase(keyword in arb_standalone_keyword()) { + let uppercase = keyword.to_uppercase(); + // Skip if uppercase happens to match (shouldn't for our keywords) + prop_assume!(keyword != uppercase); + + let result = is_standalone_keyword(&uppercase); + prop_assert!( + !result, + "Uppercase '{}' should be rejected (case sensitive)", + uppercase + ); + } + } + + // ======================================================================== + // Property Tests: is_shorthand_key + // ======================================================================== + + proptest! { + /// Token namespace shorthand keys should be correctly identified. + #[test] + fn prop_token_shorthand_correct(key in arb_token_key()) { + let result = is_shorthand_key("token", key); + // Only mint, owner, bump are shorthand in token namespace + let expected = ["mint", "owner", "bump"].contains(&key); + prop_assert_eq!( + result, expected, + "token::{} shorthand should be {}", + key, expected + ); + } + + /// ATA namespace shorthand keys should be correctly identified. + #[test] + fn prop_ata_shorthand_correct(key in arb_ata_key()) { + let result = is_shorthand_key("associated_token", key); + // All ATA keys support shorthand: authority, mint, bump + let expected = ["authority", "mint", "bump"].contains(&key); + prop_assert_eq!( + result, expected, + "associated_token::{} shorthand should be {}", + key, expected + ); + } + + /// Mint namespace should have no shorthand keys. + #[test] + fn prop_mint_no_shorthand(key in arb_mint_key()) { + let result = is_shorthand_key("mint", key); + prop_assert!( + !result, + "mint::{} should NOT support shorthand", + key + ); + } + + /// Unknown namespace should return false for any key. + #[test] + fn prop_unknown_namespace_false(key in arb_random_key()) { + let result = is_shorthand_key("unknown_namespace", &key); + prop_assert!( + !result, + "Unknown namespace should return false for any key '{}'", + key + ); + } + + /// is_shorthand_key should be deterministic. + #[test] + fn prop_shorthand_deterministic( + namespace in arb_valid_namespace(), + key in arb_random_key() + ) { + let result1 = is_shorthand_key(namespace, &key); + let result2 = is_shorthand_key(namespace, &key); + prop_assert_eq!( + result1, result2, + "is_shorthand_key should be deterministic for {}::{}", + namespace, key + ); + } + + /// All shorthand keys defined should be recognized. + #[test] + fn prop_all_defined_shorthand_recognized(_seed in 0u32..1000) { + for (namespace, keys) in SHORTHAND_KEYS_BY_NAMESPACE { + for key in *keys { + let result = is_shorthand_key(namespace, key); + prop_assert!( + result, + "Defined shorthand key {}::{} should be recognized", + namespace, key + ); + } + } + } + } + + // ======================================================================== + // Property Tests: valid_keys_for_namespace + // ======================================================================== + + proptest! { + /// Token namespace should return TOKEN_NAMESPACE_KEYS. + #[test] + fn prop_token_returns_correct_keys(_seed in 0u32..1000) { + let keys = valid_keys_for_namespace("token"); + prop_assert_eq!( + keys, TOKEN_NAMESPACE_KEYS, + "token namespace should return TOKEN_NAMESPACE_KEYS" + ); + } + + /// Associated token namespace should return ASSOCIATED_TOKEN_NAMESPACE_KEYS. + #[test] + fn prop_ata_returns_correct_keys(_seed in 0u32..1000) { + let keys = valid_keys_for_namespace("associated_token"); + prop_assert_eq!( + keys, ASSOCIATED_TOKEN_NAMESPACE_KEYS, + "associated_token namespace should return ASSOCIATED_TOKEN_NAMESPACE_KEYS" + ); + } + + /// Mint namespace should return MINT_NAMESPACE_KEYS. + #[test] + fn prop_mint_returns_correct_keys(_seed in 0u32..1000) { + let keys = valid_keys_for_namespace("mint"); + prop_assert_eq!( + keys, MINT_NAMESPACE_KEYS, + "mint namespace should return MINT_NAMESPACE_KEYS" + ); + } + + /// Unknown namespace should return empty slice. + #[test] + fn prop_unknown_namespace_empty(namespace in "[a-z]{5,10}") { + // Skip if randomly generated a valid namespace + prop_assume!(namespace != "token" && namespace != "associated_token" && namespace != "mint"); + + let keys = valid_keys_for_namespace(&namespace); + prop_assert!( + keys.is_empty(), + "Unknown namespace '{}' should return empty slice", + namespace + ); + } + + /// valid_keys_for_namespace should be deterministic. + #[test] + fn prop_valid_keys_deterministic(namespace in arb_valid_namespace()) { + let keys1 = valid_keys_for_namespace(namespace); + let keys2 = valid_keys_for_namespace(namespace); + prop_assert_eq!( + keys1, keys2, + "valid_keys_for_namespace should be deterministic for '{}'", + namespace + ); + } + } + + // ======================================================================== + // Property Tests: validate_namespaced_key + // ======================================================================== + + proptest! { + /// All keys returned by valid_keys_for_namespace should validate successfully. + #[test] + fn prop_valid_keys_accepted(namespace in arb_valid_namespace()) { + let valid_keys = valid_keys_for_namespace(namespace); + for key in valid_keys { + let result = validate_namespaced_key(namespace, key); + prop_assert!( + result.is_ok(), + "Valid key {}::{} should be accepted", + namespace, key + ); + } + } + + /// Random keys not in valid set should be rejected. + #[test] + fn prop_invalid_keys_rejected(namespace in arb_valid_namespace(), key in arb_random_key()) { + let valid_keys = valid_keys_for_namespace(namespace); + // Skip if randomly generated a valid key + prop_assume!(!valid_keys.contains(&key.as_str())); + + let result = validate_namespaced_key(namespace, &key); + prop_assert!( + result.is_err(), + "Invalid key {}::{} should be rejected", + namespace, key + ); + } + + /// Unknown namespace should return error. + #[test] + fn prop_unknown_namespace_rejected(namespace in "[a-z]{5,10}", key in arb_random_key()) { + // Skip if randomly generated a valid namespace + prop_assume!(namespace != "token" && namespace != "associated_token" && namespace != "mint"); + + let result = validate_namespaced_key(&namespace, &key); + prop_assert!( + result.is_err(), + "Unknown namespace '{}' should return error", + namespace + ); + } + + /// Error message should contain the invalid key. + #[test] + fn prop_error_contains_key(namespace in arb_valid_namespace(), key in arb_random_key()) { + let valid_keys = valid_keys_for_namespace(namespace); + prop_assume!(!valid_keys.contains(&key.as_str())); + + let result = validate_namespaced_key(namespace, &key); + if let Err(err_msg) = result { + prop_assert!( + err_msg.contains(&key), + "Error message should contain the invalid key '{}', got: {}", + key, err_msg + ); + } + } + + /// Error message should suggest valid alternatives. + #[test] + fn prop_error_suggests_valid(namespace in arb_valid_namespace(), key in arb_random_key()) { + let valid_keys = valid_keys_for_namespace(namespace); + prop_assume!(!valid_keys.contains(&key.as_str())); + + let result = validate_namespaced_key(namespace, &key); + if let Err(err_msg) = result { + // At least one valid key should be mentioned in the error + let contains_valid_key = valid_keys.iter().any(|vk| err_msg.contains(vk)); + prop_assert!( + contains_valid_key, + "Error message should suggest valid alternatives, got: {}", + err_msg + ); + } + } + + /// validate_namespaced_key should be deterministic. + #[test] + fn prop_validate_deterministic(namespace in arb_valid_namespace(), key in arb_random_key()) { + let result1 = validate_namespaced_key(namespace, &key); + let result2 = validate_namespaced_key(namespace, &key); + prop_assert_eq!( + result1, result2, + "validate_namespaced_key should be deterministic for {}::{}", + namespace, key + ); + } + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/light_account_keywords_tests.rs b/sdk-libs/macros/src/light_pdas_tests/light_account_keywords_tests.rs new file mode 100644 index 0000000000..be927fc89f --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/light_account_keywords_tests.rs @@ -0,0 +1,118 @@ +//! Unit tests for light_account keyword validation. +//! +//! Extracted from `light_pdas/light_account_keywords.rs`. + +use crate::light_pdas::light_account_keywords::{ + is_shorthand_key, is_standalone_keyword, missing_namespace_error, unknown_key_error, + valid_keys_for_namespace, validate_namespaced_key, ASSOCIATED_TOKEN_NAMESPACE_KEYS, + MINT_NAMESPACE_KEYS, TOKEN_NAMESPACE_KEYS, +}; + +#[test] +fn test_token_namespace_keys() { + assert!(TOKEN_NAMESPACE_KEYS.contains(&"authority")); + assert!(TOKEN_NAMESPACE_KEYS.contains(&"mint")); + assert!(TOKEN_NAMESPACE_KEYS.contains(&"owner")); + assert!(TOKEN_NAMESPACE_KEYS.contains(&"bump")); + assert!(!TOKEN_NAMESPACE_KEYS.contains(&"unknown")); +} + +#[test] +fn test_associated_token_namespace_keys() { + assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"authority")); + assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"mint")); + assert!(ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"bump")); + assert!(!ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"owner")); // renamed to authority + assert!(!ASSOCIATED_TOKEN_NAMESPACE_KEYS.contains(&"unknown")); +} + +#[test] +fn test_mint_namespace_keys() { + assert!(MINT_NAMESPACE_KEYS.contains(&"signer")); // renamed from mint_signer + assert!(MINT_NAMESPACE_KEYS.contains(&"authority")); + assert!(MINT_NAMESPACE_KEYS.contains(&"decimals")); + assert!(MINT_NAMESPACE_KEYS.contains(&"seeds")); // renamed from mint_seeds + assert!(MINT_NAMESPACE_KEYS.contains(&"bump")); // renamed from mint_bump + assert!(MINT_NAMESPACE_KEYS.contains(&"freeze_authority")); + assert!(MINT_NAMESPACE_KEYS.contains(&"authority_seeds")); + assert!(MINT_NAMESPACE_KEYS.contains(&"authority_bump")); + assert!(MINT_NAMESPACE_KEYS.contains(&"name")); + assert!(MINT_NAMESPACE_KEYS.contains(&"symbol")); + assert!(MINT_NAMESPACE_KEYS.contains(&"uri")); + assert!(MINT_NAMESPACE_KEYS.contains(&"update_authority")); + assert!(MINT_NAMESPACE_KEYS.contains(&"additional_metadata")); +} + +#[test] +fn test_standalone_keywords() { + assert!(is_standalone_keyword("init")); + assert!(is_standalone_keyword("token")); + assert!(is_standalone_keyword("associated_token")); + assert!(is_standalone_keyword("mint")); + assert!(!is_standalone_keyword("authority")); +} + +#[test] +fn test_shorthand_keys() { + // token namespace + assert!(is_shorthand_key("token", "mint")); + assert!(is_shorthand_key("token", "owner")); + assert!(is_shorthand_key("token", "bump")); + assert!(!is_shorthand_key("token", "authority")); // authority requires seeds array + + // associated_token namespace + assert!(is_shorthand_key("associated_token", "authority")); + assert!(is_shorthand_key("associated_token", "mint")); + assert!(is_shorthand_key("associated_token", "bump")); + + // mint namespace - no shorthand + assert!(!is_shorthand_key("mint", "signer")); + assert!(!is_shorthand_key("mint", "authority")); +} + +#[test] +fn test_valid_keys_for_namespace() { + let token_kw = valid_keys_for_namespace("token"); + assert_eq!(token_kw, TOKEN_NAMESPACE_KEYS); + + let ata_kw = valid_keys_for_namespace("associated_token"); + assert_eq!(ata_kw, ASSOCIATED_TOKEN_NAMESPACE_KEYS); + + let mint_kw = valid_keys_for_namespace("mint"); + assert_eq!(mint_kw, MINT_NAMESPACE_KEYS); + + let unknown_kw = valid_keys_for_namespace("unknown"); + assert!(unknown_kw.is_empty()); +} + +#[test] +fn test_validate_namespaced_key() { + // Valid keys + assert!(validate_namespaced_key("token", "authority").is_ok()); + assert!(validate_namespaced_key("token", "mint").is_ok()); + assert!(validate_namespaced_key("associated_token", "authority").is_ok()); + assert!(validate_namespaced_key("mint", "signer").is_ok()); + assert!(validate_namespaced_key("mint", "decimals").is_ok()); + + // Invalid keys + assert!(validate_namespaced_key("token", "invalid").is_err()); + assert!(validate_namespaced_key("unknown_namespace", "key").is_err()); +} + +#[test] +fn test_unknown_key_error() { + let error = unknown_key_error("token", "invalid"); + assert!(error.contains("invalid")); + assert!(error.contains("token")); + assert!(error.contains("authority")); + + let error = unknown_key_error("unknown", "key"); + assert!(error.contains("Unknown namespace")); +} + +#[test] +fn test_missing_namespace_error() { + let error = missing_namespace_error("authority", "token"); + assert!(error.contains("token::authority")); + assert!(error.contains("Missing namespace prefix")); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/light_account_tests.rs b/sdk-libs/macros/src/light_pdas_tests/light_account_tests.rs new file mode 100644 index 0000000000..90c70a4825 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/light_account_tests.rs @@ -0,0 +1,1005 @@ +//! Unit tests for #[light_account(...)] attribute parsing. +//! +//! Extracted from `light_pdas/accounts/light_account.rs`. + +use syn::parse_quote; + +use crate::light_pdas::accounts::light_account::{parse_light_account_attr, LightAccountField}; + +#[test] +fn test_parse_light_account_pda_bare() { + let field: syn::Field = parse_quote! { + #[light_account(init)] + pub record: Account<'info, MyRecord> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Pda(pda) => { + assert_eq!(pda.ident.to_string(), "record"); + assert!(!pda.is_boxed); + } + _ => panic!("Expected PDA field"), + } +} + +#[test] +fn test_parse_pda_tree_keywords_rejected() { + // Tree keywords are no longer allowed - they're auto-fetched from CreateAccountsProof + let field: syn::Field = parse_quote! { + #[light_account(init, pda::address_tree_info = custom_tree)] + pub record: Account<'info, MyRecord> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); +} + +#[test] +fn test_parse_light_account_mint() { + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"] + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + } + _ => panic!("Expected Mint field"), + } +} + +#[test] +fn test_parse_light_account_mint_with_metadata() { + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"], + mint::name = params.name.clone(), + mint::symbol = params.symbol.clone(), + mint::uri = params.uri.clone() + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert!(mint.name.is_some()); + assert!(mint.symbol.is_some()); + assert!(mint.uri.is_some()); + } + _ => panic!("Expected Mint field"), + } +} + +#[test] +fn test_parse_light_account_missing_init() { + let field: syn::Field = parse_quote! { + #[light_account(mint, mint::decimals = 9)] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); +} + +#[test] +fn test_parse_light_account_mint_missing_required() { + let field: syn::Field = parse_quote! { + #[light_account(init, mint, mint::decimals = 9)] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); +} + +#[test] +fn test_parse_light_account_partial_metadata_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"], + mint::name = params.name.clone() + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); +} + +#[test] +fn test_no_light_account_attr_returns_none() { + let field: syn::Field = parse_quote! { + pub record: Account<'info, MyRecord> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); +} + +// ======================================================================== +// Token Account Tests +// ======================================================================== + +#[test] +fn test_parse_token_mark_only_returns_none() { + // Mark-only mode (no init) should return None for LightAccounts derive + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"authority"])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); +} + +#[test] +fn test_parse_token_init_creates_field() { + let field: syn::Field = parse_quote! { + #[light_account(init, token, token::authority = [b"authority"], token::mint = token_mint, token::owner = vault_authority)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert_eq!(token.field_ident.to_string(), "vault"); + assert!(token.has_init); + assert!(!token.authority_seeds.is_empty()); + assert!(token.mint.is_some()); + assert!(token.owner.is_some()); + } + _ => panic!("Expected TokenAccount field"), + } +} + +#[test] +fn test_parse_token_init_missing_authority_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, token)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("authority")); +} + +#[test] +fn test_parse_token_mark_only_missing_authority_fails() { + // Mark-only token now requires authority + let field: syn::Field = parse_quote! { + #[light_account(token)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("authority"), + "Expected error about missing authority, got: {}", + err + ); +} + +#[test] +fn test_parse_token_mark_only_rejects_mint() { + // Mark-only token should not allow mint parameter + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"auth"], token::mint = token_mint)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("mint") && err.contains("only allowed with `init`"), + "Expected error about mint only for init, got: {}", + err + ); +} + +#[test] +fn test_parse_token_mark_only_rejects_owner() { + // Mark-only token should not allow owner parameter + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"auth"], token::owner = vault_authority)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("owner") && err.contains("only allowed with `init`"), + "Expected error about owner only for init, got: {}", + err + ); +} + +#[test] +fn test_parse_token_init_missing_mint_fails() { + // Token init requires mint parameter + let field: syn::Field = parse_quote! { + #[light_account(init, token, token::authority = [b"authority"], token::owner = vault_authority)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("mint"), + "Expected error about missing mint, got: {}", + err + ); +} + +#[test] +fn test_parse_token_init_missing_owner_fails() { + // Token init requires owner parameter + let field: syn::Field = parse_quote! { + #[light_account(init, token, token::authority = [b"authority"], token::mint = token_mint)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("owner"), + "Expected error about missing owner, got: {}", + err + ); +} + +// ======================================================================== +// Associated Token Tests +// ======================================================================== + +#[test] +fn test_parse_associated_token_mark_only_returns_none() { + // Mark-only mode (no init) should return None for LightAccounts derive + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); +} + +#[test] +fn test_parse_associated_token_init_creates_field() { + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, associated_token::authority = owner, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::AssociatedToken(ata) => { + assert_eq!(ata.field_ident.to_string(), "user_ata"); + assert!(ata.has_init); + } + _ => panic!("Expected AssociatedToken field"), + } +} + +#[test] +fn test_parse_associated_token_init_missing_authority_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("authority")); +} + +#[test] +fn test_parse_associated_token_init_missing_mint_fails() { + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, associated_token::authority = owner)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("mint")); +} + +#[test] +fn test_parse_token_unknown_argument_fails() { + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"auth"], token::unknown = foo)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("unknown")); +} + +#[test] +fn test_parse_associated_token_unknown_argument_fails() { + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint, associated_token::unknown = foo)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!(err.contains("unknown")); +} + +#[test] +fn test_parse_associated_token_shorthand_syntax() { + // Test shorthand syntax: mint, authority, bump without = value + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, associated_token::authority, associated_token::mint, associated_token::bump)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::AssociatedToken(ata) => { + assert_eq!(ata.field_ident.to_string(), "user_ata"); + assert!(ata.has_init); + assert!(ata.bump.is_some()); + } + _ => panic!("Expected AssociatedToken field"), + } +} + +#[test] +fn test_parse_token_duplicate_key_fails() { + // Duplicate keys should be rejected + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"auth1"], token::authority = [b"auth2"])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("Duplicate key"), + "Expected error about duplicate key, got: {}", + err + ); +} + +#[test] +fn test_parse_associated_token_duplicate_key_fails() { + // Duplicate keys in associated_token should also be rejected + let field: syn::Field = parse_quote! { + #[light_account(init, associated_token, associated_token::authority = foo, associated_token::authority = bar, associated_token::mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("Duplicate key"), + "Expected error about duplicate key, got: {}", + err + ); +} + +#[test] +fn test_parse_token_init_empty_authority_fails() { + // Empty authority seeds with init should be rejected + let field: syn::Field = parse_quote! { + #[light_account(init, token, token::authority = [], token::mint = token_mint, token::owner = vault_authority)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("Empty authority seeds"), + "Expected error about empty authority seeds, got: {}", + err + ); +} + +#[test] +fn test_parse_token_non_init_empty_authority_allowed() { + // Empty authority seeds without init should be allowed (mark-only mode) + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + // Mark-only mode returns Ok(None) + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); +} + +#[test] +fn test_parse_pda_with_direct_proof_arg_uses_proof_ident_for_defaults() { + use syn::Ident; + // When CreateAccountsProof is passed as a direct instruction arg (not nested in params), + // the default address_tree_info and output_tree should reference the proof arg directly. + let field: syn::Field = parse_quote! { + #[light_account(init)] + pub record: Account<'info, MyRecord> + }; + let field_ident = field.ident.clone().unwrap(); + + // Simulate passing CreateAccountsProof as direct arg named "proof" + let proof_ident: Ident = parse_quote!(proof); + let direct_proof_arg = Some(proof_ident.clone()); + + let result = parse_light_account_attr(&field, &field_ident, &direct_proof_arg); + assert!( + result.is_ok(), + "Should parse successfully with direct proof arg" + ); + let result = result.unwrap(); + assert!(result.is_some(), "Should return Some for init PDA"); + + match result.unwrap() { + LightAccountField::Pda(pda) => { + assert_eq!(pda.ident.to_string(), "record"); + + // Verify defaults use the direct proof identifier + // address_tree_info should be: proof.address_tree_info + let addr_tree_info = &pda.address_tree_info; + let addr_tree_str = quote::quote!(#addr_tree_info).to_string(); + assert!( + addr_tree_str.contains("proof"), + "address_tree_info should reference 'proof', got: {}", + addr_tree_str + ); + assert!( + addr_tree_str.contains("address_tree_info"), + "address_tree_info should access .address_tree_info field, got: {}", + addr_tree_str + ); + + // output_tree should be: proof.output_state_tree_index + let output_tree = &pda.output_tree; + let output_tree_str = quote::quote!(#output_tree).to_string(); + assert!( + output_tree_str.contains("proof"), + "output_tree should reference 'proof', got: {}", + output_tree_str + ); + assert!( + output_tree_str.contains("output_state_tree_index"), + "output_tree should access .output_state_tree_index field, got: {}", + output_tree_str + ); + } + _ => panic!("Expected PDA field"), + } +} + +#[test] +fn test_parse_mint_with_direct_proof_arg_uses_proof_ident_for_defaults() { + use syn::Ident; + // When CreateAccountsProof is passed as a direct instruction arg, + // the default address_tree_info should reference the proof arg directly. + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"test"] + )] + pub cmint: UncheckedAccount<'info> + }; + let field_ident = field.ident.clone().unwrap(); + + // Simulate passing CreateAccountsProof as direct arg named "create_proof" + let proof_ident: Ident = parse_quote!(create_proof); + let direct_proof_arg = Some(proof_ident.clone()); + + let result = parse_light_account_attr(&field, &field_ident, &direct_proof_arg); + assert!( + result.is_ok(), + "Should parse successfully with direct proof arg" + ); + let result = result.unwrap(); + assert!(result.is_some(), "Should return Some for init mint"); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + + // Verify default address_tree_info uses the direct proof identifier + // Should be: create_proof.address_tree_info + let addr_tree_info = &mint.address_tree_info; + let addr_tree_str = quote::quote!(#addr_tree_info).to_string(); + assert!( + addr_tree_str.contains("create_proof"), + "address_tree_info should reference 'create_proof', got: {}", + addr_tree_str + ); + assert!( + addr_tree_str.contains("address_tree_info"), + "address_tree_info should access .address_tree_info field, got: {}", + addr_tree_str + ); + + // Verify default output_tree uses the direct proof identifier + // Should be: create_proof.output_state_tree_index + let output_tree = &mint.output_tree; + let output_tree_str = quote::quote!(#output_tree).to_string(); + assert!( + output_tree_str.contains("create_proof"), + "output_tree should reference 'create_proof', got: {}", + output_tree_str + ); + assert!( + output_tree_str.contains("output_state_tree_index"), + "output_tree should access .output_state_tree_index field, got: {}", + output_tree_str + ); + } + _ => panic!("Expected Mint field"), + } +} + +// ======================================================================== +// Bump Parameter Tests +// ======================================================================== + +#[test] +fn test_parse_token_with_bump_parameter() { + // Test token with explicit bump parameter + let field: syn::Field = parse_quote! { + #[light_account(init, token, + token::authority = [b"vault", self.offer.key()], + token::mint = token_mint, + token::owner = vault_authority, + token::bump = params.vault_bump + )] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with bump parameter" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert_eq!(token.field_ident.to_string(), "vault"); + assert!(token.has_init); + assert!(!token.authority_seeds.is_empty()); + assert!(token.bump.is_some(), "bump should be Some when provided"); + } + _ => panic!("Expected TokenAccount field"), + } +} + +#[test] +fn test_parse_token_without_bump_backwards_compatible() { + // Test token without bump (backwards compatible - bump will be auto-derived) + let field: syn::Field = parse_quote! { + #[light_account(init, token, + token::authority = [b"vault", self.offer.key()], + token::mint = token_mint, + token::owner = vault_authority + )] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully without bump parameter" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert_eq!(token.field_ident.to_string(), "vault"); + assert!(token.has_init); + assert!(!token.authority_seeds.is_empty()); + assert!( + token.bump.is_none(), + "bump should be None when not provided" + ); + } + _ => panic!("Expected TokenAccount field"), + } +} + +#[test] +fn test_parse_mint_with_mint_bump() { + // Test mint with explicit mint::bump parameter + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint"], + mint::bump = params.mint_bump + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with mint::bump parameter" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + assert!( + mint.mint_bump.is_some(), + "mint_bump should be Some when provided" + ); + } + _ => panic!("Expected Mint field"), + } +} + +#[test] +fn test_parse_mint_with_authority_bump() { + // Test mint with authority_seeds and authority_bump + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint"], + mint::authority_seeds = &[b"auth"], + mint::authority_bump = params.auth_bump + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with authority_bump parameter" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + assert!( + mint.authority_seeds.is_some(), + "authority_seeds should be Some" + ); + assert!( + mint.authority_bump.is_some(), + "authority_bump should be Some when provided" + ); + } + _ => panic!("Expected Mint field"), + } +} + +#[test] +fn test_parse_mint_without_bumps_backwards_compatible() { + // Test mint without bump parameters (backwards compatible - bumps will be auto-derived) + let field: syn::Field = parse_quote! { + #[light_account(init, mint, + mint::signer = mint_signer, + mint::authority = authority, + mint::decimals = 9, + mint::seeds = &[b"mint"], + mint::authority_seeds = &[b"auth"] + )] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully without bump parameters" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::Mint(mint) => { + assert_eq!(mint.field_ident.to_string(), "cmint"); + assert!( + mint.mint_bump.is_none(), + "mint_bump should be None when not provided" + ); + assert!( + mint.authority_seeds.is_some(), + "authority_seeds should be Some" + ); + assert!( + mint.authority_bump.is_none(), + "authority_bump should be None when not provided" + ); + } + _ => panic!("Expected Mint field"), + } +} + +#[test] +fn test_parse_token_bump_shorthand_syntax() { + // Test token with bump shorthand syntax (token::bump = bump) + let field: syn::Field = parse_quote! { + #[light_account(init, token, + token::authority = [b"vault"], + token::mint = token_mint, + token::owner = vault_authority, + token::bump + )] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!( + result.is_ok(), + "Should parse successfully with bump shorthand" + ); + let result = result.unwrap(); + assert!(result.is_some()); + + match result.unwrap() { + LightAccountField::TokenAccount(token) => { + assert!( + token.bump.is_some(), + "bump should be Some with shorthand syntax" + ); + } + _ => panic!("Expected TokenAccount field"), + } +} + +// ======================================================================== +// Namespace Validation Tests +// ======================================================================== + +#[test] +fn test_parse_wrong_namespace_fails() { + // Using mint:: namespace with token account type should fail + let field: syn::Field = parse_quote! { + #[light_account(token, mint::authority = [b"auth"])] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); +} + +#[test] +fn test_old_syntax_gives_helpful_error() { + // Old syntax without namespace should give helpful migration error + let field: syn::Field = parse_quote! { + #[light_account(init, mint, authority = some_authority)] + pub cmint: UncheckedAccount<'info> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("Missing namespace prefix") || err.contains("mint::authority"), + "Expected helpful migration error, got: {}", + err + ); +} + +// ======================================================================== +// Mark-Only Associated Token Validation Tests +// ======================================================================== + +#[test] +fn test_parse_associated_token_mark_only_missing_authority_fails() { + // Mark-only associated_token requires authority + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("authority"), + "Expected error about missing authority, got: {}", + err + ); +} + +#[test] +fn test_parse_associated_token_mark_only_missing_mint_fails() { + // Mark-only associated_token requires mint + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("mint"), + "Expected error about missing mint, got: {}", + err + ); +} + +#[test] +fn test_parse_associated_token_mark_only_with_both_params_succeeds() { + // Mark-only associated_token with both authority and mint should succeed (returns None) + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner, associated_token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); // Mark-only returns None +} + +// ======================================================================== +// Mixed Namespace Prefix Tests +// ======================================================================== + +#[test] +fn test_parse_mixed_token_and_associated_token_prefix_fails() { + // Mixing token:: with associated_token type should fail + let field: syn::Field = parse_quote! { + #[light_account(associated_token, associated_token::authority = owner, token::mint = mint)] + pub user_ata: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); +} + +#[test] +fn test_parse_mixed_associated_token_and_token_prefix_fails() { + // Mixing associated_token:: with token type should fail + let field: syn::Field = parse_quote! { + #[light_account(token, token::authority = [b"auth"], associated_token::mint = mint)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); +} + +#[test] +fn test_parse_init_mixed_token_and_mint_prefix_fails() { + // Mixing token:: with mint:: in init mode should fail + let field: syn::Field = parse_quote! { + #[light_account(init, token, token::authority = [b"auth"], mint::decimals = 9)] + pub vault: Account<'info, CToken> + }; + let ident = field.ident.clone().unwrap(); + + let result = parse_light_account_attr(&field, &ident, &None); + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("doesn't match account type"), + "Expected namespace mismatch error, got: {}", + err + ); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/light_compressible_tests.rs b/sdk-libs/macros/src/light_pdas_tests/light_compressible_tests.rs new file mode 100644 index 0000000000..a4c0649bdb --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/light_compressible_tests.rs @@ -0,0 +1,147 @@ +//! Unit tests for LightCompressible derive macro. +//! +//! Extracted from `light_pdas/account/light_compressible.rs`. + +use syn::{parse_quote, DeriveInput}; + +use crate::light_pdas::account::light_compressible::derive_light_account; + +#[test] +fn test_light_compressible_basic() { + // No #[hash] or #[skip] needed - SHA256 hashes entire struct, compression_info auto-skipped + let input: DeriveInput = parse_quote! { + pub struct UserRecord { + pub owner: Pubkey, + pub name: String, + pub score: u64, + pub compression_info: Option, + } + }; + + let result = derive_light_account(input); + assert!(result.is_ok(), "LightCompressible should succeed"); + + let output = result.unwrap().to_string(); + + // Should contain LightHasherSha output + assert!(output.contains("DataHasher"), "Should implement DataHasher"); + assert!( + output.contains("ToByteArray"), + "Should implement ToByteArray" + ); + + // Should contain LightDiscriminator output + assert!( + output.contains("LightDiscriminator"), + "Should implement LightDiscriminator" + ); + assert!( + output.contains("LIGHT_DISCRIMINATOR"), + "Should have discriminator constant" + ); + + // Should contain Compressible output (HasCompressionInfo, CompressAs, Size) + assert!( + output.contains("HasCompressionInfo"), + "Should implement HasCompressionInfo" + ); + assert!(output.contains("CompressAs"), "Should implement CompressAs"); + assert!(output.contains("Size"), "Should implement Size"); + + // Should contain CompressiblePack output (Pack, Unpack, Packed struct) + assert!(output.contains("Pack"), "Should implement Pack"); + assert!(output.contains("Unpack"), "Should implement Unpack"); + assert!( + output.contains("PackedUserRecord"), + "Should generate Packed struct" + ); +} + +#[test] +fn test_light_compressible_with_compress_as() { + // compress_as still works - no #[hash] or #[skip] needed + let input: DeriveInput = parse_quote! { + #[compress_as(start_time = 0, score = 0)] + pub struct GameSession { + pub session_id: u64, + pub player: Pubkey, + pub start_time: u64, + pub score: u64, + pub compression_info: Option, + } + }; + + let result = derive_light_account(input); + assert!( + result.is_ok(), + "LightCompressible with compress_as should succeed" + ); + + let output = result.unwrap().to_string(); + + // compress_as attribute should be processed + assert!(output.contains("CompressAs"), "Should implement CompressAs"); +} + +#[test] +fn test_light_compressible_no_pubkey_fields() { + let input: DeriveInput = parse_quote! { + pub struct SimpleRecord { + pub id: u64, + pub value: u32, + pub compression_info: Option, + } + }; + + let result = derive_light_account(input); + assert!( + result.is_ok(), + "LightCompressible without Pubkey fields should succeed" + ); + + let output = result.unwrap().to_string(); + + // Should still generate everything + assert!(output.contains("DataHasher"), "Should implement DataHasher"); + assert!( + output.contains("LightDiscriminator"), + "Should implement LightDiscriminator" + ); + assert!( + output.contains("HasCompressionInfo"), + "Should implement HasCompressionInfo" + ); + + // For structs without Pubkey fields, PackedSimpleRecord should be a type alias + // (implementation detail of CompressiblePack) +} + +#[test] +fn test_light_compressible_enum_fails() { + let input: DeriveInput = parse_quote! { + pub enum NotAStruct { + A, + B, + } + }; + + let result = derive_light_account(input); + assert!(result.is_err(), "LightCompressible should fail for enums"); +} + +#[test] +fn test_light_compressible_missing_compression_info() { + let input: DeriveInput = parse_quote! { + pub struct MissingCompressionInfo { + pub id: u64, + pub value: u32, + } + }; + + let result = derive_light_account(input); + // Compressible derive validates compression_info field + assert!( + result.is_err(), + "Should fail without compression_info field" + ); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/mod.rs b/sdk-libs/macros/src/light_pdas_tests/mod.rs new file mode 100644 index 0000000000..86561c06bf --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/mod.rs @@ -0,0 +1,29 @@ +//! Property-based, fuzz, and unit tests for light_pdas module. +//! +//! This module contains comprehensive tests for: +//! - Seed extraction and classification (`prop_tests.rs`, `seed_extraction_tests.rs`) +//! - Shared utilities (`shared_utils_prop_tests.rs`, `shared_utils_tests.rs`) +//! - Keyword validation (`keywords_prop_tests.rs`, `light_account_keywords_tests.rs`) +//! - Accounts parsing (`parse_prop_tests.rs`, `parsing_tests.rs`) +//! - E2E derive macro (`e2e_prop_tests.rs`) +//! - Fuzz tests (`fuzz_tests.rs`) +//! - Unit tests extracted from source files + +// Property-based and fuzz tests +mod e2e_prop_tests; +mod fuzz_tests; +mod keywords_prop_tests; +mod parse_prop_tests; +mod prop_tests; +mod shared_utils_prop_tests; + +// Unit tests extracted from source files +mod crate_context_tests; +mod derive_tests; +mod light_account_keywords_tests; +mod light_account_tests; +mod light_compressible_tests; +mod parsing_tests; +mod seed_extraction_tests; +mod shared_utils_tests; +mod visitors_tests; diff --git a/sdk-libs/macros/src/light_pdas_tests/parse_prop_tests.rs b/sdk-libs/macros/src/light_pdas_tests/parse_prop_tests.rs new file mode 100644 index 0000000000..e9845ef7a7 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/parse_prop_tests.rs @@ -0,0 +1,344 @@ +//! Property-based tests for accounts parsing and classification. +//! +//! These tests verify correctness properties of: +//! - `InfraFieldClassifier::classify` - Infrastructure field classification +//! - `InfraFields::set` - Infrastructure field state management + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use syn::Ident; + + // Access parse module from parent (accounts module) + use crate::light_pdas::accounts::parse::{InfraFieldClassifier, InfraFieldType, InfraFields}; + + // ======================================================================== + // Helper functions + // ======================================================================== + + /// Creates an Ident from a string (for testing purposes) + fn make_ident(name: &str) -> Ident { + syn::parse_str::(name) + .unwrap_or_else(|_| syn::parse_str::("test_ident").unwrap()) + } + + /// All InfraFieldType variants + fn all_infra_types() -> Vec { + vec![ + InfraFieldType::FeePayer, + InfraFieldType::CompressionConfig, + InfraFieldType::LightTokenConfig, + InfraFieldType::LightTokenRentSponsor, + InfraFieldType::LightTokenProgram, + InfraFieldType::LightTokenCpiAuthority, + ] + } + + /// All known field names that map to InfraFieldType + fn all_known_field_names() -> Vec<&'static str> { + vec![ + "fee_payer", + "payer", + "creator", + "compression_config", + "light_token_compressible_config", + "light_token_rent_sponsor", + "rent_sponsor", + "light_token_program", + "light_token_cpi_authority", + ] + } + + // ======================================================================== + // Strategies for generating test inputs + // ======================================================================== + + /// Strategy for generating known field names + fn arb_known_field_name() -> impl Strategy { + prop::sample::select(all_known_field_names()) + } + + /// Strategy for generating random lowercase identifiers (likely unknown) + fn arb_random_field_name() -> impl Strategy { + "[a-z][a-z0-9_]{2,20}" + } + + /// Strategy for generating a random InfraFieldType + fn arb_infra_field_type() -> impl Strategy { + prop::sample::select(all_infra_types()) + } + + // ======================================================================== + // Property Tests: InfraFieldClassifier::classify + // ======================================================================== + + proptest! { + /// Known field names should be classified correctly. + #[test] + fn prop_known_names_classified(name in arb_known_field_name()) { + let result = InfraFieldClassifier::classify(name); + prop_assert!( + result.is_some(), + "Known field name '{}' should be classified", + name + ); + } + + /// All accepted names for each type should classify to that type. + #[test] + fn prop_all_accepted_names_work(_seed in 0u32..1000) { + for field_type in all_infra_types() { + for name in field_type.accepted_names() { + let result = InfraFieldClassifier::classify(name); + prop_assert!( + result == Some(field_type), + "Name '{}' should classify to {:?}, got {:?}", + name, field_type, result + ); + } + } + } + + /// Unknown field names should return None. + #[test] + fn prop_unknown_names_return_none(name in arb_random_field_name()) { + // Skip if randomly generated a known name + prop_assume!(!all_known_field_names().contains(&name.as_str())); + + let result = InfraFieldClassifier::classify(&name); + prop_assert!( + result.is_none(), + "Unknown field name '{}' should return None, got {:?}", + name, result + ); + } + + /// Classification should be deterministic. + #[test] + fn prop_classify_deterministic(name in arb_random_field_name()) { + let result1 = InfraFieldClassifier::classify(&name); + let result2 = InfraFieldClassifier::classify(&name); + prop_assert_eq!( + result1, result2, + "Classification should be deterministic for '{}'", + name + ); + } + + /// Each accepted name should map to exactly one type (bijection check). + #[test] + fn prop_bijection(_seed in 0u32..1000) { + let infra_types = all_infra_types(); + for name in all_known_field_names() { + let result = InfraFieldClassifier::classify(name); + // Count how many types accept this name + let matching_count = infra_types + .iter() + .filter(|t| t.accepted_names().contains(&name)) + .count(); + + prop_assert_eq!( + matching_count, 1, + "Name '{}' should map to exactly one type", + name + ); + prop_assert!( + result.is_some(), + "Known name '{}' should classify successfully", + name + ); + } + } + + /// All 6 InfraFieldType variants should be reachable via classification. + #[test] + fn prop_exhaustive_coverage(_seed in 0u32..1000) { + let mut covered = vec![false; 6]; + + for name in all_known_field_names() { + if let Some(field_type) = InfraFieldClassifier::classify(name) { + let index = match field_type { + InfraFieldType::FeePayer => 0, + InfraFieldType::CompressionConfig => 1, + InfraFieldType::LightTokenConfig => 2, + InfraFieldType::LightTokenRentSponsor => 3, + InfraFieldType::LightTokenProgram => 4, + InfraFieldType::LightTokenCpiAuthority => 5, + }; + covered[index] = true; + } + } + + prop_assert!( + covered.iter().all(|&c| c), + "Not all InfraFieldType variants are reachable: {:?}", + covered + ); + } + } + + // ======================================================================== + // Property Tests: InfraFields::set + // ======================================================================== + + proptest! { + /// First insert of any field type should succeed. + #[test] + fn prop_first_insert_succeeds(field_type in arb_infra_field_type()) { + let mut fields = InfraFields::default(); + let ident = make_ident("test_field"); + + let result = fields.set(field_type, ident); + prop_assert!( + result.is_ok(), + "First insert of {:?} should succeed", + field_type + ); + } + + /// Duplicate insert of same field type should fail. + #[test] + fn prop_duplicate_insert_fails(field_type in arb_infra_field_type()) { + let mut fields = InfraFields::default(); + let ident1 = make_ident("first_field"); + let ident2 = make_ident("second_field"); + + // First insert should succeed + let result1 = fields.set(field_type, ident1); + prop_assert!(result1.is_ok(), "First insert should succeed"); + + // Second insert of same type should fail + let result2 = fields.set(field_type, ident2); + prop_assert!( + result2.is_err(), + "Duplicate insert of {:?} should fail", + field_type + ); + } + + /// Different field types can coexist. + #[test] + fn prop_different_types_coexist(_seed in 0u32..1000) { + let mut fields = InfraFields::default(); + + for (i, field_type) in all_infra_types().into_iter().enumerate() { + let ident = make_ident(&format!("field_{}", i)); + let result = fields.set(field_type, ident); + prop_assert!( + result.is_ok(), + "Insert of different type {:?} should succeed", + field_type + ); + } + } + + /// After set, corresponding Option field should be Some. + #[test] + fn prop_state_mutation_correct(field_type in arb_infra_field_type()) { + let mut fields = InfraFields::default(); + let ident = make_ident("test_field"); + + fields.set(field_type, ident).unwrap(); + + let is_set = match field_type { + InfraFieldType::FeePayer => fields.fee_payer.is_some(), + InfraFieldType::CompressionConfig => fields.compression_config.is_some(), + InfraFieldType::LightTokenConfig => fields.light_token_config.is_some(), + InfraFieldType::LightTokenRentSponsor => fields.light_token_rent_sponsor.is_some(), + InfraFieldType::LightTokenProgram => fields.light_token_program.is_some(), + InfraFieldType::LightTokenCpiAuthority => fields.light_token_cpi_authority.is_some(), + }; + + prop_assert!( + is_set, + "After set({:?}), corresponding field should be Some", + field_type + ); + } + + /// Error message should identify the duplicate field type. + #[test] + fn prop_error_identifies_field(field_type in arb_infra_field_type()) { + let mut fields = InfraFields::default(); + let ident1 = make_ident("first"); + let ident2 = make_ident("second"); + + fields.set(field_type, ident1).unwrap(); + let result = fields.set(field_type, ident2); + + if let Err(err) = result { + let err_msg = err.to_string(); + prop_assert!( + err_msg.contains("duplicate"), + "Error message should mention 'duplicate', got: {}", + err_msg + ); + } + } + + /// Other fields should remain None after setting one field. + #[test] + fn prop_other_fields_unchanged(field_type in arb_infra_field_type()) { + let mut fields = InfraFields::default(); + let ident = make_ident("test_field"); + + fields.set(field_type, ident).unwrap(); + + // Count how many fields are set + let set_count = [ + fields.fee_payer.is_some(), + fields.compression_config.is_some(), + fields.light_token_config.is_some(), + fields.light_token_rent_sponsor.is_some(), + fields.light_token_program.is_some(), + fields.light_token_cpi_authority.is_some(), + ].iter().filter(|&&x| x).count(); + + prop_assert_eq!( + set_count, 1, + "Only one field should be set after single set() call for {:?}", + field_type + ); + } + } + + // ======================================================================== + // Property Tests: InfraFieldType methods + // ======================================================================== + + proptest! { + /// Each InfraFieldType should have at least one accepted name. + #[test] + fn prop_each_type_has_accepted_names(field_type in arb_infra_field_type()) { + let names = field_type.accepted_names(); + prop_assert!( + !names.is_empty(), + "{:?} should have at least one accepted name", + field_type + ); + } + + /// Each InfraFieldType should have a non-empty description. + #[test] + fn prop_each_type_has_description(field_type in arb_infra_field_type()) { + let desc = field_type.description(); + prop_assert!( + !desc.is_empty(), + "{:?} should have a non-empty description", + field_type + ); + } + + /// accepted_names should be deterministic. + #[test] + fn prop_accepted_names_deterministic(field_type in arb_infra_field_type()) { + let names1 = field_type.accepted_names(); + let names2 = field_type.accepted_names(); + prop_assert_eq!( + names1, names2, + "accepted_names should be deterministic for {:?}", + field_type + ); + } + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/parsing_tests.rs b/sdk-libs/macros/src/light_pdas_tests/parsing_tests.rs new file mode 100644 index 0000000000..b1135412a9 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/parsing_tests.rs @@ -0,0 +1,218 @@ +//! Unit tests for context and params extraction from function signatures. +//! +//! Extracted from `light_pdas/program/parsing.rs`. + +use syn::punctuated::Punctuated; + +use crate::light_pdas::program::parsing::{ + call_has_ctx_arg, extract_context_and_params, ExtractResult, +}; + +fn parse_args(code: &str) -> Punctuated { + let call: syn::ExprCall = syn::parse_str(&format!("f({})", code)).unwrap(); + call.args +} + +#[test] +fn test_call_has_ctx_arg_direct() { + // F001: Direct ctx identifier + let args = parse_args("ctx"); + assert!(call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_reference() { + // F001: Reference pattern &ctx + let args = parse_args("&ctx"); + assert!(call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_mut_reference() { + // F001: Mutable reference pattern &mut ctx + let args = parse_args("&mut ctx"); + assert!(call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_clone() { + // F001: Method call ctx.clone() + let args = parse_args("ctx.clone()"); + assert!(call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_into() { + // F001: Method call ctx.into() + let args = parse_args("ctx.into()"); + assert!(call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_other_name() { + // Non-ctx identifier should return false when looking for "ctx" + let args = parse_args("context"); + assert!(!call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_method_on_other() { + // Method call on non-ctx receiver + let args = parse_args("other.clone()"); + assert!(!call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_multiple_args() { + // F001: ctx among multiple arguments + let args = parse_args("foo, ctx.clone(), bar"); + assert!(call_has_ctx_arg(&args, "ctx")); +} + +#[test] +fn test_call_has_ctx_arg_empty() { + // Empty args should return false + let args = parse_args(""); + assert!(!call_has_ctx_arg(&args, "ctx")); +} + +// Tests for dynamic context name detection +#[test] +fn test_call_has_ctx_arg_custom_name_context() { + // Direct identifier with custom name "context" + let args = parse_args("context"); + assert!(call_has_ctx_arg(&args, "context")); +} + +#[test] +fn test_call_has_ctx_arg_custom_name_anchor_ctx() { + // Direct identifier with custom name "anchor_ctx" + let args = parse_args("anchor_ctx"); + assert!(call_has_ctx_arg(&args, "anchor_ctx")); +} + +#[test] +fn test_call_has_ctx_arg_custom_name_reference() { + // Reference pattern with custom name + let args = parse_args("&my_context"); + assert!(call_has_ctx_arg(&args, "my_context")); +} + +#[test] +fn test_call_has_ctx_arg_custom_name_method_call() { + // Method call with custom name + let args = parse_args("c.clone()"); + assert!(call_has_ctx_arg(&args, "c")); +} + +#[test] +fn test_call_has_ctx_arg_wrong_custom_name() { + // Looking for wrong name should return false + let args = parse_args("ctx"); + assert!(!call_has_ctx_arg(&args, "context")); +} + +#[test] +fn test_extract_context_and_params_standard() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(ctx: Context, params: Params) -> Result<()> { + Ok(()) + } + }; + match extract_context_and_params(&fn_item) { + ExtractResult::Success { + context_type, + params_ident, + ctx_ident, + } => { + assert_eq!(context_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "params"); + assert_eq!(ctx_ident.to_string(), "ctx"); + } + _ => panic!("Expected ExtractResult::Success"), + } +} + +#[test] +fn test_extract_context_and_params_custom_context_name() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(context: Context, params: Params) -> Result<()> { + Ok(()) + } + }; + match extract_context_and_params(&fn_item) { + ExtractResult::Success { + context_type, + params_ident, + ctx_ident, + } => { + assert_eq!(context_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "params"); + assert_eq!(ctx_ident.to_string(), "context"); + } + _ => panic!("Expected ExtractResult::Success"), + } +} + +#[test] +fn test_extract_context_and_params_anchor_ctx_name() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(anchor_ctx: Context, data: Data) -> Result<()> { + Ok(()) + } + }; + match extract_context_and_params(&fn_item) { + ExtractResult::Success { + context_type, + params_ident, + ctx_ident, + } => { + assert_eq!(context_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "data"); + assert_eq!(ctx_ident.to_string(), "anchor_ctx"); + } + _ => panic!("Expected ExtractResult::Success"), + } +} + +#[test] +fn test_extract_context_and_params_single_letter_name() { + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(c: Context, p: Params) -> Result<()> { + Ok(()) + } + }; + match extract_context_and_params(&fn_item) { + ExtractResult::Success { + context_type, + params_ident, + ctx_ident, + } => { + assert_eq!(context_type, "MyAccounts"); + assert_eq!(params_ident.to_string(), "p"); + assert_eq!(ctx_ident.to_string(), "c"); + } + _ => panic!("Expected ExtractResult::Success"), + } +} + +#[test] +fn test_extract_context_and_params_multiple_args_detected() { + // Format-2 case: multiple instruction arguments should be detected + let fn_item: syn::ItemFn = syn::parse_quote! { + pub fn handler(ctx: Context, amount: u64, owner: Pubkey) -> Result<()> { + Ok(()) + } + }; + match extract_context_and_params(&fn_item) { + ExtractResult::MultipleParams { + context_type, + param_names, + } => { + assert_eq!(context_type, "MyAccounts"); + assert!(param_names.contains(&"amount".to_string())); + assert!(param_names.contains(&"owner".to_string())); + } + _ => panic!("Expected ExtractResult::MultipleParams"), + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/prop_tests.rs b/sdk-libs/macros/src/light_pdas_tests/prop_tests.rs new file mode 100644 index 0000000000..a350aa1756 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/prop_tests.rs @@ -0,0 +1,833 @@ +//! Property-based tests for seed parsing correctness. +//! +//! These tests verify that `classify_seed_expr` correctly classifies seed expressions +//! and preserves their semantic content. Unlike fuzz tests that only verify crash-freedom, +//! property tests verify correctness properties like: +//! - Literal bytes are preserved exactly +//! - Uppercase identifiers are classified as constants +//! - Instruction args are correctly distinguished from ctx accounts +//! - Classification is deterministic + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use syn::parse_str; + + use crate::light_pdas::{ + account::seed_extraction::{classify_seed_expr, ClassifiedSeed, InstructionArgSet}, + shared_utils::is_constant_identifier, + }; + + // ======================================================================== + // Helper functions + // ======================================================================== + + /// Check if ClassifiedSeed variants match (ignoring inner values) + fn variant_matches(a: &ClassifiedSeed, b: &ClassifiedSeed) -> bool { + matches!( + (a, b), + (ClassifiedSeed::Literal(_), ClassifiedSeed::Literal(_)) + | (ClassifiedSeed::Constant(_), ClassifiedSeed::Constant(_)) + | (ClassifiedSeed::CtxAccount(_), ClassifiedSeed::CtxAccount(_)) + | ( + ClassifiedSeed::DataField { .. }, + ClassifiedSeed::DataField { .. } + ) + | ( + ClassifiedSeed::FunctionCall { .. }, + ClassifiedSeed::FunctionCall { .. } + ) + ) + } + + // ======================================================================== + // Strategies for generating test inputs + // ======================================================================== + + /// Strategy for generating valid lowercase identifiers (for accounts/fields) + fn arb_lowercase_ident() -> impl Strategy { + "[a-z][a-z0-9_]{0,15}" + } + + /// Strategy for generating valid uppercase identifiers (for constants) + fn arb_uppercase_ident() -> impl Strategy { + "[A-Z][A-Z0-9_]{0,15}" + } + + /// Strategy for generating ASCII bytes safe for byte string literals + /// Excludes quotes, backslashes, and non-printable characters + fn arb_safe_ascii_bytes() -> impl Strategy> { + prop::collection::vec( + prop::sample::select( + (0x20u8..=0x7E) + .filter(|&b| b != b'"' && b != b'\\') + .collect::>(), + ), + 0..32, + ) + } + + /// Strategy for generating valid seed expression strings + fn arb_seed_expr() -> impl Strategy { + prop_oneof![ + // Literals (use safe ASCII for reliability) + "[a-z]{1,20}".prop_map(|s| format!("b\"{}\"", s)), + "[a-z]{1,20}".prop_map(|s| format!("b\"{}\"[..]", s)), + // Constants + arb_uppercase_ident(), + // Account refs + arb_lowercase_ident().prop_map(|s| format!("{}.key().as_ref()", s)), + // Data fields + arb_lowercase_ident().prop_map(|s| format!("params.{}.as_ref()", s)), + // With conversion + arb_lowercase_ident().prop_map(|s| format!("params.{}.to_le_bytes().as_ref()", s)), + ] + } + + // ======================================================================== + // Property 1: Literal Byte Preservation + // ======================================================================== + + proptest! { + /// Byte string literals should preserve their bytes exactly. + #[test] + fn literal_preserves_bytes(bytes in arb_safe_ascii_bytes()) { + // Convert bytes to string for byte literal + let byte_str = String::from_utf8(bytes.clone()).unwrap_or_default(); + if byte_str.is_empty() { + return Ok(()); + } + + let literal = format!("b\"{}\"", byte_str); + if let Ok(expr) = parse_str::(&literal) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::Literal(output_bytes)) = result { + prop_assert_eq!(output_bytes, bytes, "Literal bytes should be preserved exactly"); + } + } + } + + /// Byte string literals with slice syntax should also preserve bytes. + #[test] + fn literal_slice_preserves_bytes(s in "[a-z]{1,20}") { + let literal = format!("b\"{}\"[..]", s); + let expr: syn::Expr = parse_str(&literal).unwrap(); + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args).unwrap(); + + if let ClassifiedSeed::Literal(output_bytes) = result { + prop_assert_eq!(output_bytes, s.as_bytes(), "Slice literal bytes should be preserved"); + } else { + prop_assert!(false, "Expected Literal variant"); + } + } + } + + // ======================================================================== + // Property 2: Constant Detection (Uppercase Rule) + // ======================================================================== + + proptest! { + /// All-uppercase identifiers should be classified as constants. + #[test] + fn uppercase_is_constant(name in arb_uppercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + if let Ok(classified) = result { + prop_assert!( + matches!(classified, ClassifiedSeed::Constant(_)), + "Uppercase identifier '{}' should be Constant, got {:?}", + name, + classified + ); + } + } + } + + /// Lowercase identifiers should NOT be classified as constants. + #[test] + fn lowercase_not_constant(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + if let Ok(classified) = result { + prop_assert!( + !matches!(classified, ClassifiedSeed::Constant(_)), + "Lowercase identifier '{}' should NOT be Constant, got {:?}", + name, + classified + ); + } + } + } + + /// Mixed-case identifiers starting with uppercase should NOT be constants. + #[test] + fn mixed_case_not_constant(upper in "[A-Z]", lower in "[a-z]{1,10}") { + let name = format!("{}{}", upper, lower); + prop_assume!(!is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + if let Ok(classified) = result { + prop_assert!( + !matches!(classified, ClassifiedSeed::Constant(_)), + "Mixed-case identifier '{}' should NOT be Constant", + name + ); + } + } + } + } + + // ======================================================================== + // Property 3: Instruction Arg Detection + // ======================================================================== + + proptest! { + /// An identifier that IS in instruction_args should become DataField. + #[test] + fn instruction_arg_becomes_data_field(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + let args = InstructionArgSet::from_names(vec![name.clone()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(classified) = result { + prop_assert!( + matches!(classified, ClassifiedSeed::DataField { .. }), + "Identifier '{}' in instruction_args should be DataField, got {:?}", + name, + classified + ); + } + } + } + + /// An identifier that is NOT in instruction_args should become CtxAccount. + #[test] + fn non_instruction_arg_becomes_ctx_account(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + let args = InstructionArgSet::empty(); // name NOT in args + let result = classify_seed_expr(&expr, &args); + + if let Ok(classified) = result { + prop_assert!( + matches!(classified, ClassifiedSeed::CtxAccount(_)), + "Identifier '{}' NOT in instruction_args should be CtxAccount, got {:?}", + name, + classified + ); + } + } + } + + /// Field access on instruction arg should extract the field name. + #[test] + fn instruction_arg_field_access( + param_name in arb_lowercase_ident(), + field_name in arb_lowercase_ident() + ) { + prop_assume!(!param_name.is_empty() && !field_name.is_empty()); + prop_assume!(param_name != field_name); // Avoid ambiguity + + let expr_str = format!("{}.{}", param_name, field_name); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec![param_name.clone()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { field_name: extracted, .. }) = result { + prop_assert_eq!( + extracted.to_string(), + field_name, + "Field name should be extracted correctly" + ); + } + } + } + } + + // ======================================================================== + // Property 4: Method Call Unwrapping (.as_ref() transparency) + // ======================================================================== + + proptest! { + /// .as_ref() should be transparent - the underlying expression type is preserved. + #[test] + fn as_ref_preserves_base_classification(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + let base_expr_str = name.clone(); + let with_as_ref = format!("{}.as_ref()", name); + + if let (Ok(base_expr), Ok(wrapped_expr)) = ( + parse_str::(&base_expr_str), + parse_str::(&with_as_ref) + ) { + let args = InstructionArgSet::empty(); + let base_result = classify_seed_expr(&base_expr, &args); + let wrapped_result = classify_seed_expr(&wrapped_expr, &args); + + // Both should succeed or both should fail + prop_assert_eq!( + base_result.is_ok(), + wrapped_result.is_ok(), + "Base and wrapped should have same success/failure" + ); + + if let (Ok(base), Ok(wrapped)) = (base_result, wrapped_result) { + prop_assert!( + variant_matches(&base, &wrapped), + "as_ref() should be transparent: base={:?}, wrapped={:?}", + base, + wrapped + ); + } + } + } + + /// .as_bytes() should also be transparent. + #[test] + fn as_bytes_preserves_base_classification(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + let base_expr_str = name.clone(); + let with_as_bytes = format!("{}.as_bytes()", name); + + if let (Ok(base_expr), Ok(wrapped_expr)) = ( + parse_str::(&base_expr_str), + parse_str::(&with_as_bytes) + ) { + let args = InstructionArgSet::empty(); + let base_result = classify_seed_expr(&base_expr, &args); + let wrapped_result = classify_seed_expr(&wrapped_expr, &args); + + prop_assert_eq!( + base_result.is_ok(), + wrapped_result.is_ok(), + "Base and wrapped should have same success/failure" + ); + + if let (Ok(base), Ok(wrapped)) = (base_result, wrapped_result) { + prop_assert!( + variant_matches(&base, &wrapped), + "as_bytes() should be transparent" + ); + } + } + } + } + + // ======================================================================== + // Property 5: Determinism + // ======================================================================== + + proptest! { + /// Classification should be deterministic - same input always gives same output. + #[test] + fn classification_is_deterministic(expr_str in arb_seed_expr()) { + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec!["params".to_string()]); + + let result1 = classify_seed_expr(&expr, &args); + let result2 = classify_seed_expr(&expr, &args); + + prop_assert_eq!( + result1.is_ok(), + result2.is_ok(), + "Classification should consistently succeed or fail" + ); + + if let (Ok(r1), Ok(r2)) = (result1, result2) { + prop_assert_eq!( + format!("{:?}", r1), + format!("{:?}", r2), + "Classification should be deterministic" + ); + } + } + } + + /// Classification with different args should still be deterministic per args. + #[test] + fn classification_deterministic_with_varying_args( + name in arb_lowercase_ident(), + include_in_args in prop::bool::ANY + ) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + let args = if include_in_args { + InstructionArgSet::from_names(vec![name.clone()]) + } else { + InstructionArgSet::empty() + }; + + let result1 = classify_seed_expr(&expr, &args); + let result2 = classify_seed_expr(&expr, &args); + + prop_assert_eq!( + format!("{:?}", result1), + format!("{:?}", result2), + "Classification should be deterministic with same args" + ); + } + } + } + + // ======================================================================== + // Property 6: Field Name Extraction + // ======================================================================== + + proptest! { + /// params.field_name.as_ref() should extract the correct field name. + #[test] + fn extracts_correct_field_name(field in arb_lowercase_ident()) { + prop_assume!(!field.is_empty()); + + let expr_str = format!("params.{}.as_ref()", field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec!["params".to_string()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { field_name, .. }) = result { + prop_assert_eq!( + field_name.to_string(), + field, + "Field name should be extracted correctly" + ); + } else { + prop_assert!(false, "Expected DataField variant for params.{}.as_ref()", field); + } + } + } + + /// Nested field access should extract the terminal field name. + #[test] + fn extracts_terminal_field_from_nested( + middle in arb_lowercase_ident(), + terminal in arb_lowercase_ident() + ) { + prop_assume!(!middle.is_empty() && !terminal.is_empty()); + prop_assume!(middle != terminal); + + let expr_str = format!("params.{}.{}.as_ref()", middle, terminal); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec!["params".to_string()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { field_name, .. }) = result { + prop_assert_eq!( + field_name.to_string(), + terminal, + "Terminal field name should be extracted from nested access" + ); + } + } + } + } + + // ======================================================================== + // Property 7: Conversion Method Capture + // ======================================================================== + + proptest! { + /// to_le_bytes() conversion should be captured in the result. + #[test] + fn captures_to_le_bytes_conversion(field in arb_lowercase_ident()) { + prop_assume!(!field.is_empty()); + + let expr_str = format!("params.{}.to_le_bytes().as_ref()", field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec!["params".to_string()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { conversion, field_name, .. }) = result { + prop_assert_eq!( + field_name.to_string(), + field, + "Field name should match" + ); + prop_assert!( + conversion.is_some(), + "Conversion should be captured" + ); + prop_assert_eq!( + conversion.map(|c| c.to_string()), + Some("to_le_bytes".to_string()), + "Conversion should be to_le_bytes" + ); + } else { + prop_assert!(false, "Expected DataField variant"); + } + } + } + + /// to_be_bytes() conversion should also be captured. + #[test] + fn captures_to_be_bytes_conversion(field in arb_lowercase_ident()) { + prop_assume!(!field.is_empty()); + + let expr_str = format!("params.{}.to_be_bytes().as_ref()", field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec!["params".to_string()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { conversion, .. }) = result { + prop_assert_eq!( + conversion.map(|c| c.to_string()), + Some("to_be_bytes".to_string()), + "Conversion should be to_be_bytes" + ); + } + } + } + + /// Bare instruction arg with to_le_bytes should capture conversion. + #[test] + fn bare_arg_captures_conversion(arg_name in arb_lowercase_ident()) { + prop_assume!(!arg_name.is_empty()); + prop_assume!(!is_constant_identifier(&arg_name)); + + let expr_str = format!("{}.to_le_bytes().as_ref()", arg_name); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec![arg_name.clone()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { field_name, conversion }) = result { + prop_assert_eq!( + field_name.to_string(), + arg_name, + "Field name should be the arg name itself" + ); + prop_assert_eq!( + conversion.map(|c| c.to_string()), + Some("to_le_bytes".to_string()), + "Conversion should be captured" + ); + } + } + } + } + + // ======================================================================== + // Property 8: Account Key Method + // ======================================================================== + + proptest! { + /// account.key().as_ref() should be classified as CtxAccount. + #[test] + fn account_key_as_ref_is_ctx_account(account in arb_lowercase_ident()) { + prop_assume!(!account.is_empty()); + prop_assume!(!is_constant_identifier(&account)); + + let expr_str = format!("{}.key().as_ref()", account); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + if let Ok(classified) = result { + prop_assert!( + matches!(classified, ClassifiedSeed::CtxAccount(ref ident) if *ident == account), + "account.key().as_ref() should be CtxAccount({}), got {:?}", + account, + classified + ); + } + } + } + + /// Account key on instruction arg field should be DataField. + #[test] + fn instruction_arg_key_is_data_field( + param in arb_lowercase_ident(), + field in arb_lowercase_ident() + ) { + prop_assume!(!param.is_empty() && !field.is_empty()); + + let expr_str = format!("{}.{}.key().as_ref()", param, field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::from_names(vec![param.clone()]); + let result = classify_seed_expr(&expr, &args); + + if let Ok(ClassifiedSeed::DataField { field_name, .. }) = result { + prop_assert_eq!( + field_name.to_string(), + field, + "Field name should be extracted from key() call on instruction arg" + ); + } + } + } + } + + // ======================================================================== + // Property 9: Reference Unwrapping + // ======================================================================== + + proptest! { + /// &expr should be equivalent to expr for classification purposes. + #[test] + fn reference_is_transparent(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + let base_str = name.clone(); + let ref_str = format!("&{}", name); + + if let (Ok(base_expr), Ok(ref_expr)) = ( + parse_str::(&base_str), + parse_str::(&ref_str) + ) { + let args = InstructionArgSet::empty(); + let base_result = classify_seed_expr(&base_expr, &args); + let ref_result = classify_seed_expr(&ref_expr, &args); + + prop_assert_eq!( + base_result.is_ok(), + ref_result.is_ok(), + "Base and ref should have same success/failure" + ); + + if let (Ok(base), Ok(referenced)) = (base_result, ref_result) { + prop_assert!( + variant_matches(&base, &referenced), + "& should be transparent: base={:?}, ref={:?}", + base, + referenced + ); + } + } + } + } + + // ======================================================================== + // Property 10: Instruction Arg Precedence + // ======================================================================== + + proptest! { + /// When a name is in instruction_args, it should always be DataField, not CtxAccount. + /// This tests the precedence rule. + #[test] + fn instruction_arg_takes_precedence(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + prop_assume!(!is_constant_identifier(&name)); + + if let Ok(expr) = parse_str::(&name) { + // With empty args -> CtxAccount + let empty_args = InstructionArgSet::empty(); + let without_arg = classify_seed_expr(&expr, &empty_args); + + // With name in args -> DataField + let with_args = InstructionArgSet::from_names(vec![name.clone()]); + let with_arg = classify_seed_expr(&expr, &with_args); + + if let (Ok(without), Ok(with)) = (without_arg, with_arg) { + prop_assert!( + matches!(without, ClassifiedSeed::CtxAccount(_)), + "Without args, '{}' should be CtxAccount", + name + ); + prop_assert!( + matches!(with, ClassifiedSeed::DataField { .. }), + "With args, '{}' should be DataField", + name + ); + } + } + } + } + + // ======================================================================== + // Property 11: Rust Keywords in Seeds + // ======================================================================== + + proptest! { + /// `self.field` expressions should be classified as CtxAccount. + /// The `self` keyword refers to the struct, and field access on it + /// should be treated as a context account reference. + #[test] + fn self_field_is_ctx_account(field in arb_lowercase_ident()) { + prop_assume!(!field.is_empty()); + + let expr_str = format!("self.{}", field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "self.{} should parse successfully", + field + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::CtxAccount(_)), + "self.{} should be classified as CtxAccount", + field + ); + } + } + + /// `self.field.as_ref()` should be classified as CtxAccount. + #[test] + fn self_field_as_ref_is_ctx_account(field in arb_lowercase_ident()) { + prop_assume!(!field.is_empty()); + + let expr_str = format!("self.{}.as_ref()", field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "self.{}.as_ref() should parse successfully", + field + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::CtxAccount(_)), + "self.{}.as_ref() should be classified as CtxAccount", + field + ); + } + } + + /// `self.field.key()` should be classified as CtxAccount. + #[test] + fn self_field_key_is_ctx_account(field in arb_lowercase_ident()) { + prop_assume!(!field.is_empty()); + + let expr_str = format!("self.{}.key()", field); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "self.{}.key() should parse successfully", + field + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::CtxAccount(_)), + "self.{}.key() should be classified as CtxAccount", + field + ); + } + } + + /// `Self::CONSTANT` should be classified as Constant. + #[test] + fn self_type_constant_is_constant(constant in arb_uppercase_ident()) { + prop_assume!(!constant.is_empty()); + + let expr_str = format!("Self::{}", constant); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "Self::{} should parse successfully", + constant + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::Constant(_)), + "Self::{} should be classified as Constant", + constant + ); + } + } + + /// `crate::CONSTANT` should be classified as Constant. + #[test] + fn crate_constant_is_constant(constant in arb_uppercase_ident()) { + prop_assume!(!constant.is_empty()); + + let expr_str = format!("crate::{}", constant); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "crate::{} should parse successfully", + constant + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::Constant(_)), + "crate::{} should be classified as Constant", + constant + ); + } + } + + /// `crate::module::CONSTANT` should be classified as Constant. + #[test] + fn crate_module_constant_is_constant( + module in arb_lowercase_ident(), + constant in arb_uppercase_ident() + ) { + prop_assume!(!module.is_empty() && !constant.is_empty()); + + let expr_str = format!("crate::{}::{}", module, constant); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "crate::{}::{} should parse successfully", + module, constant + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::Constant(_)), + "crate::{}::{} should be classified as Constant", + module, constant + ); + } + } + + /// `super::CONSTANT` should be classified as Constant. + #[test] + fn super_constant_is_constant(constant in arb_uppercase_ident()) { + prop_assume!(!constant.is_empty()); + + let expr_str = format!("super::{}", constant); + if let Ok(expr) = parse_str::(&expr_str) { + let args = InstructionArgSet::empty(); + let result = classify_seed_expr(&expr, &args); + + prop_assert!( + result.is_ok(), + "super::{} should parse successfully", + constant + ); + prop_assert!( + matches!(result.unwrap(), ClassifiedSeed::Constant(_)), + "super::{} should be classified as Constant", + constant + ); + } + } + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/seed_extraction_tests.rs b/sdk-libs/macros/src/light_pdas_tests/seed_extraction_tests.rs new file mode 100644 index 0000000000..77be79ee9b --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/seed_extraction_tests.rs @@ -0,0 +1,237 @@ +//! Unit tests for seed extraction and classification. +//! +//! Extracted from `light_pdas/account/seed_extraction.rs`. + +use syn::parse_quote; + +use crate::light_pdas::account::seed_extraction::{ + check_light_account_type, classify_seed_expr, parse_instruction_arg_names, ClassifiedSeed, + InstructionArgSet, +}; + +fn make_instruction_args(names: &[&str]) -> InstructionArgSet { + InstructionArgSet::from_names(names.iter().map(|s| s.to_string())) +} + +#[test] +fn test_bare_pubkey_instruction_arg() { + let args = make_instruction_args(&["owner", "amount"]); + let expr: syn::Expr = parse_quote!(owner); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); +} + +#[test] +fn test_bare_primitive_with_to_le_bytes() { + let args = make_instruction_args(&["amount"]); + let expr: syn::Expr = parse_quote!(amount.to_le_bytes().as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!( + result, + ClassifiedSeed::DataField { + field_name, + conversion: Some(conv) + } if field_name == "amount" && conv == "to_le_bytes" + )); +} + +#[test] +fn test_custom_struct_param_name() { + let args = make_instruction_args(&["input"]); + let expr: syn::Expr = parse_quote!(input.owner.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); +} + +#[test] +fn test_nested_field_access() { + let args = make_instruction_args(&["data"]); + let expr: syn::Expr = parse_quote!(data.inner.key.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "key")); +} + +#[test] +fn test_context_account_not_confused_with_arg() { + let args = make_instruction_args(&["owner"]); // "authority" is NOT an arg + let expr: syn::Expr = parse_quote!(authority.key().as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!( + result, + ClassifiedSeed::CtxAccount(ident) if ident == "authority" + )); +} + +#[test] +fn test_empty_instruction_args() { + let args = InstructionArgSet::empty(); + let expr: syn::Expr = parse_quote!(owner); + let result = classify_seed_expr(&expr, &args).unwrap(); + // Without instruction args, bare ident treated as ctx account + assert!(matches!(result, ClassifiedSeed::CtxAccount(_))); +} + +#[test] +fn test_literal_seed() { + let args = InstructionArgSet::empty(); + let expr: syn::Expr = parse_quote!(b"seed"); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!(result, ClassifiedSeed::Literal(bytes) if bytes == b"seed")); +} + +#[test] +fn test_constant_seed() { + let args = InstructionArgSet::empty(); + let expr: syn::Expr = parse_quote!(SEED_PREFIX); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!(result, ClassifiedSeed::Constant(_))); +} + +#[test] +fn test_standard_params_field_access() { + // Traditional format: #[instruction(params: CreateParams)] + let args = make_instruction_args(&["params"]); + let expr: syn::Expr = parse_quote!(params.owner.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!( + matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); +} + +#[test] +fn test_args_naming_format() { + // Alternative naming: #[instruction(args: MyArgs)] + let args = make_instruction_args(&["args"]); + let expr: syn::Expr = parse_quote!(args.key.as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!(result, ClassifiedSeed::DataField { field_name, .. } if field_name == "key")); +} + +#[test] +fn test_data_naming_format() { + // Alternative naming: #[instruction(data: DataInput)] + let args = make_instruction_args(&["data"]); + let expr: syn::Expr = parse_quote!(data.value.to_le_bytes().as_ref()); + let result = classify_seed_expr(&expr, &args).unwrap(); + assert!(matches!( + result, + ClassifiedSeed::DataField { + field_name, + conversion: Some(conv) + } if field_name == "value" && conv == "to_le_bytes" + )); +} + +#[test] +fn test_format2_multiple_params() { + // Format 2: #[instruction(owner: Pubkey, amount: u64)] + let args = make_instruction_args(&["owner", "amount"]); + + let expr1: syn::Expr = parse_quote!(owner.as_ref()); + let result1 = classify_seed_expr(&expr1, &args).unwrap(); + assert!( + matches!(result1, ClassifiedSeed::DataField { field_name, .. } if field_name == "owner") + ); + + let expr2: syn::Expr = parse_quote!(amount.to_le_bytes().as_ref()); + let result2 = classify_seed_expr(&expr2, &args).unwrap(); + assert!(matches!( + result2, + ClassifiedSeed::DataField { + field_name, + conversion: Some(_) + } if field_name == "amount" + )); +} + +#[test] +fn test_parse_instruction_arg_names() { + // Test that we can parse instruction attributes + let attrs: Vec = vec![parse_quote!(#[instruction(owner: Pubkey)])]; + let args = parse_instruction_arg_names(&attrs).unwrap(); + assert!(args.contains("owner")); +} + +#[test] +fn test_parse_instruction_arg_names_multiple() { + let attrs: Vec = + vec![parse_quote!(#[instruction(owner: Pubkey, amount: u64, flag: bool)])]; + let args = parse_instruction_arg_names(&attrs).unwrap(); + assert!(args.contains("owner")); + assert!(args.contains("amount")); + assert!(args.contains("flag")); +} + +#[test] +fn test_check_light_account_type_mint_namespace() { + // Test that mint:: namespace is detected correctly + let attrs: Vec = vec![parse_quote!( + #[light_account(init, + mint::signer = mint_signer, + mint::authority = fee_payer, + mint::decimals = 6 + )] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA"); + assert!(has_mint, "Should be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); +} + +#[test] +fn test_check_light_account_type_pda_only() { + // Test that plain init (no mint::) is detected as PDA + let attrs: Vec = vec![parse_quote!( + #[light_account(init)] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(has_pda, "Should be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); +} + +#[test] +fn test_check_light_account_type_token_namespace() { + // Test that token:: namespace is not detected as mint (it's neither PDA nor mint nor ATA) + let attrs: Vec = vec![parse_quote!( + #[light_account(token::authority = [b"auth"])] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA (no init)"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); +} + +#[test] +fn test_check_light_account_type_associated_token_init() { + // Test that associated_token:: with init is detected as ATA + let attrs: Vec = vec![parse_quote!( + #[light_account(init, + associated_token::authority = owner, + associated_token::mint = mint + )] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(has_ata, "Should be detected as ATA"); +} + +#[test] +fn test_check_light_account_type_token_init() { + // Test that token:: with init is NOT detected as PDA + let attrs: Vec = vec![parse_quote!( + #[light_account(init, + token::authority = [b"vault_auth"], + token::mint = mint + )] + )]; + let (has_pda, has_mint, has_ata) = check_light_account_type(&attrs); + assert!(!has_pda, "Should NOT be detected as PDA"); + assert!(!has_mint, "Should NOT be detected as mint"); + assert!(!has_ata, "Should NOT be detected as ATA"); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/shared_utils_prop_tests.rs b/sdk-libs/macros/src/light_pdas_tests/shared_utils_prop_tests.rs new file mode 100644 index 0000000000..42397d28e5 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/shared_utils_prop_tests.rs @@ -0,0 +1,425 @@ +//! Property-based tests for shared utility functions. +//! +//! These tests verify correctness properties of: +//! - `is_constant_identifier` - SCREAMING_SNAKE_CASE detection +//! - `extract_terminal_ident` - Expression identifier extraction +//! - `is_base_path` - Path base matching + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use syn::parse_str; + + use crate::light_pdas::shared_utils::{ + extract_terminal_ident, is_base_path, is_constant_identifier, + }; + + // ======================================================================== + // Constants + // ======================================================================== + + /// Rust keywords that should be excluded from identifier generation. + /// These parse as literals or reserved words, not as identifiers. + const RUST_KEYWORDS: &[&str] = &[ + "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", + "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", + "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", + "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", + "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", + ]; + + /// Check if a string is a Rust keyword + fn is_rust_keyword(s: &str) -> bool { + RUST_KEYWORDS.contains(&s) + } + + // ======================================================================== + // Strategies for generating test inputs + // ======================================================================== + + /// Strategy for generating valid uppercase identifiers (for constants) + fn arb_uppercase_ident() -> impl Strategy { + "[A-Z][A-Z0-9_]{0,15}" + } + + /// Strategy for generating valid lowercase identifiers (for variables) + /// Filters out Rust keywords that would parse as literals/reserved words. + fn arb_lowercase_ident() -> impl Strategy { + "[a-z][a-z0-9_]{0,15}".prop_filter("not a Rust keyword", |s| !is_rust_keyword(s)) + } + + /// Strategy for generating mixed-case identifiers + fn arb_mixed_case_ident() -> impl Strategy { + "[A-Z][a-z][A-Za-z0-9_]{0,14}" + } + + /// Strategy for generating arbitrary identifiers (valid Rust identifiers) + fn arb_any_ident() -> impl Strategy { + "[a-zA-Z_][a-zA-Z0-9_]{0,15}" + } + + // ======================================================================== + // Property Tests: is_constant_identifier + // ======================================================================== + + proptest! { + /// All-uppercase identifiers should be accepted as constants. + /// Pattern: "ABC", "A_B_C", "A1", "ABC_123" + #[test] + fn prop_all_uppercase_accepted(name in arb_uppercase_ident()) { + prop_assume!(!name.is_empty()); + let result = is_constant_identifier(&name); + prop_assert!( + result, + "All-uppercase identifier '{}' should be accepted as constant", + name + ); + } + + /// Any lowercase letter in the identifier should cause rejection. + #[test] + fn prop_any_lowercase_rejected(name in arb_mixed_case_ident()) { + prop_assume!(!name.is_empty()); + let result = is_constant_identifier(&name); + prop_assert!( + !result, + "Mixed-case identifier '{}' should NOT be accepted as constant", + name + ); + } + + /// Purely lowercase identifiers should be rejected. + #[test] + fn prop_lowercase_rejected(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + let result = is_constant_identifier(&name); + prop_assert!( + !result, + "Lowercase identifier '{}' should NOT be accepted as constant", + name + ); + } + + /// Empty string should always be rejected. + #[test] + fn prop_empty_rejected(_seed in 0u32..1000) { + let result = is_constant_identifier(""); + prop_assert!(!result, "Empty string should be rejected"); + } + + /// Underscore-only patterns with at least one uppercase letter should be accepted. + #[test] + fn prop_underscore_with_uppercase_accepted(name in "[A-Z]_[A-Z]") { + let result = is_constant_identifier(&name); + prop_assert!( + result, + "Underscore pattern '{}' with uppercase should be accepted", + name + ); + } + + /// Digits after letter should be accepted in constants. + #[test] + fn prop_digits_after_letter_accepted(prefix in "[A-Z]{1,3}", digits in "[0-9]{1,4}") { + let name = format!("{}{}", prefix, digits); + let result = is_constant_identifier(&name); + prop_assert!( + result, + "Constant with digits '{}' should be accepted", + name + ); + } + + /// Leading digit should be rejected - SCREAMING_SNAKE_CASE must start with uppercase letter. + #[test] + fn prop_leading_digit_rejected(digit in "[0-9]", suffix in "[A-Z]{1,5}") { + let name = format!("{}{}", digit, suffix); + let result = is_constant_identifier(&name); + prop_assert!( + !result, + "String '{}' starting with digit should be rejected as constant identifier", + name + ); + } + + /// Classification should be deterministic. + #[test] + fn prop_is_constant_deterministic(name in arb_any_ident()) { + let result1 = is_constant_identifier(&name); + let result2 = is_constant_identifier(&name); + prop_assert_eq!( + result1, result2, + "is_constant_identifier should be deterministic for '{}'", + name + ); + } + + /// Special characters (other than underscore) should cause rejection. + #[test] + fn prop_special_chars_rejected(prefix in "[A-Z]{1,3}", special in r"[!@#$%^&*()-+=]") { + let name = format!("{}{}", prefix, special); + let result = is_constant_identifier(&name); + prop_assert!( + !result, + "Identifier with special char '{}' should be rejected", + name + ); + } + } + + // ======================================================================== + // Property Tests: extract_terminal_ident + // ======================================================================== + + proptest! { + /// Simple path expressions should extract the identifier directly. + #[test] + fn prop_path_extracts_ident(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + + if let Ok(expr) = parse_str::(&name) { + let result = extract_terminal_ident(&expr, false); + prop_assert!( + result.is_some(), + "Path expression '{}' should extract an ident", + name + ); + prop_assert_eq!( + result.unwrap().to_string(), + name, + "Extracted ident should match input" + ); + } + } + + /// Field access should extract the field name. + #[test] + fn prop_field_extracts_name(base in arb_lowercase_ident(), field in arb_lowercase_ident()) { + prop_assume!(!base.is_empty() && !field.is_empty()); + + let expr_str = format!("{}.{}", base, field); + if let Ok(expr) = parse_str::(&expr_str) { + let result = extract_terminal_ident(&expr, false); + prop_assert!( + result.is_some(), + "Field expression '{}' should extract an ident", + expr_str + ); + let extracted = result.unwrap().to_string(); + let expected = field.clone(); + prop_assert_eq!( + extracted, + expected, + "Should extract field name from '{}'", + expr_str + ); + } + } + + /// Method call should extract receiver when key_method_only=false. + #[test] + fn prop_method_extracts_receiver_any(base in arb_lowercase_ident(), method in arb_lowercase_ident()) { + prop_assume!(!base.is_empty() && !method.is_empty()); + + let expr_str = format!("{}.{}()", base, method); + if let Ok(expr) = parse_str::(&expr_str) { + let result = extract_terminal_ident(&expr, false); + prop_assert!( + result.is_some(), + "Method call '{}' should extract receiver when key_method_only=false", + expr_str + ); + let extracted = result.unwrap().to_string(); + let expected = base.clone(); + prop_assert_eq!( + extracted, + expected, + "Should extract receiver from '{}'", + expr_str + ); + } + } + + /// key() method should extract receiver when key_method_only=true. + #[test] + fn prop_key_method_extracts_receiver(base in arb_lowercase_ident()) { + prop_assume!(!base.is_empty()); + + let expr_str = format!("{}.key()", base); + if let Ok(expr) = parse_str::(&expr_str) { + let result = extract_terminal_ident(&expr, true); + prop_assert!( + result.is_some(), + "key() method '{}' should extract receiver", + expr_str + ); + prop_assert_eq!( + result.unwrap().to_string(), + base, + "Should extract receiver from key() call" + ); + } + } + + /// Non-key methods should return None when key_method_only=true. + #[test] + fn prop_non_key_method_filtered(base in arb_lowercase_ident(), method in "[a-z]{2,8}") { + prop_assume!(!base.is_empty() && method != "key"); + + let expr_str = format!("{}.{}()", base, method); + if let Ok(expr) = parse_str::(&expr_str) { + let result = extract_terminal_ident(&expr, true); + prop_assert!( + result.is_none(), + "Non-key method '{}' should return None when key_method_only=true", + expr_str + ); + } + } + + /// Reference expressions should be transparent. + #[test] + fn prop_reference_transparent(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + + let base_str = name.clone(); + let ref_str = format!("&{}", name); + + if let (Ok(base_expr), Ok(ref_expr)) = ( + parse_str::(&base_str), + parse_str::(&ref_str) + ) { + let base_result = extract_terminal_ident(&base_expr, false); + let ref_result = extract_terminal_ident(&ref_expr, false); + + prop_assert_eq!( + base_result.map(|i| i.to_string()), + ref_result.map(|i| i.to_string()), + "Reference should be transparent: '{}' vs '&{}'", + name, + name + ); + } + } + + /// Nested field access should extract terminal field name. + #[test] + fn prop_nested_field_extracts_terminal( + a in arb_lowercase_ident(), + b in arb_lowercase_ident(), + c in arb_lowercase_ident() + ) { + prop_assume!(!a.is_empty() && !b.is_empty() && !c.is_empty()); + + let expr_str = format!("{}.{}.{}", a, b, c); + if let Ok(expr) = parse_str::(&expr_str) { + let result = extract_terminal_ident(&expr, false); + prop_assert!( + result.is_some(), + "Nested field '{}' should extract terminal", + expr_str + ); + let extracted = result.unwrap().to_string(); + let expected = c.clone(); + prop_assert_eq!( + extracted, + expected, + "Should extract terminal field from '{}'", + expr_str + ); + } + } + + /// Extraction should be deterministic. + #[test] + fn prop_extract_deterministic(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + + if let Ok(expr) = parse_str::(&name) { + let result1 = extract_terminal_ident(&expr, false); + let result2 = extract_terminal_ident(&expr, false); + prop_assert_eq!( + result1.map(|i| i.to_string()), + result2.map(|i| i.to_string()), + "extract_terminal_ident should be deterministic" + ); + } + } + } + + // ======================================================================== + // Property Tests: is_base_path + // ======================================================================== + + proptest! { + /// Should match when expression starts with exact base. + #[test] + fn prop_matches_exact_base(base in arb_lowercase_ident()) { + prop_assume!(!base.is_empty()); + + if let Ok(expr) = parse_str::(&base) { + let result = is_base_path(&expr, &base); + prop_assert!( + result, + "Path '{}' should match base '{}'", + base, + base + ); + } + } + + /// Should reject when expression starts with different base. + #[test] + fn prop_rejects_different_base( + actual_base in arb_lowercase_ident(), + check_base in arb_lowercase_ident() + ) { + prop_assume!(!actual_base.is_empty() && !check_base.is_empty()); + prop_assume!(actual_base != check_base); + + if let Ok(expr) = parse_str::(&actual_base) { + let result = is_base_path(&expr, &check_base); + prop_assert!( + !result, + "Path '{}' should NOT match base '{}'", + actual_base, + check_base + ); + } + } + + /// Field access expressions should match their base. + #[test] + fn prop_field_access_matches_base(base in arb_lowercase_ident(), field in arb_lowercase_ident()) { + prop_assume!(!base.is_empty() && !field.is_empty()); + + let expr_str = format!("{}.{}", base, field); + if let Ok(expr) = parse_str::(&expr_str) { + // Note: is_base_path only matches simple Path expressions, + // not Field expressions, so this should return false + let result = is_base_path(&expr, &base); + // Field expressions are NOT Path expressions + prop_assert!( + !result, + "Field expression '{}' is not a Path, should return false for base check", + expr_str + ); + } + } + + /// is_base_path should be deterministic. + #[test] + fn prop_is_base_path_deterministic(name in arb_lowercase_ident()) { + prop_assume!(!name.is_empty()); + + if let Ok(expr) = parse_str::(&name) { + let result1 = is_base_path(&expr, &name); + let result2 = is_base_path(&expr, &name); + prop_assert_eq!( + result1, result2, + "is_base_path should be deterministic" + ); + } + } + } +} diff --git a/sdk-libs/macros/src/light_pdas_tests/shared_utils_tests.rs b/sdk-libs/macros/src/light_pdas_tests/shared_utils_tests.rs new file mode 100644 index 0000000000..0ebefa7883 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/shared_utils_tests.rs @@ -0,0 +1,17 @@ +//! Unit tests for shared utility functions. +//! +//! Extracted from `light_pdas/shared_utils.rs`. + +use crate::light_pdas::shared_utils::is_constant_identifier; + +#[test] +fn test_is_constant_identifier() { + assert!(is_constant_identifier("MY_CONSTANT")); + assert!(is_constant_identifier("SEED")); + assert!(is_constant_identifier("SEED_123")); + assert!(is_constant_identifier("A")); + assert!(!is_constant_identifier("myVariable")); + assert!(!is_constant_identifier("my_variable")); + assert!(!is_constant_identifier("MyConstant")); + assert!(!is_constant_identifier("")); +} diff --git a/sdk-libs/macros/src/light_pdas_tests/visitors_tests.rs b/sdk-libs/macros/src/light_pdas_tests/visitors_tests.rs new file mode 100644 index 0000000000..01d5d2c4a5 --- /dev/null +++ b/sdk-libs/macros/src/light_pdas_tests/visitors_tests.rs @@ -0,0 +1,87 @@ +//! Unit tests for AST visitor patterns (FieldExtractor). +//! +//! Extracted from `light_pdas/program/visitors.rs`. + +use syn::Expr; + +use crate::light_pdas::program::visitors::FieldExtractor; + +#[test] +fn test_extract_ctx_accounts_field() { + let expr: Expr = syn::parse_quote!(ctx.accounts.user); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); +} + +#[test] +fn test_extract_ctx_direct_field() { + let expr: Expr = syn::parse_quote!(ctx.program_id); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "program_id"); +} + +#[test] +fn test_extract_data_field() { + let expr: Expr = syn::parse_quote!(data.owner); + let fields = FieldExtractor::data_fields().extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "owner"); +} + +#[test] +fn test_extract_nested_in_method_call() { + let expr: Expr = syn::parse_quote!(ctx.accounts.user.key()); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); +} + +#[test] +fn test_extract_nested_in_reference() { + let expr: Expr = syn::parse_quote!(&ctx.accounts.user.key()); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); +} + +#[test] +fn test_excludes_fields() { + let expr: Expr = syn::parse_quote!(ctx.accounts.fee_payer); + let fields = FieldExtractor::ctx_fields(&["fee_payer"]).extract(&expr); + assert!(fields.is_empty()); +} + +#[test] +fn test_deduplicates_fields() { + let expr: Expr = syn::parse_quote!({ + ctx.accounts.user.key(); + ctx.accounts.user.owner(); + }); + let fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].to_string(), "user"); +} + +#[test] +fn test_extract_from_call_args() { + let expr: Expr = syn::parse_quote!(some_fn(&ctx.accounts.user, data.amount)); + let ctx_fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + let data_fields = FieldExtractor::data_fields().extract(&expr); + assert_eq!(ctx_fields.len(), 1); + assert_eq!(ctx_fields[0].to_string(), "user"); + assert_eq!(data_fields.len(), 1); + assert_eq!(data_fields[0].to_string(), "amount"); +} + +#[test] +fn test_separate_extractors_for_ctx_and_data() { + let expr: Expr = syn::parse_quote!((ctx.accounts.user, data.amount)); + let ctx_fields = FieldExtractor::ctx_fields(&[]).extract(&expr); + let data_fields = FieldExtractor::data_fields().extract(&expr); + assert_eq!(ctx_fields.len(), 1); + assert_eq!(ctx_fields[0].to_string(), "user"); + assert_eq!(data_fields.len(), 1); + assert_eq!(data_fields[0].to_string(), "amount"); +}