Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 155 additions & 6 deletions smart-contracts/solana/programs/allways_swap_manager/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ pub const QUOTE_SEED: &[u8] = b"quote";
#[constant]
pub const POOL_SEED: &[u8] = b"pool";

/// On-chain schema/version, surfaced for upgrade tracking. Bumped as phases land.
/// PDA seed for the singleton subnet-revenue treasury (`seeds = [TREASURY_SEED]`) — held entirely
/// separate from the collateral vault so miner collateral is never commingled with subnet income.
#[constant]
pub const TREASURY_SEED: &[u8] = b"treasury";

/// On-chain schema/version for upgrade tracking, bumped as phases land. v6: runtime config setters
/// (fee/window/interval promoted to Config); see git history for the v2–v5 progression.
pub const CONFIG_VERSION: u32 = 6;

/// Max validators in the whitelist (bounds the Config `validators` Vec and a round's voters).
Expand All @@ -56,15 +62,158 @@ pub const REQ_TIMEOUT: u8 = 7;
/// Global (non-per-target) round for the validator-weight vector.
pub const REQ_SET_WEIGHTS: u8 = 8;

// Deploy-time economic levers live in `tunables.rs`. This file keeps only structural
// constants (seeds, request-type bytes, max lengths, chain facts).

/// Solana slot time (ms): a chain property, paired with `tunables::POOL_WINDOW_SECS` to pin
/// the draw's future seed slot.
/// Solana slot time (ms) — a chain property, used with `POOL_WINDOW_SECS` (economic-levers section
/// below) to pin the draw's future seed slot from the window duration.
pub const SLOT_MS: u64 = 400;

/// Bounded max lengths for stored strings.
pub const MAX_ADDR_LEN: usize = 80;
pub const MAX_CHAIN_LEN: usize = 16;
pub const MAX_RATE_LEN: usize = 32;
pub const MAX_TX_LEN: usize = 128;

/// Basis-points denominator (10_000 bps = 1.00×). Shared by every ×-multiplier below.
pub const BPS_DENOMINATOR: u64 = 10_000;

/// Collateral a miner must hold to back a swap, as a fraction of swap size in bps (10_000 = 1.00×,
/// 11_000 = 1.10×). >1.00× reserves a slash buffer to make a wronged user whole and penalize the
/// miner (v2 #4). Bounded to [MIN, MAX] below — enforced at compile time + by unit test.
pub const COLLATERAL_REQUIREMENT_BPS: u64 = 11_000; // 1.10× — current setting

/// Hard floor: a swap must always be at least fully collateralized.
pub const COLLATERAL_REQUIREMENT_BPS_MIN: u64 = 10_000; // 1.0×
/// Hard ceiling: more than 2× would price out honest miners with no extra safety payoff.
pub const COLLATERAL_REQUIREMENT_BPS_MAX: u64 = 20_000; // 2.0×

// Compile-time guard: the build won't compile if the setting leaves [1.0×, 2.0×].
const _: () = assert!(
COLLATERAL_REQUIREMENT_BPS >= COLLATERAL_REQUIREMENT_BPS_MIN
&& COLLATERAL_REQUIREMENT_BPS <= COLLATERAL_REQUIREMENT_BPS_MAX,
"COLLATERAL_REQUIREMENT_BPS must be within [1.0x, 2.0x] (10_000..=20_000 bps)"
);

/// Collateral (lamports) to back a swap of `sol_amount` = `sol_amount × COLLATERAL_REQUIREMENT_BPS
/// / 10_000`, rounded up. u128 math clamped to `u64::MAX` so an extreme size can't wrap.
pub fn required_collateral(sol_amount: u64) -> u64 {
let numer = (sol_amount as u128).saturating_mul(COLLATERAL_REQUIREMENT_BPS as u128);
// round up (ceil-div): require at least the exact fraction.
let req = numer
.saturating_add(BPS_DENOMINATOR as u128 - 1)
.checked_div(BPS_DENOMINATOR as u128)
.unwrap_or(u128::MAX);
req.min(u64::MAX as u128) as u64
}

// Quote-update churn fee (anti-flashing): overwriting a standing quote too soon costs a treasury-
// bound, decaying fee (free once it's stood long enough; first creation is always free). Stepwise
// tiers by seconds since last update — see `quote_update_fee` for the cutoffs; all fees → treasury.
pub const QUOTE_UPDATE_FEE_TIER1_LAMPORTS: u64 = 10_000_000; // 0.01 SOL — churn within 5 min
pub const QUOTE_UPDATE_FEE_TIER1_MAX_SECS: i64 = 300; // 5 min
pub const QUOTE_UPDATE_FEE_TIER2_LAMPORTS: u64 = 1_000_000; // 0.001 SOL — 5–10 min
pub const QUOTE_UPDATE_FEE_TIER2_MAX_SECS: i64 = 600; // 10 min → free thereafter

// Sanity: windows must increase and the fee must not increase as time passes (monotone decay).
const _: () = assert!(
QUOTE_UPDATE_FEE_TIER1_MAX_SECS < QUOTE_UPDATE_FEE_TIER2_MAX_SECS
&& QUOTE_UPDATE_FEE_TIER1_LAMPORTS >= QUOTE_UPDATE_FEE_TIER2_LAMPORTS,
"quote-update fee tiers must decay over increasing windows"
);

/// Fee (lamports) for updating a standing quote `elapsed_secs` after its previous write. A negative
/// or zero elapsed (clock skew / same-second churn) falls into the most-expensive tier. Applies only
/// to updates — the caller charges nothing on first creation.
pub fn quote_update_fee(elapsed_secs: i64) -> u64 {
if elapsed_secs < QUOTE_UPDATE_FEE_TIER1_MAX_SECS {
QUOTE_UPDATE_FEE_TIER1_LAMPORTS
} else if elapsed_secs < QUOTE_UPDATE_FEE_TIER2_MAX_SECS {
QUOTE_UPDATE_FEE_TIER2_LAMPORTS
} else {
0
}
}

// --- Protocol fees & timing ---

/// Protocol fee divisor — 1% (immutable policy), `fee = sol_amount / FEE_DIVISOR`. Compile-time
/// only (not promoted to a runtime setter).
pub const FEE_DIVISOR: u64 = 100;

// The next three are initial seed defaults — `initialize` copies them into `Config`, then they're
// runtime-tunable via the #486 admin setters. Handlers read the live `Config`, not these consts.

/// Initial flat anti-spam fee (lamports) per reservation request (`open_or_request`), validator →
/// the Treasury PDA, non-refundable. Seeds `Config.reservation_fee_lamports`. 0.02 SOL — sized so a
/// pool-open (which now busies the miner for the window + reservation TTL, #485) isn't cheap to grief.
pub const RESERVATION_FEE_LAMPORTS: u64 = 20_000_000;

/// Initial reservation-lottery pooling window (seconds). Seeds `Config.pool_window_secs` — how long
/// a pool gathers contending requests before the stake-weighted draw. Must stay well below the
/// reservation TTL; paired with `SLOT_MS` to pin the draw's seed slot.
pub const POOL_WINDOW_SECS: i64 = 60;

/// Initial minimum seconds between successful validator-weight updates (Phase 10) — an anti-thrash
/// floor, not a schedule. Seeds `Config.weights_update_min_interval_secs`.
pub const WEIGHTS_UPDATE_MIN_INTERVAL_SECS: i64 = 3600;

/// Canonical deploy value for `fulfillment_timeout_secs` — 4h. Sized so the slowest chain (BTC
/// confirmations) plus validator confirm fits before timeout fires, avoiding over-slashing an
/// honest-but-unconfirmed miner. Pass at `initialize` (or `set_fulfillment_timeout`); not per-chain yet.
pub const DEFAULT_FULFILLMENT_TIMEOUT_SECS: i64 = 14_400;

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn quote_update_fee_decays_by_tier() {
// Same-second / rapid churn → top tier.
assert_eq!(quote_update_fee(0), QUOTE_UPDATE_FEE_TIER1_LAMPORTS);
assert_eq!(quote_update_fee(299), QUOTE_UPDATE_FEE_TIER1_LAMPORTS);
// 5-min boundary drops to tier 2.
assert_eq!(quote_update_fee(300), QUOTE_UPDATE_FEE_TIER2_LAMPORTS);
assert_eq!(quote_update_fee(599), QUOTE_UPDATE_FEE_TIER2_LAMPORTS);
// 10 min and beyond → free.
assert_eq!(quote_update_fee(600), 0);
assert_eq!(quote_update_fee(86_400), 0);
// Clock skew (negative elapsed) → most-expensive tier, never free.
assert_eq!(quote_update_fee(-100), QUOTE_UPDATE_FEE_TIER1_LAMPORTS);
}

#[test]
fn collateral_requirement_within_bounds() {
assert!(
(COLLATERAL_REQUIREMENT_BPS_MIN..=COLLATERAL_REQUIREMENT_BPS_MAX)
.contains(&COLLATERAL_REQUIREMENT_BPS),
"COLLATERAL_REQUIREMENT_BPS {} outside [{}, {}] (1.0x..=2.0x)",
COLLATERAL_REQUIREMENT_BPS,
COLLATERAL_REQUIREMENT_BPS_MIN,
COLLATERAL_REQUIREMENT_BPS_MAX,
);
}

#[test]
fn required_collateral_scales_by_multiplier() {
// At the shipped 1.10× a 2 SOL swap needs 2.2 SOL of collateral.
assert_eq!(required_collateral(2_000_000_000), 2_200_000_000);
// 1.0× floor would be identity.
assert_eq!(
required_collateral(0),
0,
"zero swap requires zero collateral"
);
}

#[test]
fn required_collateral_never_under_one_x() {
// Whatever the setting, you can never be asked for less than the swap size itself.
for amt in [1u64, 1_000, 1_000_000_000, u64::MAX / 4] {
assert!(required_collateral(amt) >= amt, "under-collateralized at {amt}");
}
}

#[test]
fn required_collateral_saturates_not_wraps() {
// Extreme size must clamp, not wrap to a tiny value.
assert_eq!(required_collateral(u64::MAX), u64::MAX);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub enum ErrorCode {
AmountBelowMin,
#[msg("Swap amount is above the configured maximum")]
AmountAboveMax,
#[msg("Config bounds are contradictory (min must not exceed max)")]
InvalidBounds,
#[msg("Miner already has an active reservation")]
MinerReserved,
#[msg("No active reservation for this miner")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ pub struct QuoteRemoved {
pub miner: Pubkey,
pub from_chain: String,
pub to_chain: String,
/// Anti-flashing churn fee paid into the treasury on removal (lamports); 0 once the quote has
/// stood past the decay window.
pub remove_fee: u64,
}

// --- Phase 9: reservation lottery (pool keyed per miner) ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub fn set_validator_weight(ctx: Context<AdminConfig>, validator: Pubkey, weight
}

pub fn set_consensus_threshold(ctx: Context<AdminConfig>, percent: u8) -> Result<()> {
require!((1..=100).contains(&percent), ErrorCode::InvalidThreshold);
crate::validate::consensus_threshold(percent)?;
ctx.accounts.config.consensus_threshold_percent = percent;
msg!("consensus threshold = {}%", percent);
Ok(())
Expand All @@ -63,42 +63,48 @@ pub fn set_halted(ctx: Context<AdminConfig>, halted: bool) -> Result<()> {
Ok(())
}

// --- Runtime config setters ( port of ink! owner setters; 0 = "unset" where applicable) ---
// --- Runtime config setters (port of ink! owner setters). Bounds are validated via the shared
// `crate::validate` helpers — the SAME rules `initialize` applies, so neither path can set a value
// the other would reject (review #8). 0 means "unbounded/disabled" where a validator allows it.

pub fn set_min_collateral(ctx: Context<AdminConfig>, amount: u64) -> Result<()> {
crate::validate::collateral_bounds(amount, ctx.accounts.config.max_collateral)?;
ctx.accounts.config.min_collateral = amount;
msg!("min_collateral = {}", amount);
Ok(())
}

pub fn set_max_collateral(ctx: Context<AdminConfig>, amount: u64) -> Result<()> {
crate::validate::collateral_bounds(ctx.accounts.config.min_collateral, amount)?;
ctx.accounts.config.max_collateral = amount;
msg!("max_collateral = {}", amount);
Ok(())
}

pub fn set_fulfillment_timeout(ctx: Context<AdminConfig>, secs: i64) -> Result<()> {
require!(secs >= 60, ErrorCode::InvalidAmount);
crate::validate::fulfillment_timeout(secs)?;
ctx.accounts.config.fulfillment_timeout_secs = secs;
msg!("fulfillment_timeout_secs = {}", secs);
Ok(())
}

pub fn set_min_swap_amount(ctx: Context<AdminConfig>, amount: u64) -> Result<()> {
require!(amount == 0 || amount >= 1000, ErrorCode::InvalidAmount);
crate::validate::min_swap_amount(amount)?;
crate::validate::swap_bounds(amount, ctx.accounts.config.max_swap_amount)?;
ctx.accounts.config.min_swap_amount = amount;
msg!("min_swap_amount = {}", amount);
Ok(())
}

pub fn set_max_swap_amount(ctx: Context<AdminConfig>, amount: u64) -> Result<()> {
crate::validate::swap_bounds(ctx.accounts.config.min_swap_amount, amount)?;
ctx.accounts.config.max_swap_amount = amount;
msg!("max_swap_amount = {}", amount);
Ok(())
}

pub fn set_reservation_ttl(ctx: Context<AdminConfig>, secs: i64) -> Result<()> {
require!(secs > 0, ErrorCode::InvalidAmount);
crate::validate::reservation_ttl(secs)?;
ctx.accounts.config.reservation_ttl_secs = secs;
msg!("reservation_ttl_secs = {}", secs);
Ok(())
Expand All @@ -111,14 +117,14 @@ pub fn set_reservation_fee(ctx: Context<AdminConfig>, lamports: u64) -> Result<(
}

pub fn set_pool_window(ctx: Context<AdminConfig>, secs: i64) -> Result<()> {
require!(secs > 0, ErrorCode::InvalidAmount);
crate::validate::pool_window(secs)?;
ctx.accounts.config.pool_window_secs = secs;
msg!("pool_window_secs = {}", secs);
Ok(())
}

pub fn set_weights_update_min_interval(ctx: Context<AdminConfig>, secs: i64) -> Result<()> {
require!(secs >= 0, ErrorCode::InvalidAmount);
crate::validate::weights_update_min_interval(secs)?;
ctx.accounts.config.weights_update_min_interval_secs = secs;
msg!("weights_update_min_interval_secs = {}", secs);
Ok(())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
use anchor_lang::prelude::*;

use crate::consensus::{record_vote, reset_round, swap_request_hash};
use crate::constants::{CONFIG_SEED, MINER_SEED, REQ_CONFIRM, SWAP_SEED, VAULT_SEED, VOTE_SEED};
use crate::tunables::FEE_DIVISOR;
use crate::constants::{
CONFIG_SEED, FEE_DIVISOR, MINER_SEED, REQ_CONFIRM, SWAP_SEED, TREASURY_SEED, VAULT_SEED, VOTE_SEED,
};
use crate::error::ErrorCode;
use crate::events::SwapCompleted;
use crate::penalty::apply_penalty;
use crate::state::{Config, MinerState, Swap, SwapStatus, Vault, VoteRound};
use crate::state::{Config, MinerState, Swap, SwapStatus, Treasury, Vault, VoteRound};

/// Validators confirm a fulfilled swap. On quorum the 1% fee is moved from the miner's collateral
/// into the vault's treasury accrual (lamports stay in the vault), the miner is freed, and the Swap
/// into the Treasury PDA (lamports move vault → treasury), the miner is freed, and the Swap
/// account is closed (rent reclaimed).
#[derive(Accounts)]
#[instruction(swap_key: [u8; 32])]
Expand All @@ -34,6 +35,10 @@ pub struct ConfirmSwap<'info> {
#[account(mut, seeds = [VAULT_SEED], bump = vault.bump)]
pub vault: Account<'info, Vault>,

/// Subnet treasury — the 1% fee moves here out of the miner's collateral.
#[account(mut, seeds = [TREASURY_SEED], bump = treasury.bump)]
pub treasury: Account<'info, Treasury>,

#[account(
mut,
seeds = [SWAP_SEED, swap_key.as_ref()],
Expand Down Expand Up @@ -87,12 +92,18 @@ pub fn handler(ctx: Context<ConfirmSwap>, swap_key: [u8; 32]) -> Result<()> {
fee,
now,
)?;
ctx.accounts.vault.treasury_total = ctx
.accounts
.vault
.treasury_total
.checked_add(actual_fee)
.ok_or(ErrorCode::Overflow)?;
// Move the fee lamports out of the collateral vault and into the subnet treasury (both are
// program-owned PDAs → direct lamport math). apply_penalty already shrank total_collateral.
if actual_fee > 0 {
ctx.accounts.vault.to_account_info().sub_lamports(actual_fee)?;
ctx.accounts.treasury.to_account_info().add_lamports(actual_fee)?;
ctx.accounts.treasury.total = ctx
.accounts
.treasury
.total
.checked_add(actual_fee)
.ok_or(ErrorCode::Overflow)?;
}
ctx.accounts.miner_state.has_active_swap = false;
ctx.accounts.miner_state.busy_until = 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use anchor_lang::prelude::*;

use crate::constants::{CONFIG_SEED, CONFIG_VERSION, VAULT_SEED};
use crate::tunables::{
POOL_WINDOW_SECS, RESERVATION_FEE_LAMPORTS, WEIGHTS_UPDATE_MIN_INTERVAL_SECS,
use crate::constants::{
CONFIG_SEED, CONFIG_VERSION, POOL_WINDOW_SECS, RESERVATION_FEE_LAMPORTS, TREASURY_SEED,
VAULT_SEED, WEIGHTS_UPDATE_MIN_INTERVAL_SECS,
};
use crate::error::ErrorCode;
use crate::state::{Config, Vault};
use crate::state::{Config, Treasury, Vault};

/// Create the singleton Config + native-SOL Vault PDAs. Records admin, collateral bounds,
/// fulfillment timeout (seconds), and the consensus threshold. Validator set starts empty
Expand Down Expand Up @@ -33,6 +32,15 @@ pub struct Initialize<'info> {
)]
pub vault: Account<'info, Vault>,

#[account(
init,
payer = admin,
space = 8 + Treasury::INIT_SPACE,
seeds = [TREASURY_SEED],
bump,
)]
pub treasury: Account<'info, Treasury>,

pub system_program: Program<'info, System>,
}

Expand All @@ -47,10 +55,13 @@ pub fn handler(
max_swap_amount: u64,
reservation_ttl_secs: i64,
) -> Result<()> {
require!(
(1..=100).contains(&consensus_threshold_percent),
ErrorCode::InvalidThreshold
);
// Same validators the admin setters use, so init can't seed a value a setter would later reject.
crate::validate::consensus_threshold(consensus_threshold_percent)?;
crate::validate::fulfillment_timeout(fulfillment_timeout_secs)?;
crate::validate::reservation_ttl(reservation_ttl_secs)?;
crate::validate::min_swap_amount(min_swap_amount)?;
crate::validate::swap_bounds(min_swap_amount, max_swap_amount)?;
crate::validate::collateral_bounds(min_collateral, max_collateral)?;

let config = &mut ctx.accounts.config;
config.admin = ctx.accounts.admin.key();
Expand All @@ -72,9 +83,12 @@ pub fn handler(

let vault = &mut ctx.accounts.vault;
vault.total_collateral = 0;
vault.treasury_total = 0;
vault.bump = ctx.bumps.vault;

let treasury = &mut ctx.accounts.treasury;
treasury.total = 0;
treasury.bump = ctx.bumps.treasury;

msg!(
"initialized: admin={}, threshold={}%, min_collateral={}, max_collateral={}, timeout_secs={}",
config.admin,
Expand Down
Loading