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: 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
- feat: approval flow — multi-approver workflow — feat/approval-flow
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-170%20passing-00D4AA)
![Tests](https://img.shields.io/badge/tests-174%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 @@ -87,6 +87,7 @@ graph TD
- **Invoice tags**
- **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)
- **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 @@ -147,6 +148,7 @@ graph TD
| `set_invoice_notes(caller, id, text)` / `get_invoice_notes(id)` | Creator free-text notes `InvoiceNotes { text, updated_at }` |
| `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) |
| `pause` / `unpause` | Admin circuit breaker |

---
Expand Down
14 changes: 14 additions & 0 deletions contracts/sharpy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,17 @@ pub struct StreamingToppedUpEvent { pub invoice_id: u64, pub amount: i128 }
pub fn streaming_topped_up(env: &Env, invoice_id: u64, amount: i128) {
env.events().publish((symbol_short!("tup"),), StreamingToppedUpEvent { invoice_id, amount });
}

#[contracttype]
#[derive(Clone)]
pub struct RouteSetEvent { pub invoice_id: u64, pub target_invoice: u64 }
pub fn route_set(env: &Env, invoice_id: u64, target_invoice: u64) {
env.events().publish((symbol_short!("route"),), RouteSetEvent { invoice_id, target_invoice });
}

#[contracttype]
#[derive(Clone)]
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 });
}
35 changes: 34 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,
InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, SplitRule, StreamingState, ComposableRoute,
SubscriptionParams,
};

Expand Down Expand Up @@ -50,6 +50,7 @@ fn discount_key(id: u64) -> (Symbol, u64) { (symbol_short!("disc"), id) }
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 is_paused(env: &Env) -> bool {
env.storage().persistent().get(&paused_key()).unwrap_or(false)
Expand Down Expand Up @@ -1234,6 +1235,38 @@ impl SharpyContract {
events::streaming_topped_up(&env, invoice_id, additional);
state.amount
}

/// Point `invoice_id` at `target_invoice` as a pass-through hop.
pub fn set_route(env: Env, caller: Address, invoice_id: u64, target_invoice: u64) {
caller.require_auth();
assert!(target_invoice != invoice_id, "cannot route to self");
let _ = load_invoice(&env, invoice_id);
let _ = load_invoice(&env, target_invoice);
if let Some(back) = env.storage().persistent().get::<(Symbol,u64), ComposableRoute>(&route_key(target_invoice)) {
assert!(back.target_invoice != invoice_id, "route cycle detected");
}
env.storage().persistent().set(&route_key(invoice_id), &ComposableRoute {
target_invoice,
updated_at: env.ledger().timestamp(),
});
events::route_set(&env, invoice_id, target_invoice);
}

/// Return the configured hop for `invoice_id`, if any.
pub fn get_route(env: Env, invoice_id: u64) -> Option<ComposableRoute> {
env.storage().persistent().get::<(Symbol,u64), ComposableRoute>(&route_key(invoice_id))
}

/// Follow one pass-through hop; returns `invoice_id` itself when unrouted.
pub fn resolve_route(env: Env, invoice_id: u64) -> u64 {
let key = route_key(invoice_id);
if let Some(route) = env.storage().persistent().get::<(Symbol,u64), ComposableRoute>(&key) {
events::route_resolved(&env, invoice_id, route.target_invoice);
route.target_invoice
} else {
invoice_id
}
}
}

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

#[cfg(test)]
mod test_routing {
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 opts(env: &Env) -> crate::types::InvoiceOptions {
crate::types::InvoiceOptions {
escrow_enabled: false,
escrow_release_delay: None,
split_rules: Vec::new(env),
auto_resolve_rules: Vec::new(env),
arbitrator: None,
}
}

fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 {
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, [100i128]),
&Vec::from_array(env, [tok]),
&dl,
&opts(env),
)
}

#[test]
fn test_route_set_get_and_resolve() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id1 = mk(&env, &client, &creator);
let id2 = mk(&env, &client, &creator);
client.set_route(&creator, &id1, &id2);
let route = client.get_route(&id1).unwrap();
assert_eq!(route.target_invoice, id2);
assert_eq!(client.resolve_route(&id1), id2);
assert_eq!(client.resolve_route(&id2), id2);
}

#[test]
#[should_panic(expected = "cannot route to self")]
fn test_route_self_route_panics() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id1 = mk(&env, &client, &creator);
client.set_route(&creator, &id1, &id1);
}

#[test]
#[should_panic(expected = "route cycle detected")]
fn test_route_two_cycle_panics() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id1 = mk(&env, &client, &creator);
let id2 = mk(&env, &client, &creator);
client.set_route(&creator, &id1, &id2);
client.set_route(&creator, &id2, &id1);
}

#[test]
fn test_route_isolated_per_invoice() {
let (env, client) = setup();
let creator = Address::generate(&env);
let id1 = mk(&env, &client, &creator);
let id2 = mk(&env, &client, &creator);
let id3 = mk(&env, &client, &creator);
client.set_route(&creator, &id1, &id2);
assert!(client.get_route(&id3).is_none());
assert_eq!(client.resolve_route(&id3), id3);
assert_eq!(client.resolve_route(&id1), id2);
}
}

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

/// Pass-through hop: settling `invoice_id` forwards to `target_invoice`.
#[contracttype]
#[derive(Clone, Debug)]
pub struct ComposableRoute {
pub target_invoice: u64,
pub updated_at: u64,
}
Loading