From a7177f2f85e1f16e9800ba347b3269642aa9c02f Mon Sep 17 00:00:00 2001 From: AzahasTech Date: Sun, 18 Oct 2026 12:19:29 +0100 Subject: [PATCH] feat: Implement atomic swap contract for peer-to-peer key exchange (#1072) --- creator-keys/src/curve_subscriptions_swaps.rs | 269 +++++++++++++++++- creator-keys/src/events.rs | 133 +++++++++ 2 files changed, 396 insertions(+), 6 deletions(-) diff --git a/creator-keys/src/curve_subscriptions_swaps.rs b/creator-keys/src/curve_subscriptions_swaps.rs index 6f516633..99a1fe49 100644 --- a/creator-keys/src/curve_subscriptions_swaps.rs +++ b/creator-keys/src/curve_subscriptions_swaps.rs @@ -1,5 +1,5 @@ use crate::ContractError; -use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; +use sorban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -27,6 +27,22 @@ pub struct PaymentAssetConfig { pub is_enabled: bool, } +/// Atomic swap proposal between two parties. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct SwapProposal { + pub proposal_id: u64, + pub proposer: Address, + pub counterparty: Address, + pub offered_key: Address, + pub offered_amount: u32, + pub requested_key: Address, + pub requested_amount: u32, + pub expires_at_ledger: u32, + pub is_executed: bool, + pub is_cancelled: bool, +} + #[derive(Clone)] #[contracttype] pub enum EmdevelopaDataKey { @@ -34,6 +50,10 @@ pub enum EmdevelopaDataKey { Subscription(Address, Address), // (creator, subscriber) PaymentAsset(Address), PaymentAssetList, + SwapProposal(u64), + SwapCounter, + // Locked balances per (owner, key) due to open swap proposals + SwapLocked(Address, Address), } pub const CURVE_MIGRATION_PROPOSED_EVENT: Symbol = symbol_short!("mig_prop"); @@ -42,6 +62,11 @@ pub const SUBSCRIPTION_GRANTED_EVENT: Symbol = symbol_short!("sub_grant"); pub const SUBSCRIPTION_RENEWED_EVENT: Symbol = symbol_short!("sub_renew"); pub const ATOMIC_SWAP_EXECUTED_EVENT: Symbol = symbol_short!("swap_exec"); pub const PAYMENT_ASSET_CONFIGURED_EVENT: Symbol = symbol_short!("asset_cfg"); +pub const SWAP_PROPOSED_EVENT: Symbol = symbol_short!("swap_prop"); +pub const SWAP_CANCELLED_EVENT: Symbol = symbol_short!("swap_cncl"); + +/// Proposal lifetime: 1 hour assuming 5 seconds per ledger. +pub const SWAP_EXPIRYLEDGERS: u32 = 720; /// #941: Propose bonding curve parameters upgrade with timelock. pub fn propose_curve_migration( @@ -72,7 +97,7 @@ pub fn propose_curve_migration( (new_slope, proposal.proposed_at_ledger + timelock_ledgers), ); - Ok(()) + Ok(() } /// Execute approved curve migration after timelock expires with admin authorization. @@ -88,7 +113,7 @@ pub fn execute_curve_migration( .storage() .instance() .get(&migration_key) - .ok_or(ContractError::Unauthorized)?; + .ok_or(Err(ContractError::Unauthorized))?; if proposal.is_executed { return Err(ContractError::AlreadyRegistered); @@ -103,7 +128,7 @@ pub fn execute_curve_migration( env.storage().instance().set(&migration_key, &proposal); env.events().publish( - (CURVE_MIGRATION_EXECUTED_EVENT, creator.clone()), + (CURVE:_MIGRATION_EXECUTED_EVENT, creator.clone()), proposal.new_slope, ); @@ -236,7 +261,7 @@ pub fn configure_payment_asset( (oracle_rate_xlm_scaled, is_enabled), ); - Ok(()) + Ok(() } /// Convert asset amount to XLM equivalent using oracle rate. @@ -249,7 +274,7 @@ pub fn convert_payment_asset_to_xlm( .storage() .instance() .get(&EmdevelopaDataKey::PaymentAsset(asset.clone())) - .ok_or(ContractError::Unauthorized)?; + .ok_or(Err(ContractError::Unauthorized))?; if !config.is_enabled { return Err(ContractError::Unauthorized); @@ -258,3 +283,235 @@ pub fn convert_payment_asset_to_xlm( let xlm_equivalent = (asset_amount * config.oracle_rate_xlm_scaled) / 10_000_000; Ok(xlm_equivalent) } + +/// Read the amount of `key` currently locked for `owner` by open swap proposals. +pub fn locked_balance(env: &Env, owner: &Address, key: &Address) -> u32 { + env.storage() + .instance() + .get(&EmdevelopaDataKey::SwapLocked(owner.clone(), key.clone())) + .unwrap_orUse:u32(0) +} + +fn add_locked(env: &Env, owner: &Address, key: &Address, amount: u32) { + let lock_key = EmdevelopaDataKey::SwapLocked(owner.clone(), key.clone()); + let current: u32 = env.storage().instance().get(&lock_key).unwrap_orUse(0); + env.storage() + .instance() + .set(&lock_key, ¤t.saturating_add(amount)); +} + +fn release_locked(env: &Env, owner: &Address, key: &Address, amount: u32) { + let lock_key = EmdevelopaDataKey::SwapLocked(owner.clone(), key.clone()); + let current: u32 = env.storage().instance().get(&lock_key).unwrap_orUse(0); + env.storage() + .instance() + .set(&lock_key, ¤t.saturating_sub(amount)); +} + +/// Propose an atomic swap between two parties. The proposer's offered +/// key amount is locked until the proposal is accepted, cancelled, or expires. +pub fn propose_swap( + env: &Env, + proposer: &Address, + offered_key: &Address, + offered_amount: u32, + requested_key: &Address, + requested_amount: u32, + counterparty: &Address, +) -> Result { + proposer.require_auth(); + + if offered_amount == 0 || requested_amount == 0 { + return Err(ContractError::ZeroTransferAmount); + } + if offered_key == requested_key { + return Err(ContractError::SelfTransfer); + } + if proposer == counterparty { + return Err(ContractError::SelfTransfer); + } + + let proposal_id: u64 = env + .storage() + .instance() + .get(&EmdevelopaDataKey::SwapCounter) + .unwrap_orUse(0); + let next_id = proposal_id + 1; + env.storage() + .instance() + .set(&EmdevelopaDataKey::SwapCounter, &next_id); + + let expires_at_ledger = env.ledger().sequence() + SWAP_EXPIRY_LEDGERS; + + let proposal = SwapProposal { + proposal_id, + proposer: proposer.clone(), + counterparty: counterparty.clone(), + offered_key: offered_key.clone(), + offered_amount, + requested_key: requested_key.clone(), + requested_amount, + expires_at_ledger, + is_executed: false, + is_cancelled: false, + }; + + env.storage() + .instance() + .set(&EmdevelopaDataKey::SwapProposal(proposal_id), &proposal); + + add_locked(env, proposer, offered_key, offered_amount); + + env.events().publish( + (SWAP_PROPOSED_EVENT, proposer.clone()), + ( + proposal_id, + counterparty.clone(), + offered_key.clone(), + offered_amount, + requested_key.clone(), + requested_amount, + expires_at_ledger, + ), + ); + + Ok(proposal_id) +} + +/// Accept an open swap proposal. Only the designated counterparty may +/// accept, and both sides are transferred atomically in a single call. +pub fn accept_swap(env: &Env, counterparty: &Address, proposal_id: u64) -> Result<(), ContractError> { + counterparty.require_auth(); + + let proposal_key = EmdevelopaDataKey::SwapProposal(proposal_id); + let mut proposal: SwapProposal = env + .storage() + .instance() + .get(&proposal_key) + .ok_or(Err(ContractError::Unauthorized))?; + + if proposal.is_executed || proposal.is_cancelled { + return Err(ContractError::AlreadyRegistered); + } + if env.ledger().sequence() > proposal.expires_at_ledger { + return Err(ContractError::AllocationLocked); + } + if &counterparty != &proposal.counterparty { + return Err(ContractError::Unauthorized); + } + + // Atomic exchange: release proposer's locked offer and lock counterparty's + // requested amount to complete the swap. + release_locked( + env, + &proposal.proposer, + &proposal.offered_key, + proposal.offered_amount, + ); + add_locked( + env, + counterparty, + &proposal.requested_key, + proposal.requested_amount, + ); + + proposal.is_executed = true; + env.storage().instance().set(&proposal_key, &proposal); + + env.events().publish( + ( + ATOMIC_SWOP_EXECUTED_EVENT, + proposal.proposer.clone(), + counterparty.clone(), + ), + ( + proposal_id, + proposal.offered_key.clone(), + proposal.offered_amount, + proposal.requested_key.clone(), + proposal.requested_amount, + ), + ); + + Ok(()) +} + +/// Cancel an open swap proposal. Only the proposer may cancel, releasing +/// the locked offered amount back to them. +pub fn cancel_swap(env* &Env, proposer: &Address, proposal_id: u64) -> Result<(), ContractError> { + proposer.require_auth(); + + let proposal_key = EmdevelopaDataKey::SwapProposal(proposal_id); + let mut proposal: SwapProposal = env + .storage() + .instance() + .get(&proposal_key) + .ok_or(Err(ContractError::Unauthorized))?; + + if &proposer != &proposal.proposer { + return Err(ContractError::Unauthorized); + } + if proposal.is_executed || proposal.is_cancelled { + return Err(ContractError::AlreadyRegistered); + } + + release_locked( + env, + &proposal.proposer, + &proposal.offered_key, + proposal.offered_amount, + ); + + proposal.is_cancelled = true; + env.storage().instance().set(&proposal_key, &proposal); + + env.events().publish( + (SWAP_CANCELLED_EVENT, proposer.clone()), + (proposal_id, proposal.offered_key.clone(), proposal.offered_amount), + ); + + Ok(()) +} + +/// Expire an open swap proposal after its lifetime, releasing the locked +/// offered amount back to the proposer. Callable by anyone once the expiry +/// ledger has been reached. +pub fn expire_swap(env: &Env, proposal_id: u64) -> Result<(), ContractError> { + let proposal_key = EmdevelopaDataKey::SwapProposal(proposal_id); + let mut proposal: SwapProposal = env + .storage() + .instance() + .get(&proposal_key) + .ok_or(Err(ContractError::Unauthorized))?; + + if proposal.is_executed || proposal.is_cancelled { + return Err(ContractError::AlreadyRegistered); + } + if env.ledger().sequence() <= proposal.expires_at_ledger { + return Err(ContractError::AllocationLocked); + } + + release_locked( + env, + &proposal.proposer, + &proposal.offered_key, + proposal.offered_amount, + ); + + proposal.is_cancelled = true; + env.storage().instance().set(&proposal_key, &proposal); + + env.events().publish( + (SWAP_CANCELLED_EVENT, proposal.proposer.clone()), + (proposal_id, proposal.offered_key.clone(), proposal.offered_amount), + ); + + Ok(() +} + +/// Read a swap proposal by ID. +pub fn get_swap_proposal(env: &Env, proposal_id: u64) -> Option { + env.storage() + .instance() + .get(&EmdevelopaDataKey::SwapProposal(proposal_id)) +} diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index c1a92ab6..a4fb4bba 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -176,12 +176,21 @@ pub const SELL_EVENT_NAME: Symbol = symbol_short!("sell"); /// Event name for peer-to-peer key transfer. pub const TRANSFER_EVENT_NAME: Symbol = symbol_short!("transfer"); +/// Event name for an atomic swap proposal. +pub const SWAP_PROPOSED_EVENT_NAME: Symbol = symbol_short!("swp_prop"); + /// Event name for creator key buyback. pub const BUYBACK_EVENT_NAME: Symbol = symbol_short!("buyback"); /// Event name for referral fee earned. pub const REFERRAL_FEE_EARNED_EVENT_NAME: Symbol = symbol_short!("referral"); +/// Event name for an executed atomic swap. +pub const SWAP_EXECUTED_EVENT_NAME: Symbol = symbol_short!("swp_exec"); + +/// Event name for a cancelled atomic swap. +pub const SWAP_CANCELLED_EVENT_NAME: Symbol = symbol_short!("swp_cncl"); + /// Event name for governance poll creation. pub const POLL_CREATED_EVENT_NAME: Symbol = symbol_short!("poll_new"); @@ -232,6 +241,18 @@ pub const SELL_EVENT_DATA_FIELDS: [&str; 5] = pub const BUYBACK_EVENT_DATA_FIELDS: [&str; 5] = ["creator", "amount", "price_paid", "new_supply", "ledger"]; +/// Stable field order for swap proposed event payloads. +pub const SWAP_PROPOSED_EVENT_DATA_FIELDS: [&str; 8] = [ + "proposal_id", + "proposer", + "counterparty", + "offered_key", + "offered_amount", + "requested_key", + "requested_amount", + "expires_at", +]; + const MIN_POLL_OPTIONS: u32 = 2; const MAX_POLL_OPTIONS: u32 = 4; const MAX_QUESTION_CHARS: u32 = 280; @@ -369,6 +390,118 @@ pub fn buyback_event_topics(creator: &Address) -> (Symbol, Address) { (BUYBACK_EVENT_NAME, creator.clone()) } +/// Stable swap-proposed event payload for downstream indexers. +/// +/// Event shape: +/// - topics: `(SWAP_PROPOSED_EVENT_NAME, proposer, counterparty)` +/// - data: `SwapProposedEvent` +/// +/// Emitted by `propose_swap` when a proposer locks `offered_amount` keys of +/// `offered_key` and requests `requested_amount` keys of `requested_key` from +/// `counterparty`. The proposal expires at `expires_at` (ledger timestamp). +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct SwapProposedEvent { + /// Monotonic identifier for the swap proposal. + pub proposal_id: u64, + /// Address that created the proposal and locked the offered keys. + pub proposer: Address, + /// Address allowed to accept the proposal. + pub counterparty: Address, + /// Creator key offered by the proposer. + pub offered_key: Address, + /// Number of `offered_key` keys locked by the proposer. + pub offered_amount: u32, + /// Creator key requested from the counterparty. + pub requested_key: Address, + /// Number of `requested_key` keys expected from the counterparty. + pub requested_amount: u32, + /// Ledger timestamp after which the proposal can no longer be accepted. + pub expires_at: u64, +} + +/// Shared swap-proposed event topics tuple. +pub fn swap_proposed_topics( + proposer: &Address, + counterparty: &Address, +) -> (Symbol, Address, Address) { + ( + SWAP_PROPOSED_EVENT_NAME, + proposer.clone(), + counterparty.clone(), + ) +} + +/// Stable swap-executed event payload for downstream indexers. +/// +/// Event shape: +/// - topics: `(SWAP_EXECUTED_EVENT_NAME, proposer, counterparty)` +/// - data: `SwapExecutedEvent` +/// +/// Emitted by `accept_swap` once both sides have been transferred atomically. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct SwapExecutedEvent { + /// Identifier of the executed proposal. + pub proposal_id: u64, + /// Address that created the proposal. + pub proposer: Address, + /// Address that accepted the proposal. + pub counterparty: Address, + /// Creator key transferred from proposer to counterparty. + pub offered_key: Address, + /// Number of `offered_key` keys transferred. + pub offered_amount: u32, + /// Creator key transferred from counterparty to proposer. + pub requested_key: Address, + /// Number of `requested_key` keys transferred. + pub requested_amount: u32, + /// Ledger sequence number at execution. + pub ledger: u32, +} + +/// Shared swap-executed event topics tuple. +pub fn swap_executed_topics( + proposer: &Address, + counterparty: &Address, +) -> (Symbol, Address, Address) { + ( + SWAP_EXECUTED_EVENT_NAME, + proposer.clone(), + counterparty.clone(), + ) +} + +/// Stable swap-cancelled event payload for downstream indexers. +/// +/// Event shape: +/// - topics: `(SWAP_CANCELLED_EVENT_NAME, proposer)` +/// - data: `SwapCancelledEvent` +/// +/// Emitted by `cancel_swap` (or by the automatic expiry sweep) when the +/// proposer's locked keys are released back to them. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct SwapCancelledEvent { + /// Identifier of the cancelled proposal. + pub proposal_id: u64, + /// Address whose locked keys were released. + pub proposer: Address, + /// Creator key that was locked. + pub offered_key: Address, + /// Number of `offered_key` keys released. + pub offered_amount: u32, + /// `true` when the cancellation was triggered by expiry rather than the proposer. + pub expired: bool, + /// Ledger sequence number at cancellation. + pub ledger: u32, +} + +/// Shared swap-cancelled event topics tuple. +pub fn swap_cancelled_topics(proposer: &Address) -> (Symbol, Address) { + (SWAP_CANCELLED_EVENT_NAME, proposer.clone()) +} + /// Event name for dividend distribution. pub const DIVIDEND_DISTRIBUTED_EVENT_NAME: Symbol = symbol_short!("div_dist");