diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef7302..2ca1d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7726ebf..f1a45a0 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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` @@ -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 | --- diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index c4c1f7e..2970673 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -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 }); +} diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 7969aac..102b1f0 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -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, }; @@ -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) @@ -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 { + 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. diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 2d21dcb..5b99ffb 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -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); + } +} + diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 9eee3ae..167fb68 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -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, +}