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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 205 additions & 1 deletion contracts/atomic_swap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> = 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = <soroban_sdk::Address as TestAddress>::generate(&env);
let buyer = <soroban_sdk::Address as TestAddress>::generate(&env);
let admin = <soroban_sdk::Address as TestAddress>::generate(&env);

let registry_id = env.register(IpRegistry, ());
let registry = IpRegistryClient::new(&env, &registry_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(&registry_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 = <soroban_sdk::Address as TestAddress>::generate(&env);
let buyer = <soroban_sdk::Address as TestAddress>::generate(&env);
let admin = <soroban_sdk::Address as TestAddress>::generate(&env);

let registry_id = env.register(IpRegistry, ());
let registry = IpRegistryClient::new(&env, &registry_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(&registry_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 = <soroban_sdk::Address as TestAddress>::generate(&env);
let buyer = <soroban_sdk::Address as TestAddress>::generate(&env);
let admin = <soroban_sdk::Address as TestAddress>::generate(&env);

let registry_id = env.register(IpRegistry, ());
let registry = IpRegistryClient::new(&env, &registry_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(&registry_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 ─────────────
Expand Down
78 changes: 78 additions & 0 deletions docs/swap-partial-payment.md
Original file line number Diff line number Diff line change
@@ -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