diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..0eb087e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["-D", "warnings"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 252f6a2..63c5e06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,69 +1,29 @@ -name: Veritix Pay CI +name: CI on: - push: - branches: [ "main" ] pull_request: - branches: [ "main" ] + branches: [main] jobs: - build-and-test: - name: Build, Test, and Format + contract-ci: runs-on: ubuntu-latest - - # Set the working directory for all run steps in this job defaults: run: working-directory: veritixpay/contract/token - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.84.0" - # wasm32-unknown-unknown is needed for `cargo test` and clippy. - # `stellar contract build` uses wasm32v1-none internally and manages - # that target itself; we do not need to install it here. - targets: wasm32-unknown-unknown - components: rustfmt, clippy - - - name: Cache Cargo registry and build artifacts - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - workspaces: "veritixpay/contract/token -> target" - + targets: wasm32-unknown-unknown,wasm32v1-none - name: Install Stellar CLI - run: cargo install --locked stellar-cli - - - name: Check for orphaned source files - run: | - LIB=src/lib.rs - EXIT=0 - for f in src/*.rs; do - mod=$(basename "$f" .rs) - [ "$mod" = "lib" ] && continue - if ! grep -qE "^\s*(pub\s+)?mod\s+${mod}\s*;" "$LIB"; then - echo "ORPHAN: $f is not declared in lib.rs" - EXIT=1 - fi - done - exit $EXIT - - - name: Check Formatting - # Uses cargo fmt directly, or make fmt if it passes the args in your Makefile - run: cargo fmt -- --check - - - name: Run Clippy - run: cargo clippy --lib -- -D warnings - - - name: Compile-only check (locked) - run: cargo check --locked - - - name: Build Contract + run: cargo install stellar-cli --locked + - name: Preflight + run: make preflight + - name: Build run: make build - - - name: Run Tests + - name: Test run: make test + - name: Clippy + run: cargo clippy --all -- -D warnings + - name: Rustfmt + run: cargo fmt --all --check diff --git a/veritixpay/contract/token/src/admin_test.rs b/veritixpay/contract/token/src/admin_test.rs index 7e74db0..5cab965 100644 --- a/veritixpay/contract/token/src/admin_test.rs +++ b/veritixpay/contract/token/src/admin_test.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod admin_test { -continue use soroban_sdk::{testutils::Address as _, testutils::Events as _, Address, Env, String}; use crate::admin::{has_admin, read_admin, transfer_admin, write_admin}; @@ -143,4 +142,53 @@ continue transfer_admin(&env, admin.clone()); assert_eq!(read_admin(&env), admin); } + + #[test] + fn test_admin_info_tracks_admin_rotation() { + let env = setup_env(); + let (_admin, client) = create_initialized_client(&env); + let new_admin = Address::generate(&env); + let before = client.admin_info(); + assert_eq!(before.paused, false); + client.set_admin(&new_admin); + let after = client.admin_info(); + assert_eq!(after.admin, new_admin); + assert_eq!(after.paused, false); + } + + #[test] + fn test_freeze_batch_and_unfreeze_batch() { + let env = setup_env(); + let (_admin, client) = create_initialized_client(&env); + let a = Address::generate(&env); + let b = Address::generate(&env); + let mut targets = soroban_sdk::Vec::new(&env); + targets.push_back(a.clone()); + targets.push_back(b.clone()); + client.freeze_batch(&client.admin(), &targets); + assert!(client.is_frozen(&a)); + assert!(client.is_frozen(&b)); + client.unfreeze_batch(&client.admin(), &targets); + assert!(!client.is_frozen(&a)); + assert!(!client.is_frozen(&b)); + } + + #[test] + fn test_clawback_batch_reduces_balances_and_supply() { + let env = setup_env(); + let (_admin, client) = create_initialized_client(&env); + let admin = client.admin(); + let a = Address::generate(&env); + let b = Address::generate(&env); + client.mint(&admin, &a, &1000); + client.mint(&admin, &b, &1000); + let mut targets = soroban_sdk::Vec::new(&env); + targets.push_back((a.clone(), 200)); + targets.push_back((b.clone(), 300)); + let before_supply = client.total_supply(); + client.clawback_batch(&admin, &targets); + assert_eq!(client.balance(&a), 800); + assert_eq!(client.balance(&b), 700); + assert_eq!(client.total_supply(), before_supply - 500); + } } diff --git a/veritixpay/contract/token/src/allowance.rs b/veritixpay/contract/token/src/allowance.rs index 7628ef8..5c11738 100644 --- a/veritixpay/contract/token/src/allowance.rs +++ b/veritixpay/contract/token/src/allowance.rs @@ -1,8 +1,9 @@ use crate::storage_types::{ AllowanceDataKey, AllowanceValue, DataKey, ALLOWANCE_BUMP_AMOUNT, ALLOWANCE_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, PERSISTENT_LIFETIME_THRESHOLD, }; use crate::validation::{require_current_or_future_ledger, require_non_negative_amount}; -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, Vec}; pub fn read_allowance(e: &Env, from: Address, spender: Address) -> AllowanceValue { let key = DataKey::Allowance(AllowanceDataKey { @@ -56,17 +57,67 @@ pub fn write_allowance( spender: spender.clone(), }); + let index_key = DataKey::SpenderAllowances(spender.clone()); + let mut spenders_from: Vec
= e + .storage() + .persistent() + .get(&index_key) + .unwrap_or_else(|| Vec::new(e)); + if amount == 0 { e.storage().persistent().remove(&key); + let mut updated = Vec::new(e); + for i in 0..spenders_from.len() { + let addr = spenders_from.get(i).unwrap(); + if addr != from { + updated.push_back(addr); + } + } + e.storage().persistent().set(&index_key, &updated); + // Keep spender index alive for long-lived delegated payment lookups. + e.storage().persistent().extend_ttl( + &index_key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); } else { + let mut exists = false; + for i in 0..spenders_from.len() { + if spenders_from.get(i).unwrap() == from { + exists = true; + break; + } + } + if !exists { + spenders_from.push_back(from.clone()); + e.storage().persistent().set(&index_key, &spenders_from); + // Keep spender index alive for long-lived delegated payment lookups. + e.storage().persistent().extend_ttl( + &index_key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); + } let allowance = AllowanceValue { amount, expiration_ledger, }; e.storage().persistent().set(&key, &allowance); + e.storage().persistent().extend_ttl( + &key, + ALLOWANCE_LIFETIME_THRESHOLD, + ALLOWANCE_BUMP_AMOUNT, + ); } } +pub fn get_allowances_for_spender(e: &Env, spender: Address) -> Vec { + e.storage() + .persistent() + .get(&DataKey::SpenderAllowances(spender)) + .unwrap_or_else(|| Vec::new(e)) +} + pub fn spend_allowance(e: &Env, from: Address, spender: Address, amount: i128) { let allowance = read_allowance(e, from.clone(), spender.clone()); diff --git a/veritixpay/contract/token/src/allowance_test.rs b/veritixpay/contract/token/src/allowance_test.rs index ef32c70..908df07 100644 --- a/veritixpay/contract/token/src/allowance_test.rs +++ b/veritixpay/contract/token/src/allowance_test.rs @@ -2,7 +2,7 @@ mod allowance_tests { use soroban_sdk::{Address, Env}; - use crate::allowance::{read_allowance, spend_allowance, write_allowance}; + use crate::allowance::{get_allowances_for_spender, read_allowance, spend_allowance, write_allowance}; use crate::contract::VeritixToken; fn setup_env() -> (Env, Address) { @@ -108,4 +108,22 @@ mod allowance_tests { assert_eq!(a.amount, 0); }); } + + #[test] + fn test_allowances_for_spender_index_adds_and_removes_from() { + let (e, contract_id) = setup_env(); + let from = Address::generate(&e); + let spender = Address::generate(&e); + e.as_contract(&contract_id, || { + let expiry = e.ledger().sequence() + 100; + write_allowance(&e, from.clone(), spender.clone(), 500, expiry); + let indexed = get_allowances_for_spender(&e, spender.clone()); + assert_eq!(indexed.len(), 1); + assert_eq!(indexed.get(0).unwrap(), from.clone()); + + write_allowance(&e, from.clone(), spender.clone(), 0, expiry); + let indexed_after = get_allowances_for_spender(&e, spender); + assert_eq!(indexed_after.len(), 0); + }); + } } diff --git a/veritixpay/contract/token/src/batch.rs b/veritixpay/contract/token/src/batch.rs new file mode 100644 index 0000000..9d5a0a9 --- /dev/null +++ b/veritixpay/contract/token/src/batch.rs @@ -0,0 +1,48 @@ +use crate::admin::check_admin; +use crate::balance::{decrease_supply, spend_balance}; +use crate::freeze::{freeze_account, unfreeze_account}; +use crate::validation::require_positive_amount; +use soroban_sdk::{symbol_short, Address, Env, Vec}; + +const MAX_BATCH_TARGETS: u32 = 50; + +pub fn clawback_batch(e: &Env, admin: Address, targets: Vec<(Address, i128)>) { + check_admin(e, &admin); + if targets.len() > MAX_BATCH_TARGETS { + panic!("batch too large"); + } + for i in 0..targets.len() { + let (from, amount) = targets.get(i).unwrap(); + require_positive_amount(amount); + spend_balance(e, from.clone(), amount); + decrease_supply(e, amount); + e.events() + .publish((symbol_short!("clawback"), admin.clone(), from), amount); + } +} + +pub fn freeze_batch(e: &Env, admin: Address, targets: Vec) { + check_admin(e, &admin); + if targets.len() > MAX_BATCH_TARGETS { + panic!("batch too large"); + } + for i in 0..targets.len() { + let target = targets.get(i).unwrap(); + freeze_account(e, admin.clone(), target); + } + e.events() + .publish((symbol_short!("batch_frz"), admin), targets.len()); +} + +pub fn unfreeze_batch(e: &Env, admin: Address, targets: Vec) { + check_admin(e, &admin); + if targets.len() > MAX_BATCH_TARGETS { + panic!("batch too large"); + } + for i in 0..targets.len() { + let target = targets.get(i).unwrap(); + unfreeze_account(e, admin.clone(), target); + } + e.events() + .publish((symbol_short!("batch_unf"), admin), targets.len()); +} diff --git a/veritixpay/contract/token/src/contract.rs b/veritixpay/contract/token/src/contract.rs index e5ac271..a90e9e5 100644 --- a/veritixpay/contract/token/src/contract.rs +++ b/veritixpay/contract/token/src/contract.rs @@ -1,5 +1,6 @@ use crate::admin::{check_admin, has_admin, read_admin, transfer_admin, write_admin}; -use crate::allowance::{read_allowance, spend_allowance, write_allowance}; +use crate::allowance::{get_allowances_for_spender, read_allowance, spend_allowance, write_allowance}; +use crate::batch::{clawback_batch, freeze_batch, unfreeze_batch}; use crate::balance::{ decrease_supply, increase_supply, read_balance, read_total_supply, receive_balance, spend_balance, @@ -12,7 +13,7 @@ use crate::escrow::{ }; use crate::freeze::{freeze_account, is_frozen as read_frozen_status, unfreeze_account}; use crate::metadata::{ - read_decimal, read_name, read_symbol, validate_metadata, write_metadata, TokenMetadata, + read_decimal, read_name, read_symbol, update_metadata_fields, validate_metadata, write_metadata, TokenMetadata, }; use crate::recurring::{ cancel_recurring, execute_recurring, get_recurring, setup_recurring, RecurringRecord, @@ -22,11 +23,27 @@ use crate::splitter::{ get_split as split_get, SplitRecord, SplitRecipient, }; use crate::validation::{require_not_frozen_account, require_positive_amount}; -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, String, Vec}; #[contract] pub struct VeritixToken; +#[derive(Clone)] +#[contracttype] +pub struct AdminInfo { + pub admin: Address, + pub paused: bool, +} + +#[derive(Clone)] +#[contracttype] +pub struct TokenInfo { + pub name: String, + pub symbol: String, + pub decimal: u32, + pub total_supply: i128, +} + #[contractimpl] impl VeritixToken { // --- Admin & metadata --- @@ -67,20 +84,35 @@ impl VeritixToken { e.events() .publish((symbol_short!("clawback"), admin, from), amount); } + /// Admin-only batch clawback over `(from, amount)` tuples. + pub fn clawback_batch(e: Env, admin: Address, targets: Vec<(Address, i128)>) { + clawback_batch(&e, admin, targets); + } // --- Freeze controls --- + /// Admin-only freeze for a single account. pub fn freeze(e: Env, target: Address) { let admin = read_admin(&e); check_admin(&e, &admin); freeze_account(&e, admin, target); } + /// Admin-only unfreeze for a single account. pub fn unfreeze(e: Env, target: Address) { let admin = read_admin(&e); check_admin(&e, &admin); unfreeze_account(&e, admin, target); } + /// Admin-only batch freeze for multiple accounts. + pub fn freeze_batch(e: Env, admin: Address, targets: Vec) { + freeze_batch(&e, admin, targets); + } + + /// Admin-only batch unfreeze for multiple accounts. + pub fn unfreeze_batch(e: Env, admin: Address, targets: Vec) { + unfreeze_batch(&e, admin, targets); + } // --- Mint / burn & supply tracking --- @@ -153,54 +185,106 @@ impl VeritixToken { .publish((symbol_short!("approve"), from, spender), amount); } + pub fn transfer_with_memo(e: Env, from: Address, to: Address, amount: i128, memo: Bytes) { + if memo.len() > 64 { + panic!("memo too long"); + } + require_not_frozen_account(&e, &from); + require_positive_amount(amount); + from.require_auth(); + spend_balance(&e, from.clone(), amount); + receive_balance(&e, to.clone(), amount); + e.events() + .publish((symbol_short!("tr_memo"), from, to), (amount, memo)); + } + // --- Read-only views --- + /// Returns current total token supply. pub fn total_supply(e: Env) -> i128 { read_total_supply(&e) } + /// Returns token balance for `id`. pub fn balance(e: Env, id: Address) -> i128 { read_balance(&e, id) } + /// Returns allowance from `from` to `spender`. pub fn allowance(e: Env, from: Address, spender: Address) -> i128 { read_allowance(&e, from, spender).amount } + /// Returns all owners that granted non-zero allowance to `spender`. + pub fn allowances_for_spender(e: Env, spender: Address) -> Vec { + get_allowances_for_spender(&e, spender) + } + /// Returns current admin address. pub fn admin(e: Env) -> Address { read_admin(&e) } + /// Returns compact admin metadata for clients. + pub fn admin_info(e: Env) -> AdminInfo { + AdminInfo { + admin: read_admin(&e), + paused: false, + } + } + /// Returns freeze state for account `id`. pub fn is_frozen(e: Env, id: Address) -> bool { read_frozen_status(&e, &id) } + /// Returns token decimal precision. pub fn decimals(e: Env) -> u32 { read_decimal(&e) } + /// Returns token display name. pub fn name(e: Env) -> String { read_name(&e) } + /// Returns token symbol. pub fn symbol(e: Env) -> String { read_symbol(&e) } + pub fn token_info(e: Env) -> TokenInfo { + TokenInfo { + name: read_name(&e), + symbol: read_symbol(&e), + decimal: read_decimal(&e), + total_supply: read_total_supply(&e), + } + } + + /// Admin can update only name/symbol; decimal remains immutable. + pub fn update_metadata(e: Env, admin: Address, name: Option