diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index 684dd0c..072828a 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -2426,6 +2426,84 @@ impl AtomicSwap { // ── Installment Payments ────────────────────────────────────────────────── + /// Seller initiates a swap that the buyer may pay in installments. + /// + /// The swap is created in `Pending` state with `is_installment = true`. + /// The buyer calls `submit_installment_payment` one or more times until + /// `paid_amount >= price`, at which point the swap transitions to `Accepted` + /// and the seller can reveal the decryption key. + /// + /// `num_installments` is informational (stored as a hint for the buyer) and + /// does not enforce a maximum number of payments. + pub fn initiate_swap_installment( + env: Env, + token: Address, + ip_id: u64, + seller: Address, + price: i128, + buyer: Address, + num_installments: u32, + ) -> u64 { + require_not_paused(&env); + seller.require_auth(); + require_positive_price(&env, price); + if num_installments == 0 { + env.panic_with_error(Error::from_contract_error(ContractError::PriceTooSmall as u32)); + } + registry::ensure_seller_owns_active_ip(&env, ip_id, &seller); + require_no_active_swap(&env, ip_id); + + let id: u64 = env.storage().instance().get(&DataKey::NextId).unwrap_or(0); + + let swap = SwapRecord { + ip_id, + seller: seller.clone(), + buyer: buyer.clone(), + price, + token: token.clone(), + status: SwapStatus::Pending, + expiry: env.ledger().timestamp() + 604800u64, + accept_timestamp: 0, + required_approvals: 0, + dispute_timestamp: 0, + referrer: None, + collateral_amount: 0, + insurance_premium: 0, + insurance_enabled: false, + escrow_agent: None, + quantity: num_installments, + conditions: Vec::new(&env), + paid_amount: 0, + is_installment: true, + }; + + env.storage().persistent().set(&DataKey::Swap(id), &swap); + env.storage().persistent().extend_ttl(&DataKey::Swap(id), LEDGER_BUMP, LEDGER_BUMP); + env.storage().persistent().set(&DataKey::ActiveSwap(ip_id), &id); + env.storage().persistent().extend_ttl(&DataKey::ActiveSwap(ip_id), LEDGER_BUMP, LEDGER_BUMP); + + swap::append_swap_for_party(&env, &seller, &buyer, id); + + let mut ip_ids: Vec = env + .storage() + .persistent() + .get(&DataKey::IpSwaps(ip_id)) + .unwrap_or(Vec::new(&env)); + ip_ids.push_back(id); + env.storage().persistent().set(&DataKey::IpSwaps(ip_id), &ip_ids); + env.storage().persistent().extend_ttl(&DataKey::IpSwaps(ip_id), 50000, 50000); + + Self::append_history(&env, id, SwapStatus::Pending); + env.storage().instance().set(&DataKey::NextId, &(id + 1)); + + env.events().publish( + (symbol_short!("swap_init"),), + SwapInitiatedEvent { swap_id: id, ip_id, seller, buyer, price }, + ); + + id + } + /// Submit an installment payment toward a scheduled swap. Buyer-only. /// /// Transfers `payment_amount` tokens from buyer to escrow and accumulates @@ -4173,7 +4251,7 @@ mod chaos_tests; #[cfg(test)] mod installment_tests { use super::*; - use soroban_sdk::{testutils::Address as TestAddress, Env, Vec}; + use soroban_sdk::{testutils::Address as TestAddress, BytesN, Env, Vec}; fn make_swap(env: &Env, price: i128, paid: i128, is_installment: bool) -> SwapRecord { SwapRecord { @@ -4336,6 +4414,132 @@ mod installment_tests { // remaining is 100, paying 200 should panic client.submit_installment_payment(&0u64, &200); } + + // ── #466: initiate_swap_installment tests ───────────────────────────────── + + #[test] + fn test_initiate_swap_installment_creates_pending_installment_swap() { + use ip_registry::{IpRegistry, IpRegistryClient}; + use soroban_sdk::token::StellarAssetClient; + + let env = Env::default(); + env.mock_all_auths(); + + let seller = ::generate(&env); + let buyer = ::generate(&env); + let admin = ::generate(&env); + + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(&env, ®istry_id); + let secret = BytesN::from_array(&env, &[2u8; 32]); + let blinding = BytesN::from_array(&env, &[3u8; 32]); + let mut preimage = soroban_sdk::Bytes::new(&env); + preimage.append(&soroban_sdk::Bytes::from(secret.clone())); + preimage.append(&soroban_sdk::Bytes::from(blinding.clone())); + let commitment_hash: BytesN<32> = env.crypto().sha256(&preimage).into(); + let ip_id = registry.commit_ip(&seller, &commitment_hash); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + StellarAssetClient::new(&env, &token_id).mint(&buyer, &1000); + + let contract_id = env.register(AtomicSwap, ()); + let client = AtomicSwapClient::new(&env, &contract_id); + client.initialize(®istry_id); + + let swap_id = client.initiate_swap_installment(&token_id, &ip_id, &seller, &600_i128, &buyer, &3_u32); + + let swap = client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Pending); + assert!(swap.is_installment); + assert_eq!(swap.price, 600); + assert_eq!(swap.paid_amount, 0); + assert_eq!(swap.quantity, 3); + } + + #[test] + fn test_initiate_swap_installment_then_pay_in_parts() { + use ip_registry::{IpRegistry, IpRegistryClient}; + use soroban_sdk::token::StellarAssetClient; + + let env = Env::default(); + env.mock_all_auths(); + + let seller = ::generate(&env); + let buyer = ::generate(&env); + let admin = ::generate(&env); + + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(&env, ®istry_id); + let secret = BytesN::from_array(&env, &[2u8; 32]); + let blinding = BytesN::from_array(&env, &[3u8; 32]); + let mut preimage = soroban_sdk::Bytes::new(&env); + preimage.append(&soroban_sdk::Bytes::from(secret.clone())); + preimage.append(&soroban_sdk::Bytes::from(blinding.clone())); + let commitment_hash: BytesN<32> = env.crypto().sha256(&preimage).into(); + let ip_id = registry.commit_ip(&seller, &commitment_hash); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + StellarAssetClient::new(&env, &token_id).mint(&buyer, &1000); + + let contract_id = env.register(AtomicSwap, ()); + let client = AtomicSwapClient::new(&env, &contract_id); + client.initialize(®istry_id); + + let swap_id = client.initiate_swap_installment(&token_id, &ip_id, &seller, &300_i128, &buyer, &3_u32); + + // First installment + client.submit_installment_payment(&swap_id, &100); + let (paid, total, remaining) = client.get_installment_status(&swap_id); + assert_eq!(paid, 100); + assert_eq!(total, 300); + assert_eq!(remaining, 200); + + // Second installment + client.submit_installment_payment(&swap_id, &100); + let (paid, _, remaining) = client.get_installment_status(&swap_id); + assert_eq!(paid, 200); + assert_eq!(remaining, 100); + + // Final installment — should transition to Accepted + client.submit_installment_payment(&swap_id, &100); + let swap = client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Accepted); + assert_eq!(swap.paid_amount, 300); + } + + #[test] + #[should_panic] + fn test_initiate_swap_installment_zero_installments_panics() { + use ip_registry::{IpRegistry, IpRegistryClient}; + use soroban_sdk::token::StellarAssetClient; + + let env = Env::default(); + env.mock_all_auths(); + + let seller = ::generate(&env); + let buyer = ::generate(&env); + let admin = ::generate(&env); + + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(&env, ®istry_id); + let secret = BytesN::from_array(&env, &[2u8; 32]); + let blinding = BytesN::from_array(&env, &[3u8; 32]); + let mut preimage = soroban_sdk::Bytes::new(&env); + preimage.append(&soroban_sdk::Bytes::from(secret.clone())); + preimage.append(&soroban_sdk::Bytes::from(blinding.clone())); + let commitment_hash: BytesN<32> = env.crypto().sha256(&preimage).into(); + let ip_id = registry.commit_ip(&seller, &commitment_hash); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + StellarAssetClient::new(&env, &token_id).mint(&buyer, &1000); + + let contract_id = env.register(AtomicSwap, ()); + let client = AtomicSwapClient::new(&env, &contract_id); + client.initialize(®istry_id); + + // num_installments = 0 should panic + client.initiate_swap_installment(&token_id, &ip_id, &seller, &300_i128, &buyer, &0_u32); + } } // ── #517 & #518: Batch cancellation and fee breakdown tests ───────────── diff --git a/docs/swap-partial-payment.md b/docs/swap-partial-payment.md new file mode 100644 index 0000000..6709132 --- /dev/null +++ b/docs/swap-partial-payment.md @@ -0,0 +1,78 @@ +# Swap Partial Payment (Installments) — #466 + +Allow buyers to pay for high-value IP in installments rather than a single upfront payment. + +## Overview + +The installment payment feature lets a seller offer a patent swap where the buyer pays in multiple partial payments. The swap stays in `Pending` state while payments accumulate; once the full price is paid the swap automatically transitions to `Accepted`, at which point the seller can reveal the decryption key. + +## API + +### `initiate_swap_installment` + +```rust +pub fn initiate_swap_installment( + env: Env, + token: Address, + ip_id: u64, + seller: Address, + price: i128, + buyer: Address, + num_installments: u32, +) -> u64 +``` + +Seller creates an installment swap. `num_installments` is a hint for the buyer indicating the expected number of payments (must be ≥ 1). Returns the swap ID. + +### `submit_installment_payment` + +```rust +pub fn submit_installment_payment( + env: Env, + swap_id: u64, + payment_amount: i128, +) +``` + +Buyer submits a partial payment. Tokens are transferred to escrow immediately. When `paid_amount >= price` the swap transitions to `Accepted`. + +Panics if: +- Swap is not an installment swap +- Swap is not in `Pending` state +- `payment_amount` is zero or negative +- Payment would exceed the remaining balance (overpayment rejected) + +### `get_installment_status` + +```rust +pub fn get_installment_status(env: Env, swap_id: u64) -> (i128, i128, i128) +``` + +Returns `(paid_amount, total_price, remaining)`. + +## Flow + +``` +Seller: initiate_swap_installment(price=300, num_installments=3) + → swap created: Pending, is_installment=true, paid_amount=0 + +Buyer: submit_installment_payment(100) → paid=100, remaining=200 +Buyer: submit_installment_payment(100) → paid=200, remaining=100 +Buyer: submit_installment_payment(100) → paid=300, remaining=0 → status=Accepted + +Seller: reveal_key(secret, blinding) → status=Completed, payment released +``` + +## SwapRecord Fields + +| Field | Type | Description | +|-------|------|-------------| +| `is_installment` | `bool` | `true` for installment swaps | +| `paid_amount` | `i128` | Cumulative amount paid so far | +| `quantity` | `u32` | Stores `num_installments` hint | + +## Events + +- `swap_init` — emitted when the installment swap is created +- `inst_pay` — emitted on each installment payment: `(swap_id, payment_amount, paid_amount, price)` +- `swap_acpt` — emitted when the final payment completes the full price