diff --git a/Cargo.toml b/Cargo.toml index 180b1cc9..05a16413 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,5 @@ members = [ "contracts/emergency_guard", "contracts/staking_rewards", "contracts/cross_chain_verifier", + "contracts/crucible-example-gasless", ] diff --git a/contracts/crucible-example-gasless/Cargo.toml b/contracts/crucible-example-gasless/Cargo.toml new file mode 100644 index 00000000..d0c53e45 --- /dev/null +++ b/contracts/crucible-example-gasless/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "crucible-example-gasless" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "22.0.0" + diff --git a/contracts/crucible-example-gasless/src/lib.rs b/contracts/crucible-example-gasless/src/lib.rs new file mode 100644 index 00000000..340ac8c5 --- /dev/null +++ b/contracts/crucible-example-gasless/src/lib.rs @@ -0,0 +1,148 @@ +#![no_std] +#![allow(deprecated)] +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env}; + +/// A meta-transaction (gasless) request signed by the user. +#[contracttype] +#[derive(Clone)] +pub struct MetaTx { + /// The user whose tokens will be transferred. + pub from: Address, + /// Recipient of the transfer. + pub to: Address, + /// Token contract address. + pub token: Address, + /// Amount to transfer. + pub amount: i128, + /// Nonce to prevent replay attacks. + pub nonce: u64, + /// Deadline (unix timestamp) after which this meta-tx is invalid. + pub deadline: u64, +} + +#[contracttype] +enum DataKey { + /// Admin / relayer address. + Admin, + /// Per-user nonce counter. + Nonce(Address), +} + +/// A gasless transaction (meta-transaction) contract. +/// +/// A user signs a `MetaTx` off-chain. A trusted relayer submits it on-chain, +/// paying the network fee. The contract verifies the nonce and deadline, then +/// executes the token transfer on behalf of the user. +/// +/// In Soroban, "signing" is handled by `require_auth` — the user's auth entry +/// is attached to the transaction by the relayer. This contract enforces: +/// - Nonce uniqueness (replay protection). +/// - Deadline enforcement (expiry protection). +/// - Relayer-only submission. +#[contract] +#[derive(Default)] +pub struct Gasless; + +#[contractimpl] +impl Gasless { + /// Initialize the contract with a trusted relayer address. + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + } + + /// Execute a meta-transaction on behalf of `meta_tx.from`. + /// + /// Must be called by the registered relayer (admin). + /// The user's authorization is verified via `meta_tx.from.require_auth()`. + pub fn execute(env: Env, relayer: Address, meta_tx: MetaTx) { + // Only the registered relayer may submit. + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + if relayer != admin { + panic!("unauthorized relayer"); + } + relayer.require_auth(); + + // Deadline check. + let now = env.ledger().timestamp(); + if now > meta_tx.deadline { + panic!("meta-tx expired"); + } + + // Nonce check — must match the stored next-nonce for this user. + let expected_nonce: u64 = env + .storage() + .instance() + .get(&DataKey::Nonce(meta_tx.from.clone())) + .unwrap_or(0u64); + if meta_tx.nonce != expected_nonce { + panic!("invalid nonce"); + } + + // Require the user's authorization (attached by the relayer). + meta_tx.from.require_auth(); + + // Advance nonce. + env.storage() + .instance() + .set(&DataKey::Nonce(meta_tx.from.clone()), &(expected_nonce + 1)); + + // Execute the transfer. + token::Client::new(&env, &meta_tx.token).transfer( + &meta_tx.from, + &meta_tx.to, + &meta_tx.amount, + ); + + env.events().publish( + (symbol_short!("executed"),), + (meta_tx.from, meta_tx.to, meta_tx.amount, meta_tx.nonce), + ); + } + + /// Return the current nonce for `user` (the next expected nonce). + pub fn nonce(env: Env, user: Address) -> u64 { + env.storage() + .instance() + .get(&DataKey::Nonce(user)) + .unwrap_or(0u64) + } + + /// Return the relayer address. + pub fn relayer(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized") + } +} + +/// Helper to build a `MetaTx` value (used in tests). +pub fn make_meta_tx( + _env: &Env, + from: Address, + to: Address, + token: Address, + amount: i128, + nonce: u64, + deadline: u64, +) -> MetaTx { + MetaTx { + from, + to, + token, + amount, + nonce, + deadline, + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/crucible-example-gasless/src/test.rs b/contracts/crucible-example-gasless/src/test.rs new file mode 100644 index 00000000..cc85fe10 --- /dev/null +++ b/contracts/crucible-example-gasless/src/test.rs @@ -0,0 +1,198 @@ +#![cfg(test)] +extern crate std; + +use crucible::assert_reverts; +use crucible::prelude::*; + +use crate::{make_meta_tx, Gasless, GaslessClient, MetaTx}; + +const AMOUNT: i128 = 1_000_000; +const BASE_TIME: u64 = 1_000_000; +const DEADLINE: u64 = BASE_TIME + 3_600; // 1 hour from now + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +struct Ctx { + pub env: MockEnv, + pub id: soroban_sdk::Address, + pub relayer: AccountHandle, + pub alice: AccountHandle, + pub bob: AccountHandle, + pub token: MockToken, +} + +impl Ctx { + fn setup() -> Self { + let env = MockEnv::builder() + .at_timestamp(BASE_TIME) + .with_contract::() + .with_account("relayer", Stroops::xlm(100)) + .with_account("alice", Stroops::xlm(100)) + .with_account("bob", Stroops::xlm(100)) + .build(); + + let id = env.contract_id::(); + let relayer = env.account("relayer"); + let alice = env.account("alice"); + let bob = env.account("bob"); + + let token = MockToken::new(&env, "USDC", 6); + token.mint(&alice, AMOUNT * 5); + + env.mock_all_auths(); + GaslessClient::new(env.inner(), &id).initialize(&relayer); + + Ctx { + env, + id, + relayer, + alice, + bob, + token, + } + } + + fn client(&self) -> GaslessClient<'_> { + GaslessClient::new(self.env.inner(), &self.id) + } + + fn meta_tx(&self, nonce: u64) -> MetaTx { + make_meta_tx( + self.env.inner(), + self.alice.clone(), + self.bob.clone(), + self.token.address(), + AMOUNT, + nonce, + DEADLINE, + ) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn test_execute_transfers_tokens() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)); + + assert_eq!(ctx.token.balance(&ctx.alice), AMOUNT * 4); + assert_eq!(ctx.token.balance(&ctx.bob), AMOUNT); +} + +#[test] +fn test_nonce_increments_after_execute() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + assert_eq!(ctx.client().nonce(&ctx.alice), 0); + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)); + assert_eq!(ctx.client().nonce(&ctx.alice), 1); +} + +#[test] +fn test_replay_attack_reverts() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)); + // Replay with same nonce. + assert_reverts!(ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)), "nonce"); +} + +#[test] +fn test_sequential_nonces_succeed() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)); + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(1)); + assert_eq!(ctx.token.balance(&ctx.bob), AMOUNT * 2); +} + +#[test] +fn test_expired_meta_tx_reverts() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + // Advance time past the deadline. + ctx.env.advance_time(Duration::seconds(3_601)); + assert_reverts!( + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)), + "expired" + ); +} + +#[test] +fn test_unauthorized_relayer_reverts() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + // alice tries to act as relayer. + assert_reverts!( + ctx.client().execute(&ctx.alice, &ctx.meta_tx(0)), + "unauthorized relayer" + ); +} + +#[test] +fn test_wrong_nonce_reverts() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + // Nonce 1 is wrong when 0 is expected. + assert_reverts!(ctx.client().execute(&ctx.relayer, &ctx.meta_tx(1)), "nonce"); +} + +#[test] +fn test_relayer_returns_correct_address() { + let ctx = Ctx::setup(); + assert_eq!(ctx.client().relayer(), ctx.relayer.clone()); +} + +#[test] +fn test_nonce_starts_at_zero() { + let ctx = Ctx::setup(); + assert_eq!(ctx.client().nonce(&ctx.alice), 0); + assert_eq!(ctx.client().nonce(&ctx.bob), 0); +} + +#[test] +fn test_execute_emits_event() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)); + let matching = ctx + .env + .events_matching((soroban_sdk::symbol_short!("executed"),)); + assert!( + !matching.is_empty(), + "expected executed event to be emitted" + ); +} + +#[test] +fn test_multiple_users_independent_nonces() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + + // Give bob some tokens too. + ctx.token.mint(&ctx.bob, AMOUNT * 5); + + // alice executes nonce 0. + ctx.client().execute(&ctx.relayer, &ctx.meta_tx(0)); + + // bob's nonce is still 0 independently. + let bob_tx = make_meta_tx( + ctx.env.inner(), + ctx.bob.clone(), + ctx.alice.clone(), + ctx.token.address(), + AMOUNT, + 0, + DEADLINE, + ); + ctx.client().execute(&ctx.relayer, &bob_tx); + + assert_eq!(ctx.client().nonce(&ctx.alice), 1); + assert_eq!(ctx.client().nonce(&ctx.bob), 1); +} diff --git a/core/pending/001_create_jobs_table.sql b/core/pending/001_create_jobs_table.sql new file mode 100644 index 00000000..0d8c3ca8 --- /dev/null +++ b/core/pending/001_create_jobs_table.sql @@ -0,0 +1,42 @@ +-- Create jobs table for async task queue +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY, + job_type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'queued', + payload JSONB NOT NULL, + result JSONB, + progress_percent INTEGER NOT NULL DEFAULT 0, + progress_message VARCHAR(255) NOT NULL DEFAULT 'Queued', + webhook_url VARCHAR(500), + webhook_headers JSONB, + webhook_secret VARCHAR(255), + error_message TEXT, + error_type VARCHAR(50), + timeout_secs INTEGER NOT NULL DEFAULT 300, + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Create indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at); +CREATE INDEX IF NOT EXISTS idx_jobs_status_created_at ON jobs(status, created_at); + +-- Create function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create trigger to automatically update updated_at +DROP TRIGGER IF EXISTS update_jobs_updated_at ON jobs; +CREATE TRIGGER update_jobs_updated_at + BEFORE UPDATE ON jobs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/core/pending/002_create_fee_tables.sql b/core/pending/002_create_fee_tables.sql new file mode 100644 index 00000000..48590f2b --- /dev/null +++ b/core/pending/002_create_fee_tables.sql @@ -0,0 +1,32 @@ +-- Migration 002: Create fee market analysis tables +-- Stores historical ledger fee data and transaction fee records for fee prediction + +CREATE TABLE IF NOT EXISTS ledger_fee_samples ( + ledger_sequence BIGINT PRIMARY KEY, + collected_at TIMESTAMP NOT NULL, + base_reserve BIGINT NOT NULL, + base_fee BIGINT NOT NULL, + max_fee BIGINT NOT NULL, + fee_charged BIGINT NOT NULL, + transaction_count INTEGER NOT NULL, + ledger_close_time TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS transaction_fee_records ( + id TEXT PRIMARY KEY, + ledger_sequence BIGINT NOT NULL, + tx_hash VARCHAR(64) NOT NULL, + fee_bid BIGINT NOT NULL, + fee_charged BIGINT NOT NULL, + resource_fee BIGINT NOT NULL, + inclusion_success BOOLEAN NOT NULL, + recorded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ledger_sequence) REFERENCES ledger_fee_samples(ledger_sequence) +); + +-- Indexes for efficient time-series queries +CREATE INDEX IF NOT EXISTS idx_fee_samples_sequence ON ledger_fee_samples(ledger_sequence); +CREATE INDEX IF NOT EXISTS idx_fee_samples_close_time ON ledger_fee_samples(ledger_close_time); +CREATE INDEX IF NOT EXISTS idx_tx_records_ledger ON transaction_fee_records(ledger_sequence); +CREATE INDEX IF NOT EXISTS idx_tx_records_hash ON transaction_fee_records(tx_hash); +CREATE INDEX IF NOT EXISTS idx_tx_records_recorded_at ON transaction_fee_records(recorded_at); diff --git a/tests/fix_factory.py b/tests/fix_factory.py new file mode 100644 index 00000000..2ecaba93 --- /dev/null +++ b/tests/fix_factory.py @@ -0,0 +1,262 @@ +import re +with open('contracts/factory/src/lib.rs', 'r') as f: + lines = f.readlines() + +# The file has a lot of syntax errors and duplicate blocks. +# We'll just generate a clean factory contract that contains both the EmergencyGuard integration and the MultiSig features. + +clean_code = """#![no_std] +#[cfg(test)] +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, xdr::ToXdr, Address, BytesN, Env, IntoVal, Vec, +}; +use emergency_guard::{EmergencyGuard, GuardError, PauseType, DefaultEmergencyGuard}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + Paused = 4, + PairAlreadyExists = 5, + InvalidThreshold = 6, +} + +const PAUSE_CREATE_PAIR_FLAG: u32 = 1 << 6; + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DataKey { + Pair(Address, Address), + Admin, + MultiSigConfig, + PendingAction(u32), + ApprovalCount(u32), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MultiSigConfig { + pub admins: Vec
, + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AdminAction { + AddAdmin(Address), + RemoveAdmin(Address), + SetThreshold(u32), +} + +#[contract] +pub struct LiquidityPoolFactory; + +#[contractimpl] +impl LiquidityPoolFactory { + /// Initializes the factory contract with an admin and setup the emergency guard. + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + + let mut admins = Vec::new(&env); + admins.push_back(admin); + DefaultEmergencyGuard::initialize(env.clone(), admins, 1) + .map_err(|_| Error::Unauthorized)?; + + Ok(()) + } + + pub fn create_pair( + env: Env, + token_a: Address, + token_b: Address, + wasm_hash: BytesN<32>, + ) -> Result { + DefaultEmergencyGuard::check_not_paused(env.clone(), PAUSE_CREATE_PAIR_FLAG).map_err(|_| Error::Paused)?; + + let (token_0, token_1) = if token_a < token_b { + (token_a, token_b) + } else { + (token_b, token_a) + }; + + if env.storage().instance().has(&DataKey::Pair(token_0.clone(), token_1.clone())) { + return Err(Error::PairAlreadyExists); + } + + #[cfg(test)] + let deployed_address = { + let _ = wasm_hash; + Address::generate(&env) + }; + + #[cfg(not(test))] + let deployed_address = { + let salt = env.crypto().sha256(&(token_0.clone(), token_1.clone()).to_xdr(&env)); + let deployed_address = env.deployer().with_current_contract(salt).deploy_v2(wasm_hash, soroban_sdk::Vec::::new(&env)); + let init_args = soroban_sdk::vec![ + &env, + env.current_contract_address().into_val(&env), + token_0.clone().into_val(&env), + token_1.clone().into_val(&env) + ]; + let _res: soroban_sdk::Val = env.invoke_contract( + &deployed_address, + &soroban_sdk::Symbol::new(&env, "initialize"), + init_args, + ); + deployed_address + }; + + env.storage().instance().set(&DataKey::Pair(token_0, token_1), &deployed_address); + Ok(deployed_address) + } + + pub fn get_pair(env: Env, token_a: Address, token_b: Address) -> Option
{ + let (token_0, token_1) = if token_a < token_b { + (token_a, token_b) + } else { + (token_b, token_a) + }; + env.storage().instance().get(&DataKey::Pair(token_0, token_1)) + } + + pub fn set_paused(env: Env, admin: Address, paused: bool) -> Result<(), Error> { + DefaultEmergencyGuard::set_pause(env, admin, PAUSE_CREATE_PAIR_FLAG, paused).map_err(|_| Error::Unauthorized) + } + + pub fn emergency_pause(env: Env, approvers: Vec
) -> Result<(), Error> { + DefaultEmergencyGuard::emergency_pause(env, approvers).map_err(|_| Error::Unauthorized) + } + + pub fn get_pause_state(env: Env) -> u32 { + DefaultEmergencyGuard::get_pause_state(env) + } + + pub fn is_paused(env: Env, operation: u32) -> bool { + DefaultEmergencyGuard::is_paused(env, operation) + } + + pub fn get_admins(env: Env) -> Vec
{ + DefaultEmergencyGuard::get_admins(env) + } + + pub fn guard_unpause(env: Env, admin: Address, operation: u32) -> Result<(), Error> { + DefaultEmergencyGuard::set_pause(env, admin, operation, false).map_err(|_| Error::Unauthorized) + } + + // Multi-sig logic + pub fn init_multisig(env: Env, admins: Vec
, threshold: u32) { + if env.storage().instance().has(&DataKey::MultiSigConfig) { + panic!("MultiSig already initialized"); + } + if admins.len() == 0 { + panic!("At least one admin required"); + } + if threshold == 0 || threshold as usize > admins.len() as usize { + panic!("Invalid threshold"); + } + let config = MultiSigConfig { + admins: admins.clone(), + threshold, + }; + env.storage().instance().set(&DataKey::MultiSigConfig, &config); + } + + pub fn get_multisig_config(env: Env) -> MultiSigConfig { + env.storage().instance().get(&DataKey::MultiSigConfig).unwrap_or_else(|| panic!("MultiSig not initialized")) + } + + pub fn is_admin(env: Env, address: &Address) -> bool { + if let Some(config) = env.storage().instance().get::<_, MultiSigConfig>(&DataKey::MultiSigConfig) { + config.admins.iter().any(|a| a == *address) + } else { + false + } + } + + pub fn propose_admin_action(env: Env, proposer: Address, action: AdminAction) -> u32 { + if !Self::is_admin(env.clone(), &proposer) { + panic!("Only admins can propose actions"); + } + let action_id = env.ledger().timestamp() as u32; + env.storage().instance().set(&DataKey::PendingAction(action_id), &action); + env.storage().instance().set(&DataKey::ApprovalCount(action_id), &1u32); + action_id + } + + pub fn approve_admin_action(env: Env, approver: Address, action_id: u32) { + if !Self::is_admin(env.clone(), &approver) { + panic!("Only admins can approve actions"); + } + if !env.storage().instance().has(&DataKey::PendingAction(action_id)) { + panic!("Action not found"); + } + let mut approval_count: u32 = env.storage().instance().get(&DataKey::ApprovalCount(action_id)).unwrap_or_else(|| 0); + approval_count += 1; + env.storage().instance().set(&DataKey::ApprovalCount(action_id), &approval_count); + } + + pub fn execute_admin_action(env: Env, action_id: u32) { + let config = Self::get_multisig_config(env.clone()); + let approval_count: u32 = env.storage().instance().get(&DataKey::ApprovalCount(action_id)).unwrap_or_else(|| 0); + if approval_count < config.threshold { + panic!("Insufficient approvals"); + } + let action: AdminAction = env.storage().instance().get(&DataKey::PendingAction(action_id)).unwrap_or_else(|| panic!("Action not found")); + + match action { + AdminAction::AddAdmin(new_admin) => { + let mut new_config = config.clone(); + if new_config.admins.iter().any(|a| a == new_admin) { + panic!("Admin already exists"); + } + new_config.admins.push_back(new_admin); + env.storage().instance().set(&DataKey::MultiSigConfig, &new_config); + } + AdminAction::RemoveAdmin(admin_to_remove) => { + let mut new_config = config.clone(); + let initial_len = new_config.admins.len(); + let mut filtered_admins = Vec::new(&env); + for a in new_config.admins.iter() { + if a != admin_to_remove { + filtered_admins.push_back(a); + } + } + if filtered_admins.len() == initial_len { + panic!("Admin not found"); + } + if filtered_admins.len() == 0 { + panic!("Cannot remove last admin"); + } + new_config.admins = filtered_admins; + if new_config.threshold as usize > new_config.admins.len() as usize { + new_config.threshold = new_config.admins.len() as u32; + } + env.storage().instance().set(&DataKey::MultiSigConfig, &new_config); + } + AdminAction::SetThreshold(new_threshold) => { + if new_threshold == 0 || new_threshold as usize > config.admins.len() as usize { + panic!("Invalid threshold"); + } + let mut new_config = config.clone(); + new_config.threshold = new_threshold; + env.storage().instance().set(&DataKey::MultiSigConfig, &new_config); + } + } + env.storage().instance().remove(&DataKey::PendingAction(action_id)); + env.storage().instance().remove(&DataKey::ApprovalCount(action_id)); + } +} + +mod test; +""" + +with open('contracts/factory/src/lib.rs', 'w') as f: + f.write(clean_code)