Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
All notable changes to the Sharpy smart contract are documented here.

## [Unreleased]
- feat: tranche release — `release_tranche`/`get_released_bps` partial release in bps (`TrancheState`, 178 tests)
- feat: composable routing — `set_route`/`get_route`/`resolve_route` pass-through hop (`ComposableRoute`, 174 tests)
- feat: streaming payments — `create_stream`/`withdraw_vested`/`cancel_stream`/`top_up_stream` cliff-gated linear vesting (`StreamingState`, 170 tests)
- feat: archival — archive and restore invoices — Adds archive_invoice and is_archived query, creator-only, 3
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

![Soroban](https://img.shields.io/badge/Soroban-Protocol%2027-6C63FF?logo=stellar)
![Rust](https://img.shields.io/badge/Rust-stable-orange?logo=rust)
![Tests](https://img.shields.io/badge/tests-174%20passing-00D4AA)
![Tests](https://img.shields.io/badge/tests-178%20passing-00D4AA)
![License](https://img.shields.io/badge/license-MIT-green)
![Version](https://img.shields.io/badge/version-0.2.0-6C63FF)
[![Demo](https://img.shields.io/badge/Demo-Watch%20on%20Loom-00D4AA?logo=loom)](https://www.loom.com/share/09aa4a78e0c944dcab866a7036fde24d)
Expand Down Expand Up @@ -88,6 +88,7 @@ graph TD
- **Archival** — `archive_invoice`/`unarchive_invoice`/`is_archived` terminal invoice archiving
- **Streaming payments** — `create_stream`/`withdraw_vested`/`cancel_stream`/`top_up_stream` cliff-gated linear vesting `StreamingState`
- **Composable routing** — `set_route`/`get_route`/`resolve_route` pass-through hop to another invoice `ComposableRoute` (self-route and 2-cycle rejected)
- **Tranche release** — `release_tranche`/`get_released_bps` partial release in basis points `TrancheState` (capped at 100%)
- **Approval flow** — `set_approval_config`/`approve_invoice`/`get_approval_state` multi-sig prep
- **Invoice templates** — `create_template`/`get_template` reusable configs `InvoiceTemplate`
- **Recurring pause** — `pause_recurring`/`resume_recurring`/`is_recurring_paused`
Expand Down Expand Up @@ -149,6 +150,7 @@ graph TD
| `set_invoice_tags(caller, id, tags)` / `get_invoice_tags(id)` | Creator tags `InvoiceTags { tags, updated_at }` (10 max) |
| `create_stream(id, recipient, amount, start, end, cliff)` / `withdraw_vested(id, recipient)` / `cancel_stream(id, recipient)` / `top_up_stream(id, recipient, additional)` | Cliff-gated linear vesting schedule per invoice |
| `set_route(caller, id, target)` / `get_route(id)` / `resolve_route(id)` | Pass-through hop to another invoice (one level) |
| `release_tranche(caller, id, bps)` / `get_released_bps(id)` | Partial release accounting, cumulative cap 10_000 bps |
| `pause` / `unpause` | Admin circuit breaker |

---
Expand Down
7 changes: 7 additions & 0 deletions contracts/sharpy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,10 @@ pub struct RouteResolvedEvent { pub invoice_id: u64, pub target_invoice: u64 }
pub fn route_resolved(env: &Env, invoice_id: u64, target_invoice: u64) {
env.events().publish((symbol_short!("rslv"),), RouteResolvedEvent { invoice_id, target_invoice });
}

#[contracttype]
#[derive(Clone)]
pub struct TrancheReleasedEvent { pub invoice_id: u64, pub bps: u32, pub cumulative_bps: u32 }
pub fn tranche_released(env: &Env, invoice_id: u64, bps: u32, cumulative_bps: u32) {
env.events().publish((symbol_short!("tranch"),), TrancheReleasedEvent { invoice_id, bps, cumulative_bps });
}
23 changes: 22 additions & 1 deletion contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ mod test;
use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Bytes, Env, Map, String, Symbol, Vec};
use types::{
AuditEntry, CreateInvoiceParams, DisputeState, Invoice, InvoiceNotes, InvoiceOptions,
InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, SplitRule, StreamingState, ComposableRoute,
InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, SplitRule, StreamingState, ComposableRoute, TrancheState,
SubscriptionParams,
};

Expand Down Expand Up @@ -51,6 +51,7 @@ fn invoice_metadata_key(id: u64) -> (Symbol, u64) { (symbol_short!("imeta"), id)
fn invoice_memo_ext_key(id: u64) -> (Symbol, u64) { (symbol_short!("imemo"), id) }
fn streaming_key(id: u64) -> (Symbol, u64) { (symbol_short!("strm"), id) }
fn route_key(id: u64) -> (Symbol, u64) { (symbol_short!("route"), id) }
fn tranche_key(id: u64) -> (Symbol, u64) { (symbol_short!("tranche"), id) }

fn is_paused(env: &Env) -> bool {
env.storage().persistent().get(&paused_key()).unwrap_or(false)
Expand Down Expand Up @@ -1267,6 +1268,26 @@ impl SharpyContract {
invoice_id
}
}

/// Release a tranche of `bps` basis points; returns cumulative released bps.
pub fn release_tranche(env: Env, caller: Address, invoice_id: u64, bps: u32) -> u32 {
caller.require_auth();
let invoice = load_invoice(&env, invoice_id);
assert!(invoice.creator == caller, "only creator can release tranches");
assert!(bps > 0 && bps <= 10_000, "bps out of range");
let key = tranche_key(invoice_id);
let prior: u32 = env.storage().persistent().get::<(Symbol,u64), TrancheState>(&key).map(|s| s.released_bps).unwrap_or(0);
let cumulative = prior + bps;
assert!(cumulative <= 10_000, "tranches exceed 100%");
env.storage().persistent().set(&key, &TrancheState { released_bps: cumulative, updated_at: env.ledger().timestamp() });
events::tranche_released(&env, invoice_id, bps, cumulative);
cumulative
}

/// Cumulative released basis points for `invoice_id` (0 when untouched).
pub fn get_released_bps(env: Env, invoice_id: u64) -> u32 {
env.storage().persistent().get::<(Symbol,u64), TrancheState>(&tranche_key(invoice_id)).map(|s| s.released_bps).unwrap_or(0)
}
}

/// Validates that a token address is not the zero address.
Expand Down
79 changes: 79 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3976,3 +3976,82 @@ mod test_routing {
}
}

#[cfg(test)]
mod test_tranche {
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
use crate::SharpyContractClient;

fn setup() -> (Env, SharpyContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let cid = env.register(crate::SharpyContract, ());
let c = SharpyContractClient::new(&env, &cid);
let a = Address::generate(&env);
let t = Address::generate(&env);
c.initialize(&a, &t);
(env, c)
}

fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 {
let opts = crate::types::InvoiceOptions {
escrow_enabled: false,
escrow_release_delay: None,
split_rules: Vec::new(env),
auto_resolve_rules: Vec::new(env),
arbitrator: None,
};
let r = Address::generate(env);
let tok = Address::generate(env);
let dl = env.ledger().timestamp() + 86400;
client.create_invoice(
creator,
&Vec::from_array(env, [r]),
&Vec::from_array(env, [1000i128]),
&Vec::from_array(env, [tok]),
&dl,
&opts,
)
}

#[test]
fn test_tranche_partial_then_full() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
assert_eq!(client.get_released_bps(&id), 0u32);
assert_eq!(client.release_tranche(&creator, &id, &2500u32), 2500u32);
assert_eq!(client.release_tranche(&creator, &id, &7500u32), 10_000u32);
assert_eq!(client.get_released_bps(&id), 10_000u32);
}

#[test]
#[should_panic(expected = "tranches exceed 100%")]
fn test_tranche_over_release_panics() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
client.release_tranche(&creator, &id, &9000u32);
client.release_tranche(&creator, &id, &2000u32);
}

#[test]
#[should_panic(expected = "bps out of range")]
fn test_tranche_zero_bps_panics() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id = mk(&env, &client, &creator);
client.release_tranche(&creator, &id, &0u32);
}

#[test]
fn test_tranche_isolated_per_invoice() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id1 = mk(&env, &client, &creator);
let id2 = mk(&env, &client, &creator);
client.release_tranche(&creator, &id1, &3000u32);
assert_eq!(client.get_released_bps(&id1), 3000u32);
assert_eq!(client.get_released_bps(&id2), 0u32);
}
}

8 changes: 8 additions & 0 deletions contracts/sharpy/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,11 @@ pub struct ComposableRoute {
pub target_invoice: u64,
pub updated_at: u64,
}

/// Cumulative tranche-release accounting per invoice (basis points of 10_000).
#[contracttype]
#[derive(Clone, Debug)]
pub struct TrancheState {
pub released_bps: u32,
pub updated_at: u64,
}
Loading