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,
+};