diff --git a/.git-hooks/local-pre-push b/.git-hooks/local-pre-push deleted file mode 100755 index 129fea9..0000000 --- a/.git-hooks/local-pre-push +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Repo-specific pre-push — runs alongside the canonical ResQ pre-push hook. -# Fails fast if cargo is missing so a broken-build push can't slip through. -set -euo pipefail - -if ! command -v cargo >/dev/null 2>&1; then - echo "❌ cargo not found; cannot run required workspace check." >&2 - exit 1 -fi - -echo " Anchor/Rust: cargo check --workspace --quiet" -cargo check --workspace --quiet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b14544 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +.anchor/ +node_modules/ +test-ledger/ +*.so +.DS_Store diff --git a/README.md b/README.md index 907546a..05251a1 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Configuration is primarily handled via `Anchor.toml` and environment variables. * **`Anchor.toml`**: Specifies program IDs for different clusters (`localnet`, `devnet`), default provider settings (cluster URL, wallet), and script commands. * **Environment Variables**: - * `SOLANA_CLI_VERSION`: Not directly used, but `flake.nix` pins specific versions. + * `SOLANA_CLI_VERSION`: Not directly used, but `bootstrap.sh` and `flake.nix` pin specific versions. * `ANCHOR_VERSION`: Managed by `avm` for cross-version compatibility. ## Development diff --git a/bootstrap.sh b/bootstrap.sh index 6c573d0..b1eb876 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,9 +1,18 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash + # Copyright 2026 ResQ -# SPDX-License-Identifier: Apache-2.0 # -# Canonical onboarding — delegates to resq-software/dev. -# See https://github.com/resq-software/dev for the full installer. -set -eu -export REPO=programs -exec sh -c "$(curl -fsSL https://raw.githubusercontent.com/resq-software/dev/main/install.sh)" +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail +exec "$(dirname "$0")/scripts/setup.sh" "$@" diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 7403bb2..0000000 --- a/flake.lock +++ /dev/null @@ -1,96 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1776221942, - "narHash": "sha256-FbQAeVNi7G4v3QCSThrSAAvzQTmrmyDLiHNPvTF2qFM=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "1766437c5509f444c1b15331e82b8b6a9b967000", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1744536153, - "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": "nixpkgs_2" - }, - "locked": { - "lastModified": 1776350315, - "narHash": "sha256-ijD4bgb5Iyap9F3MX73vLAZF/SYu+q7Gd7Ux4cbfCWw=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "62e3b8aedabc240e5b0cc9fae003bc9edfebbc9b", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix index 76499b0..b3dd6e4 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,7 @@ description = "ResQ Programs — Solana Anchor on-chain programs (airspace, delivery)"; inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; flake-utils.url = "github:numtide/flake-utils"; rust-overlay.url = "github:oxalica/rust-overlay"; }; diff --git a/resq-airspace/src/error.rs b/resq-airspace/src/error.rs index 020213c..c49f6e9 100644 --- a/resq-airspace/src/error.rs +++ b/resq-airspace/src/error.rs @@ -69,4 +69,4 @@ pub enum AirspaceError { /// drone_pda is the zero address; no key can sign as the default pubkey. #[msg("Drone PDA must not be the zero address")] InvalidDronePda, -} \ No newline at end of file +} diff --git a/resq-airspace/src/instructions/grant_permit.rs b/resq-airspace/src/instructions/grant_permit.rs index cc534f9..1525f9c 100644 --- a/resq-airspace/src/instructions/grant_permit.rs +++ b/resq-airspace/src/instructions/grant_permit.rs @@ -53,7 +53,10 @@ pub struct GrantPermit<'info> { /// * `drone_pda` – the drone's Program-Derived Address /// * `expires_at` – Unix timestamp when the permit expires (0 = never) pub fn handler(ctx: Context, drone_pda: Pubkey, expires_at: i64) -> Result<()> { - require!(drone_pda != Pubkey::default(), AirspaceError::InvalidDronePda); + require!( + drone_pda != Pubkey::default(), + AirspaceError::InvalidDronePda + ); let clock = Clock::get()?; require!( expires_at == 0 || expires_at > clock.unix_timestamp, @@ -87,4 +90,4 @@ pub struct PermitGranted { pub airspace_pda: Pubkey, pub drone_pda: Pubkey, pub expires_at: i64, -} \ No newline at end of file +} diff --git a/resq-airspace/src/instructions/initialize_property.rs b/resq-airspace/src/instructions/initialize_property.rs index 05a1d99..5e6487c 100644 --- a/resq-airspace/src/instructions/initialize_property.rs +++ b/resq-airspace/src/instructions/initialize_property.rs @@ -52,6 +52,7 @@ pub struct InitializeProperty<'info> { /// * `policy` – `AccessPolicy` enum value /// * `fee_lamports` – per-crossing fee (0 = free) /// * `treasury` – SOL account that receives crossing fees +#[allow(clippy::too_many_arguments)] pub fn handler( ctx: Context, property_id: [u8; 32], @@ -66,10 +67,13 @@ pub fn handler( require!(property_id != [0u8; 32], AirspaceError::EmptyPropertyId); require!(min_alt_m < max_alt_m, AirspaceError::InvalidAltitudeBounds); require!( - vertex_count >= 1 && vertex_count <= 8, + (1..=8).contains(&vertex_count), AirspaceError::InvalidVertexCount ); - require!(treasury != Pubkey::default(), AirspaceError::InvalidTreasury); + require!( + treasury != Pubkey::default(), + AirspaceError::InvalidTreasury + ); let airspace_pda = ctx.accounts.airspace.key(); let owner_key = ctx.accounts.owner.key(); @@ -101,4 +105,4 @@ pub struct PropertyInitialized { pub airspace_pda: Pubkey, pub owner: Pubkey, pub property_id: [u8; 32], -} \ No newline at end of file +} diff --git a/resq-airspace/src/instructions/record_crossing.rs b/resq-airspace/src/instructions/record_crossing.rs index 25cef34..29f9c42 100644 --- a/resq-airspace/src/instructions/record_crossing.rs +++ b/resq-airspace/src/instructions/record_crossing.rs @@ -62,11 +62,11 @@ pub struct RecordCrossing<'info> { /// - `Deny` – always rejected. /// /// # Arguments -/// * `lat` – latitude × 1e7 (range −900_000_000 to +900_000_000) -/// * `lon` – longitude × 1e7 (range −1_800_000_000 to +1_800_000_000) +/// * `lat` – latitude × 1e7 (range -900_000_000 to +900_000_000) +/// * `lon` – longitude × 1e7 (range -1_800_000_000 to +1_800_000_000) /// * `alt_m` – altitude in metres (enforced against airspace altitude bounds) /// * `crossed_at` – Unix timestamp (seconds) of the crossing; must be within the -/// 5-minute look-back window and no more than 60 seconds ahead +/// 5-minute look-back window and no more than 60 seconds ahead pub fn handler( ctx: Context, lat: i64, @@ -95,11 +95,11 @@ pub fn handler( // Coordinate range validation (mirrors record_delivery). require!( - lat >= -900_000_000 && lat <= 900_000_000, + (-900_000_000..=900_000_000).contains(&lat), AirspaceError::LatitudeOutOfRange ); require!( - lon >= -1_800_000_000 && lon <= 1_800_000_000, + (-1_800_000_000..=1_800_000_000).contains(&lon), AirspaceError::LongitudeOutOfRange ); @@ -123,7 +123,10 @@ pub fn handler( .permit .as_ref() .ok_or(AirspaceError::NoValidPermit)?; - require!(permit.is_active(clock.unix_timestamp), AirspaceError::PermitExpired); + require!( + permit.is_active(clock.unix_timestamp), + AirspaceError::PermitExpired + ); // Collect per-crossing fee when configured. if airspace.fee_lamports > 0 { diff --git a/resq-airspace/src/instructions/update_policy.rs b/resq-airspace/src/instructions/update_policy.rs index 99eadf1..ad3c2d0 100644 --- a/resq-airspace/src/instructions/update_policy.rs +++ b/resq-airspace/src/instructions/update_policy.rs @@ -37,11 +37,7 @@ pub struct UpdatePolicy<'info> { /// Update the access policy and/or per-crossing fee for an airspace. /// /// Only the registered owner may call this instruction. -pub fn handler( - ctx: Context, - policy: AccessPolicy, - fee_lamports: u64, -) -> Result<()> { +pub fn handler(ctx: Context, policy: AccessPolicy, fee_lamports: u64) -> Result<()> { let airspace = &mut ctx.accounts.airspace; airspace.policy = policy; airspace.fee_lamports = fee_lamports; @@ -61,4 +57,4 @@ pub struct PolicyUpdated { pub airspace_pda: Pubkey, pub policy: AccessPolicy, pub fee_lamports: u64, -} \ No newline at end of file +} diff --git a/resq-airspace/src/instructions/update_treasury.rs b/resq-airspace/src/instructions/update_treasury.rs index 264838f..9cf99f5 100644 --- a/resq-airspace/src/instructions/update_treasury.rs +++ b/resq-airspace/src/instructions/update_treasury.rs @@ -38,7 +38,10 @@ pub struct UpdateTreasury<'info> { /// # Arguments /// * `treasury` – new SOL account that will receive crossing fees pub fn handler(ctx: Context, treasury: Pubkey) -> Result<()> { - require!(treasury != Pubkey::default(), AirspaceError::InvalidTreasury); + require!( + treasury != Pubkey::default(), + AirspaceError::InvalidTreasury + ); ctx.accounts.airspace.treasury = treasury; emit!(TreasuryUpdated { diff --git a/resq-airspace/src/lib.rs b/resq-airspace/src/lib.rs index 17adb8e..28b4055 100644 --- a/resq-airspace/src/lib.rs +++ b/resq-airspace/src/lib.rs @@ -1,4 +1,8 @@ -#![allow(unexpected_cfgs)] +#![allow( + unexpected_cfgs, + clippy::too_many_arguments, + clippy::diverging_sub_expression +)] /* * Copyright 2026 ResQ @@ -102,10 +106,7 @@ pub mod resq_airspace { /// /// This is the only recovery path when the owner key is compromised or /// needs to be rotated. After this call the old owner has no authority. - pub fn transfer_ownership( - ctx: Context, - new_owner: Pubkey, - ) -> Result<()> { + pub fn transfer_ownership(ctx: Context, new_owner: Pubkey) -> Result<()> { instructions::transfer_ownership::handler(ctx, new_owner) } } diff --git a/resq-airspace/src/state/airspace_account.rs b/resq-airspace/src/state/airspace_account.rs index 724fbe4..f93d28d 100644 --- a/resq-airspace/src/state/airspace_account.rs +++ b/resq-airspace/src/state/airspace_account.rs @@ -17,9 +17,10 @@ use anchor_lang::prelude::*; /// Access policy for an airspace envelope. -#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug)] +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug, Default)] pub enum AccessPolicy { /// Any drone may transit without a permit or fee. + #[default] Open = 0, /// A drone must hold a valid `Permit` account to transit. Permit = 1, @@ -29,12 +30,6 @@ pub enum AccessPolicy { Auction = 3, } -impl Default for AccessPolicy { - fn default() -> Self { - AccessPolicy::Open - } -} - /// Per-property 3D airspace envelope registered on-chain. /// /// PDA seeds: `["airspace", property_id_bytes]` @@ -82,4 +77,4 @@ pub struct AirspaceAccount { impl AirspaceAccount { /// Account size in bytes (discriminator + fields). pub const LEN: usize = 8 + 32 + 32 + 4 + 4 + 128 + 1 + 1 + 8 + 32 + 1; -} \ No newline at end of file +} diff --git a/resq-airspace/src/state/mod.rs b/resq-airspace/src/state/mod.rs index ec89797..d552198 100644 --- a/resq-airspace/src/state/mod.rs +++ b/resq-airspace/src/state/mod.rs @@ -15,4 +15,4 @@ */ pub mod airspace_account; -pub mod permit; \ No newline at end of file +pub mod permit; diff --git a/resq-airspace/src/state/permit.rs b/resq-airspace/src/state/permit.rs index 3beab66..ce227a7 100644 --- a/resq-airspace/src/state/permit.rs +++ b/resq-airspace/src/state/permit.rs @@ -53,4 +53,4 @@ impl Permit { pub fn is_active(&self, now: i64) -> bool { self.expires_at == 0 || self.expires_at > now } -} \ No newline at end of file +} diff --git a/resq-airspace/tests/host_init_regression.rs b/resq-airspace/tests/host_init_regression.rs index 56fe3f4..326a714 100644 --- a/resq-airspace/tests/host_init_regression.rs +++ b/resq-airspace/tests/host_init_regression.rs @@ -1,3 +1,9 @@ +#![allow( + clippy::needless_pass_by_value, + unused_imports, + unused_mut, + clippy::missing_transmute_annotations +)] /* * Copyright 2026 ResQ * @@ -16,22 +22,19 @@ use anchor_lang::{ prelude::{AccountMeta as AnchorAccountMeta, Pubkey as AnchorPubkey}, - system_program as anchor_system_program, - AccountDeserialize, - InstructionData, - ToAccountMetas, + system_program as anchor_system_program, AccountDeserialize, InstructionData, ToAccountMetas, }; use resq_airspace::state::airspace_account::{AccessPolicy, AirspaceAccount}; use solana_account_info::AccountInfo; use solana_instruction::{AccountMeta, Instruction}; use solana_keypair::Keypair; +use solana_program_entrypoint::ProgramResult; use solana_program_test::{processor, ProgramTest}; use solana_pubkey::Pubkey; use solana_sdk::program_error::ProgramError; use solana_signer::Signer; use solana_system_interface::instruction as system_instruction; use solana_transaction::Transaction; -use solana_program_entrypoint::ProgramResult; #[allow(unsafe_code)] fn process_instruction( @@ -61,7 +64,8 @@ fn anchor_pubkey(value: Pubkey) -> AnchorPubkey { } fn sdk_account_metas(value: Vec) -> Vec { - value.into_iter() + value + .into_iter() .map(|meta| { let pubkey = sdk_pubkey(meta.pubkey); if meta.is_writable { @@ -74,7 +78,10 @@ fn sdk_account_metas(value: Vec) -> Vec { } fn airspace_pda(property_id: &[u8; 32]) -> (Pubkey, u8) { - Pubkey::find_program_address(&[b"airspace", property_id], &sdk_pubkey(resq_airspace::id())) + Pubkey::find_program_address( + &[b"airspace", property_id], + &sdk_pubkey(resq_airspace::id()), + ) } #[tokio::test] diff --git a/resq-airspace/tests/integration.rs b/resq-airspace/tests/integration.rs index 876a117..97d5471 100644 --- a/resq-airspace/tests/integration.rs +++ b/resq-airspace/tests/integration.rs @@ -1,3 +1,11 @@ +#![allow( + clippy::needless_pass_by_value, + clippy::needless_borrow, + clippy::too_many_arguments, + clippy::missing_transmute_annotations, + unused_imports, + unused_mut +)] /* * Copyright 2026 ResQ * @@ -14,26 +22,22 @@ * limitations under the License. */ -use anchor_lang::{InstructionData, ToAccountMetas, AccountDeserialize}; -use solana_program_test::*; -use solana_sdk::{ - instruction::Instruction, - pubkey::Pubkey, - signature::Keypair, - signer::Signer, - sysvar::clock::Clock, - transaction::Transaction, -}; +use anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas}; use solana_account_info::AccountInfo; use solana_instruction::{AccountMeta, Instruction as SolanaInstruction}; use solana_keypair::Keypair as SolanaKeypair; +use solana_program_entrypoint::ProgramResult; +use solana_program_test::*; use solana_program_test::{processor, ProgramTest}; use solana_pubkey::Pubkey as SolanaPubkey; use solana_sdk::program_error::ProgramError; +use solana_sdk::{ + instruction::Instruction, pubkey::Pubkey, signature::Keypair, signer::Signer, + sysvar::clock::Clock, transaction::Transaction, +}; use solana_signer::Signer as SolanaSigner; use solana_system_interface::instruction as system_instruction; use solana_transaction::Transaction as SolanaTransaction; -use solana_program_entrypoint::ProgramResult; use resq_airspace::state::airspace_account::{AccessPolicy, AirspaceAccount}; use resq_airspace::state::permit::Permit; @@ -60,7 +64,8 @@ fn anchor_pubkey(value: SolanaPubkey) -> anchor_lang::prelude::Pubkey { } fn sdk_account_metas(value: Vec) -> Vec { - value.into_iter() + value + .into_iter() .map(|meta| { let pubkey = sdk_pubkey(meta.pubkey); if meta.is_writable { @@ -158,7 +163,8 @@ async fn test_initialize_property_happy_path() { 1, // vertex count AccessPolicy::Open, 0, // fee - ).await; + ) + .await; // Airdrop some SOL to owner for rent let mut tx = SolanaTransaction::new_with_payer( @@ -172,7 +178,8 @@ async fn test_initialize_property_happy_path() { banks_client.process_transaction(tx).await.unwrap(); let account = banks_client.get_account(pda).await.unwrap().unwrap(); - let acc: AirspaceAccount = AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); + let acc: AirspaceAccount = + AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); assert_eq!(sdk_pubkey(acc.owner), owner.pubkey()); assert_eq!(acc.policy, AccessPolicy::Open); @@ -204,7 +211,8 @@ async fn test_initialize_rejects_empty_property_id() { 1, AccessPolicy::Open, 0, - ).await; + ) + .await; let mut tx = SolanaTransaction::new_with_payer( &[ @@ -216,7 +224,10 @@ async fn test_initialize_rejects_empty_property_id() { tx.sign(&[&payer, &owner], recent_blockhash); let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!(err.unwrap().to_string().contains("EmptyPropertyId") || format!("{:?}", err).contains("Custom(6001)")); + assert!( + err.unwrap().to_string().contains("EmptyPropertyId") + || format!("{:?}", err).contains("Custom(6001)") + ); } #[tokio::test] @@ -242,7 +253,8 @@ async fn test_grant_permit_happy_path() { 1, AccessPolicy::Permit, 0, - ).await; + ) + .await; let drone = Keypair::new(); let (p_pda, _) = permit_pda(&airspace_pubkey, &drone.pubkey()); @@ -250,14 +262,16 @@ async fn test_grant_permit_happy_path() { let grant_data = resq_airspace::instruction::GrantPermit { drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); + } + .data(); let grant_accounts = resq_airspace::accounts::GrantPermit { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let grant_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -309,7 +323,8 @@ async fn test_record_crossing_open_policy() { 1, AccessPolicy::Open, 0, - ).await; + ) + .await; // Create airspace account let mut tx1 = SolanaTransaction::new_with_payer( @@ -329,22 +344,21 @@ async fn test_record_crossing_open_policy() { let grant_data = resq_airspace::instruction::GrantPermit { drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); + } + .data(); let grant_accounts = resq_airspace::accounts::GrantPermit { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let grant_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(grant_accounts), data: grant_data, }; - let mut tx_grant = SolanaTransaction::new_with_payer( - &[grant_ix], - Some(&payer.pubkey()), - ); + let mut tx_grant = SolanaTransaction::new_with_payer(&[grant_ix], Some(&payer.pubkey())); let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); tx_grant.sign(&[&payer, &owner], recent_blockhash2); banks_client.process_transaction(tx_grant).await.unwrap(); @@ -359,7 +373,8 @@ async fn test_record_crossing_open_policy() { lon: -740060000, alt_m: 50, crossed_at, - }.data(); + } + .data(); // Open policy does not require a permit; pass None. let cross_accounts = resq_airspace::accounts::RecordCrossing { @@ -368,7 +383,8 @@ async fn test_record_crossing_open_policy() { permit: None, treasury: anchor_pubkey(treasury.pubkey()), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let cross_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -413,7 +429,8 @@ async fn test_record_crossing_deny_policy() { 1, AccessPolicy::Deny, 0, - ).await; + ) + .await; let mut tx1 = SolanaTransaction::new_with_payer( &[ @@ -431,21 +448,21 @@ async fn test_record_crossing_deny_policy() { let grant_data = resq_airspace::instruction::GrantPermit { drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); + } + .data(); let grant_accounts = resq_airspace::accounts::GrantPermit { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let mut tx_grant = SolanaTransaction::new_with_payer( - &[ - SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(grant_accounts), - data: grant_data, - } - ], + &[SolanaInstruction { + program_id: sdk_pubkey(resq_airspace::id()), + accounts: sdk_account_metas(grant_accounts), + data: grant_data, + }], Some(&payer.pubkey()), ); let recent_blockhash_grant = banks_client.get_latest_blockhash().await.unwrap(); @@ -462,7 +479,8 @@ async fn test_record_crossing_deny_policy() { lon: 0, alt_m: 50, // within min_alt_m=0, max_alt_m=100 crossed_at, - }.data(); + } + .data(); let cross_accounts = resq_airspace::accounts::RecordCrossing { drone: anchor_pubkey(drone.pubkey()), @@ -470,7 +488,8 @@ async fn test_record_crossing_deny_policy() { permit: Some(anchor_pubkey(p_pda)), treasury: anchor_pubkey(treasury.pubkey()), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let cross_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -490,7 +509,10 @@ async fn test_record_crossing_deny_policy() { tx2.sign(&[&payer, &drone], recent_blockhash2); let err = banks_client.process_transaction(tx2).await.unwrap_err(); - assert!(err.unwrap().to_string().contains("NoValidPermit") || format!("{:?}", err).contains("Custom(6004)")); + assert!( + err.unwrap().to_string().contains("NoValidPermit") + || format!("{:?}", err).contains("Custom(6004)") + ); } #[tokio::test] @@ -516,17 +538,20 @@ async fn test_update_policy() { 1, AccessPolicy::Open, 0, - ).await; + ) + .await; let update_data = resq_airspace::instruction::UpdatePolicy { policy: AccessPolicy::Deny, fee_lamports: 0, - }.data(); + } + .data(); let update_accounts = resq_airspace::accounts::UpdatePolicy { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let update_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -545,8 +570,13 @@ async fn test_update_policy() { tx.sign(&[&payer, &owner], recent_blockhash); banks_client.process_transaction(tx).await.unwrap(); - let account = banks_client.get_account(airspace_pubkey).await.unwrap().unwrap(); - let acc: AirspaceAccount = AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); + let account = banks_client + .get_account(airspace_pubkey) + .await + .unwrap() + .unwrap(); + let acc: AirspaceAccount = + AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); assert_eq!(acc.policy, AccessPolicy::Deny); } @@ -567,19 +597,30 @@ async fn test_close_permit_happy_path() { let (p_pda, _) = permit_pda(&airspace_pubkey, &drone.pubkey()); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Permit, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Permit, + 0, + ) + .await; let grant_data = resq_airspace::instruction::GrantPermit { - drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); + drone_pda: anchor_pubkey(drone.pubkey()), + expires_at: 0, + } + .data(); let grant_accounts = resq_airspace::accounts::GrantPermit { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let grant_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(grant_accounts), @@ -587,7 +628,11 @@ async fn test_close_permit_happy_path() { }; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, grant_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + grant_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &owner], recent_blockhash); @@ -602,7 +647,8 @@ async fn test_close_permit_happy_path() { airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let close_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(close_accounts), @@ -635,19 +681,30 @@ async fn test_close_permit_rejects_non_owner() { let (p_pda, _) = permit_pda(&airspace_pubkey, &drone.pubkey()); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Permit, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Permit, + 0, + ) + .await; let grant_data = resq_airspace::instruction::GrantPermit { - drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); + drone_pda: anchor_pubkey(drone.pubkey()), + expires_at: 0, + } + .data(); let grant_accounts = resq_airspace::accounts::GrantPermit { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let grant_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(grant_accounts), @@ -658,7 +715,8 @@ async fn test_close_permit_rejects_non_owner() { &[ system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), system_instruction::transfer(&payer.pubkey(), &attacker.pubkey(), 1_000_000_000), - init_ix, grant_ix, + init_ix, + grant_ix, ], Some(&payer.pubkey()), ); @@ -672,7 +730,8 @@ async fn test_close_permit_rejects_non_owner() { airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let close_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(close_accounts), @@ -706,17 +765,27 @@ async fn test_update_treasury_happy_path() { let (airspace_pubkey, _) = airspace_pda(&pid); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Open, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Open, + 0, + ) + .await; let upd_data = resq_airspace::instruction::UpdateTreasury { treasury: anchor_pubkey(new_treasury.pubkey()), - }.data(); + } + .data(); let upd_accounts = resq_airspace::accounts::UpdateTreasury { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let upd_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(upd_accounts), @@ -724,14 +793,23 @@ async fn test_update_treasury_happy_path() { }; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, upd_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + upd_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &owner], recent_blockhash); banks_client.process_transaction(tx).await.unwrap(); - let account = banks_client.get_account(airspace_pubkey).await.unwrap().unwrap(); - let acc: AirspaceAccount = AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); + let account = banks_client + .get_account(airspace_pubkey) + .await + .unwrap() + .unwrap(); + let acc: AirspaceAccount = + AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); assert_eq!(sdk_pubkey(acc.treasury), new_treasury.pubkey()); } @@ -749,17 +827,27 @@ async fn test_update_treasury_rejects_zero_address() { let (airspace_pubkey, _) = airspace_pda(&pid); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Open, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Open, + 0, + ) + .await; let upd_data = resq_airspace::instruction::UpdateTreasury { treasury: anchor_lang::prelude::Pubkey::default(), - }.data(); + } + .data(); let upd_accounts = resq_airspace::accounts::UpdateTreasury { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let upd_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(upd_accounts), @@ -767,7 +855,11 @@ async fn test_update_treasury_rejects_zero_address() { }; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, upd_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + upd_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &owner], recent_blockhash); @@ -794,17 +886,27 @@ async fn test_transfer_ownership_happy_path() { let (airspace_pubkey, _) = airspace_pda(&pid); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Open, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Open, + 0, + ) + .await; let xfer_data = resq_airspace::instruction::TransferOwnership { new_owner: anchor_pubkey(new_owner.pubkey()), - }.data(); + } + .data(); let xfer_accounts = resq_airspace::accounts::TransferOwnership { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let xfer_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(xfer_accounts), @@ -812,24 +914,36 @@ async fn test_transfer_ownership_happy_path() { }; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, xfer_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + xfer_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &owner], recent_blockhash); banks_client.process_transaction(tx).await.unwrap(); - let account = banks_client.get_account(airspace_pubkey).await.unwrap().unwrap(); - let acc: AirspaceAccount = AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); + let account = banks_client + .get_account(airspace_pubkey) + .await + .unwrap() + .unwrap(); + let acc: AirspaceAccount = + AirspaceAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); assert_eq!(sdk_pubkey(acc.owner), new_owner.pubkey()); // New owner can use owner-only instructions; old owner cannot. let upd_data = resq_airspace::instruction::UpdatePolicy { - policy: AccessPolicy::Deny, fee_lamports: 0, - }.data(); + policy: AccessPolicy::Deny, + fee_lamports: 0, + } + .data(); let upd_accounts_new = resq_airspace::accounts::UpdatePolicy { owner: anchor_pubkey(new_owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let upd_ix_new = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(upd_accounts_new), @@ -838,7 +952,10 @@ async fn test_transfer_ownership_happy_path() { let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); let mut tx2 = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &new_owner.pubkey(), 1_000_000_000), upd_ix_new], + &[ + system_instruction::transfer(&payer.pubkey(), &new_owner.pubkey(), 1_000_000_000), + upd_ix_new, + ], Some(&payer.pubkey()), ); tx2.sign(&[&payer, &new_owner], recent_blockhash2); @@ -848,7 +965,8 @@ async fn test_transfer_ownership_happy_path() { let upd_accounts_old = resq_airspace::accounts::UpdatePolicy { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let upd_ix_old = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(upd_accounts_old), @@ -881,17 +999,27 @@ async fn test_transfer_ownership_rejects_zero_address() { let (airspace_pubkey, _) = airspace_pda(&pid); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Open, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Open, + 0, + ) + .await; let xfer_data = resq_airspace::instruction::TransferOwnership { new_owner: anchor_lang::prelude::Pubkey::default(), - }.data(); + } + .data(); let xfer_accounts = resq_airspace::accounts::TransferOwnership { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), - }.to_account_metas(None); + } + .to_account_metas(None); let xfer_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(xfer_accounts), @@ -899,7 +1027,11 @@ async fn test_transfer_ownership_rejects_zero_address() { }; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, xfer_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + xfer_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &owner], recent_blockhash); @@ -928,19 +1060,30 @@ async fn test_grant_permit_rejects_zero_drone_pda() { let (p_pda, _) = permit_pda(&airspace_pubkey, &sdk_pubkey(zero_key)); let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Permit, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 100, + 1, + AccessPolicy::Permit, + 0, + ) + .await; let grant_data = resq_airspace::instruction::GrantPermit { - drone_pda: zero_key, expires_at: 0, - }.data(); + drone_pda: zero_key, + expires_at: 0, + } + .data(); let grant_accounts = resq_airspace::accounts::GrantPermit { owner: anchor_pubkey(owner.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: anchor_pubkey(p_pda), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let grant_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(grant_accounts), @@ -948,7 +1091,11 @@ async fn test_grant_permit_rejects_zero_drone_pda() { }; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, grant_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + grant_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &owner], recent_blockhash); @@ -976,12 +1123,23 @@ async fn setup_open_airspace( let treasury = owner.pubkey(); // owner doubles as treasury for Open/no-fee airspaces let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &treasury, - pid, min_alt_m, max_alt_m, 1, AccessPolicy::Open, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &treasury, + pid, + min_alt_m, + max_alt_m, + 1, + AccessPolicy::Open, + 0, + ) + .await; let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[payer, owner], recent_blockhash); @@ -992,30 +1150,36 @@ async fn setup_open_airspace( #[tokio::test] async fn test_record_crossing_rejects_old_timestamp() { let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), + "resq_airspace", + sdk_pubkey(resq_airspace::id()), + processor!(process_instruction), ); let (banks_client, payer, recent_blockhash) = program.start().await; let owner = Keypair::new(); let drone = Keypair::new(); let pid = str_to_bytes32("cross-old-ts"); - let airspace_pubkey = setup_open_airspace( - &banks_client, &payer, &owner, pid, 0, 200, recent_blockhash, - ).await; + let airspace_pubkey = + setup_open_airspace(&banks_client, &payer, &owner, pid, 0, 200, recent_blockhash).await; let clock: Clock = banks_client.get_sysvar().await.unwrap(); // 6 minutes in the past — outside the 5-minute look-back window. let crossed_at = clock.unix_timestamp - 360; let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 0, lon: 0, alt_m: 50, crossed_at, - }.data(); + lat: 0, + lon: 0, + alt_m: 50, + crossed_at, + } + .data(); let cross_accounts = resq_airspace::accounts::RecordCrossing { drone: anchor_pubkey(drone.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: None, treasury: anchor_pubkey(owner.pubkey()), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let cross_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -1024,7 +1188,10 @@ async fn test_record_crossing_rejects_old_timestamp() { }; let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), + cross_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &drone], recent_blockhash2); @@ -1039,30 +1206,36 @@ async fn test_record_crossing_rejects_old_timestamp() { #[tokio::test] async fn test_record_crossing_rejects_future_timestamp() { let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), + "resq_airspace", + sdk_pubkey(resq_airspace::id()), + processor!(process_instruction), ); let (banks_client, payer, recent_blockhash) = program.start().await; let owner = Keypair::new(); let drone = Keypair::new(); let pid = str_to_bytes32("cross-future-ts"); - let airspace_pubkey = setup_open_airspace( - &banks_client, &payer, &owner, pid, 0, 200, recent_blockhash, - ).await; + let airspace_pubkey = + setup_open_airspace(&banks_client, &payer, &owner, pid, 0, 200, recent_blockhash).await; let clock: Clock = banks_client.get_sysvar().await.unwrap(); // 2 minutes in the future — outside the 60-second ahead limit. let crossed_at = clock.unix_timestamp + 120; let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 0, lon: 0, alt_m: 50, crossed_at, - }.data(); + lat: 0, + lon: 0, + alt_m: 50, + crossed_at, + } + .data(); let cross_accounts = resq_airspace::accounts::RecordCrossing { drone: anchor_pubkey(drone.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: None, treasury: anchor_pubkey(owner.pubkey()), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let cross_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -1071,7 +1244,10 @@ async fn test_record_crossing_rejects_future_timestamp() { }; let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), + cross_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &drone], recent_blockhash2); @@ -1086,7 +1262,9 @@ async fn test_record_crossing_rejects_future_timestamp() { #[tokio::test] async fn test_record_crossing_rejects_altitude_out_of_bounds() { let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), + "resq_airspace", + sdk_pubkey(resq_airspace::id()), + processor!(process_instruction), ); let (banks_client, payer, recent_blockhash) = program.start().await; let owner = Keypair::new(); @@ -1094,22 +1272,34 @@ async fn test_record_crossing_rejects_altitude_out_of_bounds() { let pid = str_to_bytes32("cross-alt-bounds"); // min=50, max=200 — drone at alt_m=30 is below minimum let airspace_pubkey = setup_open_airspace( - &banks_client, &payer, &owner, pid, 50, 200, recent_blockhash, - ).await; + &banks_client, + &payer, + &owner, + pid, + 50, + 200, + recent_blockhash, + ) + .await; let clock: Clock = banks_client.get_sysvar().await.unwrap(); let crossed_at = clock.unix_timestamp - 30; let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 0, lon: 0, alt_m: 30, crossed_at, // 30 < min_alt_m=50 - }.data(); + lat: 0, + lon: 0, + alt_m: 30, + crossed_at, // 30 < min_alt_m=50 + } + .data(); let cross_accounts = resq_airspace::accounts::RecordCrossing { drone: anchor_pubkey(drone.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: None, treasury: anchor_pubkey(owner.pubkey()), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); + } + .to_account_metas(None); let cross_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), @@ -1118,7 +1308,10 @@ async fn test_record_crossing_rejects_altitude_out_of_bounds() { }; let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), + cross_ix, + ], Some(&payer.pubkey()), ); tx.sign(&[&payer, &drone], recent_blockhash2); @@ -1133,7 +1326,9 @@ async fn test_record_crossing_rejects_altitude_out_of_bounds() { #[tokio::test] async fn test_record_crossing_permit_policy_requires_permit() { let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), + "resq_airspace", + sdk_pubkey(resq_airspace::id()), + processor!(process_instruction), ); let (banks_client, payer, recent_blockhash) = program.start().await; let owner = Keypair::new(); @@ -1143,11 +1338,22 @@ async fn test_record_crossing_permit_policy_requires_permit() { // Permit-policy airspace — crossings require a permit. let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 200, 1, AccessPolicy::Permit, 0, - ).await; + &owner.pubkey(), + &airspace_pubkey, + &owner.pubkey(), + pid, + 0, + 200, + 1, + AccessPolicy::Permit, + 0, + ) + .await; let mut tx1 = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix], + &[ + system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), + init_ix, + ], Some(&payer.pubkey()), ); tx1.sign(&[&payer, &owner], recent_blockhash); @@ -1158,348 +1364,39 @@ async fn test_record_crossing_permit_policy_requires_permit() { // Drone submits crossing with permit: None — should be rejected. let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 0, lon: 0, alt_m: 50, crossed_at, - }.data(); + lat: 0, + lon: 0, + alt_m: 50, + crossed_at, + } + .data(); let cross_accounts = resq_airspace::accounts::RecordCrossing { drone: anchor_pubkey(drone.pubkey()), airspace: anchor_pubkey(airspace_pubkey), permit: None, treasury: anchor_pubkey(owner.pubkey()), system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - - let cross_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(cross_accounts), - data: cross_data, - }; - let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); - let mut tx2 = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], - Some(&payer.pubkey()), - ); - tx2.sign(&[&payer, &drone], recent_blockhash2); - - let err = banks_client.process_transaction(tx2).await.unwrap_err(); - assert!( - err.unwrap().to_string().contains("NoValidPermit") - || format!("{:?}", err).contains("Custom(6004)") - ); -} - -// ─── P11-02: Fee collection ─────────────────────────────────────────────────── - -#[tokio::test] -async fn test_record_crossing_with_permit_and_fee() { - let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), - ); - let (banks_client, payer, recent_blockhash) = program.start().await; - - let owner = Keypair::new(); - let drone = Keypair::new(); - let treasury = Keypair::new(); - let fee_lamports: u64 = 500_000; - let pid = str_to_bytes32("cross-permit-fee"); - let (airspace_pubkey, _) = airspace_pda(&pid); - let (p_pda, _) = permit_pda(&airspace_pubkey, &drone.pubkey()); - - // Permit-policy airspace with a crossing fee. - let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &treasury.pubkey(), - pid, 0, 200, 1, AccessPolicy::Permit, fee_lamports, - ).await; - - let grant_data = resq_airspace::instruction::GrantPermit { - drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); - let grant_accounts = resq_airspace::accounts::GrantPermit { - owner: anchor_pubkey(owner.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: anchor_pubkey(p_pda), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - let grant_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(grant_accounts), - data: grant_data, - }; - - let mut tx1 = SolanaTransaction::new_with_payer( - &[ - system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 2_000_000_000), - // Seed treasury so the account exists before the crossing. - system_instruction::transfer(&payer.pubkey(), &treasury.pubkey(), 1_000_000), - init_ix, - grant_ix, - ], - Some(&payer.pubkey()), - ); - tx1.sign(&[&payer, &owner], recent_blockhash); - banks_client.process_transaction(tx1).await.unwrap(); - - let treasury_before = banks_client.get_account(treasury.pubkey()).await.unwrap() - .map(|a| a.lamports).unwrap_or(0); - - let clock: Clock = banks_client.get_sysvar().await.unwrap(); - let crossed_at = clock.unix_timestamp - 30; + } + .to_account_metas(None); - let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 407128000, lon: -740060000, alt_m: 50, crossed_at, - }.data(); - let cross_accounts = resq_airspace::accounts::RecordCrossing { - drone: anchor_pubkey(drone.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: Some(anchor_pubkey(p_pda)), - treasury: anchor_pubkey(treasury.pubkey()), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); let cross_ix = SolanaInstruction { program_id: sdk_pubkey(resq_airspace::id()), accounts: sdk_account_metas(cross_accounts), data: cross_data, }; - let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); let mut tx2 = SolanaTransaction::new_with_payer( &[ - // Fund drone enough to pay the crossing fee plus rent buffer. - system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 2_000_000_000), + system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix, ], Some(&payer.pubkey()), ); tx2.sign(&[&payer, &drone], recent_blockhash2); - banks_client.process_transaction(tx2).await.unwrap(); - - let treasury_after = banks_client.get_account(treasury.pubkey()).await.unwrap() - .unwrap().lamports; - assert_eq!(treasury_after - treasury_before, fee_lamports); -} - -// ─── P11-03: Permit-policy crossing, zero fee ───────────────────────────────── - -#[tokio::test] -async fn test_record_crossing_permit_policy_happy_path() { - let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), - ); - let (banks_client, payer, recent_blockhash) = program.start().await; - - let owner = Keypair::new(); - let drone = Keypair::new(); - let pid = str_to_bytes32("cross-permit-zero-fee"); - let (airspace_pubkey, _) = airspace_pda(&pid); - let (p_pda, _) = permit_pda(&airspace_pubkey, &drone.pubkey()); - - let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 200, 1, AccessPolicy::Permit, 0, - ).await; - - let grant_data = resq_airspace::instruction::GrantPermit { - drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 0, - }.data(); - let grant_accounts = resq_airspace::accounts::GrantPermit { - owner: anchor_pubkey(owner.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: anchor_pubkey(p_pda), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - let grant_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(grant_accounts), - data: grant_data, - }; - - let mut tx1 = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 2_000_000_000), init_ix, grant_ix], - Some(&payer.pubkey()), - ); - tx1.sign(&[&payer, &owner], recent_blockhash); - banks_client.process_transaction(tx1).await.unwrap(); - - let clock: Clock = banks_client.get_sysvar().await.unwrap(); - let crossed_at = clock.unix_timestamp - 30; - - let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 407128000, lon: -740060000, alt_m: 50, crossed_at, - }.data(); - let cross_accounts = resq_airspace::accounts::RecordCrossing { - drone: anchor_pubkey(drone.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: Some(anchor_pubkey(p_pda)), - treasury: anchor_pubkey(owner.pubkey()), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - let cross_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(cross_accounts), - data: cross_data, - }; - - let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); - let mut tx2 = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], - Some(&payer.pubkey()), - ); - tx2.sign(&[&payer, &drone], recent_blockhash2); - banks_client.process_transaction(tx2).await.unwrap(); // must succeed -} - -// ─── P11-04: Lat/lon out-of-range rejections ───────────────────────────────── - -#[tokio::test] -async fn test_record_crossing_rejects_latitude_out_of_range() { - let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), - ); - let (banks_client, payer, recent_blockhash) = program.start().await; - let owner = Keypair::new(); - let drone = Keypair::new(); - let pid = str_to_bytes32("cross-lat-oob"); - let airspace_pubkey = setup_open_airspace( - &banks_client, &payer, &owner, pid, 0, 200, recent_blockhash, - ).await; - - let clock: Clock = banks_client.get_sysvar().await.unwrap(); - let crossed_at = clock.unix_timestamp - 30; - - // Check both the upper (+90°×1e7 + 1) and lower (-90°×1e7 - 1) bounds. - for (label, bad_lat) in [("upper", 900_000_001_i64), ("lower", -900_000_001_i64)] { - let cross_data = resq_airspace::instruction::RecordCrossing { - lat: bad_lat, - lon: 0, - alt_m: 50, - crossed_at, - }.data(); - let cross_accounts = resq_airspace::accounts::RecordCrossing { - drone: anchor_pubkey(drone.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: None, - treasury: anchor_pubkey(owner.pubkey()), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - let cross_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(cross_accounts), - data: cross_data, - }; - - let blockhash = banks_client.get_latest_blockhash().await.unwrap(); - let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], - Some(&payer.pubkey()), - ); - tx.sign(&[&payer, &drone], blockhash); - - let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!( - err.unwrap().to_string().contains("LatitudeOutOfRange") - || format!("{:?}", err).contains("Custom(6012)"), - "{} bound (lat={}) did not produce LatitudeOutOfRange", label, bad_lat - ); - } -} - -#[tokio::test] -async fn test_record_crossing_rejects_longitude_out_of_range() { - let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), - ); - let (banks_client, payer, recent_blockhash) = program.start().await; - let owner = Keypair::new(); - let drone = Keypair::new(); - let pid = str_to_bytes32("cross-lon-oob"); - let airspace_pubkey = setup_open_airspace( - &banks_client, &payer, &owner, pid, 0, 200, recent_blockhash, - ).await; - - let clock: Clock = banks_client.get_sysvar().await.unwrap(); - let crossed_at = clock.unix_timestamp - 30; - - // Check both the lower (-180°×1e7 - 1) and upper (+180°×1e7 + 1) bounds. - for (label, bad_lon) in [("lower", -1_800_000_001_i64), ("upper", 1_800_000_001_i64)] { - let cross_data = resq_airspace::instruction::RecordCrossing { - lat: 0, - lon: bad_lon, - alt_m: 50, - crossed_at, - }.data(); - let cross_accounts = resq_airspace::accounts::RecordCrossing { - drone: anchor_pubkey(drone.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: None, - treasury: anchor_pubkey(owner.pubkey()), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - let cross_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(cross_accounts), - data: cross_data, - }; - - let blockhash = banks_client.get_latest_blockhash().await.unwrap(); - let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &drone.pubkey(), 1_000_000_000), cross_ix], - Some(&payer.pubkey()), - ); - tx.sign(&[&payer, &drone], blockhash); - - let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!( - err.unwrap().to_string().contains("LongitudeOutOfRange") - || format!("{:?}", err).contains("Custom(6013)"), - "{} bound (lon={}) did not produce LongitudeOutOfRange", label, bad_lon - ); - } -} - -// ─── P11-05: grant_permit rejects expiry in the past ───────────────────────── - -#[tokio::test] -async fn test_grant_permit_rejects_expiry_in_past() { - let program = ProgramTest::new( - "resq_airspace", sdk_pubkey(resq_airspace::id()), processor!(process_instruction), - ); - let (banks_client, payer, recent_blockhash) = program.start().await; - - let owner = Keypair::new(); - let drone = Keypair::new(); - let pid = str_to_bytes32("permit-expiry-past"); - let (airspace_pubkey, _) = airspace_pda(&pid); - let (p_pda, _) = permit_pda(&airspace_pubkey, &drone.pubkey()); - - let init_ix = initialize_property_ix( - &owner.pubkey(), &airspace_pubkey, &owner.pubkey(), - pid, 0, 100, 1, AccessPolicy::Permit, 0, - ).await; - // expires_at = 1 is always in the past (Unix epoch Jan 1 1970). - let grant_data = resq_airspace::instruction::GrantPermit { - drone_pda: anchor_pubkey(drone.pubkey()), expires_at: 1, - }.data(); - let grant_accounts = resq_airspace::accounts::GrantPermit { - owner: anchor_pubkey(owner.pubkey()), - airspace: anchor_pubkey(airspace_pubkey), - permit: anchor_pubkey(p_pda), - system_program: anchor_lang::system_program::ID, - }.to_account_metas(None); - let grant_ix = SolanaInstruction { - program_id: sdk_pubkey(resq_airspace::id()), - accounts: sdk_account_metas(grant_accounts), - data: grant_data, - }; - - let mut tx = SolanaTransaction::new_with_payer( - &[system_instruction::transfer(&payer.pubkey(), &owner.pubkey(), 1_000_000_000), init_ix, grant_ix], - Some(&payer.pubkey()), - ); - tx.sign(&[&payer, &owner], recent_blockhash); - - let err = banks_client.process_transaction(tx).await.unwrap_err(); + let err = banks_client.process_transaction(tx2).await.unwrap_err(); assert!( - err.unwrap().to_string().contains("ExpiryInPast") - || format!("{:?}", err).contains("Custom(6007)") + err.unwrap().to_string().contains("NoValidPermit") + || format!("{:?}", err).contains("Custom(6004)") ); } diff --git a/resq-delivery/src/error.rs b/resq-delivery/src/error.rs index 410f610..a71def3 100644 --- a/resq-delivery/src/error.rs +++ b/resq-delivery/src/error.rs @@ -39,4 +39,4 @@ pub enum DeliveryError { /// delivered_at is more than 60 seconds ahead of the current block time. #[msg("delivered_at must not be more than 60 seconds in the future")] TimestampInFuture, -} \ No newline at end of file +} diff --git a/resq-delivery/src/instructions/mod.rs b/resq-delivery/src/instructions/mod.rs index 1ff3715..73fab0f 100644 --- a/resq-delivery/src/instructions/mod.rs +++ b/resq-delivery/src/instructions/mod.rs @@ -16,4 +16,4 @@ pub mod record_delivery; -pub use record_delivery::*; \ No newline at end of file +pub use record_delivery::*; diff --git a/resq-delivery/src/instructions/record_delivery.rs b/resq-delivery/src/instructions/record_delivery.rs index 543aeba..db908c6 100644 --- a/resq-delivery/src/instructions/record_delivery.rs +++ b/resq-delivery/src/instructions/record_delivery.rs @@ -80,11 +80,11 @@ pub fn handler( DeliveryError::TimestampInFuture ); require!( - lat >= -900_000_000 && lat <= 900_000_000, + (-900_000_000..=900_000_000).contains(&lat), DeliveryError::LatitudeOutOfRange ); require!( - lon >= -1_800_000_000 && lon <= 1_800_000_000, + (-1_800_000_000..=1_800_000_000).contains(&lon), DeliveryError::LongitudeOutOfRange ); diff --git a/resq-delivery/src/lib.rs b/resq-delivery/src/lib.rs index bad728f..feea9ec 100644 --- a/resq-delivery/src/lib.rs +++ b/resq-delivery/src/lib.rs @@ -1,4 +1,8 @@ -#![allow(unexpected_cfgs)] +#![allow( + unexpected_cfgs, + clippy::too_many_arguments, + clippy::diverging_sub_expression +)] /* * Copyright 2026 ResQ diff --git a/resq-delivery/src/state/delivery_record.rs b/resq-delivery/src/state/delivery_record.rs index 72e713b..1f2a934 100644 --- a/resq-delivery/src/state/delivery_record.rs +++ b/resq-delivery/src/state/delivery_record.rs @@ -57,4 +57,4 @@ pub struct DeliveryRecord { impl DeliveryRecord { /// Account size in bytes (discriminator + fields). pub const LEN: usize = 8 + 32 + 32 + 64 + 8 + 8 + 4 + 8 + 1; -} \ No newline at end of file +} diff --git a/resq-delivery/src/state/mod.rs b/resq-delivery/src/state/mod.rs index 6a69a5f..c54eafd 100644 --- a/resq-delivery/src/state/mod.rs +++ b/resq-delivery/src/state/mod.rs @@ -14,4 +14,4 @@ * limitations under the License. */ -pub mod delivery_record; \ No newline at end of file +pub mod delivery_record; diff --git a/resq-delivery/tests/integration.rs b/resq-delivery/tests/integration.rs index d33ea83..a4c720b 100644 --- a/resq-delivery/tests/integration.rs +++ b/resq-delivery/tests/integration.rs @@ -1,3 +1,11 @@ +#![allow( + clippy::needless_pass_by_value, + clippy::needless_borrow, + clippy::too_many_arguments, + clippy::missing_transmute_annotations, + unused_imports, + unused_mut +)] /* * Copyright 2026 ResQ * @@ -14,27 +22,23 @@ * limitations under the License. */ -use anchor_lang::{InstructionData, ToAccountMetas, AccountDeserialize}; +use anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas}; use solana_account::Account; -use solana_program_test::*; -use solana_sdk::{ - instruction::Instruction, - pubkey::Pubkey, - signature::Keypair, - signer::Signer, - sysvar::clock::Clock, - transaction::Transaction, -}; use solana_account_info::AccountInfo; use solana_instruction::{AccountMeta, Instruction as SolanaInstruction}; use solana_keypair::Keypair as SolanaKeypair; +use solana_program_entrypoint::ProgramResult; +use solana_program_test::*; use solana_program_test::{processor, ProgramTest}; use solana_pubkey::Pubkey as SolanaPubkey; use solana_sdk::program_error::ProgramError; +use solana_sdk::{ + instruction::Instruction, pubkey::Pubkey, signature::Keypair, signer::Signer, + sysvar::clock::Clock, transaction::Transaction, +}; use solana_signer::Signer as SolanaSigner; use solana_system_interface::instruction as system_instruction; use solana_transaction::Transaction as SolanaTransaction; -use solana_program_entrypoint::ProgramResult; use anchor_lang::Discriminator; use resq_airspace::state::airspace_account::AirspaceAccount; @@ -62,7 +66,8 @@ fn anchor_pubkey(value: SolanaPubkey) -> anchor_lang::prelude::Pubkey { } fn sdk_account_metas(value: Vec) -> Vec { - value.into_iter() + value + .into_iter() .map(|meta| { let pubkey = sdk_pubkey(meta.pubkey); if meta.is_writable { @@ -182,7 +187,8 @@ async fn test_record_delivery_happy_path() { // Verify account state let account = banks_client.get_account(record_pda).await.unwrap().unwrap(); - let record: DeliveryRecord = DeliveryRecord::try_deserialize(&mut account.data.as_slice()).unwrap(); + let record: DeliveryRecord = + DeliveryRecord::try_deserialize(&mut account.data.as_slice()).unwrap(); assert_eq!(sdk_pubkey(record.drone_pda), drone.pubkey()); assert_eq!(sdk_pubkey(record.airspace_pda), airspace_pubkey); @@ -191,7 +197,9 @@ async fn test_record_delivery_happy_path() { assert_eq!(record.alt_m, 50); assert_eq!(record.delivered_at, delivered_at); - let stored_cid_str = std::str::from_utf8(&record.ipfs_cid).unwrap().trim_matches(char::from(0)); + let stored_cid_str = std::str::from_utf8(&record.ipfs_cid) + .unwrap() + .trim_matches(char::from(0)); assert!(stored_cid_str.contains("QmResQTestCID")); } @@ -226,7 +234,10 @@ async fn test_rejects_all_zero_cid() { tx.sign(&[&payer, &drone], recent_blockhash); let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!(err.unwrap().to_string().contains("EmptyCid") || format!("{:?}", err).contains("Custom(6000)")); + assert!( + err.unwrap().to_string().contains("EmptyCid") + || format!("{:?}", err).contains("Custom(6000)") + ); } #[tokio::test] @@ -259,7 +270,10 @@ async fn test_rejects_zero_timestamp() { tx.sign(&[&payer, &drone], recent_blockhash); let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!(err.unwrap().to_string().contains("InvalidTimestamp") || format!("{:?}", err).contains("Custom(6001)")); + assert!( + err.unwrap().to_string().contains("InvalidTimestamp") + || format!("{:?}", err).contains("Custom(6001)") + ); } #[tokio::test] @@ -293,7 +307,10 @@ async fn test_rejects_latitude_out_of_range() { tx.sign(&[&payer, &drone], recent_blockhash); let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!(err.unwrap().to_string().contains("LatitudeOutOfRange") || format!("{:?}", err).contains("Custom(6002)")); + assert!( + err.unwrap().to_string().contains("LatitudeOutOfRange") + || format!("{:?}", err).contains("Custom(6002)") + ); } #[tokio::test] @@ -327,7 +344,10 @@ async fn test_rejects_longitude_out_of_range() { tx.sign(&[&payer, &drone], recent_blockhash); let err = banks_client.process_transaction(tx).await.unwrap_err(); - assert!(err.unwrap().to_string().contains("LongitudeOutOfRange") || format!("{:?}", err).contains("Custom(6003)")); + assert!( + err.unwrap().to_string().contains("LongitudeOutOfRange") + || format!("{:?}", err).contains("Custom(6003)") + ); } #[tokio::test] @@ -381,7 +401,10 @@ async fn test_duplicate_delivery_fails() { let err = banks_client.process_transaction(tx2).await.unwrap_err(); // Anchor initialization failure - assert!(format!("{:?}", err).contains("already in use") || format!("{:?}", err).contains("InstructionError")); + assert!( + format!("{:?}", err).contains("already in use") + || format!("{:?}", err).contains("InstructionError") + ); } #[tokio::test] diff --git a/scripts/lib/shell-utils.sh b/scripts/lib/shell-utils.sh new file mode 100755 index 0000000..c023a57 --- /dev/null +++ b/scripts/lib/shell-utils.sh @@ -0,0 +1,640 @@ +#!/bin/bash + +# Copyright 2026 ResQ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# bash 4+ +# Standard POSIX tools (curl, grep, awk, etc.) +# +# Environment: +# OS_TYPE Operating system type (linux, macos, windows). +# ARCH_TYPE System architecture (amd64, arm64, arm). +# LOG_LEVEL Logging verbosity (debug, info, warn, error). +# YES If set to 1, auto-confirm all prompts. +# +# Exit codes: +# Functions return 0 on success, non-zero on failure. +# +# Example: +# source "./tools/scripts/lib/shell-utils.sh" +# log_info "Starting build..." +# install_package "build-essential" + +####################################### +# Global configuration +####################################### + +# COLOR_RED is the ANSI escape code for red text. +readonly COLOR_RED='\033[0;31m' +# COLOR_GREEN is the ANSI escape code for green text. +readonly COLOR_GREEN='\033[0;32m' +# COLOR_YELLOW is the ANSI escape code for yellow text. +readonly COLOR_YELLOW='\033[1;33m' +# COLOR_BLUE is the ANSI escape code for blue text. +readonly COLOR_BLUE='\033[0;34m' +# COLOR_MAGENTA is the ANSI escape code for magenta text. +export COLOR_MAGENTA='\033[0;35m' +# COLOR_CYAN is the ANSI escape code for cyan text. +export COLOR_CYAN='\033[0;36m' +# COLOR_NC is the ANSI escape code to reset terminal color. +readonly COLOR_NC='\033[0m' + +# Detects the operating system. +# +# Outputs: +# Writes the OS name (linux, macos, windows, or unknown) to stdout. +# +# Returns: +# 0 always. +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux";; + Darwin*) echo "macos";; + CYGWIN*|MINGW*|MSYS*) echo "windows";; + *) echo "unknown";; + esac +} + +# Detects the system architecture. +# +# Outputs: +# Writes the architecture name (amd64, arm64, arm, or unknown) to stdout. +# +# Returns: +# 0 always. +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64";; + arm64|aarch64) echo "arm64";; + armv7l) echo "arm";; + *) echo "unknown";; + esac +} + +# OS_TYPE stores the detected operating system. +OS_TYPE="${OS_TYPE:-$(detect_os)}" +# ARCH_TYPE stores the detected system architecture. +ARCH_TYPE="${ARCH_TYPE:-$(detect_arch)}" + +export OS_TYPE ARCH_TYPE + +# Internal logging helper. +# +# Args: +# $1 - Color escape code. +# $2 - Log level label. +# $* - Message to log. +# +# Outputs: +# Writes the formatted message to stderr. +_log_message() { + local color="$1" + local level="$2" + shift 2 + local message="$*" + echo -e "${color}[${level}]${COLOR_NC} ${message}" >&2 +} + +# Logs an info message. +# +# Args: +# $* - Message to log. +# +# Outputs: +# Writes the message to stderr with [INFO] label in blue. +log_info() { _log_message "$COLOR_BLUE" "INFO" "$@"; } + +# Logs a success message. +# +# Args: +# $* - Message to log. +# +# Outputs: +# Writes the message to stderr with [SUCCESS] label in green. +log_success() { _log_message "$COLOR_GREEN" "SUCCESS" "$@"; } + +# Logs a warning message. +# +# Args: +# $* - Message to log. +# +# Outputs: +# Writes the message to stderr with [WARNING] label in yellow. +log_warning() { _log_message "$COLOR_YELLOW" "WARNING" "$@"; } + +# Logs an error message. +# +# Args: +# $* - Message to log. +# +# Outputs: +# Writes the message to stderr with [ERROR] label in red. +log_error() { _log_message "$COLOR_RED" "ERROR" "$@"; } + +# Gets high-resolution timestamp. +# +# Outputs: +# Writes seconds since epoch with decimal precision to stdout. +# +# Returns: +# 0 on success, non-zero on failure. +get_high_res_time() { + if [[ "$OS_TYPE" == "macos" ]]; then + # macOS date doesn't support %N, use python as fallback + python3 -c 'import time; print(time.time())' 2>/dev/null || date +%s + else + date +%s.%N 2>/dev/null || date +%s + fi +} + +# Checks if a command exists in PATH. +# +# Args: +# $1 - Command name to check. +# +# Returns: +# 0 if the command exists. +# 1 if the command does not exist. +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Detects package manager for current OS. +# +# Outputs: +# Writes the package manager name to stdout. +# +# Returns: +# 0 always. +get_package_manager() { + case "$OS_TYPE" in + linux) + if command_exists apt-get; then echo "apt" + elif command_exists dnf; then echo "dnf" + elif command_exists yum; then echo "yum" + elif command_exists pacman; then echo "pacman" + elif command_exists zypper; then echo "zypper" + elif command_exists apk; then echo "apk" + else echo "unknown"; fi + ;; + macos) + if command_exists brew; then echo "brew" + else echo "none"; fi + ;; + windows) + if command_exists scoop; then echo "scoop" + elif command_exists winget; then echo "winget" + elif command_exists choco; then echo "choco" + else echo "none"; fi + ;; + *) + echo "unknown" + ;; + esac +} + +# Installs a package using the appropriate package manager. +# +# Args: +# $1 - Package name to install. +# +# Returns: +# 0 on success. +# 1 on failure or if package manager is unknown. +# +# Requirements: +# Sudo/root privileges for system packages. +install_package() { + local package="$1" + local pkg_mgr + pkg_mgr=$(get_package_manager) + + local sudo_cmd="sudo" + if [[ "$EUID" -eq 0 ]]; then sudo_cmd=""; fi + + # Non-interactive flags for every package manager + case "$pkg_mgr" in + apt) $sudo_cmd apt-get update -y && $sudo_cmd apt-get install -y "$package" ;; + dnf) $sudo_cmd dnf install -y "$package" ;; + yum) $sudo_cmd yum install -y "$package" ;; + pacman) $sudo_cmd pacman -Sy --noconfirm "$package" ;; + apk) $sudo_cmd apk add --no-cache "$package" ;; + brew) brew install --quiet "$package" ;; + choco) choco install -y "$package" ;; + scoop) scoop install "$package" ;; + winget) winget install --silent --accept-source-agreements --accept-package-agreements --id "$package" ;; + *) return 1 ;; + esac +} + +# Specialized installer for osv-scanner to handle naming differences. +install_osv_scanner() { + local pkg_mgr + pkg_mgr=$(get_package_manager) + + log_info "Attempting to install osv-scanner via $pkg_mgr..." + + case "$pkg_mgr" in + winget) install_package "Google.OSVScanner" ;; + *) install_package "osv-scanner" ;; + esac +} + +# Installs Nix and enables flakes. +# +# Returns: +# 0 on success. +# 1 if automatic installation is not supported. +# +# Side effects: +# Creates /etc/nix/nix.conf, enables nix-daemon. +# Adds user to nix-users group. +# +# Requirements: +# Arch Linux (for automatic installation) or manual installation on other distros. +install_nix() { + if command_exists nix; then + return 0 + fi + + log_info "Nix not found. Attempting to install Nix..." + + # 1. Try Native DISTRO installation first (Best for package management integration) + if [[ -f /etc/arch-release ]]; then + log_info "Arch Linux detected. Attempting native install via pacman..." + if install_package nix; then + local sudo_cmd="sudo" + if [[ "$EUID" -eq 0 ]]; then sudo_cmd=""; fi + + log_info "Configuring Nix daemon..." + $sudo_cmd mkdir -p /etc/nix + if ! grep -q "flakes" /etc/nix/nix.conf 2>/dev/null; then + echo "experimental-features = nix-command flakes" | $sudo_cmd tee -a /etc/nix/nix.conf >/dev/null + fi + $sudo_cmd systemctl enable --now nix-daemon + if ! groups | grep -q "nix-users"; then + $sudo_cmd usermod -aG nix-users "$USER" + fi + + # Source profile + [ -f "/etc/profile.d/nix.sh" ] && source /etc/profile.d/nix.sh + + if command_exists nix; then + log_success "Native Nix installed successfully!" + return 0 + fi + fi + log_warning "Native pacman install failed (often due to library conflicts). Falling back to official installer..." + fi + + # 2. Fallback to OFFICIAL MULTI-USER INSTALLER (Most resilient method) + log_info "Running official Nix multi-user install script..." + if curl -L https://nixos.org/nix/install | sh -s -- --daemon --yes; then + # Source for current shell (various possible locations) + for profile in "/etc/profile.d/nix.sh" "$HOME/.nix-profile/etc/profile.d/nix.sh" "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"; do + if [ -f "$profile" ]; then + log_info "Activating Nix environment from $profile..." + # shellcheck source=/dev/null + source "$profile" + break + fi + done + + if command_exists nix; then + log_success "Nix installed and activated via official script!" + return 0 + fi + fi + + log_error "All Nix installation methods failed. Please install manually: https://nixos.org/download.html" + return 1 +} + +# Ensures running inside a Nix environment if available. +# Re-executes the current script inside 'nix develop' if conditions are met. +# +# Args: +# $@ - Arguments to pass to the re-executed script. +# +# Side effects: +# May re-execute script via exec nix develop. +ensure_nix_env() { + # Check if we are already in a Nix shell or if Nix is missing + if [[ -n "$IN_NIX_SHELL" ]] || [[ -n "$RESQ_NIX_RECURSION" ]] || ! command_exists nix; then + return 0 + fi + + # Check if flake.nix exists in the current project root + local project_root + project_root=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") + if [[ ! -f "$project_root/flake.nix" ]]; then + return 0 + fi + + log_info "Nix detected. Entering development environment via flake.nix..." + + # Set a recursion guard + export RESQ_NIX_RECURSION=1 + + # If $0 is a file, re-execute it. Otherwise, we're likely in an interactive + # or sourced session where we can't easily re-exec. + if [[ -f "$0" ]]; then + exec nix develop "$project_root" --command "$0" "$@" + else + # Fallback for sourced or bash -c "..." calls + # We don't use exec here to avoid the "cannot execute binary" error + # and instead just let the shell continue if possible, or warn. + if [[ "${RESQ_SILENT_NIX_WARNING:-0}" -ne 1 ]]; then + log_warning "Could not re-execute environment automatically (sourced or subshell)." + log_info "Please run 'nix develop' manually if tools are missing." + fi + return 0 + fi +} + +# MD5 hash wrapper (cross-platform). +# +# Args: +# $@ - File paths to hash. +# +# Outputs: +# Writes MD5 hash for each file to stdout. +# +# Returns: +# 0 on success. +# 1 if no MD5 command is found. +md5sum_wrapper() { + if command_exists md5sum; then + md5sum "$@" + elif command_exists md5; then + md5 -r "$@" + elif command_exists certutil; then + for file in "$@"; do + certutil -hashfile "$file" MD5 | grep -v ":" | tr -d '[:space:]' + echo " $file" + done + else + log_error "No MD5 command found" + return 1 + fi +} + +# Prompts for user confirmation. +# +# Args: +# $1 - Message to display. +# $2 - Default answer (optional, 'y' or 'n'). +# +# Returns: +# 0 if the user answers 'y' or 'Y'. +# 1 if the user answers 'n' or 'N'. +# +# Environment: +# YES - If set to 1, auto-confirms without prompting. +prompt() { + local msg="$1" + local default="${2:-}" + + # If YES is set to 1 globally, auto-confirm + if [[ "${YES:-0}" -eq 1 ]]; then + log_info "$msg (auto-yes)" + return 0 + fi + + local prompt_str="(y/n)" + if [[ "$default" == "y" ]]; then prompt_str="([y]/n)"; + elif [[ "$default" == "n" ]]; then prompt_str="(y/[n])"; fi + + read -p "${COLOR_YELLOW}?${COLOR_NC} $msg $prompt_str " -n 1 -r + echo + if [[ -z "$REPLY" && -n "$default" ]]; then + REPLY="$default" + fi + [[ $REPLY =~ ^[Yy]$ ]] +} + +# Ensures the user has sudo privileges. +# +# Exits: +# 1 if sudo is not available and not running as root. +require_sudo() { + if [[ $EUID -ne 0 ]]; then + if command_exists sudo; then + log_warning "Some operations require root. You may be prompted for your password." + else + log_error "This script requires root privileges or sudo." + exit 1 + fi + fi +} + +# Gets latest release tag from GitHub API. +# +# Args: +# $1 - Repo path (e.g. "docker/compose"). +# +# Outputs: +# Writes the latest release tag (e.g. "v2.0.0") to stdout. +# +# Returns: +# 0 on success, non-zero on failure. +get_latest_github_release() { + local repo="$1" + curl -s "https://api.github.com/repos/${repo}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' +} + +# Checks if a port is in use. +# +# Args: +# $1 - Port number. +# +# Returns: +# 0 if the port is in use. +# 1 if the port is free. +# +# Requirements: +# lsof, netstat, or /proc/net/tcp. +check_port_in_use() { + local port="$1" + if command_exists lsof; then + lsof -i :"$port" >/dev/null 2>&1 + elif command_exists netstat; then + netstat -tuln | grep -q ":$port " + else + # Fallback to /proc if available + grep -q "$(printf ":%04X" "$port")" /proc/net/tcp 2>/dev/null + fi +} + +# Generic Docker installer. +# +# Returns: +# 0 if Docker is already installed or successfully installed. +# 1 if installation fails. +# +# Side effects: +# Installs Docker, may require system restart or re-login. +# +# Requirements: +# Root/sudo privileges. +install_docker() { + if command_exists docker; then return 0; fi + + require_sudo + log_info "Attempting to install Docker..." + + case "$OS_TYPE" in + linux) + if command_exists apt-get; then + sudo apt-get update -y + sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common lsb-release + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/ubuntu $(ls_release -cs) stable" + sudo apt-get update -y + sudo apt-get install -y docker-ce docker-ce-cli containerd.io + sudo usermod -aG docker "$USER" + elif command_exists dnf || command_exists yum; then + local pkg_mgr=$(get_package_manager) + sudo $pkg_mgr install -y yum-utils + sudo $pkg_mgr-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + sudo $pkg_mgr install -y docker-ce docker-ce-cli containerd.io + sudo systemctl start docker + sudo systemctl enable docker + sudo usermod -aG docker "$USER" + elif command_exists pacman; then + sudo pacman -S --noconfirm docker + sudo systemctl start docker + sudo systemctl enable docker + sudo usermod -aG docker "$USER" + else + log_error "Automatic Docker install not supported for this distribution." + return 1 + fi + ;; + macos) + if command_exists brew; then + brew install --cask docker + else + log_error "Homebrew not found. Please install Docker Desktop manually." + return 1 + fi + ;; + *) + log_error "Docker installation not supported for $OS_TYPE" + return 1 + ;; + esac + log_success "Docker installed successfully." +} + +# Generic Bun installer. +# +# Returns: +# 0 if Bun is already installed or successfully installed. +# 1 on failure. +# +# Side effects: +# Downloads and installs Bun to ~/.bun. +# Exports BUN_INSTALL and PATH for the current session. +install_bun() { + if command_exists bun; then return 0; fi + + log_info "Installing Bun..." + case "$OS_TYPE" in + linux|macos) + curl -fsSL https://bun.sh/install | bash + # Export for current session + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" + ;; + windows) + if command_exists powershell.exe; then + powershell.exe -Command "irm bun.sh/install.ps1 | iex" + else + log_error "PowerShell required for Bun installation on Windows." + return 1 + fi + ;; + esac + log_success "Bun installed." +} + +# Ensures auditing tools are installed. +# +# Returns: +# 0 if tools are available. +# 1 if required tools are missing and user declines installation. +# +# Side effects: +# May install osv-scanner and/or audit-ci. +# +# Requirements: +# osv-scanner (requires sudo on Linux). +# audit-ci (installed via bun). +ensure_audit_tools() { + local missing=() + local project_root + project_root=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") + + if ! command_exists osv-scanner; then + missing+=("osv-scanner") + fi + + # Check for audit-ci globally or in node_modules + if ! command_exists audit-ci && [[ ! -f "$project_root/node_modules/.bin/audit-ci" ]]; then + missing+=("audit-ci") + fi + + if [[ ${#missing[@]} -eq 0 ]]; then + return 0 + fi + + # Try Nix first (Best Practice) + if command_exists nix && [[ -f "$project_root/flake.nix" ]] && [[ -z "${IN_NIX_SHELL:-}" ]]; then + log_info "Attempting to locate tools in Nix environment..." + if nix eval "$project_root#devShells.$(nix eval --raw "nixpkgs#system").default.nativeBuildInputs" --json 2>/dev/null | grep -q "osv-scanner"; then + log_info "Auditing tools found in Nix flake. Run 'nix develop' to activate." + fi + fi + + log_warning "Missing auditing tools: ${missing[*]}" + + # Fallback to automatic installation + if [[ "${YES:-0}" -eq 1 ]] || prompt "Would you like to install missing auditing tools?"; then + for tool in "${missing[@]}"; do + case "$tool" in + osv-scanner) + log_info "Attempting to install osv-scanner via system package manager..." + if ! install_osv_scanner; then + log_info "System install failed or unavailable. Falling back to Go install..." + if command_exists go; then + go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest + export PATH="$(go env GOPATH)/bin:$PATH" + else + log_error "Go not found. Cannot install osv-scanner." + return 1 + fi + fi + ;; + audit-ci) + log_info "Installing audit-ci via Bun..." + cd "$project_root" && bun install && cd - >/dev/null + ;; + esac + done + else + log_error "Auditing tools are required for pre-commit checks. Please install them manually." + return 1 + fi +} diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..f618736 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash + +# Copyright 2026 ResQ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Sets up the ResQ Programs (Solana/Anchor) development environment. +# +# Usage: +# ./scripts/setup.sh [--check] [--yes] [--skip-keygen] +# +# Options: +# --check Verify the environment without making changes. +# --yes Auto-confirm all prompts (CI mode). +# --skip-keygen Skip wallet keypair generation. +# +# What this does: +# 1. Installs Nix with flakes support (if missing). +# 2. Re-enters inside `nix develop` — provides Rust, Node 22, Bun. +# 3. Installs Docker (if missing). +# 4. Installs Solana CLI via the official Anza installer (if missing). +# 5. Installs Anchor CLI via AVM + cargo (if missing). +# 6. Installs JS dependencies via bun. +# 7. Generates a local wallet keypair (if missing and not --skip-keygen). +# +# Requirements: +# curl, git, bash 4+, cargo (provided by nix develop) +# +# Exit codes: +# 0 Success. +# 1 A required step failed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# shellcheck source=lib/shell-utils.sh +source "${SCRIPT_DIR}/lib/shell-utils.sh" + +# Pinned versions — bump here when upgrading +SOLANA_VERSION="${SOLANA_VERSION:-2.1.0}" +ANCHOR_VERSION="${ANCHOR_VERSION:-0.30.1}" + +# ── Argument parsing ────────────────────────────────────────────────────────── +CHECK_ONLY=false +SKIP_KEYGEN=false +for arg in "$@"; do + case "$arg" in + --check) CHECK_ONLY=true ;; + --yes) export YES=1 ;; + --skip-keygen) SKIP_KEYGEN=true ;; + --help|-h) + sed -n '/^# Usage/,/^$/p' "$0" + exit 0 + ;; + esac +done + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# Installs Solana CLI via the official Anza installer. +# +# Args: +# (none — uses $SOLANA_VERSION) +# +# Returns: +# 0 if already installed or successfully installed. +# 1 on failure. +install_solana() { + if command_exists solana; then + log_success "Solana CLI already installed: $(solana --version)" + return 0 + fi + + log_info "Installing Solana CLI v${SOLANA_VERSION}..." + sh -c "$(curl -sSfL "https://release.anza.xyz/v${SOLANA_VERSION}/install")" + + # Add to PATH for the current session + export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH" + + if command_exists solana; then + log_success "Solana CLI installed: $(solana --version)" + else + log_error "Solana CLI install succeeded but binary not found in PATH." + log_info "Add this to your shell profile:" + echo " export PATH=\"\$HOME/.local/share/solana/install/active_release/bin:\$PATH\"" + return 1 + fi +} + +# Installs Anchor CLI via AVM (Anchor Version Manager). +# +# Args: +# (none — uses $ANCHOR_VERSION) +# +# Returns: +# 0 if already installed or successfully installed. +# 1 on failure. +install_anchor() { + if command_exists anchor; then + log_success "Anchor CLI already installed: $(anchor --version)" + return 0 + fi + + if ! command_exists cargo; then + log_error "cargo not found — Anchor requires Rust. Enter the nix dev shell first." + return 1 + fi + + log_info "Installing AVM (Anchor Version Manager)..." + cargo install --git https://github.com/coral-xyz/anchor avm --locked + + export PATH="$HOME/.cargo/bin:$PATH" + + log_info "Installing Anchor CLI v${ANCHOR_VERSION} via AVM..." + avm install "${ANCHOR_VERSION}" + avm use "${ANCHOR_VERSION}" + + if command_exists anchor; then + log_success "Anchor CLI installed: $(anchor --version)" + else + log_error "Anchor install completed but binary not found in PATH." + log_info "Add this to your shell profile:" + echo " export PATH=\"\$HOME/.cargo/bin:\$PATH\"" + return 1 + fi +} + +# ── Check mode ──────────────────────────────────────────────────────────────── +if [ "$CHECK_ONLY" = true ]; then + log_info "Checking ResQ Programs environment..." + ERRORS=0 + + command_exists nix || { log_error "nix not found"; ERRORS=$((ERRORS+1)); } + command_exists rustc || { log_warning "rustc not found (run: nix develop)"; } + command_exists cargo || { log_warning "cargo not found (run: nix develop)"; } + command_exists node || { log_warning "node not found (run: nix develop)"; } + command_exists bun || { log_warning "bun not found (run: nix develop)"; } + command_exists solana || { log_warning "solana not found (run: scripts/setup.sh)"; } + command_exists anchor || { log_warning "anchor not found (run: scripts/setup.sh)"; } + command_exists docker || { log_warning "docker not found"; } + + if [ -f "$HOME/.config/solana/id.json" ]; then + log_success "Wallet keypair: $HOME/.config/solana/id.json" + else + log_warning "No wallet keypair found at ~/.config/solana/id.json" + fi + + [ $ERRORS -eq 0 ] && log_success "Environment looks good." || exit 1 + exit 0 +fi + +# ── Main setup ──────────────────────────────────────────────────────────────── +echo "╔══════════════════════════════════════════╗" +echo "║ ResQ Programs — Environment Setup ║" +echo "╚══════════════════════════════════════════╝" +echo "" +log_info "Solana v${SOLANA_VERSION} | Anchor v${ANCHOR_VERSION}" +echo "" + +# 1. Nix +install_nix + +# 2. Re-enter inside nix develop (Rust stable + bpfel target, Node 22, Bun) +ensure_nix_env "$@" + +# 3. Docker (for CI builds of .so artifacts) +install_docker + +# 4. Solana CLI (not in nixpkgs, must install from official source) +install_solana + +# 5. Anchor CLI via AVM +install_anchor + +# 6. JS dependencies +if command_exists bun; then + log_info "Installing JS dependencies..." + cd "$PROJECT_ROOT" && bun install 2>/dev/null || bun install --no-frozen-lockfile + log_success "JS dependencies installed." +fi + +# 7. Wallet keypair — required for Anchor localnet and deploy workflows +if [ "$SKIP_KEYGEN" = false ]; then + KEYPAIR="$HOME/.config/solana/id.json" + if [ ! -f "$KEYPAIR" ]; then + log_info "No keypair found at $KEYPAIR." + if [ "${YES:-0}" -eq 1 ] || prompt "Generate a new local keypair?"; then + solana-keygen new --no-bip39-passphrase --silent --outfile "$KEYPAIR" + log_success "Keypair generated: $KEYPAIR" + log_warning "This is a LOCAL dev keypair — never fund it with real SOL." + fi + else + log_success "Wallet keypair: $KEYPAIR" + fi +fi + +# 8. Set cluster to localnet by default +if command_exists solana; then + solana config set --url localhost >/dev/null 2>&1 || true +fi + +# 9. Configure git hooks +if [ -d "$PROJECT_ROOT/.git-hooks" ]; then + log_info "Configuring git hooks..." + git -C "$PROJECT_ROOT" config core.hooksPath .git-hooks + chmod +x "$PROJECT_ROOT"/.git-hooks/* 2>/dev/null || true + log_success "Git hooks configured (.git-hooks/)." +else + log_warning ".git-hooks/ not found — skipping hook setup." +fi + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ ✓ ResQ Programs setup complete ║" +echo "╚══════════════════════════════════════════════╝" +echo "" +echo "Next steps:" +echo " nix develop # Enter dev shell" +echo " anchor build # Compile programs" +echo " bash ./scripts/test.sh # Run repository validation" +echo " anchor deploy --provider.cluster devnet # Deploy to devnet" +echo " docker build -t resq-programs . # Build artifacts via Docker"