diff --git a/apps/contracts/Cargo.lock b/apps/contracts/Cargo.lock index 2d6528e..59acd26 100644 --- a/apps/contracts/Cargo.lock +++ b/apps/contracts/Cargo.lock @@ -305,6 +305,13 @@ dependencies = [ "soroban-sdk 22.0.8", ] +[[package]] +name = "consensus-release-contract" +version = "1.0.0" +dependencies = [ + "soroban-sdk 21.7.7", +] + [[package]] name = "const-oid" version = "0.9.6" diff --git a/apps/contracts/contracts/consensus-release-contract/Cargo.toml b/apps/contracts/contracts/consensus-release-contract/Cargo.toml new file mode 100644 index 0000000..338e2a2 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "consensus-release-contract" +version = "1.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/README.md b/apps/contracts/contracts/consensus-release-contract/README.md new file mode 100644 index 0000000..42dcda2 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/README.md @@ -0,0 +1,338 @@ +# Consensus Release Smart Contract + +A secure, multi-party consensus-based escrow system built on Stellar using the Soroban SDK. This contract ensures funds are only released when all required parties agree, providing a transparent and trustworthy mechanism for complex transactions. + +## Overview + +The Consensus Release Contract handles scenarios where funds in escrow should only be released after consensus is reached between multiple parties (buyer, seller, and optional arbitrator). It provides various consensus mechanisms to accommodate different transaction requirements while maintaining security and transparency. + +## Features + +### ๐Ÿ” Multi-Party Consensus +- **Unanimous**: All parties must agree +- **Majority**: More than 50% of parties must agree +- **Buyer-Seller Only**: Only buyer and seller need to agree (ignores arbitrator) +- **With Arbitrator**: Buyer, seller, and arbitrator must all agree + +### ๐Ÿ›ก๏ธ Secure Escrow Management +- Funds locked in smart contract until consensus +- Prevention of unauthorized or premature releases +- Automatic refund mechanisms for expired transactions + +### โš–๏ธ Dispute Handling +- Party rejection triggers automatic refund +- Time-based fallback for expired transactions +- Optional arbitrator for complex disputes + +### ๐Ÿ“Š Event Logging +- Complete transaction lifecycle tracking +- Agreement/rejection notifications +- Fund release and refund events +- Status change notifications + +### ๐Ÿงช Thoroughly Tested +- Comprehensive unit test coverage +- Edge case validation +- Happy path and failure scenarios + +## Contract Architecture + +### Core Types + +```rust +pub struct ConsensusTransaction { + pub transaction_id: u64, + pub buyer: Address, + pub seller: Address, + pub arbitrator: Option
, + pub token: Address, + pub amount: i128, + pub description: String, + pub status: TransactionStatus, + pub consensus_rule: ConsensusRule, + pub deadline: u64, + pub created_at: u64, + pub agreements: Map, + pub required_parties: Vec
, +} + +pub enum ConsensusRule { + Unanimous, // All parties must agree + Majority, // More than 50% must agree + BuyerSellerOnly, // Only buyer and seller need to agree + WithArbitrator, // Buyer, seller, and arbitrator must all agree +} + +pub enum TransactionStatus { + Created, // Transaction created, awaiting funding + Funded, // Funds locked in escrow + ConsensusReached, // All required parties agreed + Released, // Funds released to seller + Refunded, // Funds refunded to buyer + Expired, // Transaction expired +} +``` + +### Error Handling + +The contract provides comprehensive error handling with specific error codes: + +- **Initialization Errors**: Already initialized, admin not set, unauthorized +- **Transaction Errors**: Invalid amount/deadline, duplicate parties, arbitrator issues +- **Status Errors**: Invalid status transitions, expired transactions +- **Agreement Errors**: Unauthorized parties, duplicate submissions +- **Fund Management Errors**: Transfer failures, insufficient funds + +## Usage Guide + +### 1. Contract Initialization + +```rust +// Initialize contract with admin +contract.initialize(admin_address); +``` + +### 2. Creating a Transaction + +```rust +let transaction_id = contract.create_transaction( + buyer_address, + seller_address, + Some(arbitrator_address), // Optional + token_address, + amount, // Amount in token units + "Product delivery", // Description + ConsensusRule::Unanimous, // Consensus mechanism + 3600 // Deadline in seconds +); +``` + +### 3. Funding the Transaction + +```rust +// Buyer funds the transaction +contract.fund_transaction(buyer_address, transaction_id); +``` + +### 4. Submitting Agreements + +```rust +// Each party submits their decision +contract.submit_agreement( + party_address, + transaction_id, + true, // true = agree, false = reject + Some("Quality confirmed") // Optional reason +); +``` + +### 5. Releasing Funds + +```rust +// Once consensus is reached, any party can trigger release +contract.release_funds(caller_address, transaction_id); +``` + +### 6. Handling Expired Transactions + +```rust +// Anyone can clean up expired transactions +contract.handle_expiration(transaction_id); +``` + +## Consensus Rules Explained + +### Unanimous Consensus +All parties (buyer, seller, and arbitrator if present) must agree. +```rust +ConsensusRule::Unanimous +``` + +### Majority Consensus +More than 50% of involved parties must agree. +```rust +ConsensusRule::Majority +``` + +### Buyer-Seller Only +Only buyer and seller decisions matter (arbitrator's vote ignored). +```rust +ConsensusRule::BuyerSellerOnly +``` + +### With Arbitrator +Requires all three parties (buyer, seller, arbitrator) to agree. +```rust +ConsensusRule::WithArbitrator +``` + +## Event Monitoring + +The contract emits detailed events for transparency: + +```rust +// Transaction lifecycle events +TransactionCreatedEvent +TransactionFundedEvent +TransactionStatusChangedEvent +TransactionExpiredEvent + +// Consensus events +AgreementSubmittedEvent +ConsensusReachedEvent +ConsensusRejectedEvent + +// Fund movement events +FundsReleasedEvent +FundsRefundedEvent +``` + +## Example Workflows + +### Happy Path: Successful Transaction + +1. **Create**: Buyer creates transaction with seller and arbitrator +2. **Fund**: Buyer deposits funds to contract +3. **Agree**: All parties submit agreements +4. **Release**: Funds automatically released to seller + +### Dispute Path: Rejected Transaction + +1. **Create**: Transaction created and funded +2. **Disagree**: One party rejects the agreement +3. **Refund**: Funds automatically refunded to buyer + +### Expiration Path: Time-Based Refund + +1. **Create**: Transaction created and funded +2. **Expire**: Deadline passes without consensus +3. **Cleanup**: Anyone calls `handle_expiration` +4. **Refund**: Funds returned to buyer + +## Security Considerations + +### โœ… Implemented Safeguards +- Authorization checks for all sensitive operations +- Input validation and sanitization +- Reentrancy protection via status checks +- Time-based expiration handling +- Comprehensive error handling + +### ๐Ÿ”’ Best Practices +- Always set reasonable deadlines +- Choose appropriate consensus rules +- Monitor transaction events +- Handle expired transactions promptly +- Validate token addresses and amounts + +## Testing + +Run the comprehensive test suite: + +```bash +cargo test +``` + +### Test Coverage +- โœ… Contract initialization +- โœ… Transaction creation and validation +- โœ… Fund management +- โœ… All consensus mechanisms +- โœ… Agreement submission and validation +- โœ… Fund release scenarios +- โœ… Rejection and refund flows +- โœ… Expiration handling +- โœ… Admin management +- โœ… Edge cases and error conditions + +## Integration Examples + +### E-commerce Platform +```rust +// Create escrow for product purchase +let tx_id = contract.create_transaction( + buyer, + seller, + Some(platform_arbitrator), + usdc_token, + product_price, + "Product purchase with quality guarantee", + ConsensusRule::WithArbitrator, + 7 * 24 * 3600 // 7 days +); +``` + +### Service Agreement +```rust +// Create milestone-based service payment +let tx_id = contract.create_transaction( + client, + freelancer, + None, + payment_token, + service_fee, + "Website development milestone 1", + ConsensusRule::BuyerSellerOnly, + 30 * 24 * 3600 // 30 days +); +``` + +### Complex Business Deal +```rust +// Multi-party business transaction +let tx_id = contract.create_transaction( + company_a, + company_b, + Some(legal_arbitrator), + stable_token, + contract_value, + "Merger agreement conditional release", + ConsensusRule::Unanimous, + 90 * 24 * 3600 // 90 days +); +``` + +## API Reference + +### Core Functions + +- `initialize(admin: Address)` - Initialize contract +- `create_transaction(...)` - Create new consensus transaction +- `fund_transaction(buyer: Address, transaction_id: u64)` - Fund transaction +- `submit_agreement(party: Address, transaction_id: u64, agreed: bool, reason: Option)` - Submit agreement/rejection +- `release_funds(caller: Address, transaction_id: u64)` - Release funds after consensus +- `handle_expiration(transaction_id: u64)` - Handle expired transactions + +### Query Functions + +- `get_transaction(transaction_id: u64)` - Get transaction details +- `get_user_transactions(user: Address)` - Get user's transaction IDs +- `get_admin()` - Get current admin address +- `get_transaction_counter()` - Get total transaction count + +### Admin Functions + +- `set_admin(current_admin: Address, new_admin: Address)` - Transfer admin rights + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Write comprehensive tests +4. Follow Rust and Soroban best practices +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Support + +For questions and support: +- Create an issue in the repository +- Review the test cases for usage examples +- Check the Soroban documentation for SDK details + +--- + +Built with โค๏ธ using [Soroban SDK](https://soroban.stellar.org/) on the Stellar blockchain. \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/contract.rs b/apps/contracts/contracts/consensus-release-contract/src/contract.rs new file mode 100644 index 0000000..b5751ec --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/contract.rs @@ -0,0 +1,376 @@ +use crate::{ + error::ContractError, + events, + storage::{self, add_user_transaction, get_transaction, increment_transaction_counter, save_transaction}, + types::{ConsensusRule, ConsensusTransaction, TransactionStatus}, + utils, +}; +use soroban_sdk::{token, Address, Env, String, Vec}; + +pub fn create_transaction( + env: &Env, + buyer: &Address, + seller: &Address, + arbitrator: Option
, + token: &Address, + amount: i128, + description: String, + consensus_rule: ConsensusRule, + deadline_duration: u64, +) -> Result { + // Input validation + if amount <= 0 { + return Err(ContractError::InvalidAmount); + } + + if deadline_duration == 0 { + return Err(ContractError::InvalidDeadline); + } + + if buyer == seller { + return Err(ContractError::DuplicateParties); + } + + if let Some(ref arb) = arbitrator { + if arb == buyer || arb == seller { + return Err(ContractError::DuplicateParties); + } + } + + // Validate consensus rule with arbitrator + if consensus_rule == ConsensusRule::WithArbitrator && arbitrator.is_none() { + return Err(ContractError::ArbitratorRequired); + } + + let current_time = env.ledger().timestamp(); + let deadline = current_time + deadline_duration; + + if deadline <= current_time { + return Err(ContractError::InvalidDeadline); + } + + // Generate transaction ID + let transaction_id = increment_transaction_counter(env); + + // Create new transaction + let transaction = utils::create_consensus_transaction( + env, + transaction_id, + buyer.clone(), + seller.clone(), + arbitrator.clone(), + token.clone(), + amount, + description, + consensus_rule.clone(), + deadline, + ); + + // Save transaction + save_transaction(env, &transaction)?; + + // Add to user transaction lists + add_user_transaction(env, buyer, transaction_id)?; + add_user_transaction(env, seller, transaction_id)?; + if let Some(ref arb) = arbitrator { + add_user_transaction(env, arb, transaction_id)?; + } + + // Emit event + let event = events::TransactionCreatedEvent { + transaction_id, + buyer: buyer.clone(), + seller: seller.clone(), + arbitrator: arbitrator.clone(), + token: token.clone(), + amount, + consensus_rule, + deadline, + }; + events::emit_transaction_created(env, event); + + Ok(transaction_id) +} + +pub fn fund_transaction(env: &Env, buyer: &Address, transaction_id: u64) -> Result<(), ContractError> { + let mut transaction = get_transaction(env, transaction_id)?; + + // Verify caller is the buyer + if buyer != &transaction.buyer { + return Err(ContractError::NotAuthorized); + } + + // Check transaction status + if transaction.status != TransactionStatus::Created { + return Err(ContractError::TransactionAlreadyFunded); + } + + // Check if transaction is expired + let current_time = env.ledger().timestamp(); + if utils::is_expired(&transaction, current_time) { + transaction.status = TransactionStatus::Expired; + save_transaction(env, &transaction)?; + + let event = events::TransactionExpiredEvent { + transaction_id, + deadline: transaction.deadline, + timestamp: current_time, + }; + events::emit_transaction_expired(env, event); + + return Err(ContractError::TransactionExpired); + } + + // Transfer funds to contract + let token_client = token::Client::new(env, &transaction.token); + token_client.transfer(buyer, &env.current_contract_address(), &transaction.amount); + + // Update transaction status + let old_status = transaction.status.clone(); + transaction.status = TransactionStatus::Funded; + save_transaction(env, &transaction)?; + + // Emit events + let funded_event = events::TransactionFundedEvent { + transaction_id, + buyer: buyer.clone(), + amount: transaction.amount, + timestamp: current_time, + }; + events::emit_transaction_funded(env, funded_event); + + let status_event = events::TransactionStatusChangedEvent { + transaction_id, + old_status, + new_status: transaction.status.clone(), + timestamp: current_time, + }; + events::emit_transaction_status_changed(env, status_event); + + Ok(()) +} + +pub fn submit_agreement( + env: &Env, + party: &Address, + transaction_id: u64, + agreed: bool, + reason: Option, +) -> Result<(), ContractError> { + let mut transaction = get_transaction(env, transaction_id)?; + let current_time = env.ledger().timestamp(); + + // Check if transaction is expired + if utils::is_expired(&transaction, current_time) { + transaction.status = TransactionStatus::Expired; + save_transaction(env, &transaction)?; + + let event = events::TransactionExpiredEvent { + transaction_id, + deadline: transaction.deadline, + timestamp: current_time, + }; + events::emit_transaction_expired(env, event); + + return Err(ContractError::TransactionExpired); + } + + // Verify party can submit agreement + if !utils::can_submit_agreement(&transaction, party) { + return Err(ContractError::NotAuthorizedParty); + } + + // Check if already agreed + if transaction.agreements.contains_key(party.clone()) { + return Err(ContractError::AgreementAlreadySubmitted); + } + + // Add agreement + utils::add_agreement(&mut transaction, party.clone(), agreed, reason.clone(), current_time); + + // Check for rejection + if !agreed { + transaction.status = TransactionStatus::Refunded; + save_transaction(env, &transaction)?; + + // Refund buyer + let token_client = token::Client::new(env, &transaction.token); + token_client.transfer(&env.current_contract_address(), &transaction.buyer, &transaction.amount); + + // Emit events + let rejection_event = events::ConsensusRejectedEvent { + transaction_id, + rejecting_party: party.clone(), + timestamp: current_time, + }; + events::emit_consensus_rejected(env, rejection_event); + + let refund_event = events::FundsRefundedEvent { + transaction_id, + buyer: transaction.buyer.clone(), + amount: transaction.amount, + reason: reason.unwrap_or_else(|| String::from_str(env, "Party rejected agreement")), + timestamp: current_time, + }; + events::emit_funds_refunded(env, refund_event); + + return Ok(()); + } + + // Check for consensus + if utils::has_consensus(&transaction) { + transaction.status = TransactionStatus::ConsensusReached; + + // Emit consensus reached event + let consensus_event = events::ConsensusReachedEvent { + transaction_id, + timestamp: current_time, + consensus_type: transaction.consensus_rule.clone(), + }; + events::emit_consensus_reached(env, consensus_event); + } + + save_transaction(env, &transaction)?; + + // Emit agreement submitted event + let agreement_event = events::AgreementSubmittedEvent { + transaction_id, + party: party.clone(), + agreed, + reason, + timestamp: current_time, + }; + events::emit_agreement_submitted(env, agreement_event); + + Ok(()) +} + +pub fn release_funds(env: &Env, caller: Address, transaction_id: u64) -> Result<(), ContractError> { + let mut transaction = get_transaction(env, transaction_id)?; + let current_time = env.ledger().timestamp(); + + // Check if transaction is expired + if utils::is_expired(&transaction, current_time) { + transaction.status = TransactionStatus::Expired; + save_transaction(env, &transaction)?; + + let event = events::TransactionExpiredEvent { + transaction_id, + deadline: transaction.deadline, + timestamp: current_time, + }; + events::emit_transaction_expired(env, event); + + return Err(ContractError::TransactionExpired); + } + + // Verify caller is authorized (any party in the transaction) + if !transaction.required_parties.iter().any(|p| p == caller) { + return Err(ContractError::NotAuthorized); + } + + // Check transaction status + if transaction.status != TransactionStatus::ConsensusReached { + return Err(ContractError::ConsensusNotReached); + } + + // Double-check consensus + if !utils::has_consensus(&transaction) { + return Err(ContractError::ConsensusNotReached); + } + + // Transfer funds to seller + let token_client = token::Client::new(env, &transaction.token); + token_client.transfer(&env.current_contract_address(), &transaction.seller, &transaction.amount); + + // Update transaction status + let old_status = transaction.status.clone(); + transaction.status = TransactionStatus::Released; + save_transaction(env, &transaction)?; + + // Emit events + let release_event = events::FundsReleasedEvent { + transaction_id, + seller: transaction.seller.clone(), + amount: transaction.amount, + timestamp: current_time, + }; + events::emit_funds_released(env, release_event); + + let status_event = events::TransactionStatusChangedEvent { + transaction_id, + old_status, + new_status: transaction.status.clone(), + timestamp: current_time, + }; + events::emit_transaction_status_changed(env, status_event); + + Ok(()) +} + +pub fn handle_expiration(env: &Env, transaction_id: u64) -> Result<(), ContractError> { + let mut transaction = get_transaction(env, transaction_id)?; + let current_time = env.ledger().timestamp(); + + // Check if actually expired + if !utils::is_expired(&transaction, current_time) { + return Err(ContractError::InvalidTimestamp); + } + + // Can only handle expiration if not already released or refunded + if transaction.status == TransactionStatus::Released || + transaction.status == TransactionStatus::Refunded || + transaction.status == TransactionStatus::Expired { + return Err(ContractError::InvalidTransactionStatus); + } + + // Update status to expired + let old_status = transaction.status.clone(); + transaction.status = TransactionStatus::Expired; + + // If funds were deposited, refund to buyer + if old_status == TransactionStatus::Funded || + old_status == TransactionStatus::ConsensusReached { + transaction.status = TransactionStatus::Refunded; + + let token_client = token::Client::new(env, &transaction.token); + token_client.transfer(&env.current_contract_address(), &transaction.buyer, &transaction.amount); + + let refund_event = events::FundsRefundedEvent { + transaction_id, + buyer: transaction.buyer.clone(), + amount: transaction.amount, + reason: String::from_str(env, "Transaction expired"), + timestamp: current_time, + }; + events::emit_funds_refunded(env, refund_event); + } + + save_transaction(env, &transaction)?; + + // Emit events + let expired_event = events::TransactionExpiredEvent { + transaction_id, + deadline: transaction.deadline, + timestamp: current_time, + }; + events::emit_transaction_expired(env, expired_event); + + let status_event = events::TransactionStatusChangedEvent { + transaction_id, + old_status, + new_status: transaction.status.clone(), + timestamp: current_time, + }; + events::emit_transaction_status_changed(env, status_event); + + Ok(()) +} + +pub fn get_transaction_details(env: &Env, transaction_id: u64) -> Result { + get_transaction(env, transaction_id) +} + +pub fn get_user_transactions(env: &Env, user: &Address) -> Vec { + storage::get_user_transactions(env, user) +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/error.rs b/apps/contracts/contracts/consensus-release-contract/src/error.rs new file mode 100644 index 0000000..007a055 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/error.rs @@ -0,0 +1,47 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + // Initialization Errors + AlreadyInitialized = 1, + AdminNotSet = 2, + NotAuthorized = 3, + + // Transaction Errors + TransactionNotFound = 10, + InvalidAmount = 11, + InvalidDeadline = 12, + InvalidConsensusRule = 13, + DuplicateParties = 14, + ArbitratorRequired = 15, + + // Status Errors + TransactionNotFunded = 20, + TransactionAlreadyFunded = 21, + TransactionExpired = 22, + TransactionAlreadyReleased = 23, + TransactionAlreadyRefunded = 24, + InvalidTransactionStatus = 25, + + // Agreement Errors + NotAuthorizedParty = 30, + AgreementAlreadySubmitted = 31, + ConsensusNotReached = 32, + ConsensusRejected = 33, + + // Fund Management Errors + InsufficientFunds = 40, + TransferFailed = 41, + + // System Errors + InvalidTimestamp = 50, + StorageError = 51, +} + +impl ContractError { + pub fn as_u32(&self) -> u32 { + *self as u32 + } +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/events.rs b/apps/contracts/contracts/consensus-release-contract/src/events.rs new file mode 100644 index 0000000..45eee11 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/events.rs @@ -0,0 +1,123 @@ +use soroban_sdk::{Address, Env, String, contracttype}; +use crate::types::{ConsensusRule, TransactionStatus}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionCreatedEvent { + pub transaction_id: u64, + pub buyer: Address, + pub seller: Address, + pub arbitrator: Option
, + pub token: Address, + pub amount: i128, + pub consensus_rule: ConsensusRule, + pub deadline: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionFundedEvent { + pub transaction_id: u64, + pub buyer: Address, + pub amount: i128, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AgreementSubmittedEvent { + pub transaction_id: u64, + pub party: Address, + pub agreed: bool, + pub reason: Option, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConsensusReachedEvent { + pub transaction_id: u64, + pub timestamp: u64, + pub consensus_type: ConsensusRule, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConsensusRejectedEvent { + pub transaction_id: u64, + pub rejecting_party: Address, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FundsReleasedEvent { + pub transaction_id: u64, + pub seller: Address, + pub amount: i128, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FundsRefundedEvent { + pub transaction_id: u64, + pub buyer: Address, + pub amount: i128, + pub reason: String, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionStatusChangedEvent { + pub transaction_id: u64, + pub old_status: TransactionStatus, + pub new_status: TransactionStatus, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionExpiredEvent { + pub transaction_id: u64, + pub deadline: u64, + pub timestamp: u64, +} + +// Helper functions to emit events +pub fn emit_transaction_created(env: &Env, event: TransactionCreatedEvent) { + env.events().publish(("transaction_created",), event); +} + +pub fn emit_transaction_funded(env: &Env, event: TransactionFundedEvent) { + env.events().publish(("transaction_funded",), event); +} + +pub fn emit_agreement_submitted(env: &Env, event: AgreementSubmittedEvent) { + env.events().publish(("agreement_submitted",), event); +} + +pub fn emit_consensus_reached(env: &Env, event: ConsensusReachedEvent) { + env.events().publish(("consensus_reached",), event); +} + +pub fn emit_consensus_rejected(env: &Env, event: ConsensusRejectedEvent) { + env.events().publish(("consensus_rejected",), event); +} + +pub fn emit_funds_released(env: &Env, event: FundsReleasedEvent) { + env.events().publish(("funds_released",), event); +} + +pub fn emit_funds_refunded(env: &Env, event: FundsRefundedEvent) { + env.events().publish(("funds_refunded",), event); +} + +pub fn emit_transaction_status_changed(env: &Env, event: TransactionStatusChangedEvent) { + env.events().publish(("transaction_status_changed",), event); +} + +pub fn emit_transaction_expired(env: &Env, event: TransactionExpiredEvent) { + env.events().publish(("transaction_expired",), event); +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/lib.rs b/apps/contracts/contracts/consensus-release-contract/src/lib.rs new file mode 100644 index 0000000..e72af11 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/lib.rs @@ -0,0 +1,146 @@ +#![no_std] + +mod contract; +mod error; +mod events; +mod storage; +mod types; +mod utils; + +#[cfg(test)] +mod test; + +use contract::*; +use error::ContractError; +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use types::{ConsensusRule, ConsensusTransaction}; + +#[contract] +pub struct ConsensusReleaseContract; + +#[contractimpl] +impl ConsensusReleaseContract { + /// Initialize the contract with an admin + pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { + if storage::has_admin(&env) { + return Err(ContractError::AlreadyInitialized); + } + admin.require_auth(); + storage::set_admin(&env, &admin)?; + Ok(()) + } + + /// Create a new consensus transaction + /// + /// # Arguments + /// * `buyer` - Address of the buyer who will fund the transaction + /// * `seller` - Address of the seller who will receive funds upon consensus + /// * `arbitrator` - Optional arbitrator address for dispute resolution + /// * `token` - Token contract address for the transaction + /// * `amount` - Amount to be held in escrow + /// * `description` - Description of the transaction + /// * `consensus_rule` - Rule defining how consensus is reached + /// * `deadline_duration` - Time in seconds from now until transaction expires + pub fn create_transaction( + env: Env, + buyer: Address, + seller: Address, + arbitrator: Option
, + token: Address, + amount: i128, + description: String, + consensus_rule: ConsensusRule, + deadline_duration: u64, + ) -> Result { + buyer.require_auth(); + create_transaction( + &env, + &buyer, + &seller, + arbitrator, + &token, + amount, + description, + consensus_rule, + deadline_duration, + ) + } + + /// Fund a transaction by transferring tokens to escrow + /// Can only be called by the buyer of the transaction + pub fn fund_transaction( + env: Env, + buyer: Address, + transaction_id: u64, + ) -> Result<(), ContractError> { + buyer.require_auth(); + fund_transaction(&env, &buyer, transaction_id) + } + + /// Submit agreement or rejection for a transaction + /// Can be called by any authorized party (buyer, seller, arbitrator) + /// + /// # Arguments + /// * `party` - Address of the party submitting the agreement + /// * `transaction_id` - ID of the transaction + /// * `agreed` - true if agreeing to release funds, false if rejecting + /// * `reason` - Optional reason for the decision + pub fn submit_agreement( + env: Env, + party: Address, + transaction_id: u64, + agreed: bool, + reason: Option, + ) -> Result<(), ContractError> { + party.require_auth(); + submit_agreement(&env, &party, transaction_id, agreed, reason) + } + + /// Release funds to seller after consensus is reached + /// Can be called by any authorized party once consensus is achieved + pub fn release_funds( + env: Env, + caller: Address, + transaction_id: u64, + ) -> Result<(), ContractError> { + caller.require_auth(); + release_funds(&env, caller, transaction_id) + } + + /// Handle expired transactions by refunding buyer if applicable + /// Can be called by anyone to clean up expired transactions + pub fn handle_expiration(env: Env, transaction_id: u64) -> Result<(), ContractError> { + handle_expiration(&env, transaction_id) + } + + /// Get detailed information about a transaction + pub fn get_transaction(env: Env, transaction_id: u64) -> Result { + get_transaction_details(&env, transaction_id) + } + + /// Get all transaction IDs associated with a user + pub fn get_user_transactions(env: Env, user: Address) -> Vec { + get_user_transactions(&env, &user) + } + + /// Get the current admin address + pub fn get_admin(env: Env) -> Result { + storage::get_admin(&env) + } + + /// Transfer admin rights to a new address + pub fn set_admin(env: Env, current_admin: Address, new_admin: Address) -> Result<(), ContractError> { + current_admin.require_auth(); + let admin = storage::get_admin(&env)?; + if admin != current_admin { + return Err(ContractError::NotAuthorized); + } + storage::set_admin(&env, &new_admin)?; + Ok(()) + } + + /// Get the current transaction counter + pub fn get_transaction_counter(env: Env) -> u64 { + storage::get_transaction_counter(&env) + } +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/storage.rs b/apps/contracts/contracts/consensus-release-contract/src/storage.rs new file mode 100644 index 0000000..4b2b509 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/storage.rs @@ -0,0 +1,78 @@ +use crate::types::{ConsensusTransaction, DataKey}; +use soroban_sdk::{Address, Env, Vec}; + +const INSTANCE_LIFETIME_THRESHOLD: u32 = 518400; // 30 days +const INSTANCE_BUMP_AMOUNT: u32 = 1036800; // 60 days + +pub fn get_transaction(env: &Env, transaction_id: u64) -> Result { + let key = DataKey::Transaction(transaction_id); + env.storage() + .persistent() + .get(&key) + .ok_or(crate::error::ContractError::TransactionNotFound) +} + +pub fn save_transaction(env: &Env, transaction: &ConsensusTransaction) -> Result<(), crate::error::ContractError> { + let key = DataKey::Transaction(transaction.transaction_id); + env.storage().persistent().set(&key, transaction); + env.storage() + .persistent() + .extend_ttl(&key, INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) +} + +pub fn get_transaction_counter(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::TransactionCounter) + .unwrap_or(0) +} + +pub fn increment_transaction_counter(env: &Env) -> u64 { + let counter = get_transaction_counter(env) + 1; + env.storage() + .persistent() + .set(&DataKey::TransactionCounter, &counter); + env.storage() + .persistent() + .extend_ttl(&DataKey::TransactionCounter, INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + counter +} + +pub fn get_user_transactions(env: &Env, user: &Address) -> Vec { + let key = DataKey::UserTransactions(user.clone()); + env.storage() + .persistent() + .get(&key) + .unwrap_or_else(|| soroban_sdk::vec![env]) +} + +pub fn add_user_transaction(env: &Env, user: &Address, transaction_id: u64) -> Result<(), crate::error::ContractError> { + let key = DataKey::UserTransactions(user.clone()); + let mut transactions = get_user_transactions(env, user); + transactions.push_back(transaction_id); + env.storage().persistent().set(&key, &transactions); + env.storage() + .persistent() + .extend_ttl(&key, INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) +} + +pub fn has_admin(env: &Env) -> bool { + env.storage().persistent().has(&DataKey::Admin) +} + +pub fn get_admin(env: &Env) -> Result { + env.storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(crate::error::ContractError::AdminNotSet) +} + +pub fn set_admin(env: &Env, admin: &Address) -> Result<(), crate::error::ContractError> { + env.storage().persistent().set(&DataKey::Admin, admin); + env.storage() + .persistent() + .extend_ttl(&DataKey::Admin, INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + Ok(()) +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/test.rs b/apps/contracts/contracts/consensus-release-contract/src/test.rs new file mode 100644 index 0000000..4fc36f6 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/test.rs @@ -0,0 +1,593 @@ +#![cfg(test)] + +use crate::{ + error::ContractError, + types::{ConsensusRule, TransactionStatus}, + ConsensusReleaseContract, ConsensusReleaseContractClient, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, +}; + +fn create_token_contract<'a>(env: &Env, admin: &Address) -> (token::Client<'a>, token::StellarAssetClient<'a>) { + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + ( + token::Client::new(env, &sac.address()), + token::StellarAssetClient::new(env, &sac.address()), + ) +} + +struct TestContext { + env: Env, + admin: Address, + buyer: Address, + seller: Address, + arbitrator: Address, + token: token::Client<'static>, + token_admin: token::StellarAssetClient<'static>, + contract: ConsensusReleaseContractClient<'static>, +} + +impl TestContext { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + let arbitrator = Address::generate(&env); + + let (token, token_admin) = create_token_contract(&env, &admin); + token_admin.mint(&buyer, &1000); + + let contract_address = env.register_contract(None, ConsensusReleaseContract); + let contract = ConsensusReleaseContractClient::new(&env, &contract_address); + + // Initialize contract + contract.initialize(&admin); + + Self { + env, + admin, + buyer, + seller, + arbitrator, + token, + token_admin, + contract, + } + } + + fn advance_time(&self, seconds: u64) { + self.env.ledger().with_mut(|info| { + info.timestamp += seconds; + }); + } +} + +#[test] +fn test_initialization() { + let ctx = TestContext::new(); + + // Test successful initialization + assert_eq!(ctx.contract.get_admin(), ctx.admin); + + // Test double initialization fails + let result = ctx.contract.try_initialize(&ctx.admin); + assert_eq!(result, Err(Ok(ContractError::AlreadyInitialized))); +} + +#[test] +fn test_create_transaction_success() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &Some(ctx.arbitrator.clone()), + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::Unanimous, + &3600, // 1 hour + ); + + assert_eq!(transaction_id, 1); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.buyer, ctx.buyer); + assert_eq!(transaction.seller, ctx.seller); + assert_eq!(transaction.arbitrator, Some(ctx.arbitrator.clone())); + assert_eq!(transaction.amount, 100); + assert_eq!(transaction.status, TransactionStatus::Created); +} + +#[test] +fn test_create_transaction_validation() { + let ctx = TestContext::new(); + + // Test invalid amount + let result = ctx.contract.try_create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &0, // Invalid amount + &String::from_str(&ctx.env, "Test"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + assert_eq!(result, Err(Ok(ContractError::InvalidAmount))); + + // Test duplicate parties + let result = ctx.contract.try_create_transaction( + &ctx.buyer, + &ctx.buyer, // Same as buyer + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + assert_eq!(result, Err(Ok(ContractError::DuplicateParties))); + + // Test arbitrator required for WithArbitrator rule + let result = ctx.contract.try_create_transaction( + &ctx.buyer, + &ctx.seller, + &None, // No arbitrator + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test"), + &ConsensusRule::WithArbitrator, + &3600, + ); + assert_eq!(result, Err(Ok(ContractError::ArbitratorRequired))); +} + +#[test] +fn test_fund_transaction_success() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::Funded); + assert_eq!(ctx.token.balance(&ctx.buyer), 900); + assert_eq!(ctx.token.balance(&ctx.contract.address), 100); +} + +#[test] +fn test_fund_transaction_validation() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + // Test unauthorized funding + let result = ctx.contract.try_fund_transaction(&ctx.seller, &transaction_id); + assert_eq!(result, Err(Ok(ContractError::NotAuthorized))); + + // Fund successfully first time + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Test double funding + let result = ctx.contract.try_fund_transaction(&ctx.buyer, &transaction_id); + assert_eq!(result, Err(Ok(ContractError::TransactionAlreadyFunded))); +} + +#[test] +fn test_unanimous_consensus_success() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &Some(ctx.arbitrator.clone()), + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::Unanimous, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // All parties agree + ctx.contract.submit_agreement( + &ctx.buyer, + &transaction_id, + &true, + &Some(String::from_str(&ctx.env, "Buyer agrees")), + ); + + ctx.contract.submit_agreement( + &ctx.seller, + &transaction_id, + &true, + &Some(String::from_str(&ctx.env, "Seller agrees")), + ); + + ctx.contract.submit_agreement( + &ctx.arbitrator, + &transaction_id, + &true, + &Some(String::from_str(&ctx.env, "Arbitrator agrees")), + ); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::ConsensusReached); + + // Release funds + ctx.contract.release_funds(&ctx.buyer, &transaction_id); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::Released); + assert_eq!(ctx.token.balance(&ctx.seller), 100); + assert_eq!(ctx.token.balance(&ctx.contract.address), 0); +} + +#[test] +fn test_majority_consensus_success() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &Some(ctx.arbitrator.clone()), + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::Majority, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Two out of three parties agree (majority) + ctx.contract.submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + ctx.contract.submit_agreement(&ctx.seller, &transaction_id, &true, &None); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::ConsensusReached); +} + +#[test] +fn test_buyer_seller_only_consensus() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &Some(ctx.arbitrator.clone()), + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Only buyer and seller need to agree + ctx.contract.submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + ctx.contract.submit_agreement(&ctx.seller, &transaction_id, &true, &None); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::ConsensusReached); +} + +#[test] +fn test_consensus_rejection() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Seller rejects + ctx.contract.submit_agreement( + &ctx.seller, + &transaction_id, + &false, + &Some(String::from_str(&ctx.env, "Quality issues")), + ); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::Refunded); + assert_eq!(ctx.token.balance(&ctx.buyer), 1000); // Refunded + assert_eq!(ctx.token.balance(&ctx.contract.address), 0); +} + +#[test] +fn test_agreement_validation() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Test unauthorized party + let unauthorized = Address::generate(&ctx.env); + let result = ctx.contract.try_submit_agreement(&unauthorized, &transaction_id, &true, &None); + assert_eq!(result, Err(Ok(ContractError::NotAuthorizedParty))); + + // Submit agreement + ctx.contract.submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + + // Test duplicate agreement + let result = ctx.contract.try_submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + assert_eq!(result, Err(Ok(ContractError::AgreementAlreadySubmitted))); +} + +#[test] +fn test_release_funds_validation() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Test release without consensus + let result = ctx.contract.try_release_funds(&ctx.buyer, &transaction_id); + assert_eq!(result, Err(Ok(ContractError::ConsensusNotReached))); + + // Achieve consensus but don't release yet + ctx.contract.submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + ctx.contract.submit_agreement(&ctx.seller, &transaction_id, &true, &None); + + // Test unauthorized release + let unauthorized = Address::generate(&ctx.env); + let result = ctx.contract.try_release_funds(&unauthorized, &transaction_id); + assert_eq!(result, Err(Ok(ContractError::NotAuthorized))); + + // Successful release + ctx.contract.release_funds(&ctx.buyer, &transaction_id); + + // Test duplicate release + let result = ctx.contract.try_release_funds(&ctx.buyer, &transaction_id); + assert_eq!(result, Err(Ok(ContractError::ConsensusNotReached))); +} + +#[test] +fn test_transaction_expiration() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, // 1 hour + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Advance time past deadline + ctx.advance_time(3601); + + // Try to submit agreement after expiration + let result = ctx.contract.try_submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + assert_eq!(result, Err(Ok(ContractError::TransactionExpired))); + + // Handle expiration should refund + ctx.contract.handle_expiration(&transaction_id); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::Refunded); + assert_eq!(ctx.token.balance(&ctx.buyer), 1000); // Refunded +} + +#[test] +fn test_expiration_before_funding() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, // 1 hour + ); + + // Advance time past deadline + ctx.advance_time(3601); + + // Try to fund after expiration + let result = ctx.contract.try_fund_transaction(&ctx.buyer, &transaction_id); + assert_eq!(result, Err(Ok(ContractError::TransactionExpired))); +} + +#[test] +fn test_get_user_transactions() { + let ctx = TestContext::new(); + + let tx1 = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Transaction 1"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + let tx2 = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &200, + &String::from_str(&ctx.env, "Transaction 2"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + let buyer_transactions = ctx.contract.get_user_transactions(&ctx.buyer); + assert_eq!(buyer_transactions.len(), 2); + assert!(buyer_transactions.contains(tx1)); + assert!(buyer_transactions.contains(tx2)); + + let seller_transactions = ctx.contract.get_user_transactions(&ctx.seller); + assert_eq!(seller_transactions.len(), 2); + assert!(seller_transactions.contains(tx1)); + assert!(seller_transactions.contains(tx2)); +} + +#[test] +fn test_admin_management() { + let ctx = TestContext::new(); + let new_admin = Address::generate(&ctx.env); + + // Test set admin by current admin + ctx.contract.set_admin(&ctx.admin, &new_admin); + assert_eq!(ctx.contract.get_admin(), new_admin); + + // Test unauthorized admin change + let result = ctx.contract.try_set_admin(&ctx.admin, &ctx.buyer); + assert_eq!(result, Err(Ok(ContractError::NotAuthorized))); +} + +#[test] +fn test_transaction_counter() { + let ctx = TestContext::new(); + + assert_eq!(ctx.contract.get_transaction_counter(), 0); + + ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &3600, + ); + + assert_eq!(ctx.contract.get_transaction_counter(), 1); +} + +#[test] +fn test_with_arbitrator_rule() { + let ctx = TestContext::new(); + + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &Some(ctx.arbitrator.clone()), + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::WithArbitrator, + &3600, + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Buyer and seller agree, but arbitrator is required + ctx.contract.submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + ctx.contract.submit_agreement(&ctx.seller, &transaction_id, &true, &None); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::Funded); // Not yet consensus + + // Arbitrator agrees + ctx.contract.submit_agreement(&ctx.arbitrator, &transaction_id, &true, &None); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::ConsensusReached); +} + +#[test] +fn test_invalid_transaction_id() { + let ctx = TestContext::new(); + + let result = ctx.contract.try_get_transaction(&999); + assert_eq!(result, Err(Ok(ContractError::TransactionNotFound))); + + let result = ctx.contract.try_fund_transaction(&ctx.buyer, &999); + assert_eq!(result, Err(Ok(ContractError::TransactionNotFound))); +} + +#[test] +fn test_edge_case_same_timestamp() { + let ctx = TestContext::new(); + + // Create transaction with very short deadline + let transaction_id = ctx.contract.create_transaction( + &ctx.buyer, + &ctx.seller, + &None, + &ctx.token.address, + &100, + &String::from_str(&ctx.env, "Test transaction"), + &ConsensusRule::BuyerSellerOnly, + &1, // 1 second + ); + + ctx.contract.fund_transaction(&ctx.buyer, &transaction_id); + + // Should still be able to submit agreements immediately + ctx.contract.submit_agreement(&ctx.buyer, &transaction_id, &true, &None); + ctx.contract.submit_agreement(&ctx.seller, &transaction_id, &true, &None); + + let transaction = ctx.contract.get_transaction(&transaction_id); + assert_eq!(transaction.status, TransactionStatus::ConsensusReached); +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/types.rs b/apps/contracts/contracts/consensus-release-contract/src/types.rs new file mode 100644 index 0000000..2439c56 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/types.rs @@ -0,0 +1,57 @@ +use soroban_sdk::{contracttype, Address, Map, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConsensusTransaction { + pub transaction_id: u64, + pub buyer: Address, + pub seller: Address, + pub arbitrator: Option
, + pub token: Address, + pub amount: i128, + pub description: soroban_sdk::String, + pub status: TransactionStatus, + pub consensus_rule: ConsensusRule, + pub deadline: u64, + pub created_at: u64, + pub agreements: Map, + pub required_parties: Vec
, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransactionStatus { + Created, + Funded, + ConsensusReached, + Released, + Refunded, + Expired, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConsensusRule { + Unanimous, // All parties must agree + Majority, // More than 50% must agree + BuyerSellerOnly, // Only buyer and seller need to agree (ignores arbitrator) + WithArbitrator, // Buyer, seller, and arbitrator must all agree +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Agreement { + pub party: Address, + pub agreed: bool, + pub timestamp: u64, + pub reason: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Transaction(u64), + TransactionCounter, + UserTransactions(Address), + Admin, +} \ No newline at end of file diff --git a/apps/contracts/contracts/consensus-release-contract/src/utils.rs b/apps/contracts/contracts/consensus-release-contract/src/utils.rs new file mode 100644 index 0000000..249ca09 --- /dev/null +++ b/apps/contracts/contracts/consensus-release-contract/src/utils.rs @@ -0,0 +1,102 @@ +use crate::types::{Agreement, ConsensusRule, ConsensusTransaction, TransactionStatus}; +use soroban_sdk::{Address, Env, Map}; + +pub fn create_consensus_transaction( + env: &Env, + transaction_id: u64, + buyer: Address, + seller: Address, + arbitrator: Option
, + token: Address, + amount: i128, + description: soroban_sdk::String, + consensus_rule: ConsensusRule, + deadline: u64, +) -> ConsensusTransaction { + let mut required_parties = soroban_sdk::vec![env, buyer.clone(), seller.clone()]; + + if let Some(ref arb) = arbitrator { + if consensus_rule == ConsensusRule::WithArbitrator || consensus_rule == ConsensusRule::Unanimous { + required_parties.push_back(arb.clone()); + } + } + + ConsensusTransaction { + transaction_id, + buyer, + seller, + arbitrator, + token, + amount, + description, + status: TransactionStatus::Created, + consensus_rule, + deadline, + created_at: env.ledger().timestamp(), + agreements: Map::new(env), + required_parties, + } +} + +pub fn add_agreement( + transaction: &mut ConsensusTransaction, + party: Address, + agreed: bool, + reason: Option, + timestamp: u64 +) { + let agreement = Agreement { + party: party.clone(), + agreed, + timestamp, + reason, + }; + transaction.agreements.set(party, agreement); +} + +pub fn has_consensus(transaction: &ConsensusTransaction) -> bool { + match transaction.consensus_rule { + ConsensusRule::Unanimous => { + transaction.required_parties.iter().all(|party| { + transaction.agreements.get(party).map_or(false, |agreement| agreement.agreed) + }) + } + ConsensusRule::Majority => { + let total_parties = transaction.required_parties.len() as u32; + let agreed_count = transaction.required_parties.iter() + .filter(|party| { + transaction.agreements.get(party.clone()).map_or(false, |agreement| agreement.agreed) + }) + .count() as u32; + agreed_count > total_parties / 2 + } + ConsensusRule::BuyerSellerOnly => { + transaction.agreements.get(transaction.buyer.clone()).map_or(false, |agreement| agreement.agreed) && + transaction.agreements.get(transaction.seller.clone()).map_or(false, |agreement| agreement.agreed) + } + ConsensusRule::WithArbitrator => { + if let Some(ref arbitrator) = transaction.arbitrator { + transaction.agreements.get(transaction.buyer.clone()).map_or(false, |agreement| agreement.agreed) && + transaction.agreements.get(transaction.seller.clone()).map_or(false, |agreement| agreement.agreed) && + transaction.agreements.get(arbitrator.clone()).map_or(false, |agreement| agreement.agreed) + } else { + false // Can't have WithArbitrator rule without an arbitrator + } + } + } +} + +pub fn _has_rejection(transaction: &ConsensusTransaction) -> bool { + transaction.required_parties.iter().any(|party| { + transaction.agreements.get(party).map_or(false, |agreement| !agreement.agreed) + }) +} + +pub fn is_expired(transaction: &ConsensusTransaction, current_timestamp: u64) -> bool { + current_timestamp > transaction.deadline +} + +pub fn can_submit_agreement(transaction: &ConsensusTransaction, party: &Address) -> bool { + transaction.required_parties.iter().any(|p| p == *party) && + transaction.status == TransactionStatus::Funded +} \ No newline at end of file