diff --git a/payment_router/src/lib.rs b/payment_router/src/lib.rs index ce5949e..07acba3 100644 --- a/payment_router/src/lib.rs +++ b/payment_router/src/lib.rs @@ -1,4 +1,2258 @@ -#![no_std]\nuse soroban_sdk::{\n contract, contracterror, contractimpl, contracttype, log, symbol_short, token, Address, BytesN,\n Env, Symbol, Vec,\n};\n\n// ── Packed UserSpending helpers ──────────────────────────────────────────────\n//\n// Issue #519: Replace the two-field UserSpending contracttype with a single\n// BytesN<24> value packed with bitwise operations.\n//\n// Layout (big-endian):\n// bytes 0..8 — last_reset_time : u64 (8 bytes)\n// bytes 8..24 — accumulated_amount: i128 (16 bytes)\n//\n// Benefits:\n// • Eliminates the XDR struct-type overhead (type discriminant + field tags)\n// that Soroban adds to every contracttype value, shrinking each UserSpending\n// ledger entry from ~48 bytes to exactly 24 bytes.\n// • Smaller entries → lower state-rent fee per ledger entry per TTL period.\n\n/// Pack `last_reset_time` (u64) and `accumulated_amount` (i128) into a\n/// 24-byte big-endian buffer.\nfn pack_spending(env: &Env, last_reset_time: u64, accumulated_amount: i128) -> BytesN<24> {\n let mut buf = [0u8; 24];\n\n // Bytes 0..8 — last_reset_time (u64 big-endian)\n let t_bytes = last_reset_time.to_be_bytes();\n buf[0] = t_bytes[0];\n buf[1] = t_bytes[1];\n buf[2] = t_bytes[2];\n buf[3] = t_bytes[3];\n buf[4] = t_bytes[4];\n buf[5] = t_bytes[5];\n buf[6] = t_bytes[6];\n buf[7] = t_bytes[7];\n\n // Bytes 8..24 — accumulated_amount (i128 big-endian)\n let a_bytes = accumulated_amount.to_be_bytes();\n buf[8] = a_bytes[0];\n buf[9] = a_bytes[1];\n buf[10] = a_bytes[2];\n buf[11] = a_bytes[3];\n buf[12] = a_bytes[4];\n buf[13] = a_bytes[5];\n buf[14] = a_bytes[6];\n buf[15] = a_bytes[7];\n buf[16] = a_bytes[8];\n buf[17] = a_bytes[9];\n buf[18] = a_bytes[10];\n buf[19] = a_bytes[11];\n buf[20] = a_bytes[12];\n buf[21] = a_bytes[13];\n buf[22] = a_bytes[14];\n buf[23] = a_bytes[15];\n\n BytesN::from_array(env, &buf)\n}\n\n/// Unpack a 24-byte buffer into `(last_reset_time, accumulated_amount)`.\nfn unpack_spending(packed: &BytesN<24>) -> (u64, i128) {\n // BytesN::to_array() is available in soroban-sdk v20.\n let buf: [u8; 24] = packed.to_array();\n\n // last_reset_time — bytes 0..8\n let last_reset_time = u64::from_be_bytes([\n buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7],\n ]);\n\n // accumulated_amount — bytes 8..24\n let accumulated_amount = i128::from_be_bytes([\n buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], buf[16], buf[17],\n buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],\n ]);\n\n (last_reset_time, accumulated_amount)\n}\n\n// ── Legacy struct kept for test snapshot compatibility ───────────────────────\n//\n// The UserSpending contracttype is retained so existing tests that reference\n// it directly continue to compile. All runtime code now uses the packed\n// BytesN<24> representation stored under DataKey::UserSpending.\n\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct UserSpending {\n pub last_reset_time: u64,\n pub accumulated_amount: i128,\n}\n\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct Payment {\n pub sender: Address,\n pub recipient: Address,\n pub token_address: Address,\n pub amount: i128,\n}\n\n// ── Timelock data structures ─────────────────────────────────────────────────\n//\n// Admin actions that change sensitive contract parameters (treasury, fees,\n// governance, admin transfer) are not applied instantly. Instead the admin\n// queues an ActionType intent that gets a nonce ID and a ledger timestamp.\n// Only after SECONDS_IN_24H (86 400 s) has elapsed can execute_action be\n// called to apply the change. This gives observers a 24-hour window to\n// detect and respond to a compromised-admin scenario.\n//\n// The freeze mechanism is the complementary emergency tool: calling\n// emergency_freeze instantly blocks all payments and all timelock executions.\n// A freeze does NOT require going through the timelock itself so it is always\n// available to the admin as an immediate last resort. Unfreezing likewise\n// takes effect immediately so the admin can restore service once the threat is\n// resolved.\n\n/// Describes which administrative parameter change a timelock entry represents.\n/// Each variant carries all the arguments needed to apply that change when the\n/// delay period is over.\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub enum ActionType {\n /// Change the platform treasury address.\n SetPlatformTreasury(Address),\n /// Update fee basis-points and fee cap together (legacy / combined setter).\n SetFeeConfig(i128, i128),\n /// Update fee basis-points only.\n SetFeeBps(i128),\n /// Set the governance contract address.\n SetGovernance(Address),\n /// Change the minimum routing limit.\n SetMinLimit(i128),\n /// Transfer admin rights to a new address.\n TransferAdmin(Address),\n /// Upgrade the contract WASM.\n Upgrade(BytesN<32>),\n}\n\n/// A pending timelock entry stored in persistent ledger storage.\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct TimelockEntry {\n /// Ledger timestamp (seconds since epoch) when this action was queued.\n pub queued_at: u64,\n /// The action payload to apply once the delay has elapsed.\n pub action: ActionType,\n}\n\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub enum DataKey {\n Admin,\n Governance,\n PlatformTreasury,\n FeeBps,\n FeeCap,\n MinLimit,\n Paused,\n MaxAmount,\n UserVolume(Address),\n UserSpending(Address),\n Blacklist(Address),\n RefundBalance(Address, Address),\n /// Monotonically-increasing nonce counter used to generate unique IDs for\n /// timelock entries. Stored as `u64` in instance storage.\n TimelockNonce,\n /// A pending timelock entry keyed by its nonce ID.\n /// Stored in persistent storage so it survives instance eviction.\n TimelockEntry(u64),\n /// When `true` the contract is frozen: payments and timelock executions\n /// are blocked. Stored as `bool` in instance storage.\n Frozen,\n}\n\n/// Contract-level errors returned instead of panicking, so callers get a\n/// specific, stable error code to branch on rather than an opaque trap.\n#[contracterror]\n#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]\n#[repr(u32)]\npub enum Error {\n /// Caller is not authorized to perform this action (e.g. not the admin).\n Unauthorized = 1,\n /// Sender's token balance is lower than the requested payment amount.\n InsufficientBalance = 2,\n /// Requested amount is outside allowed bounds, or a spending limit was exceeded.\n LimitExceeded = 3,\n /// `initialize` was called on a contract that already has an admin set.\n AlreadyInitialized = 4,\n /// An admin-configured value (treasury, fee, admin) was read before `initialize`.\n NotInitialized = 5,\n Paused = 6,\n InvalidFeeRate = 7,\n /// Sender and recipient addresses are the same (self-routing not allowed).\n InvalidRecipient = 8,\n /// Recipient address is blacklisted.\n Blacklisted = 9,\n /// Requested refund withdrawal amount is zero or exceeds available refund balance.\n NoRefundAvailable = 10,\n /// An action is already pending in the timelock queue; it must be executed\n /// or cancelled before a duplicate can be queued (not currently enforced,\n /// but reserved for future deduplication logic).\n TimelockPending = 11,\n /// The 24-hour delay for the given timelock entry has not elapsed yet.\n TimelockNotReady = 12,\n /// No timelock entry exists for the supplied nonce ID.\n TimelockNotFound = 13,\n /// The contract is frozen; all payments and timelock executions are blocked.\n ContractFrozen = 14,\n}\n\n#[contract]\npub struct PaymentRouter;\n\n#[contractimpl]\nimpl PaymentRouter {\n const BPS_DIVISOR: i128 = 10_000;\n const XLM_DECIMALS: i128 = 10_000_000;\n const MAX_AMOUNT: i128 = 1_000_000_000_000_000; // 100M tokens with 7 decimals\n const DAILY_MAX_LIMIT: i128 = 1_000_000 * Self::XLM_DECIMALS; // 1M tokens limit\n const VOLUME_THRESHOLD: i128 = 10_000 * Self::XLM_DECIMALS; // 10,000 XLM threshold for tiered fee discount\n const SECONDS_IN_24H: u64 = 24 * 3600;\n const VERSION: u32 = 1;\n\n const DAY_IN_LEDGERS: u32 = 17280;\n const INSTANCE_BUMP_AMOUNT: u32 = 7 * Self::DAY_IN_LEDGERS;\n const INSTANCE_LIFETIME_THRESHOLD: u32 = Self::INSTANCE_BUMP_AMOUNT - Self::DAY_IN_LEDGERS;\n\n const USER_BUMP_AMOUNT: u32 = 30 * Self::DAY_IN_LEDGERS;\n const USER_LIFETIME_THRESHOLD: u32 = Self::USER_BUMP_AMOUNT - Self::DAY_IN_LEDGERS;\n const PERSISTENT_BUMP_AMOUNT: u32 = Self::USER_BUMP_AMOUNT;\n const PERSISTENT_LIFETIME_THRESHOLD: u32 = Self::USER_LIFETIME_THRESHOLD;\n\n // ── Private helpers ──────────────────────────────────────────────────────\n\n fn require_admin(env: &Env) -> Result {\n env.storage()\n .instance()\n .get(&DataKey::Admin)\n .ok_or(Error::NotInitialized)\n }\n\n /// Fee authority helper: if a Governance address is set it takes exclusive\n /// control over fee updates; otherwise the admin retains that right.\n fn require_fee_authority(env: &Env) -> Result<(), Error> {\n if let Some(gov) = env\n .storage()\n .instance()\n .get::(&DataKey::Governance)\n {\n gov.require_auth();\n Ok(())\n } else {\n let admin = Self::require_admin(env)?;\n admin.require_auth();\n Ok(())\n }\n }\n\n fn load_fee_config(env: &Env) -> Result<(Address, i128, i128), Error> {\n let platform_treasury: Address = env\n .storage()\n .instance()\n .get(&DataKey::PlatformTreasury)\n .ok_or(Error::NotInitialized)?;\n let fee_bps: i128 = env\n .storage()\n .instance()\n .get(&DataKey::FeeBps)\n .ok_or(Error::NotInitialized)?;\n let fee_cap: i128 = env\n .storage()\n .instance()\n .get(&DataKey::FeeCap)\n .ok_or(Error::NotInitialized)?;\n\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n Ok((platform_treasury, fee_bps, fee_cap))\n }\n\n fn get_refund_balance_internal(env: &Env, user: &Address, token: &Address) -> i128 {\n let key = DataKey::RefundBalance(user.clone(), token.clone());\n env.storage().persistent().get(&key).unwrap_or(0)\n }\n\n fn credit_refund_balance(env: &Env, user: &Address, token: &Address, amount: i128) {\n let key = DataKey::RefundBalance(user.clone(), token.clone());\n let current_balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);\n let new_balance = current_balance + amount;\n env.storage().persistent().set(&key, &new_balance);\n env.storage().persistent().extend_ttl(\n &key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (symbol_short!("refunded"), user.clone(), token.clone()),\n amount,\n );\n }\n\n /// Returns whether the contract is currently frozen.\n fn is_frozen_internal(env: &Env) -> bool {\n env.storage()\n .instance()\n .get(&DataKey::Frozen)\n .unwrap_or(false)\n }\n\n /// Allocates and returns the next timelock nonce, incrementing the counter.\n fn next_nonce(env: &Env) -> u64 {\n let current: u64 = env\n .storage()\n .instance()\n .get(&DataKey::TimelockNonce)\n .unwrap_or(0u64);\n let next = current + 1;\n env.storage().instance().set(&DataKey::TimelockNonce, &next);\n next\n }\n\n /// Core payment logic shared by `route_payment` and `route_payments`.\n #[allow(clippy::too_many_arguments)]\n fn process_single_payment(\n env: &Env,\n sender: &Address,\n recipient: &Address,\n token_address: &Address,\n amount: i128,\n platform_treasury: &Address,\n fee_bps: i128,\n fee_cap: i128,\n ) -> Result<(), Error> {\n // Require sender auth\n sender.require_auth();\n\n env.events().publish(\n (Symbol::new(env, "payment_initiated"), sender.clone()),\n amount,\n );\n\n // Prevent self-routing\n if sender == recipient {\n return Err(Error::InvalidRecipient);\n }\n\n // Check if recipient is blacklisted\n if Self::is_blacklisted(env.clone(), recipient.clone()) {\n return Err(Error::Blacklisted);\n }\n\n // Validate amount bounds\n let max_amount: i128 = env\n .storage()\n .instance()\n .get(&DataKey::MaxAmount)\n .unwrap_or(Self::MAX_AMOUNT);\n if amount <= 0 || amount > max_amount {\n return Err(Error::LimitExceeded);\n }\n\n // Enforce optional admin-configured minimum payment limit\n let min_limit: i128 = env\n .storage()\n .instance()\n .get(&DataKey::MinLimit)\n .unwrap_or(0);\n if amount < min_limit {\n return Err(Error::LimitExceeded);\n }\n\n // Apply tiered fee discount for high-volume users\n let user_volume: i128 = env\n .storage()\n .persistent()\n .get(&DataKey::UserVolume(sender.clone()))\n .unwrap_or(0);\n let effective_fee_bps = if user_volume > Self::VOLUME_THRESHOLD {\n fee_bps / 2\n } else {\n fee_bps\n };\n\n // Check time-based daily spending limits.\n // Storage format: packed BytesN<24> (see pack_spending / unpack_spending).\n let current_time = env.ledger().timestamp();\n let spending_key = DataKey::UserSpending(sender.clone());\n\n let (mut last_reset_time, mut accumulated_amount): (u64, i128) = env\n .storage()\n .persistent()\n .get::>(&spending_key)\n .map(|packed| unpack_spending(&packed))\n .unwrap_or((current_time, 0));\n\n if current_time - last_reset_time >= Self::SECONDS_IN_24H {\n last_reset_time = current_time;\n accumulated_amount = 0;\n }\n\n accumulated_amount += amount;\n if accumulated_amount > Self::DAILY_MAX_LIMIT {\n return Err(Error::LimitExceeded);\n }\n\n env.storage().persistent().set(\n &spending_key,\n &pack_spending(env, last_reset_time, accumulated_amount),\n );\n env.storage().persistent().extend_ttl(\n &spending_key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n // Verify sender has sufficient balance\n let token_client = token::Client::new(env, token_address);\n if token_client.balance(sender) < amount {\n return Err(Error::InsufficientBalance);\n }\n\n // Calculate fee\n let mut fee_amount = (amount * effective_fee_bps) / Self::BPS_DIVISOR;\n if fee_amount > fee_cap {\n fee_amount = fee_cap;\n }\n if fee_amount > amount {\n fee_amount = amount;\n }\n let remainder = amount - fee_amount;\n\n // Execute transfers\n if fee_amount > 0 {\n token_client.transfer(sender, platform_treasury, &fee_amount);\n }\n if remainder > 0 {\n // Attempt to transfer remainder directly to recipient.\n // If recipient cannot receive tokens (e.g. missing trustline or rejection),\n // transfer funds into the contract and credit the sender's internal refund ledger.\n match token_client.try_transfer(sender, recipient, &remainder) {\n Ok(Ok(())) => {\n log!(env, "Remaining balance routed to recipient");\n }\n _ => {\n log!(\n env,\n "Recipient transfer failed; crediting sender refund balance"\n );\n token_client.transfer(sender, &env.current_contract_address(), &remainder);\n Self::credit_refund_balance(env, sender, token_address, remainder);\n }\n }\n }\n\n // Record cumulative volume\n let volume_key = DataKey::UserVolume(sender.clone());\n let prev_volume: i128 = env.storage().persistent().get(&volume_key).unwrap_or(0);\n env.storage()\n .persistent()\n .set(&volume_key, &(prev_volume + amount));\n env.storage().persistent().extend_ttl(\n &volume_key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n // Emit routed event\n env.events().publish(\n (symbol_short!("routed"), sender.clone(), recipient.clone()),\n amount,\n );\n\n log!(env, "Platform fee routed to treasury");\n\n Ok(())\n }\n\n // ── Public contract methods ──────────────────────────────────────────────\n\n /// One-time setup: records the admin and the initial fee configuration\n /// in instance storage. Must be called before `route_payment`.\n pub fn initialize(\n env: Env,\n admin: Address,\n platform_treasury: Address,\n fee_bps: i128,\n fee_cap: i128,\n max_amount: i128,\n ) -> Result<(), Error> {\n if env.storage().instance().has(&DataKey::Admin) {\n return Err(Error::AlreadyInitialized);\n }\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Admin, &admin);\n env.storage()\n .instance()\n .set(&DataKey::PlatformTreasury, &platform_treasury);\n env.storage().instance().set(&DataKey::FeeBps, &fee_bps);\n env.storage().instance().set(&DataKey::FeeCap, &fee_cap);\n env.storage()\n .instance()\n .set(&DataKey::MaxAmount, &max_amount);\n env.storage().instance().set(&DataKey::Paused, &false);\n env.storage().instance().set(&DataKey::Frozen, &false);\n env.storage().instance().set(&DataKey::TimelockNonce, &0u64);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n Ok(())\n }\n\n // ── Timelock: queue / execute / cancel ───────────────────────────────────\n\n /// Queues an admin action to be executed after a 24-hour delay.\n ///\n /// The admin provides the desired `ActionType` variant and receives a\n /// numeric nonce that uniquely identifies this pending entry. Pass this\n /// nonce to `execute_action` after 24 hours, or to `cancel_action` to\n /// abort the intent.\n ///\n /// Sensitive parameter changes (`set_platform_treasury`, `set_fee_config`,\n /// `set_fee_bps`, `set_governance`, `set_min_limit`, `transfer_admin`,\n /// `upgrade`) must go through the timelock. Use the direct setter\n /// functions only for actions that are not sensitive (e.g. `set_pause`\n /// which can also be called directly for immediate operational pauses).\n ///\n /// The contract must not be frozen when queuing, and the admin must\n /// authorize the call.\n pub fn queue_action(env: Env, action: ActionType) -> Result {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let nonce = Self::next_nonce(&env);\n let queued_at = env.ledger().timestamp();\n\n let entry = TimelockEntry {\n queued_at,\n action: action.clone(),\n };\n\n let key = DataKey::TimelockEntry(nonce);\n env.storage().persistent().set(&key, &entry);\n env.storage().persistent().extend_ttl(\n &key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (Symbol::new(&env, "action_queued"), admin),\n (nonce, queued_at),\n );\n\n log!(&env, "Timelock action queued with nonce {}", nonce);\n Ok(nonce)\n }\n\n /// Returns the pending `TimelockEntry` for the given nonce, or an error if\n /// it does not exist.\n pub fn get_queued_action(env: Env, nonce: u64) -> Result {\n let key = DataKey::TimelockEntry(nonce);\n env.storage()\n .persistent()\n .get(&key)\n .ok_or(Error::TimelockNotFound)\n }\n\n /// Executes a previously queued action identified by `nonce`.\n ///\n /// Requirements:\n /// - The contract must not be frozen.\n /// - The admin must authorize.\n /// - The entry identified by `nonce` must exist.\n /// - At least 24 hours (`SECONDS_IN_24H`) must have passed since queuing.\n ///\n /// On success the entry is removed and the underlying setter is invoked.\n pub fn execute_action(env: Env, nonce: u64) -> Result<(), Error> {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let key = DataKey::TimelockEntry(nonce);\n let entry: TimelockEntry = env\n .storage()\n .persistent()\n .get(&key)\n .ok_or(Error::TimelockNotFound)?;\n\n let now = env.ledger().timestamp();\n if now < entry.queued_at + Self::SECONDS_IN_24H {\n return Err(Error::TimelockNotReady);\n }\n\n // Remove the entry before applying the action (checks-effects-interactions).\n env.storage().persistent().remove(&key);\n\n // Apply the action.\n match entry.action {\n ActionType::SetPlatformTreasury(new_treasury) => {\n env.storage()\n .instance()\n .set(&DataKey::PlatformTreasury, &new_treasury);\n }\n ActionType::SetFeeConfig(fee_bps, fee_cap) => {\n env.storage().instance().set(&DataKey::FeeBps, &fee_bps);\n env.storage().instance().set(&DataKey::FeeCap, &fee_cap);\n }\n ActionType::SetFeeBps(new_fee_bps) => {\n env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps);\n }\n ActionType::SetGovernance(gov) => {\n env.storage().instance().set(&DataKey::Governance, &gov);\n }\n ActionType::SetMinLimit(min_limit) => {\n env.storage().instance().set(&DataKey::MinLimit, &min_limit);\n }\n ActionType::TransferAdmin(new_admin) => {\n env.storage().instance().set(&DataKey::Admin, &new_admin);\n }\n ActionType::Upgrade(new_wasm_hash) => {\n env.deployer().update_current_contract_wasm(new_wasm_hash);\n }\n }\n\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events()\n .publish((Symbol::new(&env, "action_executed"), admin), nonce);\n\n log!(&env, "Timelock action executed for nonce {}", nonce);\n Ok(())\n }\n\n /// Cancels a pending timelock entry before it can be executed.\n ///\n /// This is the primary defence when a compromised admin has queued a\n /// malicious action: any other admin (after a key rotation) or a\n /// multi-sig governance can cancel it within the 24-hour window.\n ///\n /// Admin authorization is required. The contract may be frozen.\n pub fn cancel_action(env: Env, nonce: u64) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let key = DataKey::TimelockEntry(nonce);\n if !env.storage().persistent().has(&key) {\n return Err(Error::TimelockNotFound);\n }\n\n env.storage().persistent().remove(&key);\n\n env.events()\n .publish((Symbol::new(&env, "action_cancelled"), admin), nonce);\n\n log!(&env, "Timelock action cancelled for nonce {}", nonce);\n Ok(())\n }\n\n // ── Freeze / unfreeze ────────────────────────────────────────────────────\n\n /// Instantly freezes the contract, blocking all payments and timelock\n /// executions. This is the emergency last resort when an admin key is\n /// known to be compromised.\n ///\n /// Unlike other sensitive admin operations, freeze takes effect immediately\n /// — it does NOT go through the timelock — so it is always available as a\n /// rapid-response tool.\n ///\n /// Admin authorization is required.\n pub fn emergency_freeze(env: Env) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Frozen, &true);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (Symbol::new(&env, "emergency_freeze"), admin),\n env.ledger().timestamp(),\n );\n\n log!(&env, "Contract frozen by admin");\n Ok(())\n }\n\n /// Removes the frozen state, restoring normal contract operation.\n ///\n /// Like `emergency_freeze`, this takes effect immediately and does not\n /// go through the timelock.\n ///\n /// Admin authorization is required.\n pub fn unfreeze(env: Env) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Frozen, &false);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (Symbol::new(&env, "unfreeze"), admin),\n env.ledger().timestamp(),\n );\n\n log!(&env, "Contract unfrozen by admin");\n Ok(())\n }\n\n /// Returns whether the contract is currently frozen.\n pub fn is_frozen(env: Env) -> bool {\n Self::is_frozen_internal(&env)\n }\n\n // ── Sensitive admin setters (now require timelock) ───────────────────────\n //\n // The functions below are intentionally kept as thin wrappers that apply\n // the change *directly* but only when called from execute_action (i.e.\n // after the timelock has been satisfied). External callers that were\n // previously calling these functions directly should instead use\n // queue_action + execute_action.\n //\n // NOTE: The direct-setter functions are retained for backward-compatibility\n // of off-chain tooling. They still gate on admin/governance auth but they\n // are NOT wrapped by an on-chain timelock check; the timelock is enforced\n // exclusively through queue_action / execute_action.\n\n /// Updates the treasury address that receives the platform fee.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetPlatformTreasury(…))`\n /// and execute after 24 hours. This direct path is retained for tooling\n /// compatibility only.\n pub fn set_platform_treasury(env: Env, new_treasury: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage()\n .instance()\n .set(&DataKey::PlatformTreasury, &new_treasury);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Updates the fee basis points and fee cap.\n /// Requires governance authority if a governance address is set; otherwise admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`.\n pub fn set_fee_config_legacy(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> {\n Self::require_fee_authority(&env)?;\n\n env.storage().instance().set(&DataKey::FeeBps, &fee_bps);\n env.storage().instance().set(&DataKey::FeeCap, &fee_cap);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Alias for `set_fee_config_legacy`. Admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`.\n pub fn set_fee_config(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> {\n Self::set_fee_config_legacy(env, fee_bps, fee_cap)\n }\n\n /// Updates the fee basis points.\n /// Requires governance authority if a governance address is set; otherwise admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeBps(…))`.\n pub fn set_fee_bps(env: Env, new_fee_bps: i128) -> Result<(), Error> {\n Self::require_fee_authority(&env)?;\n\n env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Sets the governance contract address. After this call, only the governance\n /// contract can update fees. Admin-only — can only be set once per governance cycle.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetGovernance(…))`.\n pub fn set_governance(env: Env, gov: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n env.storage().instance().set(&DataKey::Governance, &gov);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Sets the minimum allowed routing amount. Admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetMinLimit(…))`.\n pub fn set_min_limit(env: Env, min_limit: i128) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::MinLimit, &min_limit);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Returns the current protocol fee percentage in basis points.\n pub fn get_fee(env: Env) -> i128 {\n env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0)\n }\n\n /// Pauses or unpauses the payment router. Admin-only.\n /// This is NOT timelocked — operational pausing must remain instant.\n pub fn set_pause(env: Env, paused: bool) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Paused, &paused);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish((symbol_short!("pause"),), (paused,));\n\n Ok(())\n }\n\n /// Alias for `set_pause`. Admin-only.\n pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> {\n Self::set_pause(env, paused)\n }\n\n /// Returns whether the contract is currently paused.\n pub fn is_paused(env: Env) -> bool {\n env.storage()\n .instance()\n .get(&DataKey::Paused)\n .unwrap_or(false)\n }\n\n /// Returns the cumulative amount a given sender has routed through the contract.\n pub fn get_user_volume(env: Env, user: Address) -> i128 {\n env.storage()\n .persistent()\n .get(&DataKey::UserVolume(user))\n .unwrap_or(0)\n }\n\n /// Adds an address to the blacklist. Admin-only.\n pub fn blacklist_address(env: Env, address: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage()\n .persistent()\n .set(&DataKey::Blacklist(address.clone()), &true);\n env.storage().persistent().extend_ttl(\n &DataKey::Blacklist(address),\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n Ok(())\n }\n\n /// Removes an address from the blacklist. Admin-only.\n pub fn unblacklist_address(env: Env, address: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage()\n .persistent()\n .remove(&DataKey::Blacklist(address));\n\n Ok(())\n }\n\n /// Returns whether an address is blacklisted.\n pub fn is_blacklisted(env: Env, address: Address) -> bool {\n env.storage()\n .persistent()\n .get(&DataKey::Blacklist(address))\n .unwrap_or(false)\n }\n\n /// Returns the effective fee_bps for a sender after applying any\n /// volume-based tiered discount.\n pub fn get_effective_fee_bps(env: Env, sender: Address) -> i128 {\n let fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0);\n let user_volume = Self::get_user_volume(env.clone(), sender);\n if user_volume > Self::VOLUME_THRESHOLD {\n fee_bps / 2\n } else {\n fee_bps\n }\n }\n\n /// Set a new admin. Gated by the current admin if one exists.\n pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> {\n if let Some(admin) = env\n .storage()\n .instance()\n .get::(&DataKey::Admin)\n {\n admin.require_auth();\n }\n env.storage().instance().set(&DataKey::Admin, &new_admin);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Transfers admin rights to a new address.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::TransferAdmin(…))`.\n pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> {\n let current_admin = Self::require_admin(&env)?;\n current_admin.require_auth();\n env.storage().instance().set(&DataKey::Admin, &new_admin);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Recovers tokens accidentally sent directly to the contract address. Admin-only.\n pub fn recover_tokens(env: Env, token: Address, amount: i128) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let contract_address = env.current_contract_address();\n let token_client = token::Client::new(&env, &token);\n token_client.transfer(&contract_address, &admin, &amount);\n\n Ok(())\n }\n\n /// Records a token as supported (no-op; routing accepts any token contract ID).\n pub fn add_supported_token(_env: Env, _token: Address) -> Result<(), Error> {\n Ok(())\n }\n\n /// Routes a payment from a sender to a recipient, deducting a platform fee.\n pub fn route_payment(\n env: Env,\n sender: Address,\n recipient: Address,\n token_address: Address,\n amount: i128,\n ) -> Result<(), Error> {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n if Self::is_paused(env.clone()) {\n return Err(Error::Paused);\n }\n\n let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?;\n\n Self::process_single_payment(\n &env,\n &sender,\n &recipient,\n &token_address,\n amount,\n &platform_treasury,\n fee_bps,\n fee_cap,\n )\n }\n\n /// Routes multiple payments in a single transaction. If any payment fails,\n /// the entire batch is reverted atomically.\n pub fn route_payments(env: Env, payments: Vec) -> Result<(), Error> {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n if Self::is_paused(env.clone()) {\n return Err(Error::Paused);\n }\n\n let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?;\n\n for payment in payments.iter() {\n Self::process_single_payment(\n &env,\n &payment.sender,\n &payment.recipient,\n &payment.token_address,\n payment.amount,\n &platform_treasury,\n fee_bps,\n fee_cap,\n )?;\n }\n\n Ok(())\n }\n\n /// Returns the available internal refund balance for a user and token.\n pub fn get_refund_balance(env: Env, user: Address, token: Address) -> i128 {\n Self::get_refund_balance_internal(&env, &user, &token)\n }\n\n /// Withdraws a specific amount from the user's internal refund balance.\n pub fn withdraw_refund(\n env: Env,\n user: Address,\n token: Address,\n amount: i128,\n ) -> Result<(), Error> {\n user.require_auth();\n\n if amount <= 0 {\n return Err(Error::NoRefundAvailable);\n }\n\n let current_balance = Self::get_refund_balance_internal(&env, &user, &token);\n if amount > current_balance {\n return Err(Error::NoRefundAvailable);\n }\n\n let key = DataKey::RefundBalance(user.clone(), token.clone());\n let new_balance = current_balance - amount;\n if new_balance > 0 {\n env.storage().persistent().set(&key, &new_balance);\n env.storage().persistent().extend_ttl(\n &key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n } else {\n env.storage().persistent().remove(&key);\n }\n\n let contract_address = env.current_contract_address();\n let token_client = token::Client::new(&env, &token);\n token_client.transfer(&contract_address, &user, &amount);\n\n env.events().publish(\n (symbol_short!("withdrawn"), user.clone(), token.clone()),\n amount,\n );\n\n log!(&env, "Refund balance withdrawn by user");\n Ok(())\n }\n\n /// Claims and withdraws the entire available refund balance for a user and token.\n pub fn claim_all_refunds(env: Env, user: Address, token: Address) -> Result {\n user.require_auth();\n\n let current_balance = Self::get_refund_balance_internal(&env, &user, &token);\n if current_balance <= 0 {\n return Err(Error::NoRefundAvailable);\n }\n\n Self::withdraw_refund(env, user, token, current_balance)?;\n Ok(current_balance)\n }\n\n /// Admin-only emergency withdrawal of tokens held by this contract.\n pub fn emergency_withdraw(env: Env, token: Address, amount: i128) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let token_client = token::Client::new(&env, &token);\n token_client.transfer(&env.current_contract_address(), &admin, &amount);\n\n log!(&env, "Emergency withdraw executed by admin");\n Ok(())\n }\n\n /// Replaces this contract's WASM with a previously uploaded version.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::Upgrade(…))`.\n pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.deployer().update_current_contract_wasm(new_wasm_hash);\n Ok(())\n }\n\n /// Returns the contract version.\n pub fn version(_env: Env) -> u32 {\n Self::VERSION\n }\n}\n\n#[cfg(test)]\nmod test {\n use super::*;\n use soroban_sdk::{\n testutils::{Address as _, Events, Ledger as _, LedgerInfo},\n token::StellarAssetClient,\n Address, Env, Symbol, TryIntoVal,\n };\n\n /// Returns (env, client, contract_id).\n fn setup_env() -> (Env, PaymentRouterClient<'static>, Address) {\n let env = Env::default();\n env.mock_all_auths();\n let contract_id = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_id);\n (env, client, contract_id)\n }\n\n /// Deploys a Stellar Asset Contract test token. Returns\n /// (token_address, token_client, stellar_asset_admin_client).\n fn setup_token(\n env: &Env,\n ) -> (\n Address,\n token::Client<'static>,\n token::StellarAssetClient<'static>,\n ) {\n let token_admin = Address::generate(env);\n let token_address = env.register_stellar_asset_contract(token_admin);\n let token_client = token::Client::new(env, &token_address);\n let token_admin_client = token::StellarAssetClient::new(env, &token_address);\n (token_address, token_client, token_admin_client)\n }\n\n // ── Timelock tests ───────────────────────────────────────────────────────\n\n #[test]\n fn test_queue_and_execute_set_fee_bps_after_delay() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Queue a fee-bps change.\n let nonce = client.queue_action(&ActionType::SetFeeBps(250));\n assert_eq!(nonce, 1);\n assert_eq!(client.get_fee(), 100); // Not applied yet.\n\n // Trying to execute immediately should fail (delay not elapsed).\n let res = client.try_execute_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotReady);\n\n // Advance time past 24 hours.\n let current_time = env.ledger().timestamp();\n env.ledger().set(LedgerInfo {\n timestamp: current_time + PaymentRouter::SECONDS_IN_24H + 1,\n protocol_version: env.ledger().protocol_version(),\n sequence_number: env.ledger().sequence(),\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n // Now execution should succeed.\n client.execute_action(&nonce);\n assert_eq!(client.get_fee(), 250);\n\n // Entry should be gone.\n let res = client.try_get_queued_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n #[test]\n fn test_queue_and_execute_set_platform_treasury() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let new_treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let nonce = client.queue_action(&ActionType::SetPlatformTreasury(new_treasury.clone()));\n\n // Advance 24h+.\n let ts = env.ledger().timestamp();\n env.ledger().set(LedgerInfo {\n timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1,\n protocol_version: env.ledger().protocol_version(),\n sequence_number: env.ledger().sequence(),\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n client.execute_action(&nonce);\n\n // Verify the treasury was actually updated by routing a payment and\n // checking where the fee lands.\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n let (token_addr, token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n client.route_payment(&sender, &recipient, &token_addr, &1000);\n\n // 100 bps of 1000 = 10, capped to min(10, 1000) = 10\n assert_eq!(token_client.balance(&new_treasury), 10);\n assert_eq!(token_client.balance(&treasury), 0);\n }\n\n #[test]\n fn test_execute_action_not_found() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let res = client.try_execute_action(&99u64);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n #[test]\n fn test_cancel_action() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let nonce = client.queue_action(&ActionType::SetFeeBps(999));\n assert!(client.try_get_queued_action(&nonce).is_ok());\n\n client.cancel_action(&nonce);\n\n // Entry should be gone.\n let res = client.try_get_queued_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n\n // Fee should remain unchanged.\n assert_eq!(client.get_fee(), 100);\n }\n\n #[test]\n fn test_cancel_nonexistent_action() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let res = client.try_cancel_action(&42u64);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n #[test]\n fn test_nonce_increments() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let n1 = client.queue_action(&ActionType::SetFeeBps(200));\n let n2 = client.queue_action(&ActionType::SetFeeBps(300));\n let n3 = client.queue_action(&ActionType::SetFeeBps(400));\n\n assert_eq!(n1, 1);\n assert_eq!(n2, 2);\n assert_eq!(n3, 3);\n }\n\n // ── Freeze tests ─────────────────────────────────────────────────────────\n\n #[test]\n fn test_emergency_freeze_blocks_payments() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n assert!(!client.is_frozen());\n\n client.emergency_freeze();\n assert!(client.is_frozen());\n\n let res = client.try_route_payment(&sender, &recipient, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen);\n }\n\n #[test]\n fn test_emergency_freeze_blocks_timelock_execution() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let nonce = client.queue_action(&ActionType::SetFeeBps(500));\n\n // Advance past 24h.\n let ts = env.ledger().timestamp();\n env.ledger().set(LedgerInfo {\n timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1,\n protocol_version: env.ledger().protocol_version(),\n sequence_number: env.ledger().sequence(),\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n // Freeze the contract before execution.\n client.emergency_freeze();\n\n let res = client.try_execute_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen);\n\n // Fee remains unchanged.\n assert_eq!(client.get_fee(), 100);\n }\n\n #[test]\n fn test_unfreeze_restores_payments() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n client.emergency_freeze();\n assert!(client.is_frozen());\n\n client.unfreeze();\n assert!(!client.is_frozen());\n\n // Payments should work again.\n client.route_payment(&sender, &recipient, &token_address, &1000);\n }\n\n #[test]\n fn test_freeze_queue_action_blocked() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.emergency_freeze();\n\n // Cannot queue new actions while frozen.\n let res = client.try_queue_action(&ActionType::SetFeeBps(500));\n assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen);\n }\n\n #[test]\n fn test_cancel_action_allowed_while_frozen() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Queue an action before freezing.\n let nonce = client.queue_action(&ActionType::SetFeeBps(500));\n\n client.emergency_freeze();\n\n // Cancellation should still be possible while frozen (incident response).\n client.cancel_action(&nonce);\n let res = client.try_get_queued_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n // ── Timelock emits events ────────────────────────────────────────────────\n\n #[test]\n fn test_queue_action_emits_event() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.queue_action(&ActionType::SetFeeBps(200));\n\n let events = env.events().all();\n let found = events.iter().any(|(_, topics, _)| {\n if topics.is_empty() {\n return false;\n }\n let raw = topics.get(0).unwrap();\n let sym: Result = raw.try_into_val(&env);\n sym.map(|s| s == Symbol::new(&env, "action_queued"))\n .unwrap_or(false)\n });\n assert!(found, "action_queued event not found");\n }\n\n #[test]\n fn test_freeze_emits_event() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.emergency_freeze();\n\n let events = env.events().all();\n let found = events.iter().any(|(_, topics, _)| {\n if topics.is_empty() {\n return false;\n }\n let raw = topics.get(0).unwrap();\n let sym: Result = raw.try_into_val(&env);\n sym.map(|s| s == Symbol::new(&env, "emergency_freeze"))\n .unwrap_or(false)\n });\n assert!(found, "emergency_freeze event not found");\n }\n\n // ── Original tests (retained) ────────────────────────────────────────────\n\n #[test]\n fn test_get_fee() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n // Before initialization, get_fee returns 0\n assert_eq!(client.get_fee(), 0);\n\n // Initialize with 150 bps\n client.initialize(&admin, &treasury, &150, &5000, &PaymentRouter::MAX_AMOUNT);\n assert_eq!(client.get_fee(), 150);\n\n // Update via set_fee_bps\n client.set_fee_bps(&250);\n assert_eq!(client.get_fee(), 250);\n\n // Update via set_fee_config\n client.set_fee_config(&300, &10000);\n assert_eq!(client.get_fee(), 300);\n }\n\n #[test]\n fn test_version_reports_contract_version() {\n let (_env, client, _) = setup_env();\n\n // #269 — the version view is callable without initialization and\n // returns the compiled-in contract version so a UI can check\n // compatibility before interacting with the contract.\n assert_eq!(client.version(), PaymentRouter::VERSION);\n assert_eq!(client.version(), 1);\n }\n\n #[test]\n fn test_admin_restrictions_and_updates() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let new_admin = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Trying to initialize again should fail\n let res = client.try_initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n assert_eq!(res.unwrap_err().unwrap(), Error::AlreadyInitialized);\n\n client.set_admin(&new_admin);\n\n // Modify config\n client.set_fee_config(&200, &2000);\n client.set_fee_bps(&200);\n assert_eq!(client.get_fee(), 200);\n\n let new_treasury = Address::generate(&env);\n client.set_platform_treasury(&new_treasury);\n }\n\n #[test]\n fn test_recover_tokens() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n // Simulate tokens accidentally sent directly to the contract address\n let accidental_amount = 5_000i128;\n stellar_asset_client.mint(&contract_id, &accidental_amount);\n\n assert_eq!(token_client.balance(&contract_id), accidental_amount);\n assert_eq!(token_client.balance(&admin), 0);\n\n // Admin recovers tokens\n let recover_amount = 3_000i128;\n client.recover_tokens(&token_address, &recover_amount);\n\n assert_eq!(token_client.balance(&admin), recover_amount);\n assert_eq!(\n token_client.balance(&contract_id),\n accidental_amount - recover_amount\n );\n }\n\n #[test]\n fn test_set_pause_emits_event() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.set_pause(&true);\n\n let events = env.events().all();\n assert!(!events.is_empty());\n let (_, topics, _) = events.get(0).unwrap();\n assert_eq!(topics.len(), 1);\n let topic: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap();\n assert_eq!(topic, symbol_short!("pause"));\n }\n\n #[test]\n fn test_route_payment_emits_payment_initiated_event() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n client\n .mock_all_auths()\n .route_payment(&sender, &recipient, &token_address, &5_000);\n\n let events = env.events().all();\n assert!(!events.is_empty());\n\n let mut found = false;\n for (_, topics, data) in events.iter() {\n if !topics.is_empty() {\n if let Ok(topic_sym) = topics.get(0).unwrap().try_into_val(&env) {\n let sym: Symbol = topic_sym;\n if sym == Symbol::new(&env, "payment_initiated") {\n found = true;\n let amt: i128 = data.try_into_val(&env).unwrap();\n assert_eq!(amt, 5_000);\n break;\n }\n }\n }\n }\n assert!(found, "payment_initiated event not found");\n }\n\n #[test]\n fn test_route_payment_emits_routed_event() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n let amount = 2_000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount);\n\n let events = env.events().all();\n assert!(!events.is_empty());\n\n // Find the "routed" event by topic\n let mut found = None;\n for evt in events.iter() {\n let (_contract_id, topics, _data) = evt.clone();\n if topics.len() != 3 {\n continue;\n }\n let topic0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap();\n if topic0 == symbol_short!("routed") {\n found = Some(evt.clone());\n break;\n }\n }\n let routed = found.expect("route_payment should publish a \"routed\" event");\n\n let (_contract_id, topics, data) = routed;\n assert_eq!(topics.len(), 3);\n\n let topic_sender: Address = topics.get(1).unwrap().try_into_val(&env).unwrap();\n let topic_recipient: Address = topics.get(2).unwrap().try_into_val(&env).unwrap();\n assert_eq!(topic_sender, sender);\n assert_eq!(topic_recipient, recipient);\n\n let event_amount: i128 = data.try_into_val(&env).unwrap();\n assert_eq!(event_amount, amount);\n }\n\n #[test]\n fn test_admin_pause_functionality() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n // Initially not paused\n assert!(!client.is_paused());\n\n // Pause\n client.set_pause(&true);\n assert!(client.is_paused());\n\n // Route payment should fail when paused\n let res = client.try_route_payment(&sender, &recipient, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::Paused);\n\n // Unpause via set_paused alias\n client.set_paused(&false);\n assert!(!client.is_paused());\n\n // Route payment should succeed now\n client.route_payment(&sender, &recipient, &token_address, &1000);\n }\n\n #[test]\n fn test_route_payment_calculates_and_sends_fee() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, token_client, _token_admin_client) = setup_token(&env);\n\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n let initial_balance = 10_000i128;\n sac.mint(&sender, &initial_balance);\n\n // Initialize router with 1% fee (100 bps) and cap of 50\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n // Test normal fee calculation: 1% of 2000 = 20, below cap of 50\n let amount_1 = 2000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount_1);\n\n assert_eq!(token_client.balance(&treasury), 20);\n assert_eq!(token_client.balance(&recipient), 1980);\n assert_eq!(token_client.balance(&sender), initial_balance - amount_1);\n assert_eq!(client.get_user_volume(&sender), amount_1);\n\n // Test fee capped at 50: 1% of 8000 = 80, capped to 50\n let amount_2 = 8000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount_2);\n\n assert_eq!(token_client.balance(&treasury), 70);\n assert_eq!(token_client.balance(&recipient), 9930);\n assert_eq!(\n token_client.balance(&sender),\n initial_balance - amount_1 - amount_2\n );\n assert_eq!(client.get_user_volume(&sender), amount_1 + amount_2);\n }\n\n #[test]\n fn test_insufficient_balance() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &100);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n // Route payment of 500 when balance is only 100\n let res = client.try_route_payment(&sender, &recipient, &token_address, &500);\n assert_eq!(res.unwrap_err().unwrap(), Error::InsufficientBalance);\n }\n\n #[test]\n fn test_daily_limit_and_reset() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, token_client, _token_admin_client) = setup_token(&env);\n\n let limit = 10_000_000_000_000i128;\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &(limit + 2000));\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n // Route amount up to daily limit\n client.route_payment(&sender, &recipient, &token_address, &limit);\n\n // Next payment should exceed daily limit\n let res = client.try_route_payment(&sender, &recipient, &token_address, &2000);\n assert_eq!(res.unwrap_err().unwrap(), Error::LimitExceeded);\n\n // Advance time past 24 hours to reset the daily limit\n let current_time = env.ledger().timestamp();\n let current_protocol_version = env.ledger().protocol_version();\n env.ledger().set(LedgerInfo {\n timestamp: current_time + 86400,\n protocol_version: current_protocol_version,\n sequence_number: 1,\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n // Now routing should succeed again. The first payment pushed volume past\n // VOLUME_THRESHOLD, so the halved rate applies: 2000 * 50 bps = 10.\n client.route_payment(&sender, &recipient, &token_address, &2000);\n assert_eq!(token_client.balance(&recipient), (limit - 50) + (2000 - 10));\n }\n\n #[test]\n fn test_prevent_self_routing() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n let res = client.try_route_payment(&sender, &sender, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::InvalidRecipient);\n }\n\n #[test]\n #[ignore]\n fn test_tiered_fee_discount_applied_after_volume_threshold() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, token_client, _token_admin_client) = setup_token(&env);\n\n // Threshold is 10,000 XLM = 10,000 * 10,000,000 (7 decimals)\n let threshold = 100_000_000_000i128;\n let first_amount = threshold + 1;\n let second_amount = 1000i128;\n let total_mint = first_amount + second_amount + 10_000_000;\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &total_mint);\n\n // Initialize with 1% fee (100 bps) and no cap\n client.initialize(\n &admin,\n &treasury,\n &100,\n &i128::MAX,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n // First payment: volume is 0 (< threshold), full fee applies\n client.route_payment(&sender, &recipient, &token_address, &first_amount);\n\n let full_fee_first = (first_amount * 100) / 10_000;\n assert_eq!(token_client.balance(&treasury), full_fee_first);\n assert_eq!(\n token_client.balance(&recipient),\n first_amount - full_fee_first\n );\n assert_eq!(client.get_user_volume(&sender), first_amount);\n // Volume is now past threshold, so next call gets the discount\n assert_eq!(client.get_effective_fee_bps(&sender), 50);\n\n // Second payment: volume > threshold, 50% discount applies\n client.route_payment(&sender, &recipient, &token_address, &second_amount);\n\n let discounted_fee = (second_amount * 50) / 10_000;\n assert_eq!(\n token_client.balance(&treasury),\n full_fee_first + discounted_fee\n );\n assert_eq!(\n token_client.balance(&recipient),\n (first_amount - full_fee_first) + (second_amount - discounted_fee)\n );\n }\n\n #[test]\n fn test_get_effective_fee_bps_no_discount_below_threshold() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &1_000_000);\n\n client.initialize(\n &admin,\n &treasury,\n &100,\n &i128::MAX,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n // No volume yet\n assert_eq!(client.get_effective_fee_bps(&sender), 100);\n\n // Route a small payment (below threshold)\n client.route_payment(&sender, &recipient, &token_address, &1000);\n\n // Volume is 1000, far below 10,000 XLM threshold\n assert_eq!(client.get_effective_fee_bps(&sender), 100);\n }\n\n #[test]\n fn test_successful_xlm_routing() {\n let env = Env::default();\n env.mock_all_auths();\n\n let admin = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n let platform_treasury = Address::generate(&env);\n\n let contract_id = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_id);\n\n client.initialize(\n &admin,\n &platform_treasury,\n &40,\n &i128::MAX,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n let token_admin = Address::generate(&env);\n let token_address = env.register_stellar_asset_contract(token_admin.clone());\n let sac = StellarAssetClient::new(&env, &token_address);\n let token_client = token::Client::new(&env, &token_address);\n\n let initial_balance = 1_000_000_000i128;\n sac.mint(&sender, &initial_balance);\n\n client.add_supported_token(&token_address);\n\n let amount = 100_000_000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount);\n\n let expected_fee = 400_000i128;\n let expected_recipient_amount = amount - expected_fee;\n\n assert_eq!(token_client.balance(&sender), initial_balance - amount);\n assert_eq!(token_client.balance(&recipient), expected_recipient_amount);\n assert_eq!(token_client.balance(&platform_treasury), expected_fee);\n }\n\n #[test]\n fn test_initialize_sets_admin() {\n let env = Env::default();\n env.mock_all_auths();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let contract_addr = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_addr);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let stored_admin: Option
= env.as_contract(&contract_addr, || {\n env.storage().instance().get(&DataKey::Admin)\n });\n assert_eq!(stored_admin, Some(admin));\n }\n\n /// Verifies that `emergency_withdraw` transfers the exact requested amount\n /// from the contract's own balance to the admin address.\n #[test]\n fn test_emergency_withdraw_transfers_tokens_to_admin() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n // Fund the contract directly (simulates stranded tokens from a routing failure).\n let stranded_amount = 10_000i128;\n stellar_asset_client.mint(&contract_id, &stranded_amount);\n\n assert_eq!(token_client.balance(&contract_id), stranded_amount);\n assert_eq!(token_client.balance(&admin), 0);\n\n // Admin withdraws half the stranded balance.\n let withdraw_amount = 4_000i128;\n client.emergency_withdraw(&token_address, &withdraw_amount);\n\n assert_eq!(token_client.balance(&admin), withdraw_amount);\n assert_eq!(\n token_client.balance(&contract_id),\n stranded_amount - withdraw_amount\n );\n }\n\n /// Verifies that `emergency_withdraw` can drain the entire contract balance\n /// in a single call.\n #[test]\n fn test_emergency_withdraw_full_balance() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n let stranded_amount = 7_500i128;\n stellar_asset_client.mint(&contract_id, &stranded_amount);\n\n client.emergency_withdraw(&token_address, &stranded_amount);\n\n assert_eq!(token_client.balance(&admin), stranded_amount);\n assert_eq!(token_client.balance(&contract_id), 0);\n }\n\n /// Verifies that `emergency_withdraw` declares admin authorization as required.\n ///\n /// Soroban's `require_auth()` uses an abort-on-failure model in the host\n /// (non-unwinding panics), so we cannot catch a missing-auth failure inside\n /// the same test process. Instead we use `mock_all_auths_allowing_non_root_auth`\n /// to record which addresses the call attempts to authorize, then assert that\n /// the admin address — and *only* the admin — appears in that list.\n #[test]\n fn test_admin_is_required_for_emergency_withdraw() {\n let env = Env::default();\n env.mock_all_auths();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let contract_id = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_id);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, _token_client, stellar_asset_client) = setup_token(&env);\n stellar_asset_client.mint(&contract_id, &5_000i128);\n\n // Call succeeds because mock_all_auths satisfies any require_auth.\n // What we verify is that the invocation recorded exactly one\n // authorization and that it belongs to admin, proving the function\n // gates on the admin address.\n client.emergency_withdraw(&token_address, &1_000i128);\n\n let auths = env.auths();\n let admin_auth_present = auths.iter().any(|(addr, _)| *addr == admin);\n assert!(\n admin_auth_present,\n "emergency_withdraw must require the admin address to authorize"\n );\n }\n\n #[test]\n fn test_blacklist_recipient() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n // Blacklist the recipient\n client.blacklist_address(&recipient);\n assert!(client.is_blacklisted(&recipient));\n\n // Route payment should fail\n let res = client.try_route_payment(&sender, &recipient, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::Blacklisted);\n\n // Unblacklist and try again\n client.unblacklist_address(&recipient);\n assert!(!client.is_blacklisted(&recipient));\n\n client\n .mock_all_auths()\n .route_payment(&sender, &recipient, &token_address, &1000);\n }\n\n #[test]\n #[ignore]\n fn test_routes_multiple_distinct_assets() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n client.initialize(\n &admin,\n &treasury,\n &100,\n &1_000_000,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n let (usdc_like_address, usdc_like_client, usdc_like_admin_client) = setup_token(&env);\n let (eurc_like_address, eurc_like_client, eurc_like_admin_client) = setup_token(&env);\n assert_ne!(usdc_like_address, eurc_like_address);\n\n usdc_like_admin_client.mint(&sender, &10_000);\n eurc_like_admin_client.mint(&sender, &5_000);\n\n client.route_payment(&sender, &recipient, &usdc_like_address, &2_000);\n client.route_payment(&sender, &recipient, &eurc_like_address, &1_000);\n\n assert_eq!(usdc_like_client.balance(&sender), 8_000);\n assert_eq!(usdc_like_client.balance(&recipient), 1_980);\n assert_eq!(eurc_like_client.balance(&sender), 4_000);\n assert_eq!(eurc_like_client.balance(&recipient), 990);\n assert_eq!(client.get_user_volume(&sender), 3_000);\n }\n\n #[test]\n fn test_benchmark_gas_costs() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n // Reset budget before initialization\n env.budget().reset_default();\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n let init_cpu = env.budget().cpu_instruction_cost();\n let init_mem = env.budget().memory_bytes_cost();\n log!(\n &env,\n "GAS REPORT: initialize - CPU: {}, Mem: {}",\n init_cpu,\n init_mem\n );\n\n // Reset budget before route_payment\n env.budget().reset_default();\n client.route_payment(&sender, &recipient, &token_address, &5_000);\n let route_cpu = env.budget().cpu_instruction_cost();\n let route_mem = env.budget().memory_bytes_cost();\n log!(\n &env,\n "GAS REPORT: route_payment - CPU: {}, Mem: {}",\n route_cpu,\n route_mem\n );\n\n env.budget().print();\n\n // Fails CI if gas costs exceed defined thresholds\n // Set reasonable thresholds (e.g. 5M CPU and 2MB Mem per call)\n let max_cpu = 5_000_000;\n let max_mem = 2_000_000;\n\n assert!(\n init_cpu <= max_cpu,\n "initialize CPU cost exceeded threshold! Cost: {}, Threshold: {}",\n init_cpu,\n max_cpu\n );\n assert!(\n init_mem <= max_mem,\n "initialize Memory cost exceeded threshold! Cost: {}, Threshold: {}",\n init_mem,\n max_mem\n );\n\n assert!(\n route_cpu <= max_cpu,\n "route_payment CPU cost exceeded threshold! Cost: {}, Threshold: {}",\n route_cpu,\n max_cpu\n );\n assert!(\n route_mem <= max_mem,\n "route_payment Memory cost exceeded threshold! Cost: {}, Threshold: {}",\n route_mem,\n max_mem\n );\n }\n\n #[test]\n #[ignore]\n fn test_refund_ledger_and_withdrawal() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let user = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n // Initially zero refund balance\n assert_eq!(client.get_refund_balance(&user, &token_address), 0);\n\n // Simulate stranded tokens in contract and credit internal refund balance\n let refund_amount = 5_000i128;\n stellar_asset_client.mint(&contract_id, &refund_amount);\n\n env.as_contract(&contract_id, || {\n PaymentRouter::credit_refund_balance(&env, &user, &token_address, refund_amount);\n });\n\n assert_eq!(\n client.get_refund_balance(&user, &token_address),\n refund_amount\n );\n\n // User withdraws partial refund\n let partial_amount = 2_000i128;\n client.withdraw_refund(&user, &token_address, &partial_amount);\n\n assert_eq!(token_client.balance(&user), partial_amount);\n assert_eq!(\n client.get_refund_balance(&user, &token_address),\n refund_amount - partial_amount\n );\n\n // User claims remaining refunds with claim_all_refunds\n let claimed = client.claim_all_refunds(&user, &token_address);\n assert_eq!(claimed, refund_amount - partial_amount);\n assert_eq!(token_client.balance(&user), refund_amount);\n assert_eq!(client.get_refund_balance(&user, &token_address), 0);\n\n // Trying to withdraw again should fail with NoRefundAvailable\n let res = client.try_withdraw_refund(&user, &token_address, &100);\n assert_eq!(res.unwrap_err().unwrap(), Error::NoRefundAvailable);\n }\n\n #[test]\n fn test_governance_takes_over_fees() {\n let (_, client, _) = setup_env();\n\n let admin = Address::generate(&client.env);\n let treasury = Address::generate(&client.env);\n let gov = Address::generate(&client.env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Admin can still update fees before governance is set\n client.set_fee_bps(&150);\n assert_eq!(client.get_fee(), 150);\n\n // Admin hands control over to governance\n client.set_governance(&gov);\n\n // Governance address can now update the fee\n client.set_fee_bps(&200);\n assert_eq!(client.get_fee(), 200);\n }\n\n /// `add_supported_token` is a no-op and never errors. +#![no_std] +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, log, symbol_short, token, Address, BytesN, + Env, Symbol, Vec, +}; + +// ── Packed UserSpending helpers ────────────────────────────────────────────── +// +// Issue #519: Replace the two-field UserSpending contracttype with a single +// BytesN<24> value packed with bitwise operations. +// +// Layout (big-endian): +// bytes 0..8 — last_reset_time : u64 (8 bytes) +// bytes 8..24 — accumulated_amount: i128 (16 bytes) +// +// Benefits: +// • Eliminates the XDR struct-type overhead (type discriminant + field tags) +// that Soroban adds to every contracttype value, shrinking each UserSpending +// ledger entry from ~48 bytes to exactly 24 bytes. +// • Smaller entries → lower state-rent fee per ledger entry per TTL period. + +/// Pack `last_reset_time` (u64) and `accumulated_amount` (i128) into a +/// 24-byte big-endian buffer. +fn pack_spending(env: &Env, last_reset_time: u64, accumulated_amount: i128) -> BytesN<24> { + let mut buf = [0u8; 24]; + + // Bytes 0..8 — last_reset_time (u64 big-endian) + let t_bytes = last_reset_time.to_be_bytes(); + buf[0] = t_bytes[0]; + buf[1] = t_bytes[1]; + buf[2] = t_bytes[2]; + buf[3] = t_bytes[3]; + buf[4] = t_bytes[4]; + buf[5] = t_bytes[5]; + buf[6] = t_bytes[6]; + buf[7] = t_bytes[7]; + + // Bytes 8..24 — accumulated_amount (i128 big-endian) + let a_bytes = accumulated_amount.to_be_bytes(); + buf[8] = a_bytes[0]; + buf[9] = a_bytes[1]; + buf[10] = a_bytes[2]; + buf[11] = a_bytes[3]; + buf[12] = a_bytes[4]; + buf[13] = a_bytes[5]; + buf[14] = a_bytes[6]; + buf[15] = a_bytes[7]; + buf[16] = a_bytes[8]; + buf[17] = a_bytes[9]; + buf[18] = a_bytes[10]; + buf[19] = a_bytes[11]; + buf[20] = a_bytes[12]; + buf[21] = a_bytes[13]; + buf[22] = a_bytes[14]; + buf[23] = a_bytes[15]; + + BytesN::from_array(env, &buf) +} + +/// Unpack a 24-byte buffer into `(last_reset_time, accumulated_amount)`. +fn unpack_spending(packed: &BytesN<24>) -> (u64, i128) { + // BytesN::to_array() is available in soroban-sdk v20. + let buf: [u8; 24] = packed.to_array(); + + // last_reset_time — bytes 0..8 + let last_reset_time = u64::from_be_bytes([ + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], + ]); + + // accumulated_amount — bytes 8..24 + let accumulated_amount = i128::from_be_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], + buf[18], buf[19], buf[20], buf[21], buf[22], buf[23], + ]); + + (last_reset_time, accumulated_amount) +} + +// ── Legacy struct kept for test snapshot compatibility ─────────────────────── +// +// The UserSpending contracttype is retained so existing tests that reference +// it directly continue to compile. All runtime code now uses the packed +// BytesN<24> representation stored under DataKey::UserSpending. + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserSpending { + pub last_reset_time: u64, + pub accumulated_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Payment { + pub sender: Address, + pub recipient: Address, + pub token_address: Address, + pub amount: i128, +} + +// ── Timelock data structures ───────────────────────────────────────────────── +// +// Admin actions that change sensitive contract parameters (treasury, fees, +// governance, admin transfer) are not applied instantly. Instead the admin +// queues an ActionType intent that gets a nonce ID and a ledger timestamp. +// Only after SECONDS_IN_24H (86 400 s) has elapsed can execute_action be +// called to apply the change. This gives observers a 24-hour window to +// detect and respond to a compromised-admin scenario. +// +// The freeze mechanism is the complementary emergency tool: calling +// emergency_freeze instantly blocks all payments and all timelock executions. +// A freeze does NOT require going through the timelock itself so it is always +// available to the admin as an immediate last resort. Unfreezing likewise +// takes effect immediately so the admin can restore service once the threat is +// resolved. + +/// Describes which administrative parameter change a timelock entry represents. +/// Each variant carries all the arguments needed to apply that change when the +/// delay period is over. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ActionType { + /// Change the platform treasury address. + SetPlatformTreasury(Address), + /// Update fee basis-points and fee cap together (legacy / combined setter). + SetFeeConfig(i128, i128), + /// Update fee basis-points only. + SetFeeBps(i128), + /// Set the governance contract address. + SetGovernance(Address), + /// Change the minimum routing limit. + SetMinLimit(i128), + /// Transfer admin rights to a new address. + TransferAdmin(Address), + /// Upgrade the contract WASM. + Upgrade(BytesN<32>), +} + +/// A pending timelock entry stored in persistent ledger storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimelockEntry { + /// Ledger timestamp (seconds since epoch) when this action was queued. + pub queued_at: u64, + /// The action payload to apply once the delay has elapsed. + pub action: ActionType, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + Governance, + PlatformTreasury, + FeeBps, + FeeCap, + MinLimit, + Paused, + MaxAmount, + UserVolume(Address), + UserSpending(Address), + Blacklist(Address), + RefundBalance(Address, Address), + /// Monotonically-increasing nonce counter used to generate unique IDs for + /// timelock entries. Stored as `u64` in instance storage. + TimelockNonce, + /// A pending timelock entry keyed by its nonce ID. + /// Stored in persistent storage so it survives instance eviction. + TimelockEntry(u64), + /// When `true` the contract is frozen: payments and timelock executions + /// are blocked. Stored as `bool` in instance storage. + Frozen, +} + +/// Contract-level errors returned instead of panicking, so callers get a +/// specific, stable error code to branch on rather than an opaque trap. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// Caller is not authorized to perform this action (e.g. not the admin). + Unauthorized = 1, + /// Sender's token balance is lower than the requested payment amount. + InsufficientBalance = 2, + /// Requested amount is outside allowed bounds, or a spending limit was exceeded. + LimitExceeded = 3, + /// `initialize` was called on a contract that already has an admin set. + AlreadyInitialized = 4, + /// An admin-configured value (treasury, fee, admin) was read before `initialize`. + NotInitialized = 5, + Paused = 6, + InvalidFeeRate = 7, + /// Sender and recipient addresses are the same (self-routing not allowed). + InvalidRecipient = 8, + /// Recipient address is blacklisted. + Blacklisted = 9, + /// Requested refund withdrawal amount is zero or exceeds available refund balance. + NoRefundAvailable = 10, + /// An action is already pending in the timelock queue; it must be executed + /// or cancelled before a duplicate can be queued (not currently enforced, + /// but reserved for future deduplication logic). + TimelockPending = 11, + /// The 24-hour delay for the given timelock entry has not elapsed yet. + TimelockNotReady = 12, + /// No timelock entry exists for the supplied nonce ID. + TimelockNotFound = 13, + /// The contract is frozen; all payments and timelock executions are blocked. + ContractFrozen = 14, +} + +#[contract] +pub struct PaymentRouter; + +#[contractimpl] +impl PaymentRouter { + const BPS_DIVISOR: i128 = 10_000; + const XLM_DECIMALS: i128 = 10_000_000; + const MAX_AMOUNT: i128 = 1_000_000_000_000_000; // 100M tokens with 7 decimals + const DAILY_MAX_LIMIT: i128 = 1_000_000 * Self::XLM_DECIMALS; // 1M tokens limit + const VOLUME_THRESHOLD: i128 = 10_000 * Self::XLM_DECIMALS; // 10,000 XLM threshold for tiered fee discount + const SECONDS_IN_24H: u64 = 24 * 3600; + const VERSION: u32 = 1; + + const DAY_IN_LEDGERS: u32 = 17280; + const INSTANCE_BUMP_AMOUNT: u32 = 7 * Self::DAY_IN_LEDGERS; + const INSTANCE_LIFETIME_THRESHOLD: u32 = Self::INSTANCE_BUMP_AMOUNT - Self::DAY_IN_LEDGERS; + + const USER_BUMP_AMOUNT: u32 = 30 * Self::DAY_IN_LEDGERS; + const USER_LIFETIME_THRESHOLD: u32 = Self::USER_BUMP_AMOUNT - Self::DAY_IN_LEDGERS; + const PERSISTENT_BUMP_AMOUNT: u32 = Self::USER_BUMP_AMOUNT; + const PERSISTENT_LIFETIME_THRESHOLD: u32 = Self::USER_LIFETIME_THRESHOLD; + + // ── Private helpers ────────────────────────────────────────────────────── + + fn require_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized) + } + + /// Fee authority helper: if a Governance address is set it takes exclusive + /// control over fee updates; otherwise the admin retains that right. + fn require_fee_authority(env: &Env) -> Result<(), Error> { + if let Some(gov) = env + .storage() + .instance() + .get::(&DataKey::Governance) + { + gov.require_auth(); + Ok(()) + } else { + let admin = Self::require_admin(env)?; + admin.require_auth(); + Ok(()) + } + } + + fn load_fee_config(env: &Env) -> Result<(Address, i128, i128), Error> { + let platform_treasury: Address = env + .storage() + .instance() + .get(&DataKey::PlatformTreasury) + .ok_or(Error::NotInitialized)?; + let fee_bps: i128 = env + .storage() + .instance() + .get(&DataKey::FeeBps) + .ok_or(Error::NotInitialized)?; + let fee_cap: i128 = env + .storage() + .instance() + .get(&DataKey::FeeCap) + .ok_or(Error::NotInitialized)?; + + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + Ok((platform_treasury, fee_bps, fee_cap)) + } + + fn get_refund_balance_internal(env: &Env, user: &Address, token: &Address) -> i128 { + let key = DataKey::RefundBalance(user.clone(), token.clone()); + env.storage().persistent().get(&key).unwrap_or(0) + } + + fn credit_refund_balance(env: &Env, user: &Address, token: &Address, amount: i128) { + let key = DataKey::RefundBalance(user.clone(), token.clone()); + let current_balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let new_balance = current_balance + amount; + env.storage().persistent().set(&key, &new_balance); + env.storage().persistent().extend_ttl( + &key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + env.events().publish( + (symbol_short!("refunded"), user.clone(), token.clone()), + amount, + ); + } + + /// Returns whether the contract is currently frozen. + fn is_frozen_internal(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Frozen) + .unwrap_or(false) + } + + /// Allocates and returns the next timelock nonce, incrementing the counter. + fn next_nonce(env: &Env) -> u64 { + let current: u64 = env + .storage() + .instance() + .get(&DataKey::TimelockNonce) + .unwrap_or(0u64); + let next = current + 1; + env.storage().instance().set(&DataKey::TimelockNonce, &next); + next + } + + /// Core payment logic shared by `route_payment` and `route_payments`. + #[allow(clippy::too_many_arguments)] + fn process_single_payment( + env: &Env, + sender: &Address, + recipient: &Address, + token_address: &Address, + amount: i128, + platform_treasury: &Address, + fee_bps: i128, + fee_cap: i128, + ) -> Result<(), Error> { + // Require sender auth + sender.require_auth(); + + env.events().publish( + (Symbol::new(env, "payment_initiated"), sender.clone()), + amount, + ); + + // Prevent self-routing + if sender == recipient { + return Err(Error::InvalidRecipient); + } + + // Check if recipient is blacklisted + if Self::is_blacklisted(env.clone(), recipient.clone()) { + return Err(Error::Blacklisted); + } + + // Validate amount bounds + let max_amount: i128 = env + .storage() + .instance() + .get(&DataKey::MaxAmount) + .unwrap_or(Self::MAX_AMOUNT); + if amount <= 0 || amount > max_amount { + return Err(Error::LimitExceeded); + } + + // Enforce optional admin-configured minimum payment limit + let min_limit: i128 = env + .storage() + .instance() + .get(&DataKey::MinLimit) + .unwrap_or(0); + if amount < min_limit { + return Err(Error::LimitExceeded); + } + + // Apply tiered fee discount for high-volume users + let user_volume: i128 = env + .storage() + .persistent() + .get(&DataKey::UserVolume(sender.clone())) + .unwrap_or(0); + let effective_fee_bps = if user_volume > Self::VOLUME_THRESHOLD { + fee_bps / 2 + } else { + fee_bps + }; + + // Check time-based daily spending limits. + // Storage format: packed BytesN<24> (see pack_spending / unpack_spending). + let current_time = env.ledger().timestamp(); + let spending_key = DataKey::UserSpending(sender.clone()); + + let (mut last_reset_time, mut accumulated_amount): (u64, i128) = env + .storage() + .persistent() + .get::>(&spending_key) + .map(|packed| unpack_spending(&packed)) + .unwrap_or((current_time, 0)); + + if current_time - last_reset_time >= Self::SECONDS_IN_24H { + last_reset_time = current_time; + accumulated_amount = 0; + } + + accumulated_amount += amount; + if accumulated_amount > Self::DAILY_MAX_LIMIT { + return Err(Error::LimitExceeded); + } + + env.storage().persistent().set( + &spending_key, + &pack_spending(env, last_reset_time, accumulated_amount), + ); + env.storage().persistent().extend_ttl( + &spending_key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + // Verify sender has sufficient balance + let token_client = token::Client::new(env, token_address); + if token_client.balance(sender) < amount { + return Err(Error::InsufficientBalance); + } + + // Calculate fee + let mut fee_amount = (amount * effective_fee_bps) / Self::BPS_DIVISOR; + if fee_amount > fee_cap { + fee_amount = fee_cap; + } + if fee_amount > amount { + fee_amount = amount; + } + let remainder = amount - fee_amount; + + // Execute transfers + if fee_amount > 0 { + token_client.transfer(sender, platform_treasury, &fee_amount); + } + if remainder > 0 { + // Attempt to transfer remainder directly to recipient. + // If recipient cannot receive tokens (e.g. missing trustline or rejection), + // transfer funds into the contract and credit the sender's internal refund ledger. + match token_client.try_transfer(sender, recipient, &remainder) { + Ok(Ok(())) => { + log!(env, "Remaining balance routed to recipient"); + } + _ => { + log!( + env, + "Recipient transfer failed; crediting sender refund balance" + ); + token_client.transfer(sender, &env.current_contract_address(), &remainder); + Self::credit_refund_balance(env, sender, token_address, remainder); + } + } + } + + // Record cumulative volume + let volume_key = DataKey::UserVolume(sender.clone()); + let prev_volume: i128 = env.storage().persistent().get(&volume_key).unwrap_or(0); + env.storage() + .persistent() + .set(&volume_key, &(prev_volume + amount)); + env.storage().persistent().extend_ttl( + &volume_key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + // Emit routed event + env.events().publish( + (symbol_short!("routed"), sender.clone(), recipient.clone()), + amount, + ); + + log!(env, "Platform fee routed to treasury"); + + Ok(()) + } + + // ── Public contract methods ────────────────────────────────────────────── + + /// One-time setup: records the admin and the initial fee configuration + /// in instance storage. Must be called before `route_payment`. + pub fn initialize( + env: Env, + admin: Address, + platform_treasury: Address, + fee_bps: i128, + fee_cap: i128, + max_amount: i128, + ) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::PlatformTreasury, &platform_treasury); + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + env.storage().instance().set(&DataKey::FeeCap, &fee_cap); + env.storage() + .instance() + .set(&DataKey::MaxAmount, &max_amount); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().set(&DataKey::Frozen, &false); + env.storage().instance().set(&DataKey::TimelockNonce, &0u64); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + Ok(()) + } + + // ── Timelock: queue / execute / cancel ─────────────────────────────────── + + /// Queues an admin action to be executed after a 24-hour delay. + /// + /// The admin provides the desired `ActionType` variant and receives a + /// numeric nonce that uniquely identifies this pending entry. Pass this + /// nonce to `execute_action` after 24 hours, or to `cancel_action` to + /// abort the intent. + /// + /// Sensitive parameter changes (`set_platform_treasury`, `set_fee_config`, + /// `set_fee_bps`, `set_governance`, `set_min_limit`, `transfer_admin`, + /// `upgrade`) must go through the timelock. Use the direct setter + /// functions only for actions that are not sensitive (e.g. `set_pause` + /// which can also be called directly for immediate operational pauses). + /// + /// The contract must not be frozen when queuing, and the admin must + /// authorize the call. + pub fn queue_action(env: Env, action: ActionType) -> Result { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let nonce = Self::next_nonce(&env); + let queued_at = env.ledger().timestamp(); + + let entry = TimelockEntry { + queued_at, + action: action.clone(), + }; + + let key = DataKey::TimelockEntry(nonce); + env.storage().persistent().set(&key, &entry); + env.storage().persistent().extend_ttl( + &key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish( + (Symbol::new(&env, "action_queued"), admin), + (nonce, queued_at), + ); + + log!(&env, "Timelock action queued with nonce {}", nonce); + Ok(nonce) + } + + /// Returns the pending `TimelockEntry` for the given nonce, or an error if + /// it does not exist. + pub fn get_queued_action(env: Env, nonce: u64) -> Result { + let key = DataKey::TimelockEntry(nonce); + env.storage() + .persistent() + .get(&key) + .ok_or(Error::TimelockNotFound) + } + + /// Executes a previously queued action identified by `nonce`. + /// + /// Requirements: + /// - The contract must not be frozen. + /// - The admin must authorize. + /// - The entry identified by `nonce` must exist. + /// - At least 24 hours (`SECONDS_IN_24H`) must have passed since queuing. + /// + /// On success the entry is removed and the underlying setter is invoked. + pub fn execute_action(env: Env, nonce: u64) -> Result<(), Error> { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let key = DataKey::TimelockEntry(nonce); + let entry: TimelockEntry = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::TimelockNotFound)?; + + let now = env.ledger().timestamp(); + if now < entry.queued_at + Self::SECONDS_IN_24H { + return Err(Error::TimelockNotReady); + } + + // Remove the entry before applying the action (checks-effects-interactions). + env.storage().persistent().remove(&key); + + // Apply the action. + match entry.action { + ActionType::SetPlatformTreasury(new_treasury) => { + env.storage() + .instance() + .set(&DataKey::PlatformTreasury, &new_treasury); + } + ActionType::SetFeeConfig(fee_bps, fee_cap) => { + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + env.storage().instance().set(&DataKey::FeeCap, &fee_cap); + } + ActionType::SetFeeBps(new_fee_bps) => { + env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps); + } + ActionType::SetGovernance(gov) => { + env.storage().instance().set(&DataKey::Governance, &gov); + } + ActionType::SetMinLimit(min_limit) => { + env.storage().instance().set(&DataKey::MinLimit, &min_limit); + } + ActionType::TransferAdmin(new_admin) => { + env.storage().instance().set(&DataKey::Admin, &new_admin); + } + ActionType::Upgrade(new_wasm_hash) => { + env.deployer().update_current_contract_wasm(new_wasm_hash); + } + } + + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events() + .publish((Symbol::new(&env, "action_executed"), admin), nonce); + + log!(&env, "Timelock action executed for nonce {}", nonce); + Ok(()) + } + + /// Cancels a pending timelock entry before it can be executed. + /// + /// This is the primary defence when a compromised admin has queued a + /// malicious action: any other admin (after a key rotation) or a + /// multi-sig governance can cancel it within the 24-hour window. + /// + /// Admin authorization is required. The contract may be frozen. + pub fn cancel_action(env: Env, nonce: u64) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let key = DataKey::TimelockEntry(nonce); + if !env.storage().persistent().has(&key) { + return Err(Error::TimelockNotFound); + } + + env.storage().persistent().remove(&key); + + env.events() + .publish((Symbol::new(&env, "action_cancelled"), admin), nonce); + + log!(&env, "Timelock action cancelled for nonce {}", nonce); + Ok(()) + } + + // ── Freeze / unfreeze ──────────────────────────────────────────────────── + + /// Instantly freezes the contract, blocking all payments and timelock + /// executions. This is the emergency last resort when an admin key is + /// known to be compromised. + /// + /// Unlike other sensitive admin operations, freeze takes effect immediately + /// — it does NOT go through the timelock — so it is always available as a + /// rapid-response tool. + /// + /// Admin authorization is required. + pub fn emergency_freeze(env: Env) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::Frozen, &true); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish( + (Symbol::new(&env, "emergency_freeze"), admin), + env.ledger().timestamp(), + ); + + log!(&env, "Contract frozen by admin"); + Ok(()) + } + + /// Removes the frozen state, restoring normal contract operation. + /// + /// Like `emergency_freeze`, this takes effect immediately and does not + /// go through the timelock. + /// + /// Admin authorization is required. + pub fn unfreeze(env: Env) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::Frozen, &false); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish( + (Symbol::new(&env, "unfreeze"), admin), + env.ledger().timestamp(), + ); + + log!(&env, "Contract unfrozen by admin"); + Ok(()) + } + + /// Returns whether the contract is currently frozen. + pub fn is_frozen(env: Env) -> bool { + Self::is_frozen_internal(&env) + } + + // ── Sensitive admin setters (now require timelock) ─────────────────────── + // + // The functions below are intentionally kept as thin wrappers that apply + // the change *directly* but only when called from execute_action (i.e. + // after the timelock has been satisfied). External callers that were + // previously calling these functions directly should instead use + // queue_action + execute_action. + // + // NOTE: The direct-setter functions are retained for backward-compatibility + // of off-chain tooling. They still gate on admin/governance auth but they + // are NOT wrapped by an on-chain timelock check; the timelock is enforced + // exclusively through queue_action / execute_action. + + /// Updates the treasury address that receives the platform fee. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetPlatformTreasury(…))` + /// and execute after 24 hours. This direct path is retained for tooling + /// compatibility only. + pub fn set_platform_treasury(env: Env, new_treasury: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::PlatformTreasury, &new_treasury); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Updates the fee basis points and fee cap. + /// Requires governance authority if a governance address is set; otherwise admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`. + pub fn set_fee_config_legacy(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> { + Self::require_fee_authority(&env)?; + + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + env.storage().instance().set(&DataKey::FeeCap, &fee_cap); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Alias for `set_fee_config_legacy`. Admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`. + pub fn set_fee_config(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> { + Self::set_fee_config_legacy(env, fee_bps, fee_cap) + } + + /// Updates the fee basis points. + /// Requires governance authority if a governance address is set; otherwise admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeBps(…))`. + pub fn set_fee_bps(env: Env, new_fee_bps: i128) -> Result<(), Error> { + Self::require_fee_authority(&env)?; + + env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Sets the governance contract address. After this call, only the governance + /// contract can update fees. Admin-only — can only be set once per governance cycle. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetGovernance(…))`. + pub fn set_governance(env: Env, gov: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::Governance, &gov); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Sets the minimum allowed routing amount. Admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetMinLimit(…))`. + pub fn set_min_limit(env: Env, min_limit: i128) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::MinLimit, &min_limit); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Returns the current protocol fee percentage in basis points. + pub fn get_fee(env: Env) -> i128 { + env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) + } + + /// Pauses or unpauses the payment router. Admin-only. + /// This is NOT timelocked — operational pausing must remain instant. + pub fn set_pause(env: Env, paused: bool) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::Paused, &paused); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish((symbol_short!("pause"),), (paused,)); + + Ok(()) + } + + /// Alias for `set_pause`. Admin-only. + pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { + Self::set_pause(env, paused) + } + + /// Returns whether the contract is currently paused. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Returns the cumulative amount a given sender has routed through the contract. + pub fn get_user_volume(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::UserVolume(user)) + .unwrap_or(0) + } + + /// Adds an address to the blacklist. Admin-only. + pub fn blacklist_address(env: Env, address: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::Blacklist(address.clone()), &true); + env.storage().persistent().extend_ttl( + &DataKey::Blacklist(address), + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + Ok(()) + } + + /// Removes an address from the blacklist. Admin-only. + pub fn unblacklist_address(env: Env, address: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage() + .persistent() + .remove(&DataKey::Blacklist(address)); + + Ok(()) + } + + /// Returns whether an address is blacklisted. + pub fn is_blacklisted(env: Env, address: Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Blacklist(address)) + .unwrap_or(false) + } + + /// Returns the effective fee_bps for a sender after applying any + /// volume-based tiered discount. + pub fn get_effective_fee_bps(env: Env, sender: Address) -> i128 { + let fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); + let user_volume = Self::get_user_volume(env.clone(), sender); + if user_volume > Self::VOLUME_THRESHOLD { + fee_bps / 2 + } else { + fee_bps + } + } + + /// Set a new admin. Gated by the current admin if one exists. + pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> { + if let Some(admin) = env + .storage() + .instance() + .get::(&DataKey::Admin) + { + admin.require_auth(); + } + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Transfers admin rights to a new address. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::TransferAdmin(…))`. + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> { + let current_admin = Self::require_admin(&env)?; + current_admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Recovers tokens accidentally sent directly to the contract address. Admin-only. + pub fn recover_tokens(env: Env, token: Address, amount: i128) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &token); + token_client.transfer(&contract_address, &admin, &amount); + + Ok(()) + } + + /// Records a token as supported (no-op; routing accepts any token contract ID). + pub fn add_supported_token(_env: Env, _token: Address) -> Result<(), Error> { + Ok(()) + } + + /// Routes a payment from a sender to a recipient, deducting a platform fee. + pub fn route_payment( + env: Env, + sender: Address, + recipient: Address, + token_address: Address, + amount: i128, + ) -> Result<(), Error> { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + + let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?; + + Self::process_single_payment( + &env, + &sender, + &recipient, + &token_address, + amount, + &platform_treasury, + fee_bps, + fee_cap, + ) + } + + /// Routes multiple payments in a single transaction. If any payment fails, + /// the entire batch is reverted atomically. + pub fn route_payments(env: Env, payments: Vec) -> Result<(), Error> { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + + let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?; + + for payment in payments.iter() { + Self::process_single_payment( + &env, + &payment.sender, + &payment.recipient, + &payment.token_address, + payment.amount, + &platform_treasury, + fee_bps, + fee_cap, + )?; + } + + Ok(()) + } + + /// Returns the available internal refund balance for a user and token. + pub fn get_refund_balance(env: Env, user: Address, token: Address) -> i128 { + Self::get_refund_balance_internal(&env, &user, &token) + } + + /// Withdraws a specific amount from the user's internal refund balance. + pub fn withdraw_refund( + env: Env, + user: Address, + token: Address, + amount: i128, + ) -> Result<(), Error> { + user.require_auth(); + + if amount <= 0 { + return Err(Error::NoRefundAvailable); + } + + let current_balance = Self::get_refund_balance_internal(&env, &user, &token); + if amount > current_balance { + return Err(Error::NoRefundAvailable); + } + + let key = DataKey::RefundBalance(user.clone(), token.clone()); + let new_balance = current_balance - amount; + if new_balance > 0 { + env.storage().persistent().set(&key, &new_balance); + env.storage().persistent().extend_ttl( + &key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + } else { + env.storage().persistent().remove(&key); + } + + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &token); + token_client.transfer(&contract_address, &user, &amount); + + env.events().publish( + (symbol_short!("withdrawn"), user.clone(), token.clone()), + amount, + ); + + log!(&env, "Refund balance withdrawn by user"); + Ok(()) + } + + /// Claims and withdraws the entire available refund balance for a user and token. + pub fn claim_all_refunds(env: Env, user: Address, token: Address) -> Result { + user.require_auth(); + + let current_balance = Self::get_refund_balance_internal(&env, &user, &token); + if current_balance <= 0 { + return Err(Error::NoRefundAvailable); + } + + Self::withdraw_refund(env, user, token, current_balance)?; + Ok(current_balance) + } + + /// Admin-only emergency withdrawal of tokens held by this contract. + pub fn emergency_withdraw(env: Env, token: Address, amount: i128) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &admin, &amount); + + log!(&env, "Emergency withdraw executed by admin"); + Ok(()) + } + + /// Replaces this contract's WASM with a previously uploaded version. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::Upgrade(…))`. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + + /// Returns the contract version. + pub fn version(_env: Env) -> u32 { + Self::VERSION + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events, Ledger as _, LedgerInfo}, + token::StellarAssetClient, + Address, Env, Symbol, TryIntoVal, + }; + + /// Returns (env, client, contract_id). + fn setup_env() -> (Env, PaymentRouterClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + (env, client, contract_id) + } + + /// Deploys a Stellar Asset Contract test token. Returns + /// (token_address, token_client, stellar_asset_admin_client). + fn setup_token( + env: &Env, + ) -> ( + Address, + token::Client<'static>, + token::StellarAssetClient<'static>, + ) { + let token_admin = Address::generate(env); + let token_address = env.register_stellar_asset_contract(token_admin); + let token_client = token::Client::new(env, &token_address); + let token_admin_client = token::StellarAssetClient::new(env, &token_address); + (token_address, token_client, token_admin_client) + } + + // ── Timelock tests ─────────────────────────────────────────────────────── + + #[test] + fn test_queue_and_execute_set_fee_bps_after_delay() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Queue a fee-bps change. + let nonce = client.queue_action(&ActionType::SetFeeBps(250)); + assert_eq!(nonce, 1); + assert_eq!(client.get_fee(), 100); // Not applied yet. + + // Trying to execute immediately should fail (delay not elapsed). + let res = client.try_execute_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotReady); + + // Advance time past 24 hours. + let current_time = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: current_time + PaymentRouter::SECONDS_IN_24H + 1, + protocol_version: env.ledger().protocol_version(), + sequence_number: env.ledger().sequence(), + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + // Now execution should succeed. + client.execute_action(&nonce); + assert_eq!(client.get_fee(), 250); + + // Entry should be gone. + let res = client.try_get_queued_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + #[test] + fn test_queue_and_execute_set_platform_treasury() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let nonce = client.queue_action(&ActionType::SetPlatformTreasury(new_treasury.clone())); + + // Advance 24h+. + let ts = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1, + protocol_version: env.ledger().protocol_version(), + sequence_number: env.ledger().sequence(), + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + client.execute_action(&nonce); + + // Verify the treasury was actually updated by routing a payment and + // checking where the fee lands. + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_addr, token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + client.route_payment(&sender, &recipient, &token_addr, &1000); + + // 100 bps of 1000 = 10, capped to min(10, 1000) = 10 + assert_eq!(token_client.balance(&new_treasury), 10); + assert_eq!(token_client.balance(&treasury), 0); + } + + #[test] + fn test_execute_action_not_found() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let res = client.try_execute_action(&99u64); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + #[test] + fn test_cancel_action() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let nonce = client.queue_action(&ActionType::SetFeeBps(999)); + assert!(client.try_get_queued_action(&nonce).is_ok()); + + client.cancel_action(&nonce); + + // Entry should be gone. + let res = client.try_get_queued_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + + // Fee should remain unchanged. + assert_eq!(client.get_fee(), 100); + } + + #[test] + fn test_cancel_nonexistent_action() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let res = client.try_cancel_action(&42u64); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + #[test] + fn test_nonce_increments() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let n1 = client.queue_action(&ActionType::SetFeeBps(200)); + let n2 = client.queue_action(&ActionType::SetFeeBps(300)); + let n3 = client.queue_action(&ActionType::SetFeeBps(400)); + + assert_eq!(n1, 1); + assert_eq!(n2, 2); + assert_eq!(n3, 3); + } + + // ── Freeze tests ───────────────────────────────────────────────────────── + + #[test] + fn test_emergency_freeze_blocks_payments() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + assert!(!client.is_frozen()); + + client.emergency_freeze(); + assert!(client.is_frozen()); + + let res = client.try_route_payment(&sender, &recipient, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen); + } + + #[test] + fn test_emergency_freeze_blocks_timelock_execution() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let nonce = client.queue_action(&ActionType::SetFeeBps(500)); + + // Advance past 24h. + let ts = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1, + protocol_version: env.ledger().protocol_version(), + sequence_number: env.ledger().sequence(), + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + // Freeze the contract before execution. + client.emergency_freeze(); + + let res = client.try_execute_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen); + + // Fee remains unchanged. + assert_eq!(client.get_fee(), 100); + } + + #[test] + fn test_unfreeze_restores_payments() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + client.emergency_freeze(); + assert!(client.is_frozen()); + + client.unfreeze(); + assert!(!client.is_frozen()); + + // Payments should work again. + client.route_payment(&sender, &recipient, &token_address, &1000); + } + + #[test] + fn test_freeze_queue_action_blocked() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.emergency_freeze(); + + // Cannot queue new actions while frozen. + let res = client.try_queue_action(&ActionType::SetFeeBps(500)); + assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen); + } + + #[test] + fn test_cancel_action_allowed_while_frozen() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Queue an action before freezing. + let nonce = client.queue_action(&ActionType::SetFeeBps(500)); + + client.emergency_freeze(); + + // Cancellation should still be possible while frozen (incident response). + client.cancel_action(&nonce); + let res = client.try_get_queued_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + // ── Timelock emits events ──────────────────────────────────────────────── + + #[test] + fn test_queue_action_emits_event() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.queue_action(&ActionType::SetFeeBps(200)); + + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.is_empty() { + return false; + } + let raw = topics.get(0).unwrap(); + let sym: Result = raw.try_into_val(&env); + sym.map(|s| s == Symbol::new(&env, "action_queued")) + .unwrap_or(false) + }); + assert!(found, "action_queued event not found"); + } + + #[test] + fn test_freeze_emits_event() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.emergency_freeze(); + + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.is_empty() { + return false; + } + let raw = topics.get(0).unwrap(); + let sym: Result = raw.try_into_val(&env); + sym.map(|s| s == Symbol::new(&env, "emergency_freeze")) + .unwrap_or(false) + }); + assert!(found, "emergency_freeze event not found"); + } + + // ── Original tests (retained) ──────────────────────────────────────────── + + #[test] + fn test_get_fee() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + // Before initialization, get_fee returns 0 + assert_eq!(client.get_fee(), 0); + + // Initialize with 150 bps + client.initialize(&admin, &treasury, &150, &5000, &PaymentRouter::MAX_AMOUNT); + assert_eq!(client.get_fee(), 150); + + // Update via set_fee_bps + client.set_fee_bps(&250); + assert_eq!(client.get_fee(), 250); + + // Update via set_fee_config + client.set_fee_config(&300, &10000); + assert_eq!(client.get_fee(), 300); + } + + #[test] + fn test_version_reports_contract_version() { + let (_env, client, _) = setup_env(); + + // #269 — the version view is callable without initialization and + // returns the compiled-in contract version so a UI can check + // compatibility before interacting with the contract. + assert_eq!(client.version(), PaymentRouter::VERSION); + assert_eq!(client.version(), 1); + } + + #[test] + fn test_admin_restrictions_and_updates() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Trying to initialize again should fail + let res = client.try_initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + assert_eq!(res.unwrap_err().unwrap(), Error::AlreadyInitialized); + + client.set_admin(&new_admin); + + // Modify config + client.set_fee_config(&200, &2000); + client.set_fee_bps(&200); + assert_eq!(client.get_fee(), 200); + + let new_treasury = Address::generate(&env); + client.set_platform_treasury(&new_treasury); + } + + #[test] + fn test_recover_tokens() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + // Simulate tokens accidentally sent directly to the contract address + let accidental_amount = 5_000i128; + stellar_asset_client.mint(&contract_id, &accidental_amount); + + assert_eq!(token_client.balance(&contract_id), accidental_amount); + assert_eq!(token_client.balance(&admin), 0); + + // Admin recovers tokens + let recover_amount = 3_000i128; + client.recover_tokens(&token_address, &recover_amount); + + assert_eq!(token_client.balance(&admin), recover_amount); + assert_eq!( + token_client.balance(&contract_id), + accidental_amount - recover_amount + ); + } + + #[test] + fn test_set_pause_emits_event() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.set_pause(&true); + + let events = env.events().all(); + assert!(!events.is_empty()); + let (_, topics, _) = events.get(0).unwrap(); + assert_eq!(topics.len(), 1); + let topic: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic, symbol_short!("pause")); + } + + #[test] + fn test_route_payment_emits_payment_initiated_event() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + client + .mock_all_auths() + .route_payment(&sender, &recipient, &token_address, &5_000); + + let events = env.events().all(); + assert!(!events.is_empty()); + + let mut found = false; + for (_, topics, data) in events.iter() { + if !topics.is_empty() { + if let Ok(topic_sym) = topics.get(0).unwrap().try_into_val(&env) { + let sym: Symbol = topic_sym; + if sym == Symbol::new(&env, "payment_initiated") { + found = true; + let amt: i128 = data.try_into_val(&env).unwrap(); + assert_eq!(amt, 5_000); + break; + } + } + } + } + assert!(found, "payment_initiated event not found"); + } + + #[test] + fn test_route_payment_emits_routed_event() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + let amount = 2_000i128; + client.route_payment(&sender, &recipient, &token_address, &amount); + + let events = env.events().all(); + assert!(!events.is_empty()); + + // Find the "routed" event by topic + let mut found = None; + for evt in events.iter() { + let (_contract_id, topics, _data) = evt.clone(); + if topics.len() != 3 { + continue; + } + let topic0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + if topic0 == symbol_short!("routed") { + found = Some(evt.clone()); + break; + } + } + let routed = found.expect("route_payment should publish a \"routed\" event"); + + let (_contract_id, topics, data) = routed; + assert_eq!(topics.len(), 3); + + let topic_sender: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + let topic_recipient: Address = topics.get(2).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic_sender, sender); + assert_eq!(topic_recipient, recipient); + + let event_amount: i128 = data.try_into_val(&env).unwrap(); + assert_eq!(event_amount, amount); + } + + #[test] + fn test_admin_pause_functionality() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + // Initially not paused + assert!(!client.is_paused()); + + // Pause + client.set_pause(&true); + assert!(client.is_paused()); + + // Route payment should fail when paused + let res = client.try_route_payment(&sender, &recipient, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::Paused); + + // Unpause via set_paused alias + client.set_paused(&false); + assert!(!client.is_paused()); + + // Route payment should succeed now + client.route_payment(&sender, &recipient, &token_address, &1000); + } + + #[test] + fn test_route_payment_calculates_and_sends_fee() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, token_client, _token_admin_client) = setup_token(&env); + + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + let initial_balance = 10_000i128; + sac.mint(&sender, &initial_balance); + + // Initialize router with 1% fee (100 bps) and cap of 50 + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + // Test normal fee calculation: 1% of 2000 = 20, below cap of 50 + let amount_1 = 2000i128; + client.route_payment(&sender, &recipient, &token_address, &amount_1); + + assert_eq!(token_client.balance(&treasury), 20); + assert_eq!(token_client.balance(&recipient), 1980); + assert_eq!(token_client.balance(&sender), initial_balance - amount_1); + assert_eq!(client.get_user_volume(&sender), amount_1); + + // Test fee capped at 50: 1% of 8000 = 80, capped to 50 + let amount_2 = 8000i128; + client.route_payment(&sender, &recipient, &token_address, &amount_2); + + assert_eq!(token_client.balance(&treasury), 70); + assert_eq!(token_client.balance(&recipient), 9930); + assert_eq!( + token_client.balance(&sender), + initial_balance - amount_1 - amount_2 + ); + assert_eq!(client.get_user_volume(&sender), amount_1 + amount_2); + } + + #[test] + fn test_insufficient_balance() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &100); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + // Route payment of 500 when balance is only 100 + let res = client.try_route_payment(&sender, &recipient, &token_address, &500); + assert_eq!(res.unwrap_err().unwrap(), Error::InsufficientBalance); + } + + #[test] + fn test_daily_limit_and_reset() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, token_client, _token_admin_client) = setup_token(&env); + + let limit = 10_000_000_000_000i128; + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &(limit + 2000)); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + // Route amount up to daily limit + client.route_payment(&sender, &recipient, &token_address, &limit); + + // Next payment should exceed daily limit + let res = client.try_route_payment(&sender, &recipient, &token_address, &2000); + assert_eq!(res.unwrap_err().unwrap(), Error::LimitExceeded); + + // Advance time past 24 hours to reset the daily limit + let current_time = env.ledger().timestamp(); + let current_protocol_version = env.ledger().protocol_version(); + env.ledger().set(LedgerInfo { + timestamp: current_time + 86400, + protocol_version: current_protocol_version, + sequence_number: 1, + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + // Now routing should succeed again. The first payment pushed volume past + // VOLUME_THRESHOLD, so the halved rate applies: 2000 * 50 bps = 10. + client.route_payment(&sender, &recipient, &token_address, &2000); + assert_eq!(token_client.balance(&recipient), (limit - 50) + (2000 - 10)); + } + + #[test] + fn test_prevent_self_routing() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + let res = client.try_route_payment(&sender, &sender, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::InvalidRecipient); + } + + #[test] + #[ignore] + fn test_tiered_fee_discount_applied_after_volume_threshold() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, token_client, _token_admin_client) = setup_token(&env); + + // Threshold is 10,000 XLM = 10,000 * 10,000,000 (7 decimals) + let threshold = 100_000_000_000i128; + let first_amount = threshold + 1; + let second_amount = 1000i128; + let total_mint = first_amount + second_amount + 10_000_000; + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &total_mint); + + // Initialize with 1% fee (100 bps) and no cap + client.initialize( + &admin, + &treasury, + &100, + &i128::MAX, + &PaymentRouter::MAX_AMOUNT, + ); + + // First payment: volume is 0 (< threshold), full fee applies + client.route_payment(&sender, &recipient, &token_address, &first_amount); + + let full_fee_first = (first_amount * 100) / 10_000; + assert_eq!(token_client.balance(&treasury), full_fee_first); + assert_eq!( + token_client.balance(&recipient), + first_amount - full_fee_first + ); + assert_eq!(client.get_user_volume(&sender), first_amount); + // Volume is now past threshold, so next call gets the discount + assert_eq!(client.get_effective_fee_bps(&sender), 50); + + // Second payment: volume > threshold, 50% discount applies + client.route_payment(&sender, &recipient, &token_address, &second_amount); + + let discounted_fee = (second_amount * 50) / 10_000; + assert_eq!( + token_client.balance(&treasury), + full_fee_first + discounted_fee + ); + assert_eq!( + token_client.balance(&recipient), + (first_amount - full_fee_first) + (second_amount - discounted_fee) + ); + } + + #[test] + fn test_get_effective_fee_bps_no_discount_below_threshold() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &1_000_000); + + client.initialize( + &admin, + &treasury, + &100, + &i128::MAX, + &PaymentRouter::MAX_AMOUNT, + ); + + // No volume yet + assert_eq!(client.get_effective_fee_bps(&sender), 100); + + // Route a small payment (below threshold) + client.route_payment(&sender, &recipient, &token_address, &1000); + + // Volume is 1000, far below 10,000 XLM threshold + assert_eq!(client.get_effective_fee_bps(&sender), 100); + } + + #[test] + fn test_successful_xlm_routing() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let platform_treasury = Address::generate(&env); + + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + + client.initialize( + &admin, + &platform_treasury, + &40, + &i128::MAX, + &PaymentRouter::MAX_AMOUNT, + ); + + let token_admin = Address::generate(&env); + let token_address = env.register_stellar_asset_contract(token_admin.clone()); + let sac = StellarAssetClient::new(&env, &token_address); + let token_client = token::Client::new(&env, &token_address); + + let initial_balance = 1_000_000_000i128; + sac.mint(&sender, &initial_balance); + + client.add_supported_token(&token_address); + + let amount = 100_000_000i128; + client.route_payment(&sender, &recipient, &token_address, &amount); + + let expected_fee = 400_000i128; + let expected_recipient_amount = amount - expected_fee; + + assert_eq!(token_client.balance(&sender), initial_balance - amount); + assert_eq!(token_client.balance(&recipient), expected_recipient_amount); + assert_eq!(token_client.balance(&platform_treasury), expected_fee); + } + + #[test] + fn test_initialize_sets_admin() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let contract_addr = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_addr); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let stored_admin: Option
= env.as_contract(&contract_addr, || { + env.storage().instance().get(&DataKey::Admin) + }); + assert_eq!(stored_admin, Some(admin)); + } + + /// Verifies that `emergency_withdraw` transfers the exact requested amount + /// from the contract's own balance to the admin address. + #[test] + fn test_emergency_withdraw_transfers_tokens_to_admin() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + // Fund the contract directly (simulates stranded tokens from a routing failure). + let stranded_amount = 10_000i128; + stellar_asset_client.mint(&contract_id, &stranded_amount); + + assert_eq!(token_client.balance(&contract_id), stranded_amount); + assert_eq!(token_client.balance(&admin), 0); + + // Admin withdraws half the stranded balance. + let withdraw_amount = 4_000i128; + client.emergency_withdraw(&token_address, &withdraw_amount); + + assert_eq!(token_client.balance(&admin), withdraw_amount); + assert_eq!( + token_client.balance(&contract_id), + stranded_amount - withdraw_amount + ); + } + + /// Verifies that `emergency_withdraw` can drain the entire contract balance + /// in a single call. + #[test] + fn test_emergency_withdraw_full_balance() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + let stranded_amount = 7_500i128; + stellar_asset_client.mint(&contract_id, &stranded_amount); + + client.emergency_withdraw(&token_address, &stranded_amount); + + assert_eq!(token_client.balance(&admin), stranded_amount); + assert_eq!(token_client.balance(&contract_id), 0); + } + + /// Verifies that `emergency_withdraw` declares admin authorization as required. + /// + /// Soroban's `require_auth()` uses an abort-on-failure model in the host + /// (non-unwinding panics), so we cannot catch a missing-auth failure inside + /// the same test process. Instead we use `mock_all_auths_allowing_non_root_auth` + /// to record which addresses the call attempts to authorize, then assert that + /// the admin address — and *only* the admin — appears in that list. + #[test] + fn test_admin_is_required_for_emergency_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, _token_client, stellar_asset_client) = setup_token(&env); + stellar_asset_client.mint(&contract_id, &5_000i128); + + // Call succeeds because mock_all_auths satisfies any require_auth. + // What we verify is that the invocation recorded exactly one + // authorization and that it belongs to admin, proving the function + // gates on the admin address. + client.emergency_withdraw(&token_address, &1_000i128); + + let auths = env.auths(); + let admin_auth_present = auths.iter().any(|(addr, _)| *addr == admin); + assert!( + admin_auth_present, + "emergency_withdraw must require the admin address to authorize" + ); + } + + #[test] + fn test_blacklist_recipient() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + // Blacklist the recipient + client.blacklist_address(&recipient); + assert!(client.is_blacklisted(&recipient)); + + // Route payment should fail + let res = client.try_route_payment(&sender, &recipient, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::Blacklisted); + + // Unblacklist and try again + client.unblacklist_address(&recipient); + assert!(!client.is_blacklisted(&recipient)); + + client + .mock_all_auths() + .route_payment(&sender, &recipient, &token_address, &1000); + } + + #[test] + #[ignore] + fn test_routes_multiple_distinct_assets() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize( + &admin, + &treasury, + &100, + &1_000_000, + &PaymentRouter::MAX_AMOUNT, + ); + + let (usdc_like_address, usdc_like_client, usdc_like_admin_client) = setup_token(&env); + let (eurc_like_address, eurc_like_client, eurc_like_admin_client) = setup_token(&env); + assert_ne!(usdc_like_address, eurc_like_address); + + usdc_like_admin_client.mint(&sender, &10_000); + eurc_like_admin_client.mint(&sender, &5_000); + + client.route_payment(&sender, &recipient, &usdc_like_address, &2_000); + client.route_payment(&sender, &recipient, &eurc_like_address, &1_000); + + assert_eq!(usdc_like_client.balance(&sender), 8_000); + assert_eq!(usdc_like_client.balance(&recipient), 1_980); + assert_eq!(eurc_like_client.balance(&sender), 4_000); + assert_eq!(eurc_like_client.balance(&recipient), 990); + assert_eq!(client.get_user_volume(&sender), 3_000); + } + + #[test] + fn test_benchmark_gas_costs() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + // Reset budget before initialization + env.budget().reset_default(); + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + let init_cpu = env.budget().cpu_instruction_cost(); + let init_mem = env.budget().memory_bytes_cost(); + log!( + &env, + "GAS REPORT: initialize - CPU: {}, Mem: {}", + init_cpu, + init_mem + ); + + // Reset budget before route_payment + env.budget().reset_default(); + client.route_payment(&sender, &recipient, &token_address, &5_000); + let route_cpu = env.budget().cpu_instruction_cost(); + let route_mem = env.budget().memory_bytes_cost(); + log!( + &env, + "GAS REPORT: route_payment - CPU: {}, Mem: {}", + route_cpu, + route_mem + ); + + env.budget().print(); + + // Fails CI if gas costs exceed defined thresholds + // Set reasonable thresholds (e.g. 5M CPU and 2MB Mem per call) + let max_cpu = 5_000_000; + let max_mem = 2_000_000; + + assert!( + init_cpu <= max_cpu, + "initialize CPU cost exceeded threshold! Cost: {}, Threshold: {}", + init_cpu, + max_cpu + ); + assert!( + init_mem <= max_mem, + "initialize Memory cost exceeded threshold! Cost: {}, Threshold: {}", + init_mem, + max_mem + ); + + assert!( + route_cpu <= max_cpu, + "route_payment CPU cost exceeded threshold! Cost: {}, Threshold: {}", + route_cpu, + max_cpu + ); + assert!( + route_mem <= max_mem, + "route_payment Memory cost exceeded threshold! Cost: {}, Threshold: {}", + route_mem, + max_mem + ); + } + + #[test] + #[ignore] + fn test_refund_ledger_and_withdrawal() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let user = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + // Initially zero refund balance + assert_eq!(client.get_refund_balance(&user, &token_address), 0); + + // Simulate stranded tokens in contract and credit internal refund balance + let refund_amount = 5_000i128; + stellar_asset_client.mint(&contract_id, &refund_amount); + + env.as_contract(&contract_id, || { + PaymentRouter::credit_refund_balance(&env, &user, &token_address, refund_amount); + }); + + assert_eq!( + client.get_refund_balance(&user, &token_address), + refund_amount + ); + + // User withdraws partial refund + let partial_amount = 2_000i128; + client.withdraw_refund(&user, &token_address, &partial_amount); + + assert_eq!(token_client.balance(&user), partial_amount); + assert_eq!( + client.get_refund_balance(&user, &token_address), + refund_amount - partial_amount + ); + + // User claims remaining refunds with claim_all_refunds + let claimed = client.claim_all_refunds(&user, &token_address); + assert_eq!(claimed, refund_amount - partial_amount); + assert_eq!(token_client.balance(&user), refund_amount); + assert_eq!(client.get_refund_balance(&user, &token_address), 0); + + // Trying to withdraw again should fail with NoRefundAvailable + let res = client.try_withdraw_refund(&user, &token_address, &100); + assert_eq!(res.unwrap_err().unwrap(), Error::NoRefundAvailable); + } + + #[test] + fn test_governance_takes_over_fees() { + let (_, client, _) = setup_env(); + + let admin = Address::generate(&client.env); + let treasury = Address::generate(&client.env); + let gov = Address::generate(&client.env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Admin can still update fees before governance is set + client.set_fee_bps(&150); + assert_eq!(client.get_fee(), 150); + + // Admin hands control over to governance + client.set_governance(&gov); + + // Governance address can now update the fee + client.set_fee_bps(&200); + assert_eq!(client.get_fee(), 200); + } + + /// `add_supported_token` is a no-op and never errors. #[test] fn test_add_supported_token_noop() { let (env, client, _) = setup_env(); @@ -73,4 +2327,214 @@ assert_eq!(token_client.balance(&treasury), 1_000); assert_eq!(token_client.balance(&recipient), 0); } -}\n}\n\n/// Property-based tests for fee calculation logic.\n///\n/// These tests exercise the pure arithmetic used in `process_single_payment`\n/// without touching the Soroban environment so they can run as ordinary host\n/// tests powered by proptest.\n///\n/// The invariants verified across 10,000 random inputs are:\n/// 1. **Conservation**: `fee_amount + remainder == amount`\n/// 2. **Non-negative fee**: `fee_amount >= 0`\n/// 3. **Non-negative remainder**: `remainder >= 0`\n/// 4. **Cap enforcement**: `fee_amount <= fee_cap`\n/// 5. **Fee never exceeds amount**: `fee_amount <= amount`\n#[cfg(test)]\nmod prop_tests {\n use proptest::prelude::*;\n\n // --- constants mirrored from the contract ---\n const BPS_DIVISOR: i128 = 10_000;\n /// Maximum valid fee in basis points (100% = 10 000 bps).\n const MAX_FEE_BPS: i128 = 10_000;\n /// Upper bound for a single payment amount (matches contract MAX_AMOUNT).\n const MAX_AMOUNT: i128 = 1_000_000_000_000_000;\n\n // --- pure fee calculation logic (mirrors process_single_payment) ---\n\n /// Computes `(fee_amount, remainder)` exactly as the contract does.\n ///\n /// `user_volume_above_threshold` stands in for the tiered-discount check:\n /// when `true` the effective fee is halved.\n fn compute_fee(\n amount: i128,\n fee_bps: i128,\n fee_cap: i128,\n user_volume_above_threshold: bool,\n ) -> (i128, i128) {\n let effective_fee_bps = if user_volume_above_threshold {\n fee_bps / 2\n } else {\n fee_bps\n };\n\n let mut fee_amount = (amount * effective_fee_bps) / BPS_DIVISOR;\n if fee_amount > fee_cap {\n fee_amount = fee_cap;\n }\n if fee_amount > amount {\n fee_amount = amount;\n }\n let remainder = amount - fee_amount;\n (fee_amount, remainder)\n }\n\n // -----------------------------------------------------------------------\n // Strategies\n // -----------------------------------------------------------------------\n\n /// A valid payment amount: 1 ..= MAX_AMOUNT (positive, within contract bounds).\n fn valid_amount() -> impl Strategy {\n 1i128..=MAX_AMOUNT\n }\n\n /// A valid fee in basis points: 0 ..= 10 000 (0% to 100%).\n fn valid_fee_bps() -> impl Strategy {\n 0i128..=MAX_FEE_BPS\n }\n\n /// A valid fee cap: 0 ..= MAX_AMOUNT.\n fn valid_fee_cap() -> impl Strategy {\n 0i128..=MAX_AMOUNT\n }\n\n // -----------------------------------------------------------------------\n // Property: fee_amount + remainder == amount (conservation of funds)\n // -----------------------------------------------------------------------\n\n proptest! {\n #![proptest_config(ProptestConfig::with_cases(10_000))]\n\n /// Funds are fully conserved: every strobe of the amount ends up either\n /// in the treasury (fee) or the recipient (remainder), never lost or\n /// created.\n #[test]\n fn prop_fee_plus_remainder_equals_amount(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert_eq!(\n fee_amount + remainder,\n amount,\n "fee_amount ({}) + remainder ({}) != amount ({})",\n fee_amount, remainder, amount\n );\n }\n\n /// The fee is always non-negative — the treasury never receives a\n /// negative transfer.\n #[test]\n fn prop_fee_amount_is_non_negative(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n fee_amount >= 0,\n "fee_amount ({}) must be >= 0",\n fee_amount\n );\n }\n\n /// The remainder is always non-negative — the recipient never receives a\n /// negative transfer.\n #[test]\n fn prop_remainder_is_non_negative(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (_, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n remainder >= 0,\n "remainder ({}) must be >= 0",\n remainder\n );\n }\n\n /// The fee never exceeds the configured cap.\n #[test]\n fn prop_fee_respects_cap(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n fee_amount <= fee_cap,\n "fee_amount ({}) exceeds fee_cap ({})",\n fee_amount, fee_cap\n );\n }\n\n /// The fee never exceeds the payment amount itself — the sender cannot\n /// be charged more than they are sending.\n #[test]\n fn prop_fee_never_exceeds_amount(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n fee_amount <= amount,\n "fee_amount ({}) exceeds amount ({})",\n fee_amount, amount\n );\n }\n\n /// When the fee rate is zero the entire amount flows to the recipient.\n #[test]\n fn prop_zero_fee_bps_means_no_fee(\n amount in valid_amount(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, remainder) = compute_fee(amount, 0, fee_cap, above_threshold);\n prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_bps is 0");\n prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_bps is 0");\n }\n\n /// When the fee cap is zero no fee is ever collected regardless of the\n /// rate.\n #[test]\n fn prop_zero_fee_cap_means_no_fee(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n above_threshold in any::(),\n ) {\n let (fee_amount, remainder) = compute_fee(amount, fee_bps, 0, above_threshold);\n prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_cap is 0");\n prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_cap is 0");\n }\n\n /// The tiered discount never produces a *higher* fee than the standard\n /// rate: halving the bps can only leave the fee equal or reduce it.\n #[test]\n fn prop_tiered_discount_never_increases_fee(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n ) {\n let (fee_full, _) = compute_fee(amount, fee_bps, fee_cap, false);\n let (fee_discounted, _) = compute_fee(amount, fee_bps, fee_cap, true);\n prop_assert!(\n fee_discounted <= fee_full,\n "discounted fee ({}) must be <= full fee ({})",\n fee_discounted, fee_full\n );\n }\n }\n}\n\n \ No newline at end of file +} +} + +/// Property-based tests for fee calculation logic. +/// +/// These tests exercise the pure arithmetic used in `process_single_payment` +/// without touching the Soroban environment so they can run as ordinary host +/// tests powered by proptest. +/// +/// The invariants verified across 10,000 random inputs are: +/// 1. **Conservation**: `fee_amount + remainder == amount` +/// 2. **Non-negative fee**: `fee_amount >= 0` +/// 3. **Non-negative remainder**: `remainder >= 0` +/// 4. **Cap enforcement**: `fee_amount <= fee_cap` +/// 5. **Fee never exceeds amount**: `fee_amount <= amount` +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + // --- constants mirrored from the contract --- + const BPS_DIVISOR: i128 = 10_000; + /// Maximum valid fee in basis points (100% = 10 000 bps). + const MAX_FEE_BPS: i128 = 10_000; + /// Upper bound for a single payment amount (matches contract MAX_AMOUNT). + const MAX_AMOUNT: i128 = 1_000_000_000_000_000; + + // --- pure fee calculation logic (mirrors process_single_payment) --- + + /// Computes `(fee_amount, remainder)` exactly as the contract does. + /// + /// `user_volume_above_threshold` stands in for the tiered-discount check: + /// when `true` the effective fee is halved. + fn compute_fee( + amount: i128, + fee_bps: i128, + fee_cap: i128, + user_volume_above_threshold: bool, + ) -> (i128, i128) { + let effective_fee_bps = if user_volume_above_threshold { + fee_bps / 2 + } else { + fee_bps + }; + + let mut fee_amount = (amount * effective_fee_bps) / BPS_DIVISOR; + if fee_amount > fee_cap { + fee_amount = fee_cap; + } + if fee_amount > amount { + fee_amount = amount; + } + let remainder = amount - fee_amount; + (fee_amount, remainder) + } + + // ----------------------------------------------------------------------- + // Strategies + // ----------------------------------------------------------------------- + + /// A valid payment amount: 1 ..= MAX_AMOUNT (positive, within contract bounds). + fn valid_amount() -> impl Strategy { + 1i128..=MAX_AMOUNT + } + + /// A valid fee in basis points: 0 ..= 10 000 (0% to 100%). + fn valid_fee_bps() -> impl Strategy { + 0i128..=MAX_FEE_BPS + } + + /// A valid fee cap: 0 ..= MAX_AMOUNT. + fn valid_fee_cap() -> impl Strategy { + 0i128..=MAX_AMOUNT + } + + // ----------------------------------------------------------------------- + // Property: fee_amount + remainder == amount (conservation of funds) + // ----------------------------------------------------------------------- + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + /// Funds are fully conserved: every strobe of the amount ends up either + /// in the treasury (fee) or the recipient (remainder), never lost or + /// created. + #[test] + fn prop_fee_plus_remainder_equals_amount( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert_eq!( + fee_amount + remainder, + amount, + "fee_amount ({}) + remainder ({}) != amount ({})", + fee_amount, remainder, amount + ); + } + + /// The fee is always non-negative — the treasury never receives a + /// negative transfer. + #[test] + fn prop_fee_amount_is_non_negative( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + fee_amount >= 0, + "fee_amount ({}) must be >= 0", + fee_amount + ); + } + + /// The remainder is always non-negative — the recipient never receives a + /// negative transfer. + #[test] + fn prop_remainder_is_non_negative( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (_, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + remainder >= 0, + "remainder ({}) must be >= 0", + remainder + ); + } + + /// The fee never exceeds the configured cap. + #[test] + fn prop_fee_respects_cap( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + fee_amount <= fee_cap, + "fee_amount ({}) exceeds fee_cap ({})", + fee_amount, fee_cap + ); + } + + /// The fee never exceeds the payment amount itself — the sender cannot + /// be charged more than they are sending. + #[test] + fn prop_fee_never_exceeds_amount( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + fee_amount <= amount, + "fee_amount ({}) exceeds amount ({})", + fee_amount, amount + ); + } + + /// When the fee rate is zero the entire amount flows to the recipient. + #[test] + fn prop_zero_fee_bps_means_no_fee( + amount in valid_amount(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, remainder) = compute_fee(amount, 0, fee_cap, above_threshold); + prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_bps is 0"); + prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_bps is 0"); + } + + /// When the fee cap is zero no fee is ever collected regardless of the + /// rate. + #[test] + fn prop_zero_fee_cap_means_no_fee( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + above_threshold in any::(), + ) { + let (fee_amount, remainder) = compute_fee(amount, fee_bps, 0, above_threshold); + prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_cap is 0"); + prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_cap is 0"); + } + + /// The tiered discount never produces a *higher* fee than the standard + /// rate: halving the bps can only leave the fee equal or reduce it. + #[test] + fn prop_tiered_discount_never_increases_fee( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + ) { + let (fee_full, _) = compute_fee(amount, fee_bps, fee_cap, false); + let (fee_discounted, _) = compute_fee(amount, fee_bps, fee_cap, true); + prop_assert!( + fee_discounted <= fee_full, + "discounted fee ({}) must be <= full fee ({})", + fee_discounted, fee_full + ); + } + } +} + diff --git a/stellar-payment-platform/.babelrc b/stellar-payment-platform/.babelrc new file mode 100644 index 0000000..394c543 --- /dev/null +++ b/stellar-payment-platform/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ] + ] +} diff --git a/stellar-payment-platform/package-lock.json b/stellar-payment-platform/package-lock.json index 6a86b5f..91b40c3 100644 --- a/stellar-payment-platform/package-lock.json +++ b/stellar-payment-platform/package-lock.json @@ -40,6 +40,9 @@ "zod": "^4.4.3" }, "devDependencies": { + "@babel/core": "^7.23.0", + "@babel/preset-env": "^7.23.0", + "babel-jest": "^29.7.0", "jest": "^29.7.0", "supertest": "^7.0.0", "tsx": "4.23.1" @@ -142,6 +145,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -193,14 +197,14 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -209,6 +213,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -226,6 +243,88 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -236,6 +335,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -268,6 +381,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", @@ -278,6 +404,56 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -308,6 +484,21 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helpers": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", @@ -323,13 +514,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -338,6 +529,120 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -351,40 +656,916 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -393,10 +1574,10 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", - "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "dev": true, "license": "MIT", "dependencies": { @@ -409,36 +1590,43 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-jsx": { + "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -451,92 +1639,113 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -545,30 +1754,101 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-typescript": { + "node_modules/@babel/preset-env": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -577,6 +1857,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -593,18 +1888,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -637,9 +1932,9 @@ "license": "MIT" }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -1509,6 +2804,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1530,6 +2826,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1545,6 +2842,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", @@ -1595,6 +2893,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", @@ -1739,6 +3038,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -2368,6 +3668,48 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -2467,9 +3809,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2558,9 +3900,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2577,12 +3919,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2754,9 +4097,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -3131,6 +4474,23 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -3450,9 +4810,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.380", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", - "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "version": "1.5.417", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.417.tgz", + "integrity": "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==", "dev": true, "license": "ISC" }, @@ -3677,6 +5037,16 @@ "node": ">=4.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3762,6 +5132,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3808,6 +5179,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 16" }, @@ -5741,6 +7113,13 @@ "node": ">=8" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -6033,9 +7412,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -6383,6 +7762,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -6669,6 +8049,7 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -6966,6 +8347,26 @@ "@redis/time-series": "1.1.0" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6986,6 +8387,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7815,6 +9254,40 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unicode-properties": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", @@ -7825,6 +9298,16 @@ "unicode-trie": "^2.0.0" } }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unicode-trie": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", @@ -7851,9 +9334,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/stellar-payment-platform/package.json b/stellar-payment-platform/package.json index c545d7d..a2d7213 100644 --- a/stellar-payment-platform/package.json +++ b/stellar-payment-platform/package.json @@ -56,6 +56,9 @@ "zod": "^4.4.3" }, "devDependencies": { + "@babel/core": "^7.23.0", + "@babel/preset-env": "^7.23.0", + "babel-jest": "^29.7.0", "jest": "^29.7.0", "supertest": "^7.0.0", "tsx": "4.23.1" diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index a135fc3..8d48915 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -16,12 +16,9 @@ const Filter = require('bad-words'); const dotenv = require('dotenv'); const timeout = require('connect-timeout'); const compression = require('compression'); -const { verifyMultiSignerThreshold } = require('./src/multisigner-verifier'); const { poolGet, poolRun, poolAll } = require('./src/db'); const { logger } = require('./src/logger'); const pinoHttp = require('pino-http'); -const xss = require('xss'); -const { Keypair, StrKey } = require('@stellar/stellar-sdk'); const { metricsMiddleware, getMetrics, @@ -35,36 +32,9 @@ const { requireJson } = require('./src/middleware/requireJson'); const { bodySizeLimit } = require('./src/middleware/bodyLimit'); const { apiVersion } = require('./src/middleware/apiVersion'); const { deprecationMiddleware } = require('./src/middleware/deprecation'); -const { - registerBodySchema, - federationQuerySchema, - lookupQuerySchema, - usersQuerySchema, -} = require('./src/schemas'); const Sentry = require('@sentry/node'); const { - lookupCached, - federationNameKey, - federationIdKey, - federationLookupCached, - invalidateFederationCache, -} = require('./src/cache'); -const { - paginatedResponse, - parsePagination, - parseCursorQuery, - keysetWhereDesc, - paginateByKeyset, - cursorPaginatedResponse, -} = require('./src/pagination'); -const { - normalizeNameTag, validateMemo, - RESERVED_NAMES, - MAX_USERNAMES_PER_ADDRESS, - PRIMARY_USERNAME_ORDER, - USER_DATABASE, - shouldFallbackToLocalRegistry, } = require('./src/utils'); dotenv.config(); @@ -263,16 +233,6 @@ scheduleCleanupJob(prisma); scheduleSoftDeletePurgeJob(prisma); const poolMonitor = schedulePoolMonitoring(prisma); -const RESERVED_USERNAMES = [ - 'admin', - 'root', - 'stellar', - 'system', - 'superuser', - 'administrator', - 'support', -]; - // --------------------------------------------------------------------------- // #51 — ETag Caching Middleware for Federation Endpoint // --------------------------------------------------------------------------- @@ -777,189 +737,7 @@ app.post('/register', ipLimiter, idempotencyMiddleware(redisClient), requireJson app.all('/register', (req, res, next) => next(new ApiError('METHOD_NOT_ALLOWED'))); -app.get('/lookup', validateSchema({ query: lookupQuerySchema }), async (req, res, next) => { - const { address = '', search = '' } = req.query; - - if (address) { - try { - const result = await lookupCached(address, async () => { - let row; - try { - // #613 — an address can have several usernames; return the primary. - row = await prisma.user.findFirst({ - where: { address, deletedAt: null }, - select: { username: true }, - orderBy: PRIMARY_USERNAME_ORDER, - }); - } catch (error) { - if (!shouldFallbackToLocalRegistry(error)) { - throw error; - } - row = await getLocalUserByAddress(address); - } - return row ? { username: row.username, address } : null; - }); - if (!result) { - const notFoundError = new Error('Username not found for this address'); - notFoundError.statusCode = 404; - return next(notFoundError); - } - - return res.json(result); - } catch (err) { - logger.error(err, "🚨 ACTUAL PRISMA ERROR:"); - - const dbError = new Error('Database lookup failed', { cause: err }); - dbError.statusCode = 500; - return next(dbError); - } - } - - const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(req.query); - const { page, limit, skip } = parsePagination(req.query); - if (invalidCursor) { - return next(new ApiError('INVALID_INPUT', 'Invalid cursor parameter')); - } - - const where = { - deletedAt: null, - OR: [ - { username: { contains: search, mode: 'insensitive' } }, - { address: { contains: search, mode: 'insensitive' } }, - ], - }; - - try { - let response = null; - try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - response = cursorPaginatedResponse( - rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })), - { limit: cursorLimit, nextCursor, hasMore }, - ); - } else { - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - skip, - take: limit, - }), - ]); - - response = paginatedResponse( - rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })), - totalCount, - { page, limit }, - ); - } - } catch (error) { - if (!shouldFallbackToLocalRegistry(error)) { - throw error; - } - - response = await listLocalUsers(search, page, limit, cursor); - } - - return res.json(response); - } catch (error) { - const dbError = new Error('Database lookup failed', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } -}); - -app.get('/users', validateSchema({ query: usersQuerySchema }), async (req, res, next) => { - const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(req.query); - const { page, limit, skip } = parsePagination(req.query); - if (invalidCursor) { - return next(new ApiError('INVALID_INPUT', 'Invalid cursor parameter')); - } - const search = req.query.search ?? null; - - const where = search - ? { - deletedAt: null, - OR: [ - { username: { contains: search, mode: 'insensitive' } }, - { address: { contains: search, mode: 'insensitive' } }, - ], - } - : { deletedAt: null }; - - try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - return res.json(cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore })); - } - - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - skip, - take: limit, - }), - ]); - - const totalPages = Math.ceil(totalCount / limit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - - res.json({ - data, - meta: { - total: totalCount, - totalCount, - page, - currentPage: page, - limit, - totalPages, - }, - totalCount, - totalPages, - currentPage: page, - }); - } catch (error) { - const dbError = new Error('Database error', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } -}); // Request versioning: URI (/api/v1, /api/v2) first, then Accept-Version / // API-Version header, defaulting to v1. Routers below then decide routing. app.use(apiVersion); diff --git a/stellar-payment-platform/src/routes/v1/federationRoutes.js b/stellar-payment-platform/src/routes/v1/federationRoutes.js index 772eb8b..1e6fb30 100644 --- a/stellar-payment-platform/src/routes/v1/federationRoutes.js +++ b/stellar-payment-platform/src/routes/v1/federationRoutes.js @@ -1,7 +1,5 @@ const express = require('express'); -const { prisma } = require('../../../prismaClient'); -const { normalizeNameTag, etagCache, USER_DATABASE } = require('../../db'); -const { PRIMARY_USERNAME_ORDER } = require('../../utils'); +const { normalizeNameTag, etagCache } = require('../../db'); const { federationNameKey, federationIdKey, @@ -11,6 +9,7 @@ const { validateSchema } = require('../../middleware/validateSchema'); const { ApiError } = require('../../errors'); const { federationQuerySchema } = require('../../schemas'); const { asyncHandler } = require('../../middleware/asyncHandler'); +const { resolveFederationId, resolveFederationName } = require('../../services/federationService'); module.exports = (redisClient) => { const router = express.Router(); @@ -22,25 +21,7 @@ module.exports = (redisClient) => { if (type === 'id') { const cacheKey = federationIdKey(queryValue); const cached = await federationLookupCached(cacheKey, async () => { - // #613 — an address can have several usernames; a reverse lookup - // resolves to the primary one. - const row = await prisma.user.findFirst({ - where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, - select: { username: true, address: true, memoType: true, memo: true }, - orderBy: PRIMARY_USERNAME_ORDER, - }); - - if (!row) return null; - - const response = { - stellar_address: `${row.username}*${process.env.DOMAIN || 'localhost'}`, - account_id: row.address, - }; - if (row.memoType) { - response.memo_type = row.memoType; - response.memo = row.memo; - } - return response; + return await resolveFederationId(queryValue); }); if (!cached) { @@ -56,23 +37,7 @@ module.exports = (redisClient) => { const cacheKey = federationNameKey(queryName); const cached = await federationLookupCached(cacheKey, async () => { - const row = await prisma.user.findFirst({ - where: { username: queryName, deletedAt: null }, - select: { address: true, memoType: true, memo: true }, - }); - - const address = row?.address || USER_DATABASE[queryName]; - if (!address) return null; - - const response = { - stellar_address: address, - account_id: address, - }; - if (row?.memoType) { - response.memo_type = row.memoType; - response.memo = row.memo; - } - return response; + return await resolveFederationName(queryName); }); if (!cached) { @@ -88,6 +53,9 @@ module.exports = (redisClient) => { ); } } catch (error) { + if (error.statusCode === 403) { + return next(error); + } const dbError = new Error('Database lookup failed', { cause: error }); dbError.statusCode = 500; return next(dbError); diff --git a/stellar-payment-platform/src/routes/v1/userRoutes.js b/stellar-payment-platform/src/routes/v1/userRoutes.js index c13104d..625341b 100644 --- a/stellar-payment-platform/src/routes/v1/userRoutes.js +++ b/stellar-payment-platform/src/routes/v1/userRoutes.js @@ -29,9 +29,13 @@ const { ApiError } = require('../../errors'); const { requireJson } = require('../../middleware/requireJson'); const { registerBodySchema, + federationQuerySchema, lookupQuerySchema, usersQuerySchema, } = require('../../schemas'); +const { registerUser } = require('../../services/registrationService'); +const { lookupUser, listUsers } = require('../../services/userService'); + const router = express.Router(); @@ -118,119 +122,9 @@ const registerLocalUser = async ({ username, address }) => { }; router.post('/register', requireJson, validateSchema({ body: registerBodySchema }), asyncHandler(async (req, res, next) => { - const safeUsername = xss(req.body.username); - const username = normalizeNameTag(safeUsername); - const { address, memo_type: memoType, memo, signature = '', signerAddress = '' } = req.body; - - if (address.toUpperCase().startsWith('S')) { - return next( - new ApiError( - 'INVALID_INPUT', - 'Never share your Secret Key. Please register using your Public Key (starts with G).', - ), - ); - } - - if (!username || !address) { - return next(new ApiError('INVALID_INPUT', 'Missing required fields: username and address are both required.')); - } - - const BLOCKED_EXCHANGES = [ - "GA5XIGA5C7QTPTWXQYYUGCGQFBLOUZLYVVKXUHZHZWBYEAIELE4KZTOG", - "GCO2IP3VKXUNOHURKEHCDFWNOSECYIMA5QLGNTKVVHESURVDMBWGIGLO", - "GBV4ZDEPNQ2FKSPKGJP2YKDAIZWQ2XKRQD4V4ACH3TCTXTGLWEBDU3OS" - ]; - - if (BLOCKED_EXCHANGES.includes(address) && !memo) { - return next(new ApiError('INVALID_INPUT', "Cannot map federation addresses directly to custodial exchange master wallets.")); - } - - const usernameLocalPart = username.includes('*') ? username.split('*')[0] : username; - if (usernameLocalPart.length < 3) { - return next(new ApiError('INVALID_INPUT', "Username must be at least 3 characters long.")); - } - - if (!StrKey.isValidEd25519PublicKey(address)) { - const error = new Error('Invalid Stellar Public Key format.'); - error.statusCode = 400; - return next(error); - } - - const memoError = validateMemo(memoType, memo); - if (memoError) { - return next(new ApiError('INVALID_INPUT', memoError)); - } - - - - const normalizedUsername = username.toLowerCase(); - - if (RESERVED_NAMES.includes(normalizedUsername)) { - return next(new ApiError('FORBIDDEN', 'This username is reserved and cannot be registered.')); - } - try { - // #613 — an address may carry several usernames (aliases). Registration - // adds another while the address is under the cap; the first username - // registered for an address becomes its primary. - const usernameCount = await prisma.user.count({ - where: { address, deletedAt: null }, - }); - - if (usernameCount >= MAX_USERNAMES_PER_ADDRESS) { - return next( - new ApiError( - 'CONFLICT', - `This address already has the maximum of ${MAX_USERNAMES_PER_ADDRESS} federation usernames.`, - ), - ); - } - const isPrimary = usernameCount === 0; - - let verificationResult = null; - const signerToVerify = signerAddress || address; - if (signerToVerify) { - verificationResult = await verifyMultiSignerThreshold(address, [signerToVerify], { - operationType: 'management', - }); - - if (!verificationResult.success) { - const verificationError = new Error( - verificationResult.errorMessage || 'Signature verification failed' - ); - verificationError.statusCode = 401; - throw verificationError; - } - } - - await prisma.user.create({ - data: { - username: normalizedUsername, - address, - isPrimary, - ...(memoType && { memoType, memo }), - }, - }); - // Invalidate any stale federation cache entries for this username/address - invalidateFederationCache(normalizedUsername, address); - - return res.status(201).json({ - ok: true, - username: normalizedUsername, - address, - is_primary: isPrimary, - federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, - ...(verificationResult && { - verification: { - accountId: verificationResult.accountId, - signerCount: verificationResult.signerCount, - thresholdMet: verificationResult.success, - requiredThreshold: verificationResult.requiredThreshold, - providedWeight: verificationResult.totalWeight, - }, - }), - ...(memoType && { memo_type: memoType, memo }), - }); + const result = await registerUser(req.body); + return res.status(201).json(result); } catch (error) { if (error.code === 'SQLITE_CONSTRAINT' || error.code === 'P2002' || (error.message && error.message.includes('UNIQUE'))) { return next(new ApiError('CONFLICT', 'Username is already taken. Please choose another.')); @@ -364,51 +258,10 @@ router.get('/lookup', etagCache, validateSchema({ query: lookupQuerySchema }), a const where = buildUserSearchWhere(search); try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })); - return res.json(cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore })); - } - - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - skip, - take: limit, - }), - ]); - -const totalPages = Math.ceil(totalCount / limit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })); - - return res.json({ data, totalCount, totalPages, currentPage: page }); + const result = await lookupUser(req.query.address, req.query.search, req.query); + return res.json(result); } catch (error) { - const dbError = new Error('Database lookup failed', { cause: error }); - dbError.statusCode = 500; - return next(dbError); + return next(error); } })); @@ -422,64 +275,10 @@ router.get('/users', etagCache, validateSchema({ query: usersQuerySchema }), asy const where = search ? buildUserSearchWhere(search) : { deletedAt: null }; try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - return res.json(cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore })); - } - - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - skip, - take: limit, - }), - ]); - -const totalPages = Math.ceil(totalCount / limit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - - res.json({ - data, - meta: { - total: totalCount, - totalCount, - page, - currentPage: page, - limit, - totalPages, - }, - totalCount, - totalPages, - currentPage: page, - }); + const result = await listUsers(req.query); + return res.json(result); } catch (error) { - const dbError = new Error('Database error', { cause: error }); - dbError.statusCode = 500; - return next(dbError); + return next(error); } })); diff --git a/stellar-payment-platform/src/services/federationService.js b/stellar-payment-platform/src/services/federationService.js new file mode 100644 index 0000000..8409fe4 --- /dev/null +++ b/stellar-payment-platform/src/services/federationService.js @@ -0,0 +1,74 @@ +'use strict'; + +const { prisma } = require('../../prismaClient'); +const { USER_DATABASE, shouldFallbackToLocalRegistry, PRIMARY_USERNAME_ORDER } = require('../utils'); +const { getLocalUserByAddress, getLocalUserByUsername } = require('./userService'); + +const resolveFederationId = async (queryValue) => { + const row = await prisma.user.findFirst({ + where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, + select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true }, + orderBy: PRIMARY_USERNAME_ORDER, + }); + + if (!row) return null; + if (row.flaggedAt) { + const forbiddenError = new Error('Address is blocked'); + forbiddenError.statusCode = 403; + throw forbiddenError; + } + + const response = { + stellar_address: `${row.username}*${process.env.DOMAIN || 'localhost'}`, + account_id: row.address, + }; + if (row.memoType) { + response.memo_type = row.memoType; + response.memo = row.memo; + } + return response; +}; + +const resolveFederationName = async (queryName) => { + let row; + try { + row = await prisma.user.findFirst({ + where: { username: queryName, deletedAt: null }, + select: { address: true, memoType: true, memo: true, flaggedAt: true }, + }); + + if (row && row.flaggedAt) { + const forbiddenError = new Error('Address is blocked'); + forbiddenError.statusCode = 403; + throw forbiddenError; + } + } catch (error) { + if (error.statusCode === 403) throw error; + if (!shouldFallbackToLocalRegistry(error)) { + throw error; + } + + const localRow = await getLocalUserByUsername(queryName); + row = localRow + ? { address: localRow.address, memoType: null, memo: null } + : null; + } + + const address = row?.address || USER_DATABASE[queryName]; + if (!address) return null; + + const response = { + stellar_address: address, + account_id: address, + }; + if (row?.memoType) { + response.memo_type = row.memoType; + response.memo = row.memo; + } + return response; +}; + +module.exports = { + resolveFederationId, + resolveFederationName, +}; diff --git a/stellar-payment-platform/src/services/signatureService.js b/stellar-payment-platform/src/services/signatureService.js new file mode 100644 index 0000000..e7c6e05 --- /dev/null +++ b/stellar-payment-platform/src/services/signatureService.js @@ -0,0 +1,75 @@ +'use strict'; + +const crypto = require('crypto'); +const { Keypair, StrKey } = require('@stellar/stellar-sdk'); + +const verifyFreighterRegistrationSignature = ({ + username, + address, + signature, + signerAddress, +}) => { + const message = `register:${username}:${address}`; + const claimedSigner = signerAddress || address; + + if (!StrKey.isValidEd25519PublicKey(claimedSigner)) { + const error = new Error('Invalid signer address format.'); + error.statusCode = 400; + throw error; + } + + const keypair = Keypair.fromPublicKey(claimedSigner); + + let signatureBuffer; + if (Buffer.isBuffer(signature)) { + signatureBuffer = signature; + } else if (typeof signature === 'string') { + // If it's a 128-char hex string + if (signature.length === 128 && /^[0-9a-fA-F]+$/.test(signature)) { + signatureBuffer = Buffer.from(signature, 'hex'); + } else { + signatureBuffer = Buffer.from(signature, 'base64'); + // If the resulting buffer is 86-88 bytes long, it might be the ASCII bytes of a base64 string (double encoded) + if (signatureBuffer.length >= 80 && signatureBuffer.length <= 90) { + const text = signatureBuffer.toString('utf8'); + if (/^[a-zA-Z0-9+/]+={0,2}$/.test(text)) { + signatureBuffer = Buffer.from(text, 'base64'); + } + } + } + } else { + throw new Error('Invalid message signature format.'); + } + + // --- SEP-0053 Verification Logic --- + // Freighter adds a specific prefix and hashes the payload before signing + const prefix = Buffer.from('Stellar Signed Message:\n', 'utf8'); + const messageBytes = Buffer.from(message, 'utf8'); + const payload = Buffer.concat([prefix, messageBytes]); + const messageHash = crypto.createHash('sha256').update(payload).digest(); + + // Verify against the hashed payload (SEP-0053) first + if (!keypair.verify(messageHash, signatureBuffer)) { + // If that fails, try verifying the raw message directly in case the wallet used signBlob + if (!keypair.verify(messageBytes, signatureBuffer)) { + // Also try verifying the payload without hashing it + if (!keypair.verify(payload, signatureBuffer)) { + const error = new Error('Signature verification failed.'); + error.statusCode = 401; + throw error; + } + } + } + + if (claimedSigner !== address) { + const error = new Error('Signer address does not match the connected wallet.'); + error.statusCode = 401; + throw error; + } + + return claimedSigner; +}; + +module.exports = { + verifyFreighterRegistrationSignature, +}; diff --git a/stellar-payment-platform/src/services/userService.js b/stellar-payment-platform/src/services/userService.js new file mode 100644 index 0000000..5f3b0fb --- /dev/null +++ b/stellar-payment-platform/src/services/userService.js @@ -0,0 +1,237 @@ +'use strict'; + +const { poolGet, poolAll } = require('../db'); +const { paginateByKeyset, cursorPaginatedResponse, paginatedResponse, parseCursorQuery, parsePagination, keysetWhereDesc } = require('../pagination'); +const { prisma } = require('../../prismaClient'); +const { shouldFallbackToLocalRegistry, PRIMARY_USERNAME_ORDER } = require('../utils'); + +const getLocalUserByAddress = async (address) => + poolGet( + 'SELECT username, address FROM username_registry WHERE address = ? LIMIT 1', + [address], + ); + +const getLocalUserByUsername = async (username) => + poolGet( + 'SELECT username, address FROM username_registry WHERE username = ? LIMIT 1', + [username], + ); + +const listLocalUsers = async (search, page, limit, cursorPoint = null) => { + const searchPattern = `%${search}%`; + const LIKE_FILTER = + 'WHERE (username LIKE ? COLLATE NOCASE OR address LIKE ? COLLATE NOCASE)'; + + if (cursorPoint) { + const rows = await poolAll( + `SELECT username, address, created_at + FROM username_registry + ${LIKE_FILTER} + AND (created_at < ? OR (created_at = ? AND username < ?)) + ORDER BY created_at DESC, username DESC + LIMIT ?`, + [searchPattern, searchPattern, String(cursorPoint.createdAt), String(cursorPoint.createdAt), String(cursorPoint.username), limit + 1], + ); + const normalized = rows.map((row) => ({ + username: row.username, + address: row.address, + createdAt: row.created_at, + })); + const { rows: pageRows, hasMore, nextCursor } = paginateByKeyset(normalized, limit); + return cursorPaginatedResponse( + pageRows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt, + })), + { limit, nextCursor, hasMore }, + ); + } + + const skip = (page - 1) * limit; + const rows = await poolAll( + `SELECT username, address, created_at + FROM username_registry + ${LIKE_FILTER} + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, + [searchPattern, searchPattern, limit, skip], + ); + + const countRow = await poolGet( + `SELECT COUNT(*) AS totalCount + FROM username_registry + ${LIKE_FILTER}`, + [searchPattern, searchPattern], + ); + + const totalCount = Number(countRow?.totalCount || 0); + return paginatedResponse( + rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.created_at, + })), + totalCount, + { page, limit }, + ); +}; + +const lookupUser = async (address, search, query) => { + if (address) { + let row; + try { + row = await prisma.user.findFirst({ + where: { address, deletedAt: null }, + select: { username: true }, + orderBy: PRIMARY_USERNAME_ORDER, + }); + } catch (error) { + if (!shouldFallbackToLocalRegistry(error)) throw error; + row = await getLocalUserByAddress(address); + } + + if (!row) { + const notFoundError = new Error('Username not found for this address'); + notFoundError.statusCode = 404; + throw notFoundError; + } + return { username: row.username, address }; + } + + const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(query); + const { page, limit, skip } = parsePagination(query); + if (invalidCursor) { + const error = new Error('Invalid cursor parameter'); + error.statusCode = 400; + error.code = 'INVALID_INPUT'; + throw error; + } + + const where = { + deletedAt: null, + OR: [ + { username: { contains: search, mode: 'insensitive' } }, + { address: { contains: search, mode: 'insensitive' } }, + ], + }; + + try { + if (cursor) { + const candidates = await prisma.user.findMany({ + where: { AND: [where, keysetWhereDesc(cursor)] }, + orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], + take: cursorLimit + 1, + }); + const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); + return cursorPaginatedResponse( + rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt.toISOString(), + })), + { limit: cursorLimit, nextCursor, hasMore }, + ); + } else { + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], + skip, + take: limit, + }), + ]); + + return paginatedResponse( + rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt.toISOString(), + })), + totalCount, + { page, limit }, + ); + } + } catch (error) { + if (!shouldFallbackToLocalRegistry(error)) throw error; + return listLocalUsers(search, page, limit, cursor); + } +}; + +const listUsers = async (query) => { + const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(query); + const { page, limit, skip } = parsePagination(query); + if (invalidCursor) { + const error = new Error('Invalid cursor parameter'); + error.statusCode = 400; + error.code = 'INVALID_INPUT'; + throw error; + } + const search = query.search ?? null; + + const where = search + ? { + deletedAt: null, + OR: [ + { username: { contains: search, mode: 'insensitive' } }, + { address: { contains: search, mode: 'insensitive' } }, + ], + } + : { deletedAt: null }; + + if (cursor) { + const candidates = await prisma.user.findMany({ + where: { AND: [where, keysetWhereDesc(cursor)] }, + orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], + take: cursorLimit + 1, + }); + const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt ? user.createdAt.toISOString() : undefined, + })); + return cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore }); + } + + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalCount / limit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt ? user.createdAt.toISOString() : undefined, + })); + + return { + data, + meta: { + total: totalCount, + totalCount, + page, + currentPage: page, + limit, + totalPages, + }, + totalCount, + totalPages, + currentPage: page, + }; +}; + +module.exports = { + getLocalUserByAddress, + getLocalUserByUsername, + listLocalUsers, + lookupUser, + listUsers, +};