diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c78c27b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,153 @@ +name: release + +# Triggered by pushing an `audit-frozen-v*` tag (or a `v*` semver release tag). +# Builds the mainnet and devnet SBF binaries, records their SHA-256 hashes, +# and attaches the .so files + a manifest to the GitHub Release. +# +# The release artifacts are the source of truth for what gets deployed to +# mainnet — production deploys must use a binary downloaded from a release, +# not a locally-built one. See docs/MAINNET_DEPLOY.md. + +on: + push: + tags: + - 'audit-frozen-v*' + - 'v*.*.*' + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write # for creating GitHub Releases + + steps: + - uses: actions/checkout@v4 + with: + # Full history so source_revision in security_txt embeds the right SHA. + fetch-depth: 0 + + - name: Install Solana toolchain + run: | + sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" + echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" + + - name: Pin Solana CLI to the version declared in Cargo.toml + run: | + DECLARED=$(grep -A1 'workspace.metadata.cli' Cargo.toml | grep solana | sed -E 's/.*"([^"]+)".*/\1/') + INSTALLED=$(solana --version | awk '{print $2}') + echo "Declared: $DECLARED" + echo "Installed: $INSTALLED" + if [ "$DECLARED" != "$INSTALLED" ]; then + echo "::warning::Installed Solana CLI ($INSTALLED) does not match declared ($DECLARED). Verified-build hashes may differ from what consumers reproduce." + fi + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: release-${{ hashFiles('**/Cargo.lock') }}-${{ github.ref_name }} + + - name: Build mainnet binary + working-directory: program + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: cargo build-sbf --features mainnet + + - name: Hash + stage mainnet artifact + run: | + mkdir -p release-artifacts + cp target/deploy/lazorkit_program.so release-artifacts/lazorkit_program-mainnet.so + MAINNET_SHA=$(shasum -a 256 release-artifacts/lazorkit_program-mainnet.so | awk '{print $1}') + echo "MAINNET_SHA=$MAINNET_SHA" >> "$GITHUB_ENV" + echo "mainnet sha256: $MAINNET_SHA" + + - name: Build devnet binary + working-directory: program + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: cargo build-sbf --features devnet + + - name: Hash + stage devnet artifact + run: | + cp target/deploy/lazorkit_program.so release-artifacts/lazorkit_program-devnet.so + DEVNET_SHA=$(shasum -a 256 release-artifacts/lazorkit_program-devnet.so | awk '{print $1}') + echo "DEVNET_SHA=$DEVNET_SHA" >> "$GITHUB_ENV" + echo "devnet sha256: $DEVNET_SHA" + + - name: Verify binaries differ + run: | + if [ "$MAINNET_SHA" = "$DEVNET_SHA" ]; then + echo "::error::mainnet and devnet binaries are identical — dual-cluster mechanism broken" + exit 1 + fi + + - name: Stage IDL + keypair + run: | + cp program/idl.json release-artifacts/idl.json + # The keypair file is regenerated per build; useful as a record but + # NOT for deployment (the actual mainnet keypair is held off-CI). + if [ -f target/deploy/lazorkit_program-keypair.json ]; then + cp target/deploy/lazorkit_program-keypair.json release-artifacts/build-keypair.json + fi + + - name: Write release manifest + run: | + cat > release-artifacts/MANIFEST.txt <> "$GITHUB_PATH" + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: sbf-${{ hashFiles('**/Cargo.lock') }} + + - name: Build mainnet binary + working-directory: program + run: cargo build-sbf --features mainnet + + - name: Record mainnet hash + id: mainnet + run: | + M=$(shasum -a 256 target/deploy/lazorkit_program.so | awk '{print $1}') + echo "sha=$M" >> "$GITHUB_OUTPUT" + echo "mainnet SBF: $M" + + - name: Build devnet binary + working-directory: program + run: cargo build-sbf --features devnet + + - name: Record devnet hash + id: devnet + run: | + D=$(shasum -a 256 target/deploy/lazorkit_program.so | awk '{print $1}') + echo "sha=$D" >> "$GITHUB_OUTPUT" + echo "devnet SBF: $D" + + - name: Verify binaries differ + run: | + if [ "${{ steps.mainnet.outputs.sha }}" = "${{ steps.devnet.outputs.sha }}" ]; then + echo "ERROR: mainnet + devnet SBF binaries are identical." + echo " Pattern D's compile-time cluster switch has been" + echo " neutralised — likely a refactor removed the cfg gate" + echo " on declare_id! in assertions/src/lib.rs." + exit 1 + fi + echo "✓ binaries differ as expected" + + - name: Verify no-feature build fails with compile_error! + working-directory: program + run: | + # Capture exit code separately — `cmd | tee` returns tee's exit + # (always 0), masking cargo's failure. `set -o pipefail` would + # also work, but capturing to a file gives us the log to search + # afterwards regardless of pipeline state. + set +e + cargo build-sbf > /tmp/build.log 2>&1 + BUILD_EXIT=$? + set -e + cat /tmp/build.log + if [ "$BUILD_EXIT" -eq 0 ]; then + echo "ERROR: cargo build-sbf without --features mainnet/devnet succeeded." + echo " The compile_error! in assertions/src/lib.rs is no longer firing." + exit 1 + fi + if ! grep -q "pick exactly one cluster" /tmp/build.log; then + echo "ERROR: build failed but not with the expected compile_error message." + echo " Expected: 'pick exactly one cluster — --features mainnet OR --features devnet'" + exit 1 + fi + echo "✓ no-feature build correctly rejected by compile_error!" + + - name: Verify both-features build fails + working-directory: program + run: | + set +e + cargo build-sbf --features mainnet --features devnet > /tmp/build-both.log 2>&1 + BUILD_EXIT=$? + set -e + cat /tmp/build-both.log + if [ "$BUILD_EXIT" -eq 0 ]; then + echo "ERROR: cargo build-sbf with BOTH features succeeded." + echo " The compile_error! mutual-exclusion guard is broken." + exit 1 + fi + if ! grep -q "pick exactly one cluster" /tmp/build-both.log; then + echo "ERROR: build failed but not with the expected compile_error message." + exit 1 + fi + echo "✓ both-features build correctly rejected" diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d8d16..6fee6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Session action permissions: 8 immutable permission rules attachable at session creation — `SolLimit`, `SolRecurringLimit`, `SolMaxPerTx`, `TokenLimit`, `TokenRecurringLimit`, `TokenMaxPerTx`, `ProgramWhitelist`, `ProgramBlacklist`. Action discriminators (1, 2, 3, 4, 5, 6, 10, 11) and the 11-byte header layout match `lazorkit-protocol` so the unified SDK can encode actions identically for both builds. +- `SessionAccount` is now variable-size: a session can carry a trailing action buffer (max 16 actions, ≤ 2048 bytes) validated at creation time. +- `CreateSession` instruction data accepts the new `[actions_len: u16][actions: N]` extension after the legacy 40-byte args; old 40-byte clients continue to work via the legacy parser branch. +- Pre-CPI action enforcement at `Execute` time: program whitelist/blacklist checks against each CPI target. +- Post-CPI action enforcement: SOL/token spending caps with saturating arithmetic; recurring-window resets aligned to slot boundaries; per-execute SOL outflow tracked across all CPIs for `SolMaxPerTx`. +- Vault-invariant defenses against `System::Assign` / `SetAuthority` / `Approve` escapes: vault owner + data-length snapshotted pre-CPI and verified unchanged post-CPI; vault-owned token accounts on listed mints have their owner / delegate / close_authority fields snapshotted and verified. +- Anti-CPI guard for session-authenticated `Execute`: stack-height must be 1 (rejects wrapper programs chaining through `Execute`). +- Error codes 3020–3029 (action validation + enforcement) and 3030–3032 (`SessionVaultOwnerChanged`, `SessionVaultDataLenChanged`, `SessionTokenAuthorityChanged`). +- Dual-cluster Cargo features (`mainnet`, `devnet`): the embedded program ID is chosen at compile time via a feature flag with a `compile_error!` if neither / both is set. The `mainnet` feature embeds `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` (same slot as `lazorkit-protocol`) for the foundation deployment; `devnet` keeps `FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao`. +- `security.txt` block embedded via `solana-security-txt` macro: links to SECURITY.md, contact email, source repo, source revision (from `GITHUB_SHA`), and the Accretion audit PDF. +- Zero-copy `CompactInstructionRef` parser (`parse_compact_instructions_ref_with_len`) used by the Execute hot path — no per-instruction `Vec` allocations for account-index bytes or instruction data. +- Cherry-pick guardrails: `scripts/fee-paths.txt` declares forbidden fee-surface paths and symbols, `scripts/check-no-fee.sh` verifies the working tree (used by CI), `scripts/strip-fee.sh` auto-removes fee files post-cherry-pick. +- CI workflow `check-no-fee` runs the verifier on every PR. +- CI workflow `sbf-cluster-check` builds both mainnet and devnet SBF binaries, verifies their hashes differ, and asserts that an unflagged `cargo build-sbf` fails with the expected `compile_error!`. +- `scripts/build-all.sh ` now drives a feature-flagged build + IDL regen + SDK regen in one step. The previous `scripts/sync-program-id.sh` is removed (program ID is now a compile-time feature, not a sed target). +- `solana-security-txt` and `default-env` dependencies, `[workspace.metadata.cli]` pinning Solana CLI 3.0.4 for verified builds. - Unified SDK API with discriminated union signer types (`ed25519()`, `secp256r1()`, `session()` helper constructors) - `CreateWalletOwner` union type: single `createWallet()` method for both Ed25519 and Secp256r1 - `AdminSigner` union type for admin operations (addAuthority, removeAuthority, transferOwnership, createSession) diff --git a/Cargo.lock b/Cargo.lock index 889967d..baacd71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -159,7 +165,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" dependencies = [ - "quote", + "quote 1.0.44", "syn 1.0.109", ] @@ -171,8 +177,8 @@ checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ "num-bigint 0.4.6", "num-traits", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -207,8 +213,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -393,7 +399,7 @@ dependencies = [ "borsh-derive-internal", "borsh-schema-derive-internal", "proc-macro-crate 0.1.5", - "proc-macro2", + "proc-macro2 1.0.106", "syn 1.0.109", ] @@ -405,8 +411,8 @@ checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" dependencies = [ "once_cell", "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -416,8 +422,8 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -427,8 +433,8 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -493,8 +499,8 @@ version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -548,8 +554,8 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -774,8 +780,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -797,8 +803,8 @@ checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "strsim", "syn 2.0.114", ] @@ -810,10 +816,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", - "quote", + "quote 1.0.44", "syn 2.0.114", ] +[[package]] +name = "default-env" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f753eb82d29277e79efc625e84aecacfd4851ee50e05a8573a4740239a77bfd3" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + [[package]] name = "der" version = "0.7.10" @@ -837,8 +854,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -889,8 +906,8 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -999,8 +1016,8 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -1092,6 +1109,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1271,6 +1294,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1620,6 +1654,7 @@ dependencies = [ "assertions", "base64ct", "blake3", + "default-env", "ecdsa", "getrandom 0.2.17", "litesvm", @@ -1633,6 +1668,7 @@ dependencies = [ "shank", "shank_idl", "solana-sdk", + "solana-security-txt", ] [[package]] @@ -1868,8 +1904,8 @@ dependencies = [ name = "no-padding" version = "0.1.0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -1924,8 +1960,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -1987,8 +2023,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2025,8 +2061,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2228,6 +2264,15 @@ dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +dependencies = [ + "unicode-xid", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2252,18 +2297,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +dependencies = [ + "proc-macro2 0.4.30", +] + [[package]] name = "quote" version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.106", ] [[package]] @@ -2597,8 +2651,8 @@ version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2653,8 +2707,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2725,8 +2779,8 @@ name = "shank_macro" version = "0.4.2" source = "git+https://github.com/anagrambuild/shank.git#d4f046b22b87c896fdb77e55256d74dad6a13a68" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "shank_macro_impl", "shank_render", "syn 1.0.109", @@ -2738,8 +2792,8 @@ version = "0.4.2" source = "git+https://github.com/anagrambuild/shank.git#d4f046b22b87c896fdb77e55256d74dad6a13a68" dependencies = [ "anyhow", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "serde", "syn 1.0.109", ] @@ -2749,8 +2803,8 @@ name = "shank_render" version = "0.4.2" source = "git+https://github.com/anagrambuild/shank.git#d4f046b22b87c896fdb77e55256d74dad6a13a68" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "shank_macro_impl", ] @@ -4222,8 +4276,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86280da8b99d03560f6ab5aca9de2e38805681df34e0bb8f238e69b29433b9df" dependencies = [ "bs58", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -4271,6 +4325,15 @@ dependencies = [ "solana-sdk-ids", ] +[[package]] +name = "solana-security-txt" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "156bb61a96c605fa124e052d630dba2f6fb57e08c7d15b757e1e958b3ed7b3fe" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "solana-seed-derivable" version = "2.2.1" @@ -4862,14 +4925,25 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "0.15.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "unicode-xid", +] + [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "unicode-ident", ] @@ -4879,8 +4953,8 @@ version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "unicode-ident", ] @@ -4896,8 +4970,8 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -4955,8 +5029,8 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -4966,8 +5040,8 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5162,6 +5236,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" + [[package]] name = "universal-hash" version = "0.5.1" @@ -5296,7 +5376,7 @@ version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "quote", + "quote 1.0.44", "wasm-bindgen-macro-support", ] @@ -5307,8 +5387,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", "wasm-bindgen-shared", ] @@ -5388,8 +5468,8 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5399,8 +5479,8 @@ version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5698,8 +5778,8 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", "synstructure", ] @@ -5719,8 +5799,8 @@ version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5739,8 +5819,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", "synstructure", ] @@ -5760,8 +5840,8 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5793,8 +5873,8 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d7e7d96..862f8d3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -25,20 +25,39 @@ This document outlines the standard procedures for building, deploying, and test ### A. Build Program +The program ID is chosen at build time via the `mainnet` / `devnet` cargo features +(see `assertions/src/lib.rs`). Exactly one must be set; an unflagged build fails +with a `compile_error!`. + ```bash -cargo build-sbf +# Devnet build — embeds FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao +cargo build-sbf --features devnet + +# Mainnet build — embeds LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi +# (slot is shared with lazorkit-protocol — see "Mainnet Deploy Strategy" below) +cargo build-sbf --features mainnet ``` +The convenience script `./scripts/build-all.sh ` builds, generates +the IDL, and regenerates the SDK in one shot. + ### B. Run Rust Tests ```bash -cargo test +cargo test --features devnet ``` +The `--features devnet` flag is required because the assertions crate's +`compile_error!` fires on un-flagged builds. Choose either feature — host-side +tests use a runtime `program_id: Pubkey::new_unique()`, so the embedded ID +doesn't affect test outcomes. + ### C. IDL Generation (using Shank) ```bash -cd program && shank idl -o . --out-filename idl.json -p FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao +cd program +PROGRAM_ID=$(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) +shank idl -o . --out-filename idl.json -p "$PROGRAM_ID" ``` ### D. SDK Generation (using Solita) @@ -67,19 +86,42 @@ cd tests-sdk && npm run benchmark Measures CU usage and transaction sizes for all instructions, including deferred execution (Authorize TX1 + ExecuteDeferred TX2). -### G. Program ID Sync +### G. Deploy to Devnet ```bash -./scripts/sync-program-id.sh +cargo build-sbf --features devnet +solana program deploy target/deploy/lazorkit_program.so -u d ``` -### H. Deploy to Devnet +### H. Mainnet Deploy Strategy (Foundation Build) + +program-v2 is the no-fee "foundation" variant of LazorKit. For the duration of +the foundation contract, its mainnet binary occupies the SAME mainnet program +slot as `lazorkit-protocol`'s commercial binary — `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi`. +dApp integrators keep using one stable program ID; only the on-chain behavior +changes when the binary is swapped. + +**During contract:** ```bash -cargo build-sbf -solana program deploy target/deploy/lazorkit_program.so -u d +cargo build-sbf --features mainnet +solana program deploy target/deploy/lazorkit_program.so -u m +# Optionally pin the upgrade authority to a multisig held jointly by foundation +# and the lazorkit team so the post-contract swap can happen. +``` + +**At contract end** — swap to lazorkit-protocol's commercial binary: + +```bash +# In the lazorkit-protocol repo: +cargo build-sbf --features mainnet +solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi ``` +The upgrade authority key must control the `LazorjRF…` slot for both deploys. +There is no separate program-v2 vanity keypair to manage. + ## Troubleshooting - **429 Too Many Requests**: Check RPC credits or use local validator. diff --git a/assertions/Cargo.toml b/assertions/Cargo.toml index e810079..2cd256a 100644 --- a/assertions/Cargo.toml +++ b/assertions/Cargo.toml @@ -7,3 +7,9 @@ edition = "2021" pinocchio = { workspace = true } pinocchio-pubkey = { workspace = true } pinocchio-system = { workspace = true } + +# Cluster selection for the embedded program ID. Exactly one must be set +# at build time; lib.rs emits a compile_error! otherwise. +[features] +mainnet = [] +devnet = [] diff --git a/assertions/src/lib.rs b/assertions/src/lib.rs index 477828f..5c22e52 100644 --- a/assertions/src/lib.rs +++ b/assertions/src/lib.rs @@ -10,9 +10,32 @@ use pinocchio::{ use pinocchio_pubkey::declare_id; use pinocchio_system::ID as SYSTEM_ID; -// LazorKit Program ID +// LazorKit Program ID — chosen at build time via the `mainnet` / `devnet` +// cargo features. Exactly one must be enabled; otherwise the build fails +// loudly via the `compile_error!` below. This prevents accidental cross- +// cluster deploys (a binary compiled with one ID malfunctions if deployed +// to a slot at the other ID — every internal `crate::ID` check fails). +// +// The mainnet ID intentionally matches lazorkit-protocol's mainnet ID: +// program-v2 (no-fee foundation variant) is deployed to that slot for the +// duration of the foundation contract; at contract end, the upgrade +// authority swaps the binary at the same slot to lazorkit-protocol's +// commercial build. dApp integrators keep using the same program ID +// throughout — only the on-chain behavior changes. +#[cfg(all(feature = "mainnet", not(feature = "devnet")))] +declare_id!("LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi"); + +#[cfg(all(feature = "devnet", not(feature = "mainnet")))] declare_id!("FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao"); +#[cfg(any( + all(feature = "mainnet", feature = "devnet"), + all(not(feature = "mainnet"), not(feature = "devnet")) +))] +compile_error!( + "LazorKit: pick exactly one cluster — `--features mainnet` OR `--features devnet`" +); + #[allow(unused_imports)] use std::mem::MaybeUninit; diff --git a/docs/AUDIT_PREP.md b/docs/AUDIT_PREP.md new file mode 100644 index 0000000..0706de7 --- /dev/null +++ b/docs/AUDIT_PREP.md @@ -0,0 +1,127 @@ +# Audit-Frozen Tag Checklist + +Before submitting a `program-v2` revision to Accretion (or any auditor) for +review, walk this checklist and tag the resulting state. The tag becomes the +exact source the audit report references — a deployment that doesn't match +the tag falls outside the audit's scope. + +## Naming convention + +Tags use the format `audit-frozen-vN` where `N` increments per audit cycle. +Example: the first delta audit after the initial Accretion review is +`audit-frozen-v2`. The audit report should cite the tag in its scope section. + +## Pre-tag checklist + +### 1. Code state + +- [ ] Working tree clean (`git status` empty). +- [ ] `git log --oneline main..HEAD` only contains commits intended for this + audit. No experimental work, no half-finished refactors. +- [ ] No `TODO`, `FIXME`, `XXX`, or `unimplemented!` markers in `program/src/` + or `assertions/src/`. Anything genuinely unfinished should be reverted + from the audit branch. +- [ ] No `dbg!`, `println!`, `eprintln!`, or commented-out code in + `program/src/` or `assertions/src/`. + +```bash +git grep -nE "TODO|FIXME|XXX|unimplemented!|dbg!|println!|eprintln!" -- program/src assertions/src +``` + +### 2. Build verification + +- [ ] `cargo build-sbf --features mainnet` succeeds. Record the SHA-256 of + `target/deploy/lazorkit_program.so`. +- [ ] `cargo build-sbf --features devnet` succeeds. Record the SHA-256. +- [ ] The two hashes differ (verifies the dual-cluster mechanism is intact). +- [ ] `cargo build-sbf` (no features) fails with the expected + `compile_error!` message containing "pick exactly one cluster". +- [ ] The CI `sbf-cluster-check` job is green for the tagged commit. + +### 3. Test suite + +- [ ] `cargo test --features devnet` — all unit + litesvm integration tests + pass. +- [ ] `cd tests-sdk && npm test` (with the validator running and the + devnet-built `.so` loaded) — all integration tests pass. +- [ ] No tests are skipped, ignored (`#[ignore]`), or commented out without + a tracking issue. + +```bash +git grep -nE "#\[ignore\]|it\.skip|xit\(|describe\.skip" -- program tests-sdk +``` + +### 4. Fee surface invariant (foundation build) + +- [ ] `bash scripts/check-no-fee.sh` exits 0. The CI `check-no-fee` job is + green for the tagged commit. +- [ ] Diff against the previous audit-frozen tag has no new files matching + paths in `scripts/fee-paths.txt`. + +```bash +git diff audit-frozen-v$(N-1)..HEAD -- 'program/src/state/protocol_config.rs' \ + 'program/src/state/treasury_shard.rs' \ + 'program/src/state/integrator_record.rs' \ + 'program/src/processor/protocol/' +# Should print nothing. +``` + +### 5. Documentation alignment + +- [ ] `CHANGELOG.md` has an entry under `[Unreleased]` describing every + observable behavior change since the last tag. +- [ ] `docs/Architecture.md` reflects new state accounts / instructions. +- [ ] `docs/Costs.md` benchmarks have been re-measured if any instruction + changed CU usage. +- [ ] `SECURITY.md` references the auditor that's about to look at this + revision (or notes the audit is in progress). +- [ ] `program/src/lib.rs` `security_txt!` block — `auditors:` field still + reflects the most recent finalised audit until this one ships. + +### 6. Diff bounded + +- [ ] Touched files are listed in the `Unreleased` CHANGELOG. +- [ ] Each touched file has a clear reason in the commit message that + introduced the change. + +```bash +git diff --name-only audit-frozen-v$(N-1)..HEAD | sort +``` + +### 7. Audit packet contents + +When you push the tag, also produce the audit packet: + +- [ ] PDF / Markdown summary of the changes since the last tag (1–2 pages). +- [ ] List of touched files with line counts. +- [ ] CHANGELOG diff. +- [ ] SHA-256 hashes of the `mainnet` and `devnet` SBF binaries. +- [ ] Output of `solana-verify get-program-hash` against the binaries (so + the auditor can verify they correspond to the source). +- [ ] The two binaries themselves, if the auditor wants byte-level review. +- [ ] Note any out-of-scope changes (typos, README edits, dependency bumps + that don't affect program logic). + +## Tagging + +When every box is checked: + +```bash +git tag -a audit-frozen-vN -m "audit-frozen state submitted for Accretion review N" +git push origin audit-frozen-vN +``` + +The push to origin triggers `.github/workflows/release.yml`, which builds +both feature variants, attaches the binaries + their hashes to a GitHub +Release, and emits the `solana-verify` build attestation for reproducibility. + +## Post-audit + +When the audit comes back with findings: + +1. Branch off the tagged state: `git checkout -b fix/audit-vN audit-frozen-vN`. +2. Land each fix as its own commit with a `Fixes: ` line. +3. After all findings are addressed, repeat this checklist and tag + `audit-frozen-vN.1` (or `vN+1` if the changes are substantial). +4. Update `program/src/lib.rs` `security_txt!` `auditors:` field to reference + the finalised audit report URL once the auditor publishes. diff --git a/docs/MAINNET_DEPLOY.md b/docs/MAINNET_DEPLOY.md new file mode 100644 index 0000000..ff13fb6 --- /dev/null +++ b/docs/MAINNET_DEPLOY.md @@ -0,0 +1,219 @@ +# Mainnet Deployment Runbook (Foundation Build) + +This runbook covers deploying `program-v2` (the no-fee foundation build) to the +LazorKit mainnet program slot, and the binary swap back to `lazorkit-protocol` +(the commercial build) at the end of the foundation contract. + +The same on-chain slot — `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` — hosts +both binaries at different times. dApp integrators keep using one stable +program ID through the transition; only the on-chain behavior changes when +the binary is swapped. + +## Why this scheme + +- **Foundation contract** requires a no-profit deploy (no admin, no protocol + fees). `program-v2` ships exactly that. +- **Brand continuity** — the mainnet program ID was published before the + foundation contract. Switching IDs would force every integrating dApp to + redeploy and migrate their on-chain references. +- **Reversibility** — Solana program upgrades replace the binary at a slot. + The same upgrade authority can swap from `program-v2` → `lazorkit-protocol` + in one transaction once the foundation contract ends. + +## Prerequisites + +- Solana CLI 3.0.4 (pinned in `Cargo.toml [workspace.metadata.cli]`). +- The mainnet program **upgrade authority** — a multisig keypair held jointly + by the foundation and the lazorkit team. (If a single key holds the + authority, transition to multisig before deploy — losing it locks the slot.) +- The mainnet program **address keypair** for `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` + (only needed for the *initial* deploy; subsequent upgrades use the upgrade + authority). +- A funded mainnet wallet for rent + transaction fees (~5 SOL leaves room). +- Audit sign-off on the exact source revision being deployed (see + `docs/AUDIT_PREP.md` for the pre-tag checklist). + +## Initial deploy (program-v2 → mainnet slot) + +1. Verify the source matches the audit-frozen tag: + + ```bash + git fetch --tags + git checkout audit-frozen-vN # whatever tag was audited + git status # must be clean + ``` + +2. Confirm the workspace is on the right toolchain: + + ```bash + cat Cargo.toml | grep -A1 'metadata.cli' # → solana = "3.0.4" + solana --version # → must be 3.0.4 + rustup show active-toolchain # matches rust-toolchain.toml + ``` + +3. Build the mainnet binary: + + ```bash + cd program + cargo build-sbf --features mainnet + cd .. + ``` + +4. Verify the embedded program ID: + + ```bash + solana-keygen pubkey target/deploy/lazorkit_program-keypair.json + # → must print LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi + ``` + + If the printed pubkey is anything else, the build picked up the wrong + keypair. Replace `target/deploy/lazorkit_program-keypair.json` with the + keypair file for `LazorjRF…` before deploying. **Do not deploy with a + mismatched ID** — the binary's compile-time `crate::ID` reads from + `assertions/src/lib.rs` (the `LazorjRF…` constant under the `mainnet` + feature) and will fail every PDA derivation if loaded into a different + slot. + +5. Record the binary hash for the release artifact: + + ```bash + shasum -a 256 target/deploy/lazorkit_program.so + # Compare against the hash published in the GitHub Release for this tag. + ``` + +6. Deploy: + + ```bash + solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id \ + --upgrade-authority + ``` + +7. Verify the deployed program: + + ```bash + solana program show LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi -u m + # Look for: ProgramData Address, Authority, Last Deployed In Slot, Data Len + ``` + + The `Authority` line must match the multisig pubkey that should retain + upgrade control. + +8. Smoke-test on-chain: + + - Create a wallet via the SDK pointing at the foundation flavor. + - Verify no fee transfer occurs (compare lamport balances pre/post). + - Run `solana account ` and confirm the discriminator is `1`. + +## Subsequent upgrades (program-v2 patch deploy) + +For a routine upgrade of the foundation build (e.g., a security fix): + +```bash +git checkout +cd program && cargo build-sbf --features mainnet && cd .. +solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority +``` + +Document each upgrade in `CHANGELOG.md` and update the on-chain `security.txt` +metadata via the next build (the macro embeds `GITHUB_SHA` automatically when +built in CI). + +## Binary swap at contract end (program-v2 → lazorkit-protocol) + +When the foundation contract ends and the slot needs to host the commercial +build again: + +1. In the **`lazorkit-protocol`** repo, check out the audit-frozen tag for + the commercial build: + + ```bash + cd ../lazorkit-protocol + git checkout audit-frozen-commercial-vN + ``` + +2. Build the commercial mainnet binary: + + ```bash + cd program && cargo build-sbf --features mainnet && cd .. + ``` + +3. Verify it embeds the same program ID (`LazorjRF…`): + + ```bash + solana-keygen pubkey target/deploy/lazorkit_program-keypair.json + ``` + +4. Deploy the upgrade. **No `--program-id` flag** — the slot already exists, + we're upgrading it: + + ```bash + solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority + ``` + +5. Initialise the protocol config (commercial-only instruction; no equivalent + exists in the foundation build, so this is a fresh state): + + ```bash + # Use the lazorkit-protocol SDK to send InitializeProtocol + InitializeTreasuryShard. + # This step is only needed the first time the commercial build is on this + # slot; subsequent upgrades preserve the existing ProtocolConfig PDA. + ``` + +6. Smoke-test: + + - Create a wallet — the fee transfer to the treasury shard should succeed. + - Verify the SDK consumer receives `protocolConfigPda` etc. without error. + +## Pre-existing wallet accounts across the swap + +Wallet, Vault, Authority, Session, and DeferredExec PDAs created under the +foundation build remain valid and accessible under the commercial build — +they all derive from the same program ID and the same seed schema, and the +account-discriminator layout is identical between the two builds. + +What changes: + +- Fee accounts (`ProtocolConfig`, `FeeRecord`, `TreasuryShard`) start + appearing once the commercial binary is live. Existing wallets continue to + work; the fee is charged on subsequent `CreateWallet` / `Execute` / + `ExecuteDeferred` calls if the SDK appends fee accounts. +- Sessions created with action permissions under the foundation build remain + enforced under the commercial build — action handling is identical. +- The on-chain `security.txt` updates to advertise `lazorkit-protocol` as the + source repo. Integrators querying it should re-fetch. + +## Rollback + +If a deployed upgrade misbehaves: + +```bash +solana program deploy target/deploy/.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority +``` + +Solana program slots are upgrade-replaceable (so long as the upgrade +authority hasn't been frozen). Keep the previous mainnet binary archived +locally + in the GitHub Release for at least one audit cycle so a rollback +is one command, not a rebuild. + +## Locking the upgrade authority + +After the post-contract swap to `lazorkit-protocol` is stable and you no +longer want any further upgrades: + +```bash +solana program set-upgrade-authority \ + LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority \ + --final +``` + +This is **irreversible**. Only do this after a long stability window and +explicit foundation/team agreement — once finalised, the binary cannot be +patched, security advisories included. diff --git a/program/Cargo.toml b/program/Cargo.toml index 37229b6..a9b9e4b 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -6,6 +6,14 @@ edition = "2021" [lib] crate-type = ["cdylib", "lib"] +# Cluster selection — forwards to the assertions crate where the program ID +# is embedded via declare_id!. Exactly one must be set at build time: +# cargo build-sbf --features mainnet +# cargo build-sbf --features devnet +[features] +mainnet = ["assertions/mainnet"] +devnet = ["assertions/devnet"] + [dependencies] pinocchio = { workspace = true } pinocchio-pubkey = { workspace = true } @@ -13,6 +21,8 @@ pinocchio-system = { workspace = true } no-padding = { workspace = true } assertions = { workspace = true } shank = { version = "0.4.2", git = "https://github.com/anagrambuild/shank.git" } +solana-security-txt = "1.1.2" +default-env = "0.1" [dev-dependencies] solana-sdk = "2.1" diff --git a/program/src/compact.rs b/program/src/compact.rs index fd4db2f..4b2cca9 100644 --- a/program/src/compact.rs +++ b/program/src/compact.rs @@ -26,6 +26,8 @@ pub struct CompactInstruction { } /// Reference version of CompactInstruction that borrows its data. +/// Used by the Execute hot path to avoid Vec allocations during parse +/// + decompress. /// /// # Fields /// * `program_id_index` - Index of the program ID in the account list @@ -37,6 +39,108 @@ pub struct CompactInstructionRef<'a> { pub data: &'a [u8], } +impl<'a> CompactInstructionRef<'a> { + /// Deserialize a CompactInstructionRef from bytes — zero-copy, the + /// returned struct borrows from `bytes`. + /// Format: [program_id_index: u8][num_accounts: u8][accounts...][data_len: u16][data...] + pub fn from_bytes(bytes: &'a [u8]) -> Result<(Self, &'a [u8]), ProgramError> { + if bytes.len() < 4 { + return Err(ProgramError::InvalidInstructionData); + } + + let program_id_index = bytes[0]; + let num_accounts = bytes[1] as usize; + + if bytes.len() < 2 + num_accounts + 2 { + return Err(ProgramError::InvalidInstructionData); + } + + let accounts = &bytes[2..2 + num_accounts]; + let data_len_offset = 2 + num_accounts; + let data_len = + u16::from_le_bytes([bytes[data_len_offset], bytes[data_len_offset + 1]]) as usize; + + let data_start = data_len_offset + 2; + if bytes.len() < data_start + data_len { + return Err(ProgramError::InvalidInstructionData); + } + + let data = &bytes[data_start..data_start + data_len]; + let rest = &bytes[data_start + data_len..]; + + Ok(( + CompactInstructionRef { + program_id_index, + accounts, + data, + }, + rest, + )) + } + + /// Decompress into a full Instruction without cloning the instruction + /// data. `account_infos` lifetime 'b is tracked separately from the + /// instruction-data lifetime 'a. + pub fn decompress<'b>( + &self, + account_infos: &'b [AccountInfo], + ) -> Result, ProgramError> { + if (self.program_id_index as usize) >= account_infos.len() { + return Err(ProgramError::InvalidInstructionData); + } + let program_id = account_infos[self.program_id_index as usize].key(); + + let mut accounts: Vec<&AccountInfo> = Vec::with_capacity(self.accounts.len()); + for &index in self.accounts { + if (index as usize) >= account_infos.len() { + return Err(ProgramError::InvalidInstructionData); + } + accounts.push(&account_infos[index as usize]); + } + + Ok(DecompressedInstructionRef { + program_id, + accounts, + data: self.data, + }) + } +} + +/// Zero-copy variant of DecompressedInstruction. `data` borrows from the +/// original instruction_data — no clone. +pub struct DecompressedInstructionRef<'a, 'b> { + pub program_id: &'b Pubkey, + pub accounts: Vec<&'b AccountInfo>, + pub data: &'a [u8], +} + +/// Parse + return total bytes consumed (ref-based, no allocations for +/// account index bytes or instruction data). +pub fn parse_compact_instructions_ref_with_len<'a>( + bytes: &'a [u8], +) -> Result<(Vec>, usize), ProgramError> { + if bytes.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + + let num_instructions = bytes[0] as usize; + if num_instructions > MAX_COMPACT_INSTRUCTIONS { + return Err(ProgramError::InvalidInstructionData); + } + + let mut instructions = Vec::with_capacity(num_instructions); + let mut remaining = &bytes[1..]; + + for _ in 0..num_instructions { + let (ix, rest) = CompactInstructionRef::from_bytes(remaining)?; + instructions.push(ix); + remaining = rest; + } + + let consumed = bytes.len() - remaining.len(); + Ok((instructions, consumed)) +} + impl CompactInstructions { /// Serializes the compact instructions into bytes. /// @@ -52,8 +156,12 @@ impl CompactInstructions { /// # Returns /// * `Vec` - Serialized instruction data pub fn into_bytes(&self) -> Vec { + // Lengths are encoded as u8 — values > 255 would silently truncate and corrupt + // the instruction stream on deserialization. Enforce at runtime, not just debug. + assert!(self.inner_instructions.len() <= 255, "instruction count exceeds u8 max"); let mut bytes = vec![self.inner_instructions.len() as u8]; for ix in self.inner_instructions.iter() { + assert!(ix.accounts.len() <= 255, "account count exceeds u8 max"); bytes.push(ix.program_id_index); bytes.push(ix.accounts.len() as u8); bytes.extend(ix.accounts.iter()); @@ -105,6 +213,7 @@ impl CompactInstruction { /// Serialize this CompactInstruction to bytes pub fn to_bytes(&self) -> Vec { + assert!(self.accounts.len() <= 255, "account count exceeds u8 max"); let mut bytes = Vec::with_capacity(4 + self.accounts.len() + self.data.len()); bytes.push(self.program_id_index); bytes.push(self.accounts.len() as u8); @@ -150,14 +259,31 @@ pub struct DecompressedInstruction<'a> { pub data: Vec, // Owned data to avoid lifetime issues } +/// Maximum number of compact instructions per Execute call. +/// Prevents compute-unit exhaustion DoS. +pub const MAX_COMPACT_INSTRUCTIONS: usize = 16; + /// Parse multiple CompactInstructions from bytes /// Format: [num_instructions: u8][instruction_0][instruction_1]... pub fn parse_compact_instructions(bytes: &[u8]) -> Result, ProgramError> { + parse_compact_instructions_with_len(bytes).map(|(ixs, _)| ixs) +} + +/// Parse + return total bytes consumed. Used by the Execute processor to +/// split the instruction data into the compact-instructions prefix and the +/// auth payload suffix without re-serializing. +pub fn parse_compact_instructions_with_len( + bytes: &[u8], +) -> Result<(Vec, usize), ProgramError> { if bytes.is_empty() { return Err(ProgramError::InvalidInstructionData); } let num_instructions = bytes[0] as usize; + if num_instructions > MAX_COMPACT_INSTRUCTIONS { + return Err(ProgramError::InvalidInstructionData); + } + let mut instructions = Vec::with_capacity(num_instructions); let mut remaining = &bytes[1..]; @@ -167,7 +293,8 @@ pub fn parse_compact_instructions(bytes: &[u8]) -> Result = (0..=255).collect(); + // Test with 255 accounts (the valid u8 max) + let accounts: Vec = (0..255).collect(); let ix = CompactInstruction { program_id_index: 0, - accounts, + accounts: accounts.clone(), data: vec![1], }; let bytes = ix.to_bytes(); let (deserialized, _) = CompactInstruction::from_bytes(&bytes).unwrap(); + assert_eq!(deserialized.accounts.len(), 255); + } - // Note: accounts.len() wraps to 0 when cast to u8! - // This is a known limitation - can't have exactly 256 accounts - assert_eq!(deserialized.accounts.len(), 0); // Wraps around! + #[test] + #[should_panic(expected = "account count exceeds u8 max")] + fn test_256_accounts_panics() { + // 256 accounts would silently truncate to 0 via `as u8`. + // The assert! guard must catch this. + let accounts: Vec = (0..=255).collect(); // 256 elements + let ix = CompactInstruction { + program_id_index: 0, + accounts, + data: vec![1], + }; + let _ = ix.to_bytes(); // should panic } #[test] diff --git a/program/src/error.rs b/program/src/error.rs index f555307..c3b483b 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -22,6 +22,21 @@ pub enum AuthError { UnauthorizedReclaim = 3017, DeferredAuthorizationNotExpired = 3018, InvalidSessionAccount = 3019, + // Session action errors (codes aligned with lazorkit-protocol for unified SDK error decoding) + ActionBufferInvalid = 3020, + ActionProgramNotWhitelisted = 3021, + ActionProgramBlacklisted = 3022, + ActionSolMaxPerTxExceeded = 3023, + ActionSolLimitExceeded = 3024, + ActionSolRecurringLimitExceeded = 3025, + ActionTokenLimitExceeded = 3026, + ActionTokenRecurringLimitExceeded = 3027, + ActionWhitelistBlacklistConflict = 3028, + ActionTokenMaxPerTxExceeded = 3029, + // Session vault + token invariants (defense against System::Assign / SetAuthority escapes) + SessionVaultOwnerChanged = 3030, + SessionVaultDataLenChanged = 3031, + SessionTokenAuthorityChanged = 3032, } impl From for ProgramError { diff --git a/program/src/lib.rs b/program/src/lib.rs index aad119c..2a41038 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,5 +1,22 @@ #![allow(unexpected_cfgs)] +#[cfg(not(feature = "no-entrypoint"))] +use {default_env::default_env, solana_security_txt::security_txt}; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "LazorKit Smart Wallet", + project_url: "https://lazorkit.com", + contacts: "email:security@lazorkit.app,link:https://github.com/lazor-kit/program-v2/security/advisories/new", + policy: "https://github.com/lazor-kit/program-v2/blob/main/SECURITY.md", + + preferred_languages: "en,vi", + source_code: "https://github.com/lazor-kit/program-v2", + source_revision: default_env!("GITHUB_SHA", ""), + source_release: default_env!("GITHUB_REF_NAME", ""), + auditors: "Accretion Labs (Solana Foundation) — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" +} + pub mod auth; pub mod compact; pub mod entrypoint; diff --git a/program/src/processor/create_session.rs b/program/src/processor/create_session.rs index ed378cb..175b06b 100644 --- a/program/src/processor/create_session.rs +++ b/program/src/processor/create_session.rs @@ -14,14 +14,24 @@ use crate::{ ed25519::Ed25519Authenticator, secp256r1::Secp256r1Authenticator, traits::Authenticator, }, error::AuthError, - state::{authority::AuthorityAccountHeader, session::SessionAccount, AccountDiscriminator}, + state::{ + action::validate_actions_buffer, + authority::AuthorityAccountHeader, + session::{SessionAccount, SESSION_HEADER_SIZE}, + AccountDiscriminator, + }, }; /// Arguments for the `CreateSession` instruction. /// /// Layout: -/// - `session_key`: The public key of the ephemeral session signer. -/// - `expires_at`: The absolute slot height when this session expires. +/// - `session_key`: The public key of the ephemeral session signer (32 bytes). +/// - `expires_at`: The absolute slot height when this session expires (8 bytes). +/// - `actions_len`: Length of the actions buffer in bytes (2 bytes, u16 LE). 0 = no actions. +/// - `actions`: Raw actions buffer (variable, `actions_len` bytes). +/// +/// Total fixed: 42 bytes minimum. Backwards compatible: old clients sending 40 bytes +/// will have actions_len=0 (no actions). #[repr(C, align(8))] #[derive(NoPadding)] pub struct CreateSessionArgs { @@ -29,23 +39,75 @@ pub struct CreateSessionArgs { pub expires_at: u64, } -impl CreateSessionArgs { +/// Parsed session creation arguments including optional actions. +pub struct ParsedCreateSessionArgs { + pub session_key: [u8; 32], + pub expires_at: u64, + /// Raw actions buffer bytes (empty if no actions). + pub actions_bytes: Vec, + /// Byte offset where the actions section ends in instruction_data. + /// Everything after this is auth_payload for Secp256r1. + pub args_end_offset: usize, +} + +impl ParsedCreateSessionArgs { pub fn from_bytes(data: &[u8]) -> Result { if data.len() < 40 { return Err(ProgramError::InvalidInstructionData); } - // args are: [session_key(32)][expires_at(8)] - let (key_bytes, rest) = data.split_at(32); - let (alloc_bytes, _) = rest.split_at(8); let mut session_key = [0u8; 32]; - session_key.copy_from_slice(key_bytes); - - let expires_at = u64::from_le_bytes(alloc_bytes.try_into().unwrap()); + session_key.copy_from_slice(&data[..32]); + let expires_at = u64::from_le_bytes(data[32..40].try_into().unwrap()); + + // Check for actions buffer + if data.len() >= 42 { + let actions_len = u16::from_le_bytes(data[40..42].try_into().unwrap()) as usize; + + // Cap actions buffer size to prevent BPF heap exhaustion. + // 16 actions * max ~128 bytes each = 2048 is generous. + // The BPF heap is 32KB; allocating 64KB (u16 max) would OOM. + const MAX_ACTIONS_BUFFER_SIZE: usize = 2048; + if actions_len > MAX_ACTIONS_BUFFER_SIZE { + return Err(ProgramError::InvalidInstructionData); + } + + if actions_len > 0 { + let actions_start = 42; + let actions_end = actions_start + actions_len; + + if data.len() < actions_end { + return Err(ProgramError::InvalidInstructionData); + } + + let actions_bytes = data[actions_start..actions_end].to_vec(); + + // Validate actions buffer at creation time + validate_actions_buffer(&actions_bytes)?; + + return Ok(Self { + session_key, + expires_at, + actions_bytes, + args_end_offset: actions_end, + }); + } + + // actions_len == 0 + return Ok(Self { + session_key, + expires_at, + actions_bytes: Vec::new(), + args_end_offset: 42, + }); + } + // Legacy format: exactly 40 bytes, no actions Ok(Self { session_key, expires_at, + actions_bytes: Vec::new(), + args_end_offset: 40, }) } } @@ -53,11 +115,13 @@ impl CreateSessionArgs { /// Processes the `CreateSession` instruction. /// /// Creates a temporary `Session` account that facilitates limited-scope execution (Spender role). +/// Optional actions (permissions) can be attached to restrict what the session can do. /// /// # Logic: /// 1. Verifies the authorizing authority (must be Owner or Admin). -/// 2. Derives a fresh Session PDA from `["session", wallet, session_key]`. -/// 3. Allocates and initializes the Session account with expiry. +/// 2. Validates optional actions buffer. +/// 3. Derives a fresh Session PDA from `["session", wallet, session_key]`. +/// 4. Allocates and initializes the Session account with expiry and actions. /// /// # Accounts: /// 1. `[signer, writable]` Payer: Pays for rent. @@ -71,7 +135,7 @@ pub fn process( accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { - let args = CreateSessionArgs::from_bytes(instruction_data)?; + let args = ParsedCreateSessionArgs::from_bytes(instruction_data)?; let account_info_iter = &mut accounts.iter(); let payer = account_info_iter @@ -115,9 +179,6 @@ pub fn process( return Err(ProgramError::InvalidAccountData); } - // Verify Authorizer - // Check removed: conditional writable check inside match - let auth_data = unsafe { authorizer_pda.borrow_mut_data_unchecked() }; // Safe Copy of Header using read_unaligned @@ -154,35 +215,36 @@ pub fn process( } // Authenticate Authorizer - - // We assume CreateSession instruction data AFTER the args is payload for Secp256r1 if any - let payload_offset = std::mem::size_of::(); - let authority_payload = if instruction_data.len() > payload_offset { - &instruction_data[payload_offset..] + // instruction_data layout: [args(40)][actions_len(2)][actions(N)][auth_payload...] + // args.args_end_offset points to the end of the args+actions section. + let data_payload = &instruction_data[..args.args_end_offset]; + let authority_payload = if instruction_data.len() > args.args_end_offset { + &instruction_data[args.args_end_offset..] } else { &[] }; - // But wait, `CreateSessionArgs` consumes 40 bytes. - // `instruction_data` passed here is whatever follows the discriminator. - // `Execute` passes compact instructions. - // Here we pass args. - - // For Secp256r1, we need to distinguish args from auth payload. - // The instruction format is [discriminator][args][payload]. - // `instruction_data` here is [args][payload]. - let data_payload = &instruction_data[..payload_offset]; - - // Include payer in signed payload to prevent payer swap - let mut ed25519_payload = Vec::with_capacity(64); + // Ed25519 signed payload — includes payer + session_key + actions. + // Note: Ed25519Authenticator only checks that the authority keypair is a tx signer, + // so this payload is not cryptographically verified. The protection is that only the + // keypair holder can sign the transaction. For Secp256r1, the data_payload IS verified. + let mut ed25519_payload = Vec::with_capacity(64 + args.actions_bytes.len()); ed25519_payload.extend_from_slice(payer.key().as_ref()); ed25519_payload.extend_from_slice(&args.session_key); + ed25519_payload.extend_from_slice(&args.actions_bytes); match auth_header.authority_type { 0 => { - // Ed25519: Include payer + session_key in signed payload - Ed25519Authenticator.authenticate(accounts, auth_data, &[], &ed25519_payload, &[5], program_id)?; - }, + // Ed25519: Include payer + session_key + actions in signed payload + Ed25519Authenticator.authenticate( + accounts, + auth_data, + &[], + &ed25519_payload, + &[5], + program_id, + )?; + } 1 => { // Secp256r1: Include payer in data_payload let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); @@ -197,7 +259,7 @@ pub fn process( &[5], program_id, )?; - }, + } _ => return Err(AuthError::InvalidAuthenticationKind.into()), } @@ -211,8 +273,8 @@ pub fn process( } check_zero_data(session_pda, ProgramError::AccountAlreadyInitialized)?; - // Create Session Account - let space = std::mem::size_of::(); + // Create Session Account — variable size if actions are present + let space = SESSION_HEADER_SIZE + args.actions_bytes.len(); let session_rent = rent.minimum_balance(space); let bump_arr = [bump]; @@ -245,14 +307,20 @@ pub fn process( expires_at: args.expires_at, }; - // Safe write + // Write fixed header let session_bytes = unsafe { std::slice::from_raw_parts( &session as *const SessionAccount as *const u8, std::mem::size_of::(), ) }; - data[0..std::mem::size_of::()].copy_from_slice(session_bytes); + data[..SESSION_HEADER_SIZE].copy_from_slice(session_bytes); + + // Write actions buffer (if any) + if !args.actions_bytes.is_empty() { + data[SESSION_HEADER_SIZE..SESSION_HEADER_SIZE + args.actions_bytes.len()] + .copy_from_slice(&args.actions_bytes); + } Ok(()) } @@ -264,20 +332,140 @@ mod tests { #[test] fn test_create_session_args_from_bytes() { let mut data = Vec::new(); - // session_key(32) + expires_at(8) let session_key = [7u8; 32]; let expires_at = 12345678u64; data.extend_from_slice(&session_key); data.extend_from_slice(&expires_at.to_le_bytes()); - let args = CreateSessionArgs::from_bytes(&data).unwrap(); + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); assert_eq!(args.session_key, session_key); assert_eq!(args.expires_at, expires_at); + assert!(args.actions_bytes.is_empty()); + assert_eq!(args.args_end_offset, 40); } #[test] fn test_create_session_args_too_short() { - let data = vec![0u8; 39]; // Need 40 - assert!(CreateSessionArgs::from_bytes(&data).is_err()); + let data = vec![0u8; 39]; + assert!(ParsedCreateSessionArgs::from_bytes(&data).is_err()); + } + + #[test] + fn test_create_session_args_with_no_actions() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + data.extend_from_slice(&0u16.to_le_bytes()); // actions_len = 0 + + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert!(args.actions_bytes.is_empty()); + assert_eq!(args.args_end_offset, 42); + } + + #[test] + fn test_create_session_args_with_actions() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // Build a SolMaxPerTx action: header(11) + data(8) = 19 bytes + let mut actions = Vec::new(); + actions.push(3u8); // type = SolMaxPerTx + actions.extend_from_slice(&8u16.to_le_bytes()); // data_len + actions.extend_from_slice(&0u64.to_le_bytes()); // expires_at + actions.extend_from_slice(&500_000u64.to_le_bytes()); // max + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); // actions_len + data.extend_from_slice(&actions); + + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert_eq!(args.actions_bytes.len(), 19); + assert_eq!(args.args_end_offset, 42 + 19); + } + + #[test] + fn test_create_session_args_with_invalid_actions() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // Invalid action type + let mut actions = Vec::new(); + actions.push(99u8); // bad type + actions.extend_from_slice(&8u16.to_le_bytes()); + actions.extend_from_slice(&0u64.to_le_bytes()); + actions.extend_from_slice(&500_000u64.to_le_bytes()); + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); + data.extend_from_slice(&actions); + + assert!(ParsedCreateSessionArgs::from_bytes(&data).is_err()); + } + + #[test] + fn test_create_session_args_with_trailing_auth_payload() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // SolMaxPerTx action + let mut actions = Vec::new(); + actions.push(3u8); + actions.extend_from_slice(&8u16.to_le_bytes()); + actions.extend_from_slice(&0u64.to_le_bytes()); + actions.extend_from_slice(&500_000u64.to_le_bytes()); + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); + data.extend_from_slice(&actions); + + // Simulate trailing auth payload + data.extend_from_slice(&[0xAA; 50]); + + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert_eq!(args.actions_bytes.len(), 19); + assert_eq!(args.args_end_offset, 42 + 19); + // Trailing 50 bytes would be auth_payload — not parsed here + } + + #[test] + fn test_actions_len_exceeds_cap_rejected() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // actions_len = 3000 > MAX_ACTIONS_BUFFER_SIZE (2048) + data.extend_from_slice(&3000u16.to_le_bytes()); + // Pad enough bytes so the length check doesn't fail first + data.extend_from_slice(&vec![0u8; 3000]); + + let result = ParsedCreateSessionArgs::from_bytes(&data); + assert!(result.is_err()); + } + + #[test] + fn test_actions_len_at_cap_allowed() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // Build a valid action buffer that's under 2048 + // 16 ProgramWhitelist actions = 16 * (11 header + 32 data) = 16 * 43 = 688 bytes + let mut actions = Vec::new(); + for i in 0..16u8 { + let mut prog = [0u8; 32]; + prog[0] = i; + actions.push(10u8); // ProgramWhitelist type + actions.extend_from_slice(&32u16.to_le_bytes()); // data_len + actions.extend_from_slice(&0u64.to_le_bytes()); // expires_at + actions.extend_from_slice(&prog); + } + assert!(actions.len() <= 2048); + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); + data.extend_from_slice(&actions); + + let result = ParsedCreateSessionArgs::from_bytes(&data); + assert!(result.is_ok()); + assert_eq!(result.unwrap().actions_bytes.len(), actions.len()); } } diff --git a/program/src/processor/execute.rs b/program/src/processor/execute.rs index 32175e1..4d05e50 100644 --- a/program/src/processor/execute.rs +++ b/program/src/processor/execute.rs @@ -2,9 +2,14 @@ use crate::{ auth::{ ed25519::Ed25519Authenticator, secp256r1::Secp256r1Authenticator, traits::Authenticator, }, - compact::parse_compact_instructions, + compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, error::AuthError, - state::{authority::AuthorityAccountHeader, AccountDiscriminator}, + processor::execute_actions::{ + evaluate_post_actions, evaluate_pre_actions, snapshot_token_authorities, + snapshot_token_balances, verify_token_authorities_unchanged, + }, + state::{authority::AuthorityAccountHeader, session::has_actions, AccountDiscriminator}, + utils::get_stack_height, }; use pinocchio::{ account_info::AccountInfo, @@ -16,14 +21,13 @@ use pinocchio::{ ProgramResult, }; -/// Process the Execute instruction -/// Processes the `Execute` instruction. +/// Process the Execute instruction. /// /// Executes a batch of condensed "Compact Instructions" on behalf of the wallet. /// /// # Logic: /// 1. **Authentication**: Verifies that the signer is a valid `Authority` or `Session` for this wallet. -/// 2. **Session Checks**: If authenticated via Session, enforces slot expiry. +/// 2. **Session Checks**: If authenticated via Session, enforces slot expiry and action permissions. /// 3. **Decompression**: Expands `CompactInstructions` (index-based references) into full Solana instructions. /// 4. **Execution**: Invokes the Instructions via CPI, signing with the Vault PDA. /// @@ -72,8 +76,6 @@ pub fn process( } // Read authority header - // Safe copy header - // Read authority data let authority_data = unsafe { authority_pda.borrow_mut_data_unchecked() }; // Authenticate based on discriminator @@ -83,12 +85,17 @@ pub fn process( return Err(ProgramError::InvalidAccountData); }; - // Parse compact instructions - let compact_instructions = parse_compact_instructions(instruction_data)?; + // Parse compact instructions and get their consumed byte length. The + // length is used to split `instruction_data` into the compact-instructions + // prefix (the data_payload bound into the Secp256r1 signature) and the + // auth payload suffix. Tracking the parse cursor avoids re-serializing + // just to measure length. + let (compact_instructions, compact_len) = + parse_compact_instructions_ref_with_len(instruction_data)?; - // Serialize compact instructions to get their byte length - let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); - let compact_len = compact_bytes.len(); + // Track whether this is a session-based execution and the current slot + let mut is_session = false; + let mut session_slot: u64 = 0; match discriminator { 2 => { @@ -96,7 +103,6 @@ pub fn process( if authority_data.len() < std::mem::size_of::() { return Err(ProgramError::InvalidAccountData); } - // Use read_unaligned to safely copy potentially unaligned data into a local struct let authority_header = unsafe { std::ptr::read_unaligned(authority_data.as_ptr() as *const AuthorityAccountHeader) }; @@ -110,21 +116,22 @@ pub fn process( } match authority_header.authority_type { 0 => { - // Ed25519: Verify signer (authority_payload ignored) - Ed25519Authenticator.authenticate(accounts, authority_data, &[], &[], &[4], program_id)?; - }, + // Ed25519 + Ed25519Authenticator.authenticate( + accounts, + authority_data, + &[], + &[], + &[4], + program_id, + )?; + } 1 => { // Secp256r1 (WebAuthn) - // Issue #11: Include accounts hash to prevent account reordering attacks - // signed_payload is compact_instructions bytes + accounts hash for Execute let data_payload = &instruction_data[..compact_len]; let authority_payload = &instruction_data[compact_len..]; - - // Compute hash of all account pubkeys referenced by compact instructions - // This binds the signature to the exact accounts, preventing reordering - let accounts_hash = compute_accounts_hash(accounts, &compact_instructions)?; - - // Extended payload: compact_instructions + accounts_hash + let accounts_hash = + compute_accounts_hash(accounts, &compact_instructions)?; let mut extended_payload = Vec::with_capacity(compact_len + 32); extended_payload.extend_from_slice(data_payload); extended_payload.extend_from_slice(&accounts_hash); @@ -137,21 +144,30 @@ pub fn process( &[4], program_id, )?; - }, + } _ => return Err(AuthError::InvalidAuthenticationKind.into()), } - }, + } 3 => { - // Session - let session_data = unsafe { authority_pda.borrow_mut_data_unchecked() }; - if session_data.len() < std::mem::size_of::() { + // Session — reuse the existing `authority_data` borrow; no re-borrow needed. + + // L5: anti-CPI guard, mirroring the Secp256r1 authenticator check. + // A session-authenticated Execute is only valid as a top-level instruction + // (stack_height == 1). Rejecting CPI entry prevents any future bugs where + // a wrapper program could chain through Execute with forged account context. + if get_stack_height() > 1 { + return Err(AuthError::PermissionDenied.into()); + } + + if authority_data.len() + < std::mem::size_of::() + { return Err(ProgramError::InvalidAccountData); } - // Use read_unaligned to safely load SessionAccount let session = unsafe { std::ptr::read_unaligned( - session_data.as_ptr() as *const crate::state::session::SessionAccount + authority_data.as_ptr() as *const crate::state::session::SessionAccount, ) }; @@ -179,7 +195,20 @@ pub fn process( if !signer_matched { return Err(ProgramError::MissingRequiredSignature); } - }, + + // Pre-CPI action checks (program whitelist/blacklist) + if has_actions(authority_data) { + evaluate_pre_actions( + authority_data, + &compact_instructions, + accounts, + current_slot, + )?; + } + + is_session = true; + session_slot = current_slot; + } _ => return Err(ProgramError::InvalidAccountData), } @@ -188,118 +217,187 @@ pub fn process( find_program_address(&[b"vault", wallet_pda.key().as_ref()], program_id); // Verify vault PDA. - // CRITICAL: Ensure we are signing with the correct Vault derived from this Wallet. if vault_pda.key() != &vault_key { return Err(ProgramError::InvalidSeeds); } + // Snapshot balances before CPI (for session action enforcement) + let vault_lamports_before = if is_session { vault_pda.lamports() } else { 0 }; + let token_snapshots_before = if is_session { + // Reuse the existing `authority_data` borrow — no additional borrow of authority_pda. + snapshot_token_balances(authority_data, accounts, vault_pda.key())? + } else { + Vec::new() + }; + + // ── Session invariants (defense against System::Assign / SetAuthority escapes) ── + // A session that whitelists System Program (a common pattern for SOL transfers) + // could otherwise craft `System::Assign(vault, attacker)` — the lamport-based + // limits see no outflow, but ownership of the vault silently transfers to the + // attacker, who then drains it in a follow-up tx. Same class of attack via + // SPL Token's `SetAuthority` / `Approve` on vault-owned token accounts. + // + // Snapshot the vault's metadata + every listed-mint vault-owned token account's + // authority fields BEFORE the CPI loop; verify unchanged AFTER. + let session_has_actions = is_session && has_actions(authority_data); + let vault_owner_before = if session_has_actions { + Some(*vault_pda.owner()) + } else { + None + }; + let vault_data_len_before = if session_has_actions { + Some(unsafe { vault_pda.borrow_data_unchecked().len() }) + } else { + None + }; + let token_authority_snapshots = if session_has_actions { + snapshot_token_authorities(authority_data, accounts, vault_pda.key())? + } else { + Vec::new() + }; + + // Track gross SOL outflow across all CPIs (for SolMaxPerTx check) + let mut vault_lamports_gross_out: u64 = 0; + let mut prev_vault_lamports = vault_lamports_before; + + // Reuse the same Vecs across all inner CPIs — allocated once, cleared + + // repushed each iteration. Saves 2 Vec::with_capacity allocations per + // inner instruction vs. .collect()ing fresh Vecs each time. + const MAX_INNER_ACCOUNTS: usize = 32; + let mut account_metas: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); + let mut cpi_accounts: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); + + // PDA signer seeds (constant across the loop) + let vault_bump_arr = [vault_bump]; + let seeds = [ + Seed::from(b"vault"), + Seed::from(wallet_pda.key().as_ref()), + Seed::from(&vault_bump_arr), + ]; + // Execute each compact instruction for compact_ix in &compact_instructions { let decompressed = compact_ix.decompress(accounts)?; - // Build AccountMeta array for instruction - let account_metas: Vec = decompressed - .accounts - .iter() - .map(|acc| AccountMeta { - pubkey: acc.key(), - is_signer: acc.is_signer() || acc.key() == vault_pda.key(), - is_writable: acc.is_writable(), - }) - .collect(); - // Prevent self-reentrancy (Issue #10) - // Reject CPI calls back into this program to avoid unexpected state mutations if decompressed.program_id.as_ref() == program_id.as_ref() { return Err(AuthError::SelfReentrancyNotAllowed.into()); } - // Create instruction + account_metas.clear(); + cpi_accounts.clear(); + for &acc in &decompressed.accounts { + account_metas.push(AccountMeta { + pubkey: acc.key(), + is_signer: acc.is_signer() || acc.key() == vault_pda.key(), + is_writable: acc.is_writable(), + }); + cpi_accounts.push(Account::from(acc)); + } + let ix = Instruction { program_id: decompressed.program_id, accounts: &account_metas, - data: &decompressed.data, + data: decompressed.data, }; - // Create seeds for vault signing (pinocchio style) - let vault_bump_arr = [vault_bump]; - let seeds = [ - Seed::from(b"vault"), - Seed::from(wallet_pda.key().as_ref()), - Seed::from(&vault_bump_arr), - ]; let signer: Signer = (&seeds).into(); - // Convert AccountInfo to Account for invoke_signed_unchecked - let cpi_accounts: Vec = decompressed - .accounts - .iter() - .map(|acc| Account::from(*acc)) - .collect(); - - // Invoke with vault as signer - // Use unchecked invocation to support dynamic account list (slice) unsafe { invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); } + + // Track gross SOL outflow per CPI (used for SolMaxPerTx — not net balance diff). + if is_session { + let post = vault_pda.lamports(); + if prev_vault_lamports > post { + vault_lamports_gross_out = vault_lamports_gross_out + .saturating_add(prev_vault_lamports - post); + } + prev_vault_lamports = post; + } + } + + // ── Post-CPI session invariants ──────────────────────────────────── + // Verify vault's ownership and data layout were not tampered with. Any + // change (System::Assign, Allocate, AllocateWithSeed, AssignWithSeed) is + // rejected. This complements the balance-based limits below. + if let Some(owner_before) = vault_owner_before { + if *vault_pda.owner() != owner_before { + return Err(AuthError::SessionVaultOwnerChanged.into()); + } + } + if let Some(len_before) = vault_data_len_before { + let len_after = unsafe { vault_pda.borrow_data_unchecked().len() }; + if len_after != len_before { + return Err(AuthError::SessionVaultDataLenChanged.into()); + } + } + // Verify no SetAuthority / Approve on listed-mint vault-owned token accounts. + verify_token_authorities_unchanged(&token_authority_snapshots, accounts)?; + + // Post-CPI action checks (spending limits) + // Reuse the existing `authority_data` borrow — no additional borrow of authority_pda. + if session_has_actions { + evaluate_post_actions( + authority_data, + accounts, + vault_pda.key(), + vault_lamports_before, + vault_pda.lamports(), + vault_lamports_gross_out, + &token_snapshots_before, + session_slot, + )?; } Ok(()) } -/// Compute SHA256 hash of all account pubkeys referenced by compact instructions (Issue #11) -/// -/// This binds the signature to the exact accounts in their exact order, -/// preventing account reordering attacks where an attacker could swap -/// recipient addresses while keeping the signature valid. -/// -/// # Arguments -/// * `accounts` - Slice of all account infos in the transaction -/// * `compact_instructions` - Parsed compact instructions containing account indices +/// Compute SHA256 hash of all account pubkeys referenced by compact instructions (Issue #11). /// -/// # Returns -/// * 32-byte SHA256 hash of all referenced pubkeys +/// Optimisation: pass each 32-byte pubkey as a separate slice to sol_sha256 +/// instead of concatenating them into an owned Vec first. sol_sha256 accepts +/// an array of slices natively, so the concat step was pure overhead. fn compute_accounts_hash( accounts: &[AccountInfo], - compact_instructions: &[crate::compact::CompactInstruction], + compact_instructions: &[CompactInstructionRef<'_>], ) -> Result<[u8; 32], ProgramError> { - // Collect all account pubkeys in order of reference - let mut pubkeys_data = Vec::new(); + // Collect slice references (16 bytes each) instead of copying 32-byte pubkeys. + // With MAX_COMPACT_INSTRUCTIONS = 16 and a reasonable per-ix account count, + // this fits comfortably on the BPF heap. + let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); for ix in compact_instructions { - // Include program_id let program_idx = ix.program_id_index as usize; if program_idx >= accounts.len() { return Err(ProgramError::InvalidInstructionData); } - pubkeys_data.extend_from_slice(accounts[program_idx].key().as_ref()); + refs.push(accounts[program_idx].key().as_ref()); - // Include all account pubkeys - for &acc_idx in &ix.accounts { + for &acc_idx in ix.accounts { let idx = acc_idx as usize; if idx >= accounts.len() { return Err(ProgramError::InvalidInstructionData); } - pubkeys_data.extend_from_slice(accounts[idx].key().as_ref()); + refs.push(accounts[idx].key().as_ref()); } } - // Compute SHA256 hash #[allow(unused_assignments)] let mut hash = [0u8; 32]; #[cfg(target_os = "solana")] unsafe { pinocchio::syscalls::sol_sha256( - [pubkeys_data.as_slice()].as_ptr() as *const u8, - 1, + refs.as_ptr() as *const u8, + refs.len() as u64, hash.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { - // For tests, use a dummy hash hash = [0xAA; 32]; - let _ = pubkeys_data; // suppress warning + let _ = refs; } Ok(hash) diff --git a/program/src/processor/execute_actions.rs b/program/src/processor/execute_actions.rs new file mode 100644 index 0000000..bdf5b05 --- /dev/null +++ b/program/src/processor/execute_actions.rs @@ -0,0 +1,1644 @@ +//! Session action evaluation for the Execute instruction. +//! +//! Provides pre-CPI and post-CPI checks for session-based execution. +//! Pre-CPI: program whitelist/blacklist enforcement. +//! Post-CPI: spending limit enforcement with balance diffing. +//! +//! Security model (learned from Swig wallet): +//! - Saturating arithmetic throughout to prevent overflow/underflow +//! - Balance increases (vault gains) are ignored, only outflows count +//! - Recurring limit windows align to slot boundaries +//! - Recurring limits validate single-tx doesn't exceed full window limit +//! - State mutations only happen after all checks pass +//! - Zero spending transactions pass through without triggering limits + +use pinocchio::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; + +use crate::{ + compact::CompactInstructionRef, + error::AuthError, + state::{ + action::{parse_actions, read_u64, write_u64, ActionType, ActionView}, + session::{has_actions, SESSION_HEADER_SIZE}, + }, +}; + +// ─── Token Account Layout (SPL Token) ──────────────────────────────── +// mint: bytes 0..32 +// owner: bytes 32..64 (the authority for Transfer/Burn — changed by SetAuthority(AccountOwner)) +// amount: bytes 64..72 +// delegate_coi: bytes 72..108 (COption — changed by Approve/Revoke) +// close_authority: bytes 129..165 (COption — changed by SetAuthority(CloseAccount)) + +const TOKEN_MINT_OFFSET: usize = 0; +const TOKEN_OWNER_OFFSET: usize = 32; +const TOKEN_AMOUNT_OFFSET: usize = 64; +const TOKEN_DELEGATE_OFFSET: usize = 72; +const TOKEN_CLOSE_AUTHORITY_OFFSET: usize = 129; +const TOKEN_ACCOUNT_MIN_SIZE: usize = 165; + +/// A snapshot of a token account balance for a specific mint. +pub struct TokenSnapshot { + pub mint: [u8; 32], + pub amount: u64, +} + +/// Per-token-account snapshot of authority-related fields, captured BEFORE the +/// CPI loop in a session+actions execute. +/// +/// Detects SetAuthority attacks (changing owner/close_authority to attacker) and +/// Approve-delegation attacks (granting delegate to attacker who drains outside +/// the session). All three fields are frozen for vault-owned token accounts on +/// listed mints while a session is executing. +pub struct TokenAuthoritySnapshot { + /// The token account address (so we can re-find it post-CPI). + pub account_key: [u8; 32], + /// owner field bytes [32..64] + pub owner: [u8; 32], + /// delegate COption bytes [72..108] + pub delegate: [u8; 36], + /// close_authority COption bytes [129..165] + pub close_authority: [u8; 36], +} + +/// Evaluate pre-CPI actions (program whitelist/blacklist). +/// +/// Call this BEFORE executing compact instructions. +/// Returns early with Ok(()) if no actions exist. +pub fn evaluate_pre_actions( + session_data: &[u8], + compact_instructions: &[CompactInstructionRef<'_>], + accounts: &[AccountInfo], + current_slot: u64, +) -> Result<(), ProgramError> { + if !has_actions(session_data) { + return Ok(()); + } + + let actions_buf = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf)?; + + // Collect whitelist/blacklist program IDs. + // Expired whitelist actions are intentionally NOT added to `whitelisted`, but they still set + // `has_any_whitelist_action = true`. This means if a whitelist existed but has now expired, + // NO program is permitted — treating an expired whitelist as a hard deny rather than open + // access. An expired blacklist entry, however, is silently dropped (the ban has lifted). + let mut whitelisted: Vec<[u8; 32]> = Vec::new(); + let mut blacklisted: Vec<[u8; 32]> = Vec::new(); + let mut has_any_whitelist_action = false; + + for action in &actions { + match action.action_type { + ActionType::ProgramWhitelist => { + has_any_whitelist_action = true; + if !is_expired(action, current_slot) { + let mut prog_id = [0u8; 32]; + prog_id.copy_from_slice( + &actions_buf[action.data_offset..action.data_offset + 32], + ); + whitelisted.push(prog_id); + } + } + ActionType::ProgramBlacklist => { + if !is_expired(action, current_slot) { + let mut prog_id = [0u8; 32]; + prog_id.copy_from_slice( + &actions_buf[action.data_offset..action.data_offset + 32], + ); + blacklisted.push(prog_id); + } + } + _ => {} + } + } + + // Enforce program restrictions on each instruction + for ix in compact_instructions { + let prog_idx = ix.program_id_index as usize; + if prog_idx >= accounts.len() { + return Err(ProgramError::InvalidInstructionData); + } + let target_program = accounts[prog_idx].key(); + + // Whitelist: if any whitelist action EVER existed (even expired), program must be in the + // active set. An expired whitelist = deny all programs. + if has_any_whitelist_action && !whitelisted.iter().any(|p| p == target_program.as_ref()) { + return Err(AuthError::ActionProgramNotWhitelisted.into()); + } + + // Blacklist: program must NOT be in the active set (expired entries already dropped above). + if blacklisted.iter().any(|p| p == target_program.as_ref()) { + return Err(AuthError::ActionProgramBlacklisted.into()); + } + } + + Ok(()) +} + +/// Snapshot token balances for mints referenced in token actions. +pub fn snapshot_token_balances( + session_data: &[u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, +) -> Result, ProgramError> { + if !has_actions(session_data) { + return Ok(Vec::new()); + } + + let actions_buf = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf)?; + + let mut mints: Vec<[u8; 32]> = Vec::new(); + for action in &actions { + match action.action_type { + ActionType::TokenLimit + | ActionType::TokenRecurringLimit + | ActionType::TokenMaxPerTx => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&actions_buf[action.data_offset..action.data_offset + 32]); + if !mints.iter().any(|m| m == &mint) { + mints.push(mint); + } + } + _ => {} + } + } + + if mints.is_empty() { + return Ok(Vec::new()); + } + + let mut snapshots = Vec::new(); + for mint in &mints { + if let Some(amount) = find_token_balance(accounts, vault_key, mint) { + snapshots.push(TokenSnapshot { + mint: *mint, + amount, + }); + } + } + + Ok(snapshots) +} + +/// Snapshot per-token-account authority fields for every vault-owned token +/// account whose mint appears in a token action. +/// +/// Paired with `verify_token_authorities_unchanged` post-CPI. Together they +/// prevent `SetAuthority` and `Approve`-style escapes where the session key +/// would otherwise reassign control of vault-owned token accounts without +/// moving any lamports (so the balance-based limits would miss it). +pub fn snapshot_token_authorities( + session_data: &[u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, +) -> Result, ProgramError> { + if !has_actions(session_data) { + return Ok(Vec::new()); + } + + let actions_buf = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf)?; + + // Collect listed mints (same logic as snapshot_token_balances). + let mut mints: Vec<[u8; 32]> = Vec::new(); + for action in &actions { + match action.action_type { + ActionType::TokenLimit + | ActionType::TokenRecurringLimit + | ActionType::TokenMaxPerTx => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&actions_buf[action.data_offset..action.data_offset + 32]); + if !mints.iter().any(|m| m == &mint) { + mints.push(mint); + } + } + _ => {} + } + } + if mints.is_empty() { + return Ok(Vec::new()); + } + + // Scan all SPL-Token-owned accounts; snapshot each vault-owned one whose + // mint is listed in the session actions. + let mut out = Vec::new(); + for acc in accounts { + let owner = acc.owner(); + if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID + && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID + { + continue; + } + let data = unsafe { acc.borrow_data_unchecked() }; + if data.len() < TOKEN_ACCOUNT_MIN_SIZE { + continue; + } + // vault must currently own it + if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != vault_key.as_ref() { + continue; + } + // mint must be listed + let mut mint = [0u8; 32]; + mint.copy_from_slice(&data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32]); + if !mints.iter().any(|m| m == &mint) { + continue; + } + + let mut owner_bytes = [0u8; 32]; + owner_bytes.copy_from_slice(&data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32]); + let mut delegate = [0u8; 36]; + delegate.copy_from_slice(&data[TOKEN_DELEGATE_OFFSET..TOKEN_DELEGATE_OFFSET + 36]); + let mut close_authority = [0u8; 36]; + close_authority.copy_from_slice( + &data[TOKEN_CLOSE_AUTHORITY_OFFSET..TOKEN_CLOSE_AUTHORITY_OFFSET + 36], + ); + + let mut account_key = [0u8; 32]; + account_key.copy_from_slice(acc.key().as_ref()); + + out.push(TokenAuthoritySnapshot { + account_key, + owner: owner_bytes, + delegate, + close_authority, + }); + } + + Ok(out) +} + +/// Verify that every snapshotted token account still has the same owner, +/// delegate, and close_authority fields. Returns an error if any field has +/// changed. +pub fn verify_token_authorities_unchanged( + snapshots: &[TokenAuthoritySnapshot], + accounts: &[AccountInfo], +) -> Result<(), ProgramError> { + for snap in snapshots { + // Find the account by key in the tx accounts list. + let acc = match accounts.iter().find(|a| a.key().as_ref() == snap.account_key) { + Some(a) => a, + // If the account disappeared (e.g. CloseAccount closed it), that's also a + // mutation we should reject. CloseAccount sends rent lamports to an + // attacker-chosen destination without touching the token balance. + None => return Err(AuthError::SessionTokenAuthorityChanged.into()), + }; + + // Must still be owned by SPL Token (not re-assigned to another program) + let owner = acc.owner(); + if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID + && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID + { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + + let data = unsafe { acc.borrow_data_unchecked() }; + if data.len() < TOKEN_ACCOUNT_MIN_SIZE { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + + if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != snap.owner { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + if &data[TOKEN_DELEGATE_OFFSET..TOKEN_DELEGATE_OFFSET + 36] != snap.delegate { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + if &data[TOKEN_CLOSE_AUTHORITY_OFFSET..TOKEN_CLOSE_AUTHORITY_OFFSET + 36] + != snap.close_authority + { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + } + Ok(()) +} + +/// Evaluate post-CPI actions (spending limits). +/// +/// `vault_lamports_gross_out` is the sum of all per-CPI outflows from the vault, used for +/// `SolMaxPerTx` (which must block even DeFi round-trips that appear net-zero). +/// `vault_lamports_before`/`after` net diff is used for the cumulative limits (SolLimit, +/// SolRecurringLimit), where net accounting is conservative and appropriate. +/// +/// Security: This function first computes all spending deltas and validates +/// ALL limits before writing any state. This ensures no partial state mutation +/// if a later check fails. +pub fn evaluate_post_actions( + session_data: &mut [u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, + vault_lamports_before: u64, + vault_lamports_after: u64, + vault_lamports_gross_out: u64, + token_snapshots_before: &[TokenSnapshot], + current_slot: u64, +) -> Result<(), ProgramError> { + if !has_actions(session_data) { + return Ok(()); + } + + // Only count outflows. If vault gained lamports, sol_spent = 0. + // This matches Swig's pattern: balance increases are tracked but not counted against limits. + let sol_spent = vault_lamports_before.saturating_sub(vault_lamports_after); + + // If nothing was spent, skip all checks (no state mutation needed for SOL). + // Token checks still need to run. + + let actions_buf_readonly = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf_readonly)?; + + // ── Phase 1: Validate all SOL limits (read-only check) ────────── + // Expired spending-limit actions are treated as fully exhausted / "0 remaining": + // if any SOL was spent and a limit action has expired, the tx is rejected. + // This prevents a session with expired limits from becoming unrestricted. + for action in &actions { + let action_expired = is_expired(action, current_slot); + let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; + + match action.action_type { + ActionType::SolMaxPerTx => { + // Use gross outflow so DeFi round-trips that return most lamports cannot bypass + // a per-tx cap (the net diff would be near-zero but gross could be large). + if vault_lamports_gross_out > 0 { + if action_expired { + return Err(AuthError::ActionSolMaxPerTxExceeded.into()); + } + let max = read_u64(&session_data[abs_data_offset..], 0); + if vault_lamports_gross_out > max { + return Err(AuthError::ActionSolMaxPerTxExceeded.into()); + } + } + } + ActionType::SolLimit => { + if sol_spent > 0 { + if action_expired { + return Err(AuthError::ActionSolLimitExceeded.into()); + } + let remaining = read_u64(&session_data[abs_data_offset..], 0); + if sol_spent > remaining { + return Err(AuthError::ActionSolLimitExceeded.into()); + } + } + } + ActionType::SolRecurringLimit => { + if sol_spent > 0 { + if action_expired { + return Err(AuthError::ActionSolRecurringLimitExceeded.into()); + } + let limit = read_u64(&session_data[abs_data_offset..], 0); + let spent = read_u64(&session_data[abs_data_offset..], 8); + let window = read_u64(&session_data[abs_data_offset..], 16); + let last_reset = read_u64(&session_data[abs_data_offset..], 24); + + let effective_spent = if current_slot.saturating_sub(last_reset) > window { + // Window expired — reset. But single tx can't exceed full limit. + if sol_spent > limit { + return Err(AuthError::ActionSolRecurringLimitExceeded.into()); + } + 0u64 + } else { + spent + }; + + // Use saturating_add to prevent overflow + if effective_spent.saturating_add(sol_spent) > limit { + return Err(AuthError::ActionSolRecurringLimitExceeded.into()); + } + } + } + _ => {} + } + } + + // ── Phase 1b: Validate all token limits (read-only check) ─────── + // Same policy as SOL limits: expired = treat as fully exhausted. + for action in &actions { + let action_expired = is_expired(action, current_slot); + let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; + + match action.action_type { + ActionType::TokenMaxPerTx | ActionType::TokenLimit | ActionType::TokenRecurringLimit => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); + + let before_amount = token_snapshots_before + .iter() + .find(|s| s.mint == mint) + .map(|s| s.amount) + .unwrap_or(0); + + let after_amount = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); + + // Only count outflows + let token_spent = before_amount.saturating_sub(after_amount); + + if token_spent > 0 { + if action_expired { + // Treat expired token limit as fully exhausted — deny any spend. + return match action.action_type { + ActionType::TokenMaxPerTx => Err(AuthError::ActionTokenMaxPerTxExceeded.into()), + ActionType::TokenLimit => Err(AuthError::ActionTokenLimitExceeded.into()), + _ => Err(AuthError::ActionTokenRecurringLimitExceeded.into()), + }; + } + match action.action_type { + ActionType::TokenMaxPerTx => { + let max = read_u64(&session_data[abs_data_offset..], 32); + if token_spent > max { + return Err(AuthError::ActionTokenMaxPerTxExceeded.into()); + } + } + ActionType::TokenLimit => { + let remaining = read_u64(&session_data[abs_data_offset..], 32); + if token_spent > remaining { + return Err(AuthError::ActionTokenLimitExceeded.into()); + } + } + ActionType::TokenRecurringLimit => { + let limit = read_u64(&session_data[abs_data_offset..], 32); + let spent = read_u64(&session_data[abs_data_offset..], 40); + let window = read_u64(&session_data[abs_data_offset..], 48); + let last_reset = read_u64(&session_data[abs_data_offset..], 56); + + let effective_spent = + if current_slot.saturating_sub(last_reset) > window { + if token_spent > limit { + return Err( + AuthError::ActionTokenRecurringLimitExceeded.into() + ); + } + 0u64 + } else { + spent + }; + + if effective_spent.saturating_add(token_spent) > limit { + return Err(AuthError::ActionTokenRecurringLimitExceeded.into()); + } + } + _ => {} + } + } + } + _ => {} + } + } + + // ── Phase 2: All checks passed. Now write state mutations. ────── + // Re-parse using a slice reference — no allocation needed, same bytes, same offsets. + let actions = parse_actions(&session_data[SESSION_HEADER_SIZE..])?; + + for action in &actions { + if is_expired(action, current_slot) { + continue; + } + + let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; + + match action.action_type { + ActionType::SolLimit => { + if sol_spent > 0 { + let remaining = read_u64(&session_data[abs_data_offset..], 0); + write_u64( + &mut session_data[abs_data_offset..], + 0, + remaining.saturating_sub(sol_spent), + ); + } + } + ActionType::SolRecurringLimit => { + if sol_spent > 0 { + let _limit = read_u64(&session_data[abs_data_offset..], 0); + let spent = read_u64(&session_data[abs_data_offset..], 8); + let window = read_u64(&session_data[abs_data_offset..], 16); + let last_reset = read_u64(&session_data[abs_data_offset..], 24); + + let (new_spent, new_last_reset) = + if current_slot.saturating_sub(last_reset) > window { + let aligned = (current_slot / window) * window; + (sol_spent, aligned) + } else { + (spent.saturating_add(sol_spent), last_reset) + }; + + write_u64(&mut session_data[abs_data_offset..], 8, new_spent); + write_u64(&mut session_data[abs_data_offset..], 24, new_last_reset); + } + } + ActionType::TokenLimit => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); + let before = token_snapshots_before + .iter() + .find(|s| s.mint == mint) + .map(|s| s.amount) + .unwrap_or(0); + let after = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); + let token_spent = before.saturating_sub(after); + + if token_spent > 0 { + let remaining = read_u64(&session_data[abs_data_offset..], 32); + write_u64( + &mut session_data[abs_data_offset..], + 32, + remaining.saturating_sub(token_spent), + ); + } + } + ActionType::TokenRecurringLimit => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); + let before = token_snapshots_before + .iter() + .find(|s| s.mint == mint) + .map(|s| s.amount) + .unwrap_or(0); + let after = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); + let token_spent = before.saturating_sub(after); + + if token_spent > 0 { + let spent = read_u64(&session_data[abs_data_offset..], 40); + let window = read_u64(&session_data[abs_data_offset..], 48); + let last_reset = read_u64(&session_data[abs_data_offset..], 56); + + let (new_spent, new_last_reset) = + if current_slot.saturating_sub(last_reset) > window { + let aligned = (current_slot / window) * window; + (token_spent, aligned) + } else { + (spent.saturating_add(token_spent), last_reset) + }; + + write_u64(&mut session_data[abs_data_offset..], 40, new_spent); + write_u64(&mut session_data[abs_data_offset..], 56, new_last_reset); + } + } + _ => {} // SolMaxPerTx, TokenMaxPerTx, whitelist/blacklist have no mutable state + } + } + + Ok(()) +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +/// Check if an action has expired. +#[inline] +fn is_expired(action: &ActionView, current_slot: u64) -> bool { + action.expires_at != 0 && current_slot > action.expires_at +} + +/// SPL Token program ID +const SPL_TOKEN_PROGRAM_ID: [u8; 32] = [ + 6, 221, 246, 225, 215, 101, 161, 147, 217, 203, 225, 70, 206, 235, 121, 172, 28, 180, 133, + 237, 95, 91, 55, 145, 58, 140, 245, 133, 126, 255, 0, 169, +]; + +/// SPL Token-2022 program ID +const SPL_TOKEN_2022_PROGRAM_ID: [u8; 32] = [ + 6, 221, 246, 225, 238, 117, 143, 222, 170, 164, 12, 4, 223, 116, 174, 240, 70, 137, 163, 89, + 77, 149, 128, 12, 61, 73, 196, 253, 210, 164, 82, 159, +]; + +/// Find the total token balance across ALL token accounts for a given mint owned by the vault. +/// +/// Security: Sums every matching account rather than returning the first match. +/// Returning only the first match allowed an attacker to place a 0-balance dummy +/// token account (owned by vault, same mint) before the real account in the +/// accounts list, causing both the pre-CPI snapshot and post-CPI check to read +/// the dummy account (balance always 0) and bypass all token spending limits. +/// +/// Verifies each account is owned by SPL Token or Token-2022 to prevent fake +/// accounts with fabricated mint/owner fields. +fn find_token_balance( + accounts: &[AccountInfo], + vault_key: &Pubkey, + mint: &[u8; 32], +) -> Option { + let mut total: u64 = 0; + let mut found = false; + + for acc in accounts { + // CRITICAL: Verify account is owned by SPL Token or Token-2022 program. + let owner = acc.owner(); + if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID + { + continue; + } + + let data = unsafe { acc.borrow_data_unchecked() }; + if data.len() < TOKEN_ACCOUNT_MIN_SIZE { + continue; + } + if &data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32] != mint { + continue; + } + if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != vault_key.as_ref() { + continue; + } + let amount = u64::from_le_bytes( + match data[TOKEN_AMOUNT_OFFSET..TOKEN_AMOUNT_OFFSET + 8].try_into() { + Ok(b) => b, + Err(_) => continue, + }, + ); + total = total.saturating_add(amount); + found = true; + } + + if found { Some(total) } else { None } +} + +// ─── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::action::ACTION_HEADER_SIZE; + + fn build_action(action_type: u8, expires_at: u64, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.push(action_type); + buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); + buf.extend_from_slice(&expires_at.to_le_bytes()); + buf.extend_from_slice(data); + buf + } + + fn build_session_data(actions: &[u8]) -> Vec { + let mut data = vec![0u8; SESSION_HEADER_SIZE]; + data[0] = 3; // discriminator + data.extend_from_slice(actions); + data + } + + /// Test helper: calls evaluate_post_actions with gross_out = before - after (single CPI). + fn eval_post( + session_data: &mut [u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, + before: u64, + after: u64, + token_snapshots: &[TokenSnapshot], + slot: u64, + ) -> Result<(), ProgramError> { + let gross = before.saturating_sub(after); + evaluate_post_actions(session_data, accounts, vault_key, before, after, gross, token_snapshots, slot) + } + + fn build_sol_recurring(limit: u64, spent: u64, window: u64, last_reset: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(&limit.to_le_bytes()); + data.extend_from_slice(&spent.to_le_bytes()); + data.extend_from_slice(&window.to_le_bytes()); + data.extend_from_slice(&last_reset.to_le_bytes()); + data + } + + // ─── Basic functionality ────────────────────────────────────── + + #[test] + fn test_no_actions_passthrough() { + let mut session_data = vec![0u8; SESSION_HEADER_SIZE]; + session_data[0] = 3; + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 10_000_000, 0, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_zero_spending_no_state_change() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + let original = session_data.clone(); + + // vault gained lamports (before < after) → sol_spent = 0 + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 2_000_000, // vault gained 1M + &[], 100, + ); + assert!(result.is_ok()); + // State unchanged — remaining should still be 1M + assert_eq!(session_data, original); + } + + #[test] + fn test_vault_balance_increase_ignored() { + // SolMaxPerTx of 500k, but vault GAINS lamports + let actions = build_action(3, 0, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 5_000_000, // gained 4M + &[], 100, + ); + assert!(result.is_ok()); // No violation, gains are ignored + } + + // ─── SolLimit ───────────────────────────────────────────────── + + #[test] + fn test_sol_limit_exact_remaining() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Spend exactly the remaining amount — should succeed + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_000_000, // spent exactly 1M + &[], 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let remaining = read_u64(&session_data[abs_offset..], 0); + assert_eq!(remaining, 0); + } + + #[test] + fn test_sol_limit_depletes_across_txs() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Tx 1: spend 600k + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 0), 400_000); + + // Tx 2: spend 400k (exact remaining) — OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_400_000, 1_000_000, &[], 101, + ); + assert!(result.is_ok()); + assert_eq!(read_u64(&session_data[abs_offset..], 0), 0); + + // Tx 3: spend 1 lamport — should fail (0 remaining) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 102, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_limit_single_overspend() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Try to spend 1M + 1 — should fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 999_999, // spent 1_000_001 + &[], 100, + ); + assert!(result.is_err()); + + // State unchanged after failed check + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 0), 1_000_000); + } + + // ─── SolMaxPerTx ────────────────────────────────────────────── + + #[test] + fn test_sol_max_per_tx_exact_limit() { + let actions = build_action(3, 0, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Spend exactly the max — OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_500_000, &[], 100, + ); + assert!(result.is_ok()); + + // Exceed by 1 — fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_499_999, &[], 101, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_max_per_tx_repeatable() { + // MaxPerTx does NOT accumulate — each tx is independent + let actions = build_action(3, 0, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + for slot in 100..110 { + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_500_000, // 500k each time + &[], slot, + ); + assert!(result.is_ok()); + } + } + + // ─── SolRecurringLimit ──────────────────────────────────────── + + #[test] + fn test_sol_recurring_limit_basic() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend 600k at slot 50 + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 50, + ); + assert!(result.is_ok()); + + // Spend 500k more at slot 60 — total 1.1M > 1M limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_400_000, 900_000, &[], 60, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_recurring_limit_window_reset() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend 900k at slot 50 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_100_000, &[], 50, + ).unwrap(); + + // At slot 150 (after window), 500k should work again + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_100_000, 600_000, &[], 150, + ); + assert!(result.is_ok()); + + // Verify last_reset was aligned to window boundary + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let last_reset = read_u64(&session_data[abs_offset..], 24); + assert_eq!(last_reset, 100); // (150 / 100) * 100 = 100 + } + + #[test] + fn test_sol_recurring_single_tx_exceeds_full_limit_after_reset() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // At slot 150 (fresh window), try to spend more than the full limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 3_500_000, // 1.5M > 1M limit + &[], 150, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_recurring_exact_limit_in_window() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend exactly the limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_000_000, &[], 50, + ); + assert!(result.is_ok()); + + // Spend 1 more in same window — fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 60, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_recurring_overflow_protection() { + // spent is near u64::MAX, adding more would overflow + let data = build_sol_recurring(u64::MAX, u64::MAX - 100, 1000, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend 200 — would overflow spent + sol_spent without saturating_add + // But limit is u64::MAX so it should be within limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_800, // spent 200 + &[], 50, + ); + // saturating_add(u64::MAX - 100, 200) = u64::MAX, which == limit, so OK + assert!(result.is_ok()); + } + + // ─── Combined actions ───────────────────────────────────────── + + #[test] + fn test_combined_sol_limit_and_max_per_tx() { + let mut actions_buf = Vec::new(); + // SolLimit: 2M lifetime + actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + // SolMaxPerTx: 500k per tx + actions_buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + + // 400k — under both limits + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_600_000, &[], 100, + ); + assert!(result.is_ok()); + + // 600k — under lifetime (1.6M left) but over per-tx (500k) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 4_600_000, 4_000_000, &[], 101, + ); + assert!(result.is_err()); + } + + #[test] + fn test_combined_recurring_and_max_per_tx() { + let mut actions_buf = Vec::new(); + // SolRecurringLimit: 1M per 100 slots + actions_buf.extend_from_slice(&build_action(2, 0, &build_sol_recurring(1_000_000, 0, 100, 0))); + // SolMaxPerTx: 300k per tx + actions_buf.extend_from_slice(&build_action(3, 0, &300_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + + // 200k — OK + eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_800_000, &[], 50, + ).unwrap(); + + // 200k more — OK (400k total in window, under 1M; 200k under 300k per-tx) + eval_post( + &mut session_data, &[], &Pubkey::default(), + 4_800_000, 4_600_000, &[], 60, + ).unwrap(); + + // 350k — fails per-tx (350k > 300k) even though recurring has room + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 4_600_000, 4_250_000, &[], 70, + ); + assert!(result.is_err()); + } + + // ─── Action expiry ──────────────────────────────────────────── + + #[test] + fn test_expired_action_blocks_spending() { + // Expired spending limits are treated as fully exhausted (not skipped). + let actions = build_action(3, 50, &500_000u64.to_le_bytes()); // Expires at slot 50 + let mut session_data = build_session_data(&actions); + + // At slot 100, action expired — any spend should FAIL + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 100, + ); + assert!(result.is_err()); + + // Zero spending is still OK even with expired action + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 2_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_action_active_at_expiry_slot() { + // Action expires at slot 50. At exactly slot 50 it should still be active. + // Only expired when current_slot > expires_at. + let actions = build_action(3, 50, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // At slot 50 — still active, 600k > 500k → fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 50, + ); + assert!(result.is_err()); + + // At slot 51 — expired, any spend → also fail (expired = exhausted) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 51, + ); + assert!(result.is_err()); + } + + #[test] + fn test_mixed_expired_and_active_actions() { + let mut actions_buf = Vec::new(); + // SolMaxPerTx: 500k, expires at slot 50 + actions_buf.extend_from_slice(&build_action(3, 50, &500_000u64.to_le_bytes())); + // SolLimit: 2M, never expires + actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + + // At slot 100: MaxPerTx expired → any spend blocked by expired MaxPerTx + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 2_000_000, &[], 100, + ); + assert!(result.is_err()); + + // Even 1 lamport fails because expired MaxPerTx blocks all spending + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_999_999, &[], 100, + ); + assert!(result.is_err()); + + // Zero spend is OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 5_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + // ─── State mutation safety ──────────────────────────────────── + + #[test] + fn test_failed_check_no_state_mutation() { + let mut actions_buf = Vec::new(); + // SolLimit: 2M + actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + // SolMaxPerTx: 100k (will fail) + actions_buf.extend_from_slice(&build_action(3, 0, &100_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + let original = session_data.clone(); + + // 500k spend — passes SolLimit but fails SolMaxPerTx + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_500_000, &[], 100, + ); + assert!(result.is_err()); + + // Because we validate ALL checks before writing, state is unchanged + assert_eq!(session_data, original); + } + + #[test] + fn test_recurring_state_persists_correctly() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + + // Spend 300k at slot 50 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_700_000, &[], 50, + ).unwrap(); + + assert_eq!(read_u64(&session_data[abs_offset..], 8), 300_000); // spent + assert_eq!(read_u64(&session_data[abs_offset..], 24), 0); // last_reset (first window) + + // Spend 200k at slot 60 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_700_000, 1_500_000, &[], 60, + ).unwrap(); + + assert_eq!(read_u64(&session_data[abs_offset..], 8), 500_000); // cumulative + + // Window reset at slot 200 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_500_000, 1_300_000, &[], 200, + ).unwrap(); + + assert_eq!(read_u64(&session_data[abs_offset..], 8), 200_000); // reset + new spend + assert_eq!(read_u64(&session_data[abs_offset..], 24), 200); // aligned: (200/100)*100 + } + + // ─── Edge: zero limit ───────────────────────────────────────── + + #[test] + fn test_zero_sol_limit_blocks_all_spending() { + let actions = build_action(1, 0, &0u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Even 1 lamport should fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 100, + ); + assert!(result.is_err()); + + // But zero spending is OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_zero_max_per_tx_blocks_all_spending() { + let actions = build_action(3, 0, &0u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 100, + ); + assert!(result.is_err()); + + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + // ══════════════════════════════════════════════════════════════════ + // Token spending limit tests + // ══════════════════════════════════════════════════════════════════ + // + // Token tests use empty `accounts` slice so find_token_balance returns + // None → unwrap_or(0), meaning "all tokens drained." We provide + // token_snapshots_before with the initial balance to compute spending. + + fn build_token_limit(mint: &[u8; 32], remaining: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(mint); // [0..32] + data.extend_from_slice(&remaining.to_le_bytes()); // [32..40] + data + } + + fn build_token_max_per_tx(mint: &[u8; 32], max: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(mint); + data.extend_from_slice(&max.to_le_bytes()); + data + } + + fn build_token_recurring(mint: &[u8; 32], limit: u64, spent: u64, window: u64, last_reset: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(mint); // [0..32] + data.extend_from_slice(&limit.to_le_bytes()); // [32..40] + data.extend_from_slice(&spent.to_le_bytes()); // [40..48] + data.extend_from_slice(&window.to_le_bytes()); // [48..56] + data.extend_from_slice(&last_reset.to_le_bytes()); // [56..64] + data + } + + // ── TokenLimit ─────────────────────────────────────────────────── + + #[test] + fn test_token_limit_within_budget() { + let mint = [0xAA; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; + + // accounts=[] → after=0, token_spent=500_000, within 1M limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &snapshots, 100, + ); + assert!(result.is_ok()); + + // Verify remaining was decremented + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let remaining = read_u64(&session_data[abs_offset..], 32); + assert_eq!(remaining, 500_000); + } + + #[test] + fn test_token_limit_exceeds_budget() { + let mint = [0xBB; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 100_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 200_000 }]; + + // token_spent=200k > remaining=100k → fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &snapshots, 100, + ); + assert!(result.is_err()); + } + + #[test] + fn test_token_limit_exact_budget() { + let mint = [0xCC; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; + + // token_spent = exactly remaining → OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &snapshots, 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 32), 0); + } + + #[test] + fn test_token_limit_depletes_across_txs() { + let mint = [0xDD; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); + let mut session_data = build_session_data(&actions); + + // Tx 1: drain 600k + let s1 = vec![TokenSnapshot { mint, amount: 600_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 100).unwrap(); + + // remaining = 400k + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 32), 400_000); + + // Tx 2: drain 400k → exact + let s2 = vec![TokenSnapshot { mint, amount: 400_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 101).unwrap(); + assert_eq!(read_u64(&session_data[abs_offset..], 32), 0); + + // Tx 3: drain 1 → fail + let s3 = vec![TokenSnapshot { mint, amount: 1 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s3, 102); + assert!(result.is_err()); + } + + // ── TokenMaxPerTx ─────────────────────────────────────────────── + + #[test] + fn test_token_max_per_tx_within_limit() { + let mint = [0xEE; 32]; + let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 300_000 }]; + + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_ok()); + } + + #[test] + fn test_token_max_per_tx_exceeds() { + let mint = [0xFF; 32]; + let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 600_000 }]; + + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + #[test] + fn test_token_max_per_tx_repeatable() { + // MaxPerTx has no cumulative state — repeated spends within limit all pass + let mint = [0x11; 32]; + let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + + for slot in 100..105 { + let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, slot); + assert!(result.is_ok()); + } + } + + // ── TokenRecurringLimit ───────────────────────────────────────── + + #[test] + fn test_token_recurring_basic() { + let mint = [0x22; 32]; + let actions = build_action(5, 0, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); + let mut session_data = build_session_data(&actions); + + // Spend 600k at slot 50 — OK + let s1 = vec![TokenSnapshot { mint, amount: 600_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 50).unwrap(); + + // Spend 500k more at slot 60 → total 1.1M > 1M limit → fail + let s2 = vec![TokenSnapshot { mint, amount: 500_000 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 60); + assert!(result.is_err()); + } + + #[test] + fn test_token_recurring_window_reset() { + let mint = [0x33; 32]; + let actions = build_action(5, 0, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); + let mut session_data = build_session_data(&actions); + + // Spend 900k at slot 50 + let s1 = vec![TokenSnapshot { mint, amount: 900_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 50).unwrap(); + + // At slot 150 (after window), spending resets → 500k OK + let s2 = vec![TokenSnapshot { mint, amount: 500_000 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 150); + assert!(result.is_ok()); + } + + // ── Expired token limits ───────────────────────────────────────── + + #[test] + fn test_expired_token_limit_blocks_spending() { + let mint = [0x44; 32]; + let actions = build_action(4, 50, &build_token_limit(&mint, 1_000_000)); // expires at slot 50 + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 100 }]; + + // At slot 100 (expired), any token spend → fail + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + #[test] + fn test_expired_token_max_per_tx_blocks_spending() { + let mint = [0x55; 32]; + let actions = build_action(6, 50, &build_token_max_per_tx(&mint, 1_000_000)); // expires at 50 + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 1 }]; + + // Expired → even 1 token blocked + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + #[test] + fn test_expired_token_recurring_blocks_spending() { + let mint = [0x66; 32]; + let actions = build_action(5, 50, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 1 }]; + + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + // ── Multiple mint limits ──────────────────────────────────────── + + #[test] + fn test_multiple_mints_independent() { + let mint_a = [0xAA; 32]; + let mint_b = [0xBB; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_a, 100_000))); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_b, 500_000))); + let mut session_data = build_session_data(&actions_buf); + + // Drain mint_a within its limit, drain mint_b within its limit + let snapshots = vec![ + TokenSnapshot { mint: mint_a, amount: 50_000 }, + TokenSnapshot { mint: mint_b, amount: 400_000 }, + ]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_ok()); + } + + #[test] + fn test_multiple_mints_one_exceeds() { + let mint_a = [0xAA; 32]; + let mint_b = [0xBB; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_a, 100_000))); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_b, 500_000))); + let mut session_data = build_session_data(&actions_buf); + + // mint_a: 50k OK, mint_b: 600k > 500k → fail + let snapshots = vec![ + TokenSnapshot { mint: mint_a, amount: 50_000 }, + TokenSnapshot { mint: mint_b, amount: 600_000 }, + ]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + // ── Combined SOL + Token limits ───────────────────────────────── + + #[test] + fn test_combined_sol_and_token_limits() { + let mint = [0xCC; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 0, &1_000_000u64.to_le_bytes())); // SolLimit: 1M + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint, 500_000))); // TokenLimit: 500k + let mut session_data = build_session_data(&actions_buf); + + let snapshots = vec![TokenSnapshot { mint, amount: 300_000 }]; + + // SOL: 200k spent (under 1M), Token: 300k spent (under 500k) → OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 800_000, &snapshots, 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_combined_sol_ok_token_exceeds() { + let mint = [0xDD; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 0, &10_000_000u64.to_le_bytes())); // SolLimit: 10M + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint, 100_000))); // TokenLimit: 100k + let mut session_data = build_session_data(&actions_buf); + + let snapshots = vec![TokenSnapshot { mint, amount: 200_000 }]; + + // SOL: 500k spent (under 10M), Token: 200k > 100k → fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_500_000, &snapshots, 100, + ); + assert!(result.is_err()); + } + + // ══════════════════════════════════════════════════════════════════ + // Gross outflow tests (SolMaxPerTx uses gross, not net) + // ══════════════════════════════════════════════════════════════════ + + #[test] + fn test_sol_max_per_tx_gross_vs_net() { + // SolMaxPerTx = 1 SOL. A DeFi swap sends 10 SOL out and receives 9.5 back. + // Net = 0.5 SOL (would pass if using net), Gross = 10 SOL (must fail). + let actions = build_action(3, 0, &1_000_000_000u64.to_le_bytes()); // 1 SOL max + let mut session_data = build_session_data(&actions); + + // before=20 SOL, after=19.5 SOL → net = 0.5 SOL + // But gross = 10 SOL (passed explicitly) + let result = evaluate_post_actions( + &mut session_data, &[], &Pubkey::default(), + 20_000_000_000, 19_500_000_000, + 10_000_000_000, // gross = 10 SOL + &[], 100, + ); + assert!(result.is_err()); // 10 SOL gross > 1 SOL max → fail + } + + #[test] + fn test_sol_max_per_tx_gross_within_limit() { + let actions = build_action(3, 0, &5_000_000_000u64.to_le_bytes()); // 5 SOL max + let mut session_data = build_session_data(&actions); + + // Gross = 3 SOL, net = 1 SOL + let result = evaluate_post_actions( + &mut session_data, &[], &Pubkey::default(), + 20_000_000_000, 19_000_000_000, + 3_000_000_000, // gross = 3 SOL + &[], 100, + ); + assert!(result.is_ok()); // 3 SOL gross < 5 SOL max → OK + } + + #[test] + fn test_sol_limit_uses_net_not_gross() { + // SolLimit (cumulative) should use net, not gross. + // A round-trip that returns most lamports shouldn't deplete the budget. + let actions = build_action(1, 0, &2_000_000_000u64.to_le_bytes()); // 2 SOL limit + let mut session_data = build_session_data(&actions); + + // net = 0.5 SOL, gross = 10 SOL + let result = evaluate_post_actions( + &mut session_data, &[], &Pubkey::default(), + 20_000_000_000, 19_500_000_000, + 10_000_000_000, + &[], 100, + ); + assert!(result.is_ok()); // SolLimit uses net: 0.5 SOL < 2 SOL → OK + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let remaining = read_u64(&session_data[abs_offset..], 0); + assert_eq!(remaining, 1_500_000_000); // 2 SOL - 0.5 SOL net + } + + // ══════════════════════════════════════════════════════════════════ + // Attacker pattern tests + // ══════════════════════════════════════════════════════════════════ + + #[test] + fn test_attacker_all_limits_expired_session_locked() { + // Attacker scenario: create a session with a short-lived SolLimit. + // After expiry, the session should be locked — not unrestricted. + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 50, &1_000_000u64.to_le_bytes())); // SolLimit, expires at 50 + actions_buf.extend_from_slice(&build_action(3, 50, &500_000u64.to_le_bytes())); // SolMaxPerTx, expires at 50 + let mut session_data = build_session_data(&actions_buf); + + // At slot 100 (both expired), even 1 lamport spend is blocked + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 100, + ); + assert!(result.is_err()); + + // Zero spend still OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_attacker_expired_token_and_sol_limits() { + // All limits expired — both SOL and token spending blocked + let mint = [0xFF; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 50, &1_000_000u64.to_le_bytes())); + actions_buf.extend_from_slice(&build_action(4, 50, &build_token_limit(&mint, 500_000))); + let mut session_data = build_session_data(&actions_buf); + + // SOL spend → blocked + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_000, &[], 100, + ); + assert!(result.is_err()); + + // Token spend → blocked + let snapshots = vec![TokenSnapshot { mint, amount: 100 }]; + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, // no SOL change + &snapshots, 100, + ); + assert!(result.is_err()); + } + + #[test] + fn test_attacker_u64_max_overflow() { + // Attacker tries u64::MAX as remaining — should not cause overflow + let actions = build_action(1, 0, &u64::MAX.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Spend u64::MAX → should succeed (exact match) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + u64::MAX, 0, &[], 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 0), 0); + } + + #[test] + fn test_no_token_snapshot_means_no_change() { + // If a token mint has a limit but no before-snapshot, token_spent = 0 + let mint = [0xAA; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); + let mut session_data = build_session_data(&actions); + + // No snapshots → before=0, after=0 → spent=0 → OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &[], 100, + ); + assert!(result.is_ok()); + } + + // ── evaluate_pre_actions tests ─────────────────────────────────── + // These test whitelist/blacklist logic directly. + // We create minimal CompactInstructions that reference account indexes. + + #[test] + fn test_pre_actions_no_actions_passthrough() { + let mut session_data = vec![0u8; SESSION_HEADER_SIZE]; + session_data[0] = 3; + + let result = evaluate_pre_actions(&session_data, &[], &[], 100); + assert!(result.is_ok()); + } + + // ── Session creation: actions_len cap ──────────────────────────── + // (tested in session/create.rs but we verify the constant here) + + #[test] + fn test_max_actions_constant_is_16() { + assert_eq!(crate::state::action::MAX_ACTIONS, 16); + } +} diff --git a/program/src/processor/mod.rs b/program/src/processor/mod.rs index a777116..2d2086a 100644 --- a/program/src/processor/mod.rs +++ b/program/src/processor/mod.rs @@ -6,6 +6,7 @@ pub mod authorize; pub mod create_session; pub mod create_wallet; pub mod execute; +pub mod execute_actions; pub mod execute_deferred; pub mod manage_authority; pub mod reclaim_deferred; diff --git a/program/src/state/action.rs b/program/src/state/action.rs new file mode 100644 index 0000000..7195fa4 --- /dev/null +++ b/program/src/state/action.rs @@ -0,0 +1,697 @@ +//! Session action types for permission enforcement. +//! +//! Actions are optional, immutable permission rules attached to sessions at creation time. +//! They are stored as a flat byte buffer appended after the 80-byte SessionAccount header. +//! +//! Each action has an 11-byte header: [type: u8][data_len: u16 LE][expires_at: u64 LE] +//! followed by type-specific data bytes. + +use pinocchio::program_error::ProgramError; + +use crate::error::AuthError; + +// ─── Action Header ──────────────────────────────────────────────────── + +/// Size of each action header in bytes. +pub const ACTION_HEADER_SIZE: usize = 11; + +/// Maximum number of actions per session. +pub const MAX_ACTIONS: usize = 16; + +// ─── Action Types ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ActionType { + /// Lifetime SOL spending cap. Data: {remaining: u64} + SolLimit = 1, + /// Per-window SOL spending cap. Data: {limit, spent, window, last_reset} + SolRecurringLimit = 2, + /// Maximum SOL per single execute. Data: {max: u64} + SolMaxPerTx = 3, + /// Lifetime token spending cap per mint. Data: {mint: [u8;32], remaining: u64} + TokenLimit = 4, + /// Per-window token spending cap per mint. Data: {mint, limit, spent, window, last_reset} + TokenRecurringLimit = 5, + /// Maximum tokens per single execute per mint. Data: {mint: [u8;32], max: u64} + TokenMaxPerTx = 6, + /// Allow CPI only to this program. Repeatable. Data: {program_id: [u8;32]} + ProgramWhitelist = 10, + /// Block CPI to this program. Repeatable. Data: {program_id: [u8;32]} + ProgramBlacklist = 11, +} + +impl ActionType { + pub fn from_u8(v: u8) -> Result { + match v { + 1 => Ok(Self::SolLimit), + 2 => Ok(Self::SolRecurringLimit), + 3 => Ok(Self::SolMaxPerTx), + 4 => Ok(Self::TokenLimit), + 5 => Ok(Self::TokenRecurringLimit), + 6 => Ok(Self::TokenMaxPerTx), + 10 => Ok(Self::ProgramWhitelist), + 11 => Ok(Self::ProgramBlacklist), + _ => Err(AuthError::ActionBufferInvalid.into()), + } + } + + /// Expected data size for this action type (excluding header). + pub fn expected_data_size(&self) -> usize { + match self { + Self::SolLimit => SOL_LIMIT_SIZE, + Self::SolRecurringLimit => SOL_RECURRING_LIMIT_SIZE, + Self::SolMaxPerTx => SOL_MAX_PER_TX_SIZE, + Self::TokenLimit => TOKEN_LIMIT_SIZE, + Self::TokenRecurringLimit => TOKEN_RECURRING_LIMIT_SIZE, + Self::TokenMaxPerTx => TOKEN_MAX_PER_TX_SIZE, + Self::ProgramWhitelist => PROGRAM_WHITELIST_SIZE, + Self::ProgramBlacklist => PROGRAM_BLACKLIST_SIZE, + } + } +} + +// ─── Data Sizes ─────────────────────────────────────────────────────── + +pub const SOL_LIMIT_SIZE: usize = 8; +pub const SOL_RECURRING_LIMIT_SIZE: usize = 32; +pub const SOL_MAX_PER_TX_SIZE: usize = 8; +pub const TOKEN_LIMIT_SIZE: usize = 40; +pub const TOKEN_RECURRING_LIMIT_SIZE: usize = 64; +pub const TOKEN_MAX_PER_TX_SIZE: usize = 40; +pub const PROGRAM_WHITELIST_SIZE: usize = 32; +pub const PROGRAM_BLACKLIST_SIZE: usize = 32; + +// ─── Action View (zero-copy index into buffer) ─────────────────────── + +/// A parsed reference to an action within the session data buffer. +/// Does not own data — just indexes into the buffer. +#[derive(Debug, Clone)] +pub struct ActionView { + pub action_type: ActionType, + pub expires_at: u64, + /// Byte offset of this action's data within the actions buffer + /// (relative to start of actions buffer, NOT session account start). + pub data_offset: usize, + pub data_len: usize, +} + +// ─── Buffer Parsing ─────────────────────────────────────────────────── + +/// Parse all actions from a raw actions buffer. +/// +/// The buffer starts immediately after the 80-byte session header. +/// Returns a Vec of ActionViews indexing into the buffer. +pub fn parse_actions(buf: &[u8]) -> Result, ProgramError> { + let mut actions = Vec::new(); + let mut cursor = 0; + + while cursor < buf.len() { + if cursor + ACTION_HEADER_SIZE > buf.len() { + return Err(AuthError::ActionBufferInvalid.into()); + } + + let action_type = ActionType::from_u8(buf[cursor])?; + let data_len = u16::from_le_bytes([buf[cursor + 1], buf[cursor + 2]]) as usize; + let expires_at = u64::from_le_bytes( + buf[cursor + 3..cursor + 11] + .try_into() + .map_err(|_| AuthError::ActionBufferInvalid)?, + ); + + let data_offset = cursor + ACTION_HEADER_SIZE; + if data_offset + data_len > buf.len() { + return Err(AuthError::ActionBufferInvalid.into()); + } + + actions.push(ActionView { + action_type, + expires_at, + data_offset, + data_len, + }); + + cursor = data_offset + data_len; + + if actions.len() > MAX_ACTIONS { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + + Ok(actions) +} + +/// Validate an actions buffer at session creation time. +/// +/// Checks: +/// - All action types are known +/// - Data sizes match expected sizes per type +/// - No simultaneous ProgramWhitelist + ProgramBlacklist +/// - Buffer is fully consumed (no trailing bytes) +/// - Not exceeding MAX_ACTIONS +pub fn validate_actions_buffer(buf: &[u8]) -> Result<(), ProgramError> { + if buf.is_empty() { + return Ok(()); + } + + let actions = parse_actions(buf)?; + + // Verify data sizes match expected + for action in &actions { + if action.data_len != action.action_type.expected_data_size() { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + + // Check no whitelist + blacklist coexistence + let has_whitelist = actions + .iter() + .any(|a| a.action_type == ActionType::ProgramWhitelist); + let has_blacklist = actions + .iter() + .any(|a| a.action_type == ActionType::ProgramBlacklist); + if has_whitelist && has_blacklist { + return Err(AuthError::ActionWhitelistBlacklistConflict.into()); + } + + // Check no duplicate non-repeatable actions + let mut has_sol_limit = false; + let mut has_sol_recurring = false; + let mut has_sol_max_per_tx = false; + for action in &actions { + match action.action_type { + ActionType::SolLimit => { + if has_sol_limit { + return Err(AuthError::ActionBufferInvalid.into()); + } + has_sol_limit = true; + } + ActionType::SolRecurringLimit => { + if has_sol_recurring { + return Err(AuthError::ActionBufferInvalid.into()); + } + has_sol_recurring = true; + } + ActionType::SolMaxPerTx => { + if has_sol_max_per_tx { + return Err(AuthError::ActionBufferInvalid.into()); + } + has_sol_max_per_tx = true; + } + _ => {} // Repeatable types are fine + } + } + + // Check no duplicate token actions for the same mint + // (Two TokenLimit for the same mint would create confusing deduction semantics) + { + let token_types = [ + ActionType::TokenLimit, + ActionType::TokenRecurringLimit, + ActionType::TokenMaxPerTx, + ]; + for token_type in &token_types { + let token_actions: Vec<&ActionView> = actions + .iter() + .filter(|a| &a.action_type == token_type) + .collect(); + for i in 0..token_actions.len() { + for j in (i + 1)..token_actions.len() { + let mint_a = &buf[token_actions[i].data_offset..token_actions[i].data_offset + 32]; + let mint_b = &buf[token_actions[j].data_offset..token_actions[j].data_offset + 32]; + if mint_a == mint_b { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + } + } + } + + // Validate recurring limit initial state + for action in &actions { + if action.action_type == ActionType::SolRecurringLimit { + let data = &buf[action.data_offset..action.data_offset + action.data_len]; + // spent must be 0 at creation + let spent = u64::from_le_bytes(data[8..16].try_into().unwrap()); + if spent != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // window must be > 0 + let window = u64::from_le_bytes(data[16..24].try_into().unwrap()); + if window == 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // last_reset must be 0 + let last_reset = u64::from_le_bytes(data[24..32].try_into().unwrap()); + if last_reset != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + if action.action_type == ActionType::TokenRecurringLimit { + let data = &buf[action.data_offset..action.data_offset + action.data_len]; + // spent must be 0 (bytes 40..48) + let spent = u64::from_le_bytes(data[40..48].try_into().unwrap()); + if spent != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // window must be > 0 (bytes 48..56) + let window = u64::from_le_bytes(data[48..56].try_into().unwrap()); + if window == 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // last_reset must be 0 (bytes 56..64) + let last_reset = u64::from_le_bytes(data[56..64].try_into().unwrap()); + if last_reset != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + } + + Ok(()) +} + +// ─── Data Layout Helpers ────────────────────────────────────────────── + +// SolLimit: [remaining: u64] = 8 bytes +// Offsets within data: remaining = 0..8 + +// SolRecurringLimit: [limit: u64][spent: u64][window: u64][last_reset: u64] = 32 bytes +// Offsets: limit = 0..8, spent = 8..16, window = 16..24, last_reset = 24..32 + +// SolMaxPerTx: [max: u64] = 8 bytes +// Offsets: max = 0..8 + +// TokenLimit: [mint: [u8;32]][remaining: u64] = 40 bytes +// Offsets: mint = 0..32, remaining = 32..40 + +// TokenRecurringLimit: [mint: [u8;32]][limit: u64][spent: u64][window: u64][last_reset: u64] = 64 bytes +// Offsets: mint = 0..32, limit = 32..40, spent = 40..48, window = 48..56, last_reset = 56..64 + +// TokenMaxPerTx: [mint: [u8;32]][max: u64] = 40 bytes +// Offsets: mint = 0..32, max = 32..40 + +// ProgramWhitelist: [program_id: [u8;32]] = 32 bytes +// ProgramBlacklist: [program_id: [u8;32]] = 32 bytes + +/// Read a u64 from a byte slice at the given offset (LE). +#[inline(always)] +pub fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +/// Write a u64 to a byte slice at the given offset (LE). +#[inline(always)] +pub fn write_u64(data: &mut [u8], offset: usize, value: u64) { + data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +// ─── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn build_action(action_type: u8, expires_at: u64, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.push(action_type); + buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); + buf.extend_from_slice(&expires_at.to_le_bytes()); + buf.extend_from_slice(data); + buf + } + + #[test] + fn test_parse_empty_buffer() { + let actions = parse_actions(&[]).unwrap(); + assert!(actions.is_empty()); + } + + #[test] + fn test_parse_sol_limit() { + let data = 1_000_000u64.to_le_bytes(); + let buf = build_action(1, 0, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].action_type, ActionType::SolLimit); + assert_eq!(actions[0].expires_at, 0); + assert_eq!(actions[0].data_len, 8); + } + + #[test] + fn test_parse_sol_recurring_limit() { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&216_000u64.to_le_bytes()); // window (~1 day) + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(2, 0, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].action_type, ActionType::SolRecurringLimit); + assert_eq!(actions[0].data_len, 32); + } + + #[test] + fn test_parse_program_whitelist_multiple() { + let prog1 = [1u8; 32]; + let prog2 = [2u8; 32]; + let mut buf = build_action(10, 0, &prog1); + buf.extend_from_slice(&build_action(10, 0, &prog2)); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].action_type, ActionType::ProgramWhitelist); + assert_eq!(actions[1].action_type, ActionType::ProgramWhitelist); + } + + #[test] + fn test_parse_multiple_action_types() { + let mut buf = Vec::new(); + // SolMaxPerTx + buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); + // ProgramWhitelist + buf.extend_from_slice(&build_action(10, 0, &[0xAA; 32])); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].action_type, ActionType::SolMaxPerTx); + assert_eq!(actions[1].action_type, ActionType::ProgramWhitelist); + } + + #[test] + fn test_parse_with_expiry() { + let data = 1_000_000u64.to_le_bytes(); + let buf = build_action(1, 5000, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions[0].expires_at, 5000); + } + + #[test] + fn test_validate_unknown_type() { + let buf = build_action(99, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_wrong_data_size() { + // SolLimit expects 8 bytes, give it 16 + let buf = build_action(1, 0, &[0u8; 16]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_whitelist_blacklist_conflict() { + let mut buf = build_action(10, 0, &[1u8; 32]); // whitelist + buf.extend_from_slice(&build_action(11, 0, &[2u8; 32])); // blacklist + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_duplicate_sol_limit() { + let mut buf = build_action(1, 0, &1_000_000u64.to_le_bytes()); + buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_recurring_nonzero_spent() { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&100u64.to_le_bytes()); // spent (should be 0!) + data.extend_from_slice(&216_000u64.to_le_bytes()); // window + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(2, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_recurring_zero_window() { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&0u64.to_le_bytes()); // window = 0 (invalid!) + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(2, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_empty_ok() { + assert!(validate_actions_buffer(&[]).is_ok()); + } + + #[test] + fn test_validate_valid_combined() { + let mut buf = Vec::new(); + // SolRecurringLimit + let mut sol_rec = Vec::new(); + sol_rec.extend_from_slice(&1_000_000u64.to_le_bytes()); + sol_rec.extend_from_slice(&0u64.to_le_bytes()); + sol_rec.extend_from_slice(&216_000u64.to_le_bytes()); + sol_rec.extend_from_slice(&0u64.to_le_bytes()); + buf.extend_from_slice(&build_action(2, 0, &sol_rec)); + // ProgramWhitelist + buf.extend_from_slice(&build_action(10, 0, &[0xBB; 32])); + // SolMaxPerTx + buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); + assert!(validate_actions_buffer(&buf).is_ok()); + } + + #[test] + fn test_truncated_header() { + let buf = vec![1u8, 0]; // Only 2 bytes, need 11 + assert!(parse_actions(&buf).is_err()); + } + + #[test] + fn test_truncated_data() { + let mut buf = Vec::new(); + buf.push(1); // type + buf.extend_from_slice(&8u16.to_le_bytes()); // data_len = 8 + buf.extend_from_slice(&0u64.to_le_bytes()); // expires_at + buf.extend_from_slice(&[0u8; 4]); // only 4 bytes of data (need 8) + assert!(parse_actions(&buf).is_err()); + } + + #[test] + fn test_token_max_per_tx() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xCC; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // max + let buf = build_action(6, 0, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions[0].action_type, ActionType::TokenMaxPerTx); + assert!(validate_actions_buffer(&buf).is_ok()); + } + + #[test] + fn test_read_write_u64() { + let mut data = [0u8; 16]; + write_u64(&mut data, 0, 12345); + write_u64(&mut data, 8, 67890); + assert_eq!(read_u64(&data, 0), 12345); + assert_eq!(read_u64(&data, 8), 67890); + } + + // ─── Security: Duplicate token mint validation (Audit Finding 6) ── + + #[test] + fn test_validate_duplicate_token_limit_same_mint() { + let mint = [0xAA; 32]; + let mut data1 = Vec::new(); + data1.extend_from_slice(&mint); + data1.extend_from_slice(&1_000_000u64.to_le_bytes()); + + let mut data2 = Vec::new(); + data2.extend_from_slice(&mint); // same mint! + data2.extend_from_slice(&2_000_000u64.to_le_bytes()); + + let mut buf = build_action(4, 0, &data1); // TokenLimit + buf.extend_from_slice(&build_action(4, 0, &data2)); // TokenLimit same mint + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_duplicate_token_recurring_same_mint() { + let mint = [0xBB; 32]; + let mut make_data = || { + let mut data = Vec::new(); + data.extend_from_slice(&mint); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&100u64.to_le_bytes()); // window + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + data + }; + + let mut buf = build_action(5, 0, &make_data()); // TokenRecurringLimit + buf.extend_from_slice(&build_action(5, 0, &make_data())); // same mint + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_different_token_mints_ok() { + let mut data1 = Vec::new(); + data1.extend_from_slice(&[0xAA; 32]); // mint A + data1.extend_from_slice(&1_000_000u64.to_le_bytes()); + + let mut data2 = Vec::new(); + data2.extend_from_slice(&[0xBB; 32]); // mint B (different!) + data2.extend_from_slice(&2_000_000u64.to_le_bytes()); + + let mut buf = build_action(4, 0, &data1); + buf.extend_from_slice(&build_action(4, 0, &data2)); + assert!(validate_actions_buffer(&buf).is_ok()); + } + + #[test] + fn test_validate_same_mint_different_action_types_ok() { + // Same mint in TokenLimit and TokenMaxPerTx is OK (different action types) + let mint = [0xCC; 32]; + let mut data_limit = Vec::new(); + data_limit.extend_from_slice(&mint); + data_limit.extend_from_slice(&1_000_000u64.to_le_bytes()); + + let mut data_max = Vec::new(); + data_max.extend_from_slice(&mint); + data_max.extend_from_slice(&500_000u64.to_le_bytes()); + + let mut buf = build_action(4, 0, &data_limit); // TokenLimit + buf.extend_from_slice(&build_action(6, 0, &data_max)); // TokenMaxPerTx + assert!(validate_actions_buffer(&buf).is_ok()); + } + + // ─── Security: Duplicate SOL actions ────────────────────────────── + + #[test] + fn test_validate_duplicate_sol_recurring_limit() { + let make_data = || { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&100u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data + }; + let mut buf = build_action(2, 0, &make_data()); + buf.extend_from_slice(&build_action(2, 0, &make_data())); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_duplicate_sol_max_per_tx() { + let mut buf = build_action(3, 0, &500_000u64.to_le_bytes()); + buf.extend_from_slice(&build_action(3, 0, &300_000u64.to_le_bytes())); + assert!(validate_actions_buffer(&buf).is_err()); + } + + // ─── Security: Token recurring limit initial state ──────────────── + + #[test] + fn test_validate_token_recurring_nonzero_spent() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xAA; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&100u64.to_le_bytes()); // spent (should be 0!) + data.extend_from_slice(&100u64.to_le_bytes()); // window + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(5, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_token_recurring_zero_window() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xAA; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&0u64.to_le_bytes()); // window = 0! + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(5, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_token_recurring_nonzero_last_reset() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xAA; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&100u64.to_le_bytes()); // window + data.extend_from_slice(&50u64.to_le_bytes()); // last_reset != 0! + let buf = build_action(5, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + // ─── Security: Action type boundary values ──────────────────────── + + #[test] + fn test_validate_action_type_7_rejected() { + let buf = build_action(7, 0, &[0u8; 8]); // gap value + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_8_rejected() { + let buf = build_action(8, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_9_rejected() { + let buf = build_action(9, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_12_rejected() { + let buf = build_action(12, 0, &[0u8; 32]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_255_rejected() { + let buf = build_action(255, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_0_rejected() { + let buf = build_action(0, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + // ─── Security: MAX_ACTIONS limit ────────────────────────────────── + + #[test] + fn test_validate_max_actions_limit() { + let mut buf = Vec::new(); + for i in 0..=MAX_ACTIONS { + let mut prog = [0u8; 32]; + prog[0] = i as u8; + buf.extend_from_slice(&build_action(10, 0, &prog)); // ProgramWhitelist + } + // 17 actions should fail (MAX_ACTIONS = 16) + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_exactly_max_actions_ok() { + let mut buf = Vec::new(); + for i in 0..MAX_ACTIONS { + let mut prog = [0u8; 32]; + prog[0] = i as u8; + buf.extend_from_slice(&build_action(10, 0, &prog)); + } + // 16 actions should be fine + assert!(validate_actions_buffer(&buf).is_ok()); + } + + // ─── Security: Trailing bytes ───────────────────────────────────── + + #[test] + fn test_validate_trailing_bytes_rejected() { + let mut buf = build_action(3, 0, &500_000u64.to_le_bytes()); + buf.push(0xFF); // trailing garbage byte + // parse_actions should fail because the trailing byte doesn't form a valid header + assert!(validate_actions_buffer(&buf).is_err()); + } +} diff --git a/program/src/state/mod.rs b/program/src/state/mod.rs index 22802b8..55ece4a 100644 --- a/program/src/state/mod.rs +++ b/program/src/state/mod.rs @@ -1,3 +1,4 @@ +pub mod action; pub mod authority; pub mod deferred; pub mod session; diff --git a/program/src/state/session.rs b/program/src/state/session.rs index 4593bdf..655dbdb 100644 --- a/program/src/state/session.rs +++ b/program/src/state/session.rs @@ -1,13 +1,15 @@ use no_padding::NoPadding; use pinocchio::pubkey::Pubkey; +/// Size of the fixed session header (excluding actions). +pub const SESSION_HEADER_SIZE: usize = 80; + #[repr(C, align(8))] #[derive(NoPadding)] /// Ephemeral Session Account. /// /// Represents a temporary delegated authority with an expiration time. -// Removed duplicate attribute -#[derive(NoPadding)] +/// Optional actions may follow the 80-byte header as a flat byte buffer. pub struct SessionAccount { /// Account discriminator (must be `3` for Session). pub discriminator: u8, // 1 @@ -24,3 +26,20 @@ pub struct SessionAccount { /// Absolute slot height when this session expires. pub expires_at: u64, // 8 } + +/// Returns true if the session account data contains actions after the header. +#[inline] +pub fn has_actions(session_data: &[u8]) -> bool { + session_data.len() > SESSION_HEADER_SIZE +} + +/// Returns the actions buffer slice (bytes after the 80-byte header). +/// Returns empty slice if no actions. +#[inline] +pub fn actions_slice(session_data: &[u8]) -> &[u8] { + if session_data.len() > SESSION_HEADER_SIZE { + &session_data[SESSION_HEADER_SIZE..] + } else { + &[] + } +} diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 8211f0e..29a9953 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -1,31 +1,38 @@ #!/bin/bash - -# Configuration -PROGRAM_ID=$1 +# Build the Rust program for a chosen cluster, derive the program ID from the +# resulting keypair, regenerate IDL + SDK against that ID. +# +# Usage: +# ./scripts/build-all.sh devnet # builds with --features devnet (FLb7...) +# ./scripts/build-all.sh mainnet # builds with --features mainnet (LazorjRF... — slot shared with lazorkit-protocol) +# +# After this script the .so + keypair live at target/deploy/. Deploy with: +# solana program deploy target/deploy/lazorkit_program.so -u +set -e + +CLUSTER=$1 ROOT_DIR=$(pwd) PROGRAM_DIR="$ROOT_DIR/program" SDK_DIR="$ROOT_DIR/sdk/solita-client" -if [ -z "$PROGRAM_ID" ]; then - echo "Usage: $0 " - echo "Example: $0 FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao" +if [ "$CLUSTER" != "mainnet" ] && [ "$CLUSTER" != "devnet" ]; then + echo "Usage: $0 " exit 1 fi -echo "--- 🚀 Starting LazorKit Full Sync Workflow ---" - -# Step 1: Update Program ID everywhere -echo "[1/4] Syncing Program ID to $PROGRAM_ID..." -./scripts/sync-program-id.sh "$PROGRAM_ID" +echo "--- 🚀 LazorKit build (cluster: $CLUSTER) ---" -# Step 2: Build Rust Program -echo "[2/4] Building Rust Program..." -cargo build-sbf - -# Step 3: Generate IDL using Shank -echo "[3/4] Generating IDL..." +# Step 1: Build Rust Program with the chosen cluster feature. +# This embeds the right declare_id! at compile time via assertions/src/lib.rs. +echo "[1/3] Building Rust Program (cargo build-sbf --features $CLUSTER)..." cd "$PROGRAM_DIR" -# Assuming shank is installed. If not, this will fail with a clear msg. +cargo build-sbf --features "$CLUSTER" + +# Step 2: Generate IDL using Shank, picking the program ID from the keypair +# the build emitted at target/deploy/lazorkit_program-keypair.json. +echo "[2/3] Generating IDL..." +PROGRAM_ID=$(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) +echo " resolved program ID: $PROGRAM_ID" if command -v shank &> /dev/null; then shank idl -o . --out-filename idl.json -p "$PROGRAM_ID" else @@ -33,10 +40,10 @@ else exit 1 fi -# Step 4: Regenerate SDK with Solita -echo "[4/4] Regenerating Solita SDK..." +# Step 3: Regenerate SDK with Solita. +echo "[3/3] Regenerating Solita SDK..." cd "$SDK_DIR" node generate.mjs -echo "--- ✅ All Done! ---" -echo "Next: Deploy your program using 'solana program deploy program/target/deploy/lazorkit_program.so -u d'" +echo "--- ✅ Done ($CLUSTER) ---" +echo "Deploy: solana program deploy ../target/deploy/lazorkit_program.so -u $([ "$CLUSTER" = "mainnet" ] && echo m || echo d)" diff --git a/scripts/sync-program-id.sh b/scripts/sync-program-id.sh deleted file mode 100755 index bba425d..0000000 --- a/scripts/sync-program-id.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash - -# Check if new Program ID is provided -if [ -z "$1" ]; then - echo "Usage: $0 " - exit 1 -fi - -NEW_ID=$1 - -# Detect OLD_ID from assertions/src/lib.rs -OLD_ID=$(grep -oE "declare_id\!\(\"[A-Za-z0-9]+\"\)" assertions/src/lib.rs | sed -E 's/declare_id\!\(\"([A-Za-z0-9]+)\"\)/\1/') - -if [ -z "$OLD_ID" ]; then - echo "❌ Error: Could not detect current Program ID from assertions/src/lib.rs" - exit 1 -fi - -if [ "$OLD_ID" == "$NEW_ID" ]; then - echo "Program ID is already $NEW_ID. Skipping sync." - exit 0 -fi - -echo "Syncing Program ID: $OLD_ID -> $NEW_ID" - -# 1. Update Rust assertions -sed -i '' "s/$OLD_ID/$NEW_ID/g" assertions/src/lib.rs - -# 2. Update SDK generation script -sed -i '' "s/$OLD_ID/$NEW_ID/g" sdk/solita-client/generate.mjs - -# 3. Update SDK tests common configuration -sed -i '' "s/$OLD_ID/$NEW_ID/g" tests-sdk/tests/common.ts - -# 4. Update validator start script in tests -sed -i '' "s/$OLD_ID/$NEW_ID/g" tests-sdk/package.json - -# 5. Run SDK generation to update the TypeScript client -echo "Regenerating SDK..." -cd sdk/solita-client -node generate.mjs -cd ../.. - -echo "✓ Program ID synced across: Rust code, SDK, and Tests." -echo "✓ SDK regenerated with new address." -echo "Pro tip: Now run 'cargo build-sbf' to rebuild the program with the correct ID." diff --git a/tests-sdk/package.json b/tests-sdk/package.json index 98d11f0..8f1f03b 100644 --- a/tests-sdk/package.json +++ b/tests-sdk/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "vitest run --fileParallelism=false", "test:watch": "vitest --fileParallelism=false", - "validator:start": "solana-test-validator --bpf-program FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao ../target/deploy/lazorkit_program.so --reset", + "validator:start": "cd ../program && cargo build-sbf --features devnet && cd - && solana-test-validator --bpf-program $(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) ../target/deploy/lazorkit_program.so --reset", "benchmark": "npx tsx tests/benchmark.ts", "pretest": "echo 'Ensure solana-test-validator is running with the program loaded'" },