Skip to content

Commit 571951d

Browse files
authored
Merge pull request #667 from laddyr141-ui/docs-639-642
docs: add ContractError guide, event Indexer Guides, calc.rs rationale, InvoiceExt3 storage layout
2 parents d49bfc8 + 0503a08 commit 571951d

4 files changed

Lines changed: 121 additions & 8 deletions

File tree

CONTRIBUTING.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,49 @@ feat: add partial release function (#7)
6666
- No `unwrap()` in production code paths — use `expect("descriptive message")` or proper error handling.
6767
- Keep functions small and focused.
6868

69+
## Adding a new `ContractError` variant
70+
71+
`ContractError` (`contracts/split/src/error.rs`) is a `#[repr(u32)]` enum with an explicit
72+
discriminant on every variant. Its doc comment states the rule: **discriminants are stable —
73+
never reorder, only append.** Soroban clients and indexers match on the numeric error code, so
74+
changing an existing variant's number (or reusing a retired one) is a breaking change even
75+
though the Rust source still compiles.
76+
77+
When you need a new error case:
78+
79+
1. **Never reorder or renumber existing variants.** Do not "tidy up" the list, fill gaps, or
80+
resequence numbers to keep them contiguous — gaps (e.g. `50`, `52` with `51` used elsewhere)
81+
are expected and are not bugs to fix.
82+
2. **Append your variant at the end of the enum**, with the next unused discriminant. Find the
83+
current highest number in the file and add one to it — do not reuse a number that is skipped
84+
earlier in the list.
85+
3. **Document it.** Add a `///` doc comment above the variant explaining when it is returned,
86+
and reference the issue number that introduced it (the existing variants follow an
87+
`/// Issue #NNN: ...` convention).
88+
4. **Update any call sites** that need to return the new error, and add/extend tests in
89+
`contracts/split/src/test.rs` covering the new failure path.
90+
91+
### Before
92+
93+
```rust
94+
/// Issue #522: Parent chain depth exceeds the allowed maximum.
95+
ParentChainTooDeep = 63,
96+
}
97+
```
98+
99+
### After
100+
101+
```rust
102+
/// Issue #522: Parent chain depth exceeds the allowed maximum.
103+
ParentChainTooDeep = 63,
104+
/// Issue #611: Payout schedule references a milestone that does not exist.
105+
MilestoneNotFound = 64,
106+
}
107+
```
108+
109+
Note that the new variant is appended after the last existing one with the next free
110+
discriminant (`64`); none of the earlier numbers are touched.
111+
69112
## Questions?
70113

71114
Open a [Discussion](../../discussions) or ask in the issue thread.

contracts/split/src/calc.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,34 @@
33
//! Implements the **largest-remainder method** to distribute an integer `total`
44
//! across recipients proportionally, ensuring every stroop is accounted for
55
//! (i.e. `sum(result) == total` always holds).
6+
//!
7+
//! # Why largest-remainder?
8+
//!
9+
//! Splitting an integer `total` proportionally by ratios almost never divides evenly.
10+
//! A naive implementation would compute each recipient's share with floor division
11+
//! (`total * ratio / denom`) and stop there, but floor division systematically discards
12+
//! the fractional part of every share. With `n` recipients that can leave up to `n - 1`
13+
//! stroops undistributed — money that was paid in but never assigned to anyone, silently
14+
//! stuck in the contract and breaking the `sum(result) == total` invariant the rest of the
15+
//! contract relies on (e.g. reconciling `funded` against amounts actually paid out).
16+
//!
17+
//! The largest-remainder method fixes this without abandoning integer (floor) division:
18+
//! 1. Compute each recipient's floor share (`total * ratio / denom`) and remainder
19+
//! (`total * ratio % denom`).
20+
//! 2. Sum the floor shares; the difference between `total` and that sum is the number of
21+
//! leftover stroops still owed (always `< n`).
22+
//! 3. Sort recipients by remainder descending and hand out one extra stroop each, in that
23+
//! order, until the leftover is exhausted.
24+
//!
25+
//! This guarantees `sum(result) == total` exactly, while keeping the discrepancy from
26+
//! true proportionality to at most one stroop per recipient — the smallest error possible
27+
//! for integer division — and it deterministically favors the recipients whose exact
28+
//! (real-valued) share was closest to rounding up.
29+
//!
30+
//! **Example:** distributing `10` stroops among 3 recipients with equal ratios (`1:1:1`,
31+
//! `denom = 3`) gives floor shares of `[3, 3, 3]` (sum `9`) with `1` stroop leftover, all
32+
//! three remainders tied at `1`. The tie-break (first index wins) assigns the leftover
33+
//! stroop to the first recipient, producing `[4, 3, 3]` — which sums to `10`.
634
735
#[allow(unused_imports)]
836
use crate::types::BASIS_POINTS_TOTAL;

contracts/split/src/events.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -878,14 +878,18 @@ pub fn recipient_paid(env: &Env, invoice_id: u64, recipient: &Address, amount: i
878878
/// Issue #333: Emitted when an invoice crosses a funding milestone (25%, 50%, 75%, 100%).
879879
///
880880
/// # Indexer Guide
881-
/// `milestone_bps` encodes the threshold in basis points:
882-
/// - 2500 = 25%
883-
/// - 5000 = 50%
884-
/// - 7500 = 75%
885-
/// - 10000 = 100%
881+
/// Indexers can subscribe to milestone crossings by filtering events with
882+
/// topic[1] == "milestone" (optionally narrowed further by topic[2] == invoice_id). Each
883+
/// event carries:
884+
/// - `milestone_bps`: the crossed threshold in basis points relative to the invoice total —
885+
/// 2500 = 25%, 5000 = 50%, 7500 = 75%, 10000 = 100%.
886+
/// - `funded_amount`: the invoice's cumulative funded amount at the moment the threshold
887+
/// was crossed (in the invoice's payment token's base units).
888+
/// - `ledger`: the ledger sequence number at which the crossing was recorded.
886889
///
887890
/// Multiple events can be emitted in a single `pay()` call when a large payment
888-
/// crosses several thresholds at once.
891+
/// crosses several thresholds at once — do not assume one event per payment; instead
892+
/// group by `invoice_id` and treat each `milestone_bps` as an independent crossing.
889893
///
890894
/// Topics: (split, milestone, invoice_id)
891895
/// Data: (milestone_bps, funded_amount, ledger)
@@ -906,6 +910,23 @@ pub fn milestone_reached(env: &Env, invoice_id: u64, milestone_bps: u32, funded_
906910
/// `10_000 = 100%`. A single payment can emit multiple checkpoint events when it
907911
/// crosses several configured thresholds at once.
908912
///
913+
/// # Indexer Guide
914+
/// Filter events with topic[1] == "fnd_chk" (topic[2] is the `invoice_id`, so narrow to a
915+
/// single invoice by matching that topic too). Unlike `milestone_reached`, whose thresholds
916+
/// are the fixed 25/50/75/100% set, `funding_checkpoint` thresholds are admin-configurable,
917+
/// so `threshold_bps` must always be read from the event data rather than assumed. The
918+
/// event's `FundingCheckpoint` payload carries:
919+
/// - `invoice_id`: redundant with topic[2], included in the data for convenience so the
920+
/// event can be decoded without also decoding topics.
921+
/// - `threshold_bps`: the configured checkpoint that was crossed, in basis points of the
922+
/// invoice total (`10_000 = 100%`).
923+
/// - `funded`: the invoice's cumulative funded amount at the moment of crossing.
924+
/// - `total`: the invoice's total amount, i.e. `funded / total` (scaled to bps) is
925+
/// approximately `threshold_bps` at the instant the event fires.
926+
///
927+
/// As with `milestone_reached`, a single payment may cross several configured checkpoints,
928+
/// emitting one event per checkpoint — group by `invoice_id` and treat each as independent.
929+
///
909930
/// Topics: (split, fnd_chk, invoice_id)
910931
/// Data: FundingCheckpoint { invoice_id, threshold_bps, funded, total }
911932
#[contracttype]

contracts/split/src/types.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,8 +1447,29 @@ impl Invoice {
14471447
}
14481448

14491449
/// Issue #327 / #329 / #330: Extended invoice fields for new features.
1450-
/// Stored in separate persistent storage (key: inv_ex3 + invoice_id) so existing
1451-
/// InvoiceCore / InvoiceExt / InvoiceExt2 XDR layouts are not disturbed.
1450+
/// Stored in separate persistent storage so existing InvoiceCore / InvoiceExt / InvoiceExt2
1451+
/// XDR layouts are not disturbed.
1452+
///
1453+
/// # Storage layout
1454+
/// Unlike `InvoiceCore`/`InvoiceExt`/`InvoiceExt2` (which are read from a single storage
1455+
/// entry per invoice, keyed via the `InvoiceKey` enum in `storage_keys.rs`), `InvoiceExt3`
1456+
/// is **not** persisted as one serialized struct under a single key. It is a read-model
1457+
/// assembled on demand (see `get_invoice_ext3` in `lib.rs`) by reading four independent
1458+
/// persistent-storage entries for the same `invoice_id`, each under its own `(Symbol, u64)`
1459+
/// key defined in `lib.rs`:
1460+
/// - `release_delay_ledgers` <- `release_delay_key(id)` -> `(symbol_short!("rel_dly"), id)`
1461+
/// - `funded_at_ledger` <- `funded_at_ledger_key(id)` -> `(symbol_short!("fund_led"), id)`
1462+
/// - `metadata_hash` <- `metadata_hash_key(id)` -> `(symbol_short!("meta_hsh"), id)`
1463+
/// - `paid_recipients` <- `paid_recipients_key(id)` -> `(symbol_short!("paid_rec"), id)`
1464+
///
1465+
/// `unlock_at_ledger` is not stored at all; it is derived at read time as
1466+
/// `funded_at_ledger + release_delay_ledgers` (or `None` if either input is unset).
1467+
///
1468+
/// This per-field key layout — rather than a single `InvoiceKey::Ext3(invoice_id)`-style
1469+
/// entry — lets each field evolve (be added, migrated, or left absent for older invoices)
1470+
/// independently, without needing to re-serialize or migrate the whole struct. It keeps
1471+
/// `InvoiceCore`/`InvoiceExt`/`InvoiceExt2`'s existing XDR layouts completely untouched,
1472+
/// since none of these new fields share storage with them.
14521473
#[contracttype]
14531474
#[derive(Clone, Debug)]
14541475
pub struct InvoiceExt3 {

0 commit comments

Comments
 (0)