forked from Liquifact/Liquifact-contracts
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlib.rs
More file actions
5667 lines (5139 loc) · 229 KB
/
Copy pathlib.rs
File metadata and controls
5667 lines (5139 loc) · 229 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#![cfg_attr(not(test), no_std)]
//! LiquiFact Escrow Contract
//!
//! Holds investor funds for an invoice until settlement.
//! - SME receives stablecoin when funding target is met ([`LiquifactEscrow::withdraw`])
//! - SME records optional **collateral commitments** ([`LiquifactEscrow::record_sme_collateral_commitment`]) —
//! these are **ledger records only**; they do **not** move tokens, freeze balances,
//! reserve assets, or create an enforceable on-chain claim.
//! - [`LiquifactEscrow::settle`] finalizes the escrow after maturity (when configured).
//!
//! ## Schema version ([`SCHEMA_VERSION`] / [`DataKey::Version`])
//!
//! The constant [`SCHEMA_VERSION`] is written to [`DataKey::Version`] by [`LiquifactEscrow::init`]
//! and is the canonical source of truth for upgrade decisions. **Current value: 6.**
//!
//! [`LiquifactEscrow::migrate`] **fails with typed errors in all current execution paths** — no
//! silent migration work is promised or performed. Operators must extend `migrate` before calling
//! it, or redeploy when stored struct layout changes. See `docs/OPERATOR_RUNBOOK.md` for the full
//! decision tree.
//!
//! ## SME collateral commitment metadata
//!
//! [`LiquifactEscrow::record_sme_collateral_commitment`] is an SME-authenticated metadata write for
//! off-chain risk review. The stored [`SmeCollateralCommitment`] and emitted
//! [`CollateralRecordedEvt`] are not proof of custody, lien, encumbrance, asset control, or token
//! movement. Risk teams and indexers must label this state as reported collateral metadata and must
//! verify supporting evidence outside this contract.
//!
//! ## Compliance hold (legal hold)
//!
//! An admin may set [`DataKey::LegalHold`] to block risk-bearing transitions until cleared:
//! [`LiquifactEscrow::settle`], SME [`LiquifactEscrow::withdraw`], and
//! [`LiquifactEscrow::claim_investor_payout`]. **Clearing** requires the **current**
//! [`InvoiceEscrow::admin`] to call [`LiquifactEscrow::set_legal_hold`] with `active = false`
//! (or [`LiquifactEscrow::clear_legal_hold`]). This contract does not embed a timelock or
//! council multisig: production deployments **must** use a governed `admin` (multisig or
//! protocol DAO) so a single lost key cannot strand funds indefinitely.
//!
//! **Failure mode:** a hold plus loss of the current admin signing key leaves funds blocked
//! on-chain until governance regains control of admin authority. There is no break-glass bypass.
//!
//! **Recovery lever:** [`LiquifactEscrow::propose_admin`] and
//! [`LiquifactEscrow::accept_admin`] are **not** gated by the hold. Governance proposes a new
//! admin, the proposed address accepts, then the new admin clears the hold. Invariant: a hold is
//! always clearable by whoever holds `InvoiceEscrow::admin`; recovery requires controlling that
//! authority. See `docs/escrow-legal-hold.md` and [ADR-004](docs/adr/ADR-004-legal-hold.md).
//!
//! ## Authorization guard ordering
//!
//! Every state-mutating entrypoint follows a canonical sequence (see
//! `docs/escrow-security-checklist.md` §6 and [ADR-002](docs/adr/ADR-002-auth-boundaries.md)):
//!
//! 1. **Read-only** preconditions (legal hold, status checks, input validation).
//! 2. **`Address::require_auth()`** for the bound role ([Stellar authorization](https://developers.stellar.org/docs/build/guides/auth/contract-authorization)).
//! 3. **Storage writes** and **SEP-41 transfers** (via [`external_calls`]).
//!
//! Invariant: no instance/persistent storage mutation and no token transfer occurs until
//! step 2 succeeds. Reading [`DataKey::Escrow`] before `require_auth` is intentional — it is
//! read-only and does not weaken the auth boundary.
//!
//! ## Invoice identifier (`invoice_id`)
//!
//! At initialization, `invoice_id` is supplied as a Soroban [`String`] and validated for length
//! and charset before conversion to [`Symbol`] for storage. Align off-chain invoice slugs with the
//! same rules (ASCII alphanumeric + `_`, max length [`MAX_INVOICE_ID_STRING_LEN`]) so indexers stay
//! unambiguous.
//!
//! ## Funding token and registry (immutable hints)
//!
//! Each escrow instance binds exactly one **funding token** contract ([`DataKey::FundingToken`])
//! at [`LiquifactEscrow::init`]; it cannot be changed after deploy. An optional **registry**
//! ([`DataKey::RegistryRef`]) is a read-only discoverability hint only — it is **not** an authority
//! for this contract and must not be used on-chain as proof of registry state without calling the
//! registry yourself.
//!
//! ## Terminal dust sweep
//!
//! [`LiquifactEscrow::sweep_terminal_dust`] moves at most [`MAX_DUST_SWEEP_AMOUNT`] units of the
//! bound funding token from this contract to the immutable **treasury** address, only when the
//! escrow has reached a **terminal** [`InvoiceEscrow::status`] (settled, withdrawn, or cancelled).
//! It cannot run during a legal hold. Transfers go through [`crate::external_calls`] so **pre/post
//! token balances** must match the requested amount (standard SEP-41 behavior); fee-on-transfer or
//! malicious tokens are **explicitly out of scope** and fail with typed errors at the balance-check
//! boundary. This is meant for rounding residue / stray transfers, not for settling live liabilities —
//! integrations that custody principal on-chain must keep token balances reconciled with
//! `funded_amount` so treasury sweeps cannot pull user funds.
//!
//! ## Ledger time trust model
//!
//! [`LiquifactEscrow::settle`] and [`LiquifactEscrow::claim_investor_payout`] compare against
//! [`Env::ledger`] timestamps only (no wall-clock oracle). Maturity, per-investor **claim locks**
//! from [`LiquifactEscrow::fund_with_commitment`], and [`FundingCloseSnapshot`] metadata must be
//! interpreted as **validator-observed ledger time**, including possible skew between simulated and
//! live networks—integrators should treat boundaries as `>=` / `<` tests on integer seconds.
//!
//! ## Optional tiered yield (immutable table at init)
//!
//! Pass `yield_tiers` to [`LiquifactEscrow::init`] as [`Option`] of a Soroban [`Vec`] of [`YieldTier`].
//! The table is **immutable** for the escrow instance. Investors who use [`LiquifactEscrow::fund_with_commitment`]
//! on their **first** deposit select an effective [`DataKey::InvestorEffectiveYield`] from the ladder;
//! further principal from that address must use [`LiquifactEscrow::fund`]. **Fairness:** tiers are
//! validated non-decreasing in both `min_lock_secs` and `yield_bps` relative to the base [`InvoiceEscrow::yield_bps`].
//!
//! ## Funding-close snapshot (pro-rata)
//!
//! When status first becomes **funded**, [`DataKey::FundingCloseSnapshot`] stores total principal
//! (including over-funding past target), the target, and ledger timestamp/sequence. **Immutable** once
//! written; see `docs/escrow-pro-rata.md` for the authoritative pro-rata payout math and rounding rules.
//! Off-chain share for an investor is `get_contribution(addr) / snapshot.total_principal`.
//!
//! ## Immutable protocol fee (SME disbursement split)
//!
//! [`LiquifactEscrow::init`] accepts an optional `protocol_fee_bps` (basis points, `0..=10_000`,
//! default `0`) stored immutably under [`DataKey::ProtocolFeeBps`]. At
//! [`LiquifactEscrow::withdraw`] the funded principal is split:
//!
//! ```text
//! fee = funded_amount * protocol_fee_bps / 10_000 (floor, checked)
//! sme_payout = funded_amount - fee (checked)
//! ```
//!
//! `fee` is routed to [`DataKey::Treasury`] and `sme_payout` to [`InvoiceEscrow::sme_address`].
//! **Conservation invariant:** `sme_payout + fee == funded_amount` for every withdrawal, so no
//! principal is created or destroyed by the split. Rounding is **floor**, so any sub-`10_000`
//! residue stays with the SME (never over-charges the treasury). With `protocol_fee_bps == 0`
//! the behavior is byte-for-byte identical to the pre-fee contract: the full `funded_amount`
//! goes to the SME and no treasury transfer occurs.
//!
//! **Interaction with on-chain disbursement:** the fee is only realized when principal is
//! custodied on-chain and the SME calls [`LiquifactEscrow::withdraw`] — this feature depends on
//! the on-chain disbursement path. It does **not** apply to off-chain settlement
//! ([`LiquifactEscrow::settle`]), investor refunds ([`LiquifactEscrow::refund`]), or investor
//! claims ([`LiquifactEscrow::claim_investor_payout`]). The treasury here is the same immutable
//! address used by [`LiquifactEscrow::sweep_terminal_dust`]; the fee transfer reuses the same
//! SEP-41 balance-delta–checked path in [`external_calls`].
#![allow(clippy::too_many_arguments)]
#[cfg(test)]
extern crate std;
use core::{clone::Clone, default::Default};
use soroban_sdk::{
contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error,
symbol_short, token::TokenClient, Address, BytesN, Env, String, Symbol, Vec,
};
pub mod external_calls;
/// Current storage schema version written to [`DataKey::Version`] by [`LiquifactEscrow::init`].
///
/// # Schema version changelog
///
/// | Version | Summary | Upgrade path |
/// |---------|---------|-------------|
/// | 1 | Initial schema (`InvoiceEscrow` v1, basic fund / settle) | N/A |
/// | 2 | Added `InvestorEffectiveYield`, `InvestorClaimNotBefore` | Additive keys — no `migrate` call required |
/// | 3 | Added `FundingCloseSnapshot`, `MinContributionFloor`, `MaxUniqueInvestorsCap`, `UniqueFunderCount` | Additive keys — old instances return defaults |
/// | 4 | Added `PrimaryAttestationHash`, `AttestationAppendLog` | Additive keys — no `migrate` call required |
/// | 5 | Added `YieldTierTable`, `RegistryRef`, `Treasury`; `fund_with_commitment` | **Redeploy required** if `InvoiceEscrow` XDR changed |
/// | 6 | Per-investor keys moved to **persistent** storage (see ADR-007) | **Redeploy required** — no `migrate` path (addresses not enumerable) |
///
/// See `docs/OPERATOR_RUNBOOK.md` for the full redeploy-vs-upgrade decision tree.
pub const SCHEMA_VERSION: u32 = 6;
// See the schema version contract documentation: [Escrow schema versioning](../docs/escrow-schema-versioning.md)
/// Upper bound on [`LiquifactEscrow::append_attestation_digest`] entries to keep storage bounded.
/// Revocation via [`LiquifactEscrow::revoke_attestation_digest`] does not consume a slot.
pub const MAX_ATTESTATION_APPEND_ENTRIES: u32 = 32;
/// Maximum number of indices that can be revoked in a single batch call.
pub const MAX_ATTESTATION_REVOKE_BATCH: u32 = 32;
/// Default maximum maturity horizon in seconds (~5 years) when no explicit horizon is configured.
pub const DEFAULT_MATURITY_MAX_HORIZON_SECS: u64 = 157_680_000; // ~5 years (365.25 * 24 * 3600 * 5)
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
/// Maximum invoice `amount` accepted by [`LiquifactEscrow::init`].
///
/// # Derivation (overflow-free coupon math)
///
/// `compute_investor_payout` uses this integer math (see docs/escrow-pro-rata.md):
///
/// ```text
/// coupon = total_principal × yield_bps / 10_000 (floor) (1)
/// settle_pool = total_principal + coupon (2)
/// gross_payout = contribution × settle_pool / total_principal (3)
/// ```
///
/// Each step uses `checked_*` arithmetic on `i128`. We need the tightest
/// bound that keeps all three steps overflow-free for every valid
/// `yield_bps ∈ [0, 10_000]` and every `contribution ∈ (0, total_principal]`.
///
/// **Step (1)** — `total_principal × 10_000 ≤ i128::MAX` ⇒
/// `total_principal ≤ i128::MAX / 10_000` (≈ 1.7×10³⁴).
///
/// **Step (2)** — worst-case coupon is `total_principal` (when
/// `yield_bps = 10_000` and division is exact), so
/// `settle_pool = 2 × total_principal ≤ i128::MAX` ⇒
/// `total_principal ≤ i128::MAX / 2` (≈ 8.5×10³⁷).
///
/// **Step (3)** — the tightest gate: `contribution × settle_pool`
/// must not overflow. Maximise the product by setting
/// `contribution = total_principal` (single investor) and
/// `yield_bps = 10_000` so that `settle_pool = 2 × total_principal`.
/// Then
///
/// ```text
/// contribution × settle_pool = total_principal × 2 × total_principal
/// = 2 × total_principal²
/// ```
///
/// Requiring `2 × total_principal² ≤ i128::MAX` gives
///
/// ```text
/// total_principal ≤ floor(√(i128::MAX / 2))
/// = floor(√(2¹²⁷ − 1) / 2)
/// = 2⁶³ − 1
/// = 9_223_372_036_854_775_807
/// ```
///
/// This is the limiting constraint: it is tighter than both (1) and (2)
/// by many orders of magnitude. All intermediate `checked_*` operations
/// are overflow-free by construction for every valid init.
pub const MAX_INVOICE_AMOUNT: i128 = (1i128 << 63) - 1; // floor(√(i128::MAX / 2))
/// Upper bound on [`LiquifactEscrow::fund_batch`] entries to keep storage/CPU bounded.
/// Mirrors the spirit of `MAX_ATTESTATION_APPEND_ENTRIES` to limit per-call work.
pub const MAX_FUND_BATCH: u32 = 50;
/// Upper bound on [`LiquifactEscrow::refund_batch`] entries to keep storage/CPU bounded.
pub const MAX_REFUND_BATCH: u32 = 50;
/// Upper bound on [`LiquifactEscrow::set_investors_allowlisted`] batch size.
pub const MAX_INVESTOR_ALLOWLIST_BATCH: u32 = 32;
/// Upper bound on [`LiquifactEscrow::get_contributions`] / investor read batch size.
pub const MAX_INVESTOR_READ_BATCH: u32 = 50;
/// Upper bound on attestation digest read page size.
pub const MAX_ATTESTATION_READ_PAGE: u32 = 20;
/// Upper bound on [`LiquifactEscrow::sweep_terminal_dust`] per call (base units of the funding token).
///
/// Caps blast radius if instrumentation mis-estimates “dust”; tune per asset decimals off-chain.
pub const MAX_DUST_SWEEP_AMOUNT: i128 = 100_000_000;
/// Maximum UTF-8 byte length for the invoice `String` at init (matches Soroban [`Symbol`] max).
pub const MAX_INVOICE_ID_STRING_LEN: u32 = 32;
/// Default validity window for [`LiquifactEscrow::propose_admin`] when no explicit window is supplied.
///
/// After `ledger.timestamp() + DEFAULT_ADMIN_PROPOSAL_VALIDITY_SECS`, [`LiquifactEscrow::accept_admin`]
/// rejects the stale proposal with [`EscrowError::AdminProposalExpired`].
pub const DEFAULT_ADMIN_PROPOSAL_VALIDITY_SECS: u64 = 604_800; // 7 days
/// Minimum instance storage TTL extension horizon for time-sensitive escrow entries.
///
/// `bump_ttl` extends instance-storage entries to avoid rent/archival edge cases when
/// maturity/claim locks are far in the future.
///
/// Named as a constant so operators can reason about and audit the threshold.
pub const INSTANCE_TTL_MIN_EXTENSION_LEDGERS: u32 = 60 * 60; // Approx. 1h at 1 ledger/sec.
/// Minimum persistent storage TTL extension horizon for per-investor allowlist entries.
///
/// When the escrow uses the allowlist gate, investor funding depends on persistent entries.
/// Extending persistent allowlist TTL reduces the risk of silent allowlist disablement.
pub const PERSISTENT_TTL_MIN_EXTENSION_LEDGERS: u32 = 60 * 60; // Approx. 1h at 1 ledger/sec.
/// Stable typed errors emitted by LiquiFact escrow entrypoints.
///
/// Codes are append-only: never reuse or renumber a variant. Client SDKs should branch on the
/// numeric code rather than legacy panic strings. See `docs/escrow-error-messages.md`.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum EscrowError {
/// [`LiquifactEscrow::init`] rejected a non-positive invoice amount.
AmountMustBePositive = 1,
/// [`LiquifactEscrow::init`] rejected `yield_bps` outside `0..=10_000`.
YieldBpsOutOfRange = 2,
/// [`LiquifactEscrow::init`] called when escrow storage already exists.
EscrowAlreadyInitialized = 3,
/// [`LiquifactEscrow::init`] rejected an invoice amount too large to keep
/// `compute_investor_payout` arithmetic overflow-free.
AmountExceedsMax = 14,
/// [`LiquifactEscrow::init`] rejected an `invoice_id` outside the allowed length range.
InvoiceIdInvalidLength = 4,
/// [`LiquifactEscrow::init`] rejected an `invoice_id` with disallowed characters.
InvoiceIdInvalidCharset = 5,
/// [`LiquifactEscrow::init`] configured `min_contribution` but it is not positive.
MinContributionNotPositive = 6,
/// [`LiquifactEscrow::init`] configured `min_contribution` above the target hint.
MinContributionExceedsAmount = 7,
/// [`LiquifactEscrow::init`] configured `max_unique_investors` but it is not positive.
MaxUniqueInvestorsNotPositive = 8,
/// [`LiquifactEscrow::init`] configured `max_per_investor` but it is not positive.
MaxPerInvestorNotPositive = 9,
/// [`LiquifactEscrow::init`] rejected a tier with `yield_bps` outside `0..=10_000`.
TierYieldOutOfRange = 10,
/// [`LiquifactEscrow::init`] rejected a tier yield below the base `yield_bps`.
TierYieldBelowBase = 11,
/// [`LiquifactEscrow::init`] rejected tiers whose `min_lock_secs` are not strictly increasing.
TierLockNotIncreasing = 12,
/// [`LiquifactEscrow::init`] rejected tiers whose `yield_bps` decrease across tiers.
TierYieldNotNonDecreasing = 13,
/// Escrow storage is missing; entrypoint requires prior [`LiquifactEscrow::init`].
EscrowNotInitialized = 20,
/// [`DataKey::FundingToken`] is unset (escrow not fully initialized).
FundingTokenNotSet = 21,
/// [`DataKey::Treasury`] is unset (escrow not fully initialized).
TreasuryNotSet = 22,
/// [`LiquifactEscrow::sweep_terminal_dust`] blocked while a legal hold is active.
LegalHoldBlocksTreasuryDustSweep = 30,
/// [`LiquifactEscrow::sweep_terminal_dust`] received a non-positive sweep amount.
SweepAmountNotPositive = 31,
/// [`LiquifactEscrow::sweep_terminal_dust`] exceeded [`MAX_DUST_SWEEP_AMOUNT`].
SweepAmountExceedsMax = 32,
/// [`LiquifactEscrow::sweep_terminal_dust`] called before a terminal escrow status.
DustSweepNotTerminal = 33,
/// [`LiquifactEscrow::sweep_terminal_dust`] found no funding-token balance to sweep.
NoFundingTokenBalanceToSweep = 34,
/// [`LiquifactEscrow::sweep_terminal_dust`] computed an effective sweep amount of zero.
EffectiveSweepAmountZero = 35,
/// Token transfer wrapper received a non-positive amount (see `external_calls`).
TransferAmountNotPositive = 36,
/// Token transfer wrapper found insufficient sender balance before transfer.
InsufficientTokenBalanceBeforeTransfer = 37,
/// Token transfer wrapper detected sender balance delta underflow.
SenderBalanceUnderflow = 38,
/// Token transfer wrapper detected recipient balance delta underflow.
RecipientBalanceUnderflow = 39,
/// Token transfer wrapper detected sender spent amount differs from requested transfer.
SenderBalanceDeltaMismatch = 40,
/// Token transfer wrapper detected recipient received amount differs from requested transfer.
RecipientBalanceDeltaMismatch = 41,
/// Sweep would reduce the contract balance below outstanding investor liabilities.
/// `balance - sweep_amt` must be `>= funded_amount - distributed_principal`.
SweepExceedsLiabilityFloor = 42,
/// [`LiquifactEscrow::bind_primary_attestation_hash`] called when a primary hash exists.
PrimaryAttestationAlreadyBound = 50,
/// [`LiquifactEscrow::append_attestation_digest`] exceeded [`MAX_ATTESTATION_APPEND_ENTRIES`].
AttestationAppendLogCapacityReached = 51,
/// [`LiquifactEscrow::revoke_attestation_digest`] received an `index >= log.len()`.
AttestationIndexOutOfRange = 52,
/// [`LiquifactEscrow::revoke_attestation_digest`] called on an already-revoked index.
AttestationAlreadyRevoked = 53,
/// [`LiquifactEscrow::revoke_attestation_digests`] received an empty indices list.
AttestationBatchEmpty = 54,
/// [`LiquifactEscrow::revoke_attestation_digests`] exceeded [`MAX_ATTESTATION_REVOKE_BATCH`].
AttestationBatchTooLarge = 55,
/// [`LiquifactEscrow::unrevoke_attestation_digest`] called on an index that is not revoked.
AttestationNotRevoked = 56,
/// [`LiquifactEscrow::record_sme_collateral_commitment`] received a non-positive amount.
CollateralAmountNotPositive = 60,
/// [`LiquifactEscrow::record_sme_collateral_commitment`] received an empty asset symbol.
CollateralAssetEmpty = 61,
/// [`LiquifactEscrow::record_sme_collateral_commitment`] received a timestamp before the stored record.
CollateralTimestampBackwards = 62,
/// [`LiquifactEscrow::set_investors_allowlisted`] received an empty batch.
InvestorBatchEmpty = 70,
/// [`LiquifactEscrow::set_investors_allowlisted`] exceeded [`MAX_INVESTOR_ALLOWLIST_BATCH`].
InvestorBatchTooLarge = 71,
/// [`LiquifactEscrow::fund_batch`] received an empty entries vector.
FundingBatchEmpty = 82,
/// [`LiquifactEscrow::fund_batch`] exceeded [`MAX_FUND_BATCH`].
FundingBatchTooLarge = 83,
/// [`LiquifactEscrow::fund_batch`] contains two or more entries with the same investor address.
///
/// Every investor address in the batch must be unique. Duplicate addresses indicate a
/// malformed batch and the entire call is rejected atomically before any state mutation.
FundingBatchDuplicateInvestor = 84,
/// [`LiquifactEscrow::get_contributions`] exceeded [`MAX_INVESTOR_READ_BATCH`].
ContributionReadBatchTooLarge = 203,
/// [`LiquifactEscrow::update_funding_target`] received a non-positive target.
TargetNotPositive = 72,
/// [`LiquifactEscrow::update_funding_target`] called while escrow is not open.
TargetUpdateNotOpen = 73,
/// [`LiquifactEscrow::update_funding_target`] set target below already-funded principal.
TargetBelowFundedAmount = 74,
/// [`LiquifactEscrow::lower_max_unique_investors`] called while escrow is not open.
CapLowerNotOpen = 75,
/// [`LiquifactEscrow::lower_max_unique_investors`] called with no investor cap configured.
NoInvestorCapConfigured = 76,
/// [`LiquifactEscrow::lower_max_unique_investors`] did not strictly lower the cap.
NewCapNotLower = 77,
/// [`LiquifactEscrow::raise_max_unique_investors`] did not strictly raise the cap.
NewCapNotHigher = 176,
/// [`LiquifactEscrow::lower_max_unique_investors`] set cap below current unique funder count.
NewCapBelowCurrentFunderCount = 78,
/// [`LiquifactEscrow::update_maturity`] called while escrow is not open.
MaturityUpdateNotOpen = 79,
/// [`LiquifactEscrow::propose_admin`] nominated the current admin address.
NewAdminSameAsCurrent = 80,
/// [`LiquifactEscrow::propose_admin`] repeated the already-pending admin address.
PendingAdminUnchanged = 177,
/// [`LiquifactEscrow::update_maturity`] set maturity to the same value as current.
MaturityUnchanged = 81,
/// [`LiquifactEscrow::accept_admin`] called after the proposal expiry recorded at
/// [`DataKey::PendingAdminExpiry`]. Re-propose to nominate a fresh successor.
AdminProposalExpired = 85,
/// [`LiquifactEscrow::migrate`] `from_version` does not match stored version.
MigrationVersionMismatch = 90,
/// [`LiquifactEscrow::migrate`] called at or above [`SCHEMA_VERSION`].
AlreadyCurrentSchemaVersion = 91,
/// [`LiquifactEscrow::migrate`] has no implemented path from the requested version.
NoMigrationPath = 92,
/// [`LiquifactEscrow::fund`] / [`LiquifactEscrow::fund_with_commitment`] received non-positive amount.
FundingAmountNotPositive = 100,
/// Funding amount is below configured `min_contribution`.
FundingBelowMinContribution = 101,
/// Funding blocked while a legal hold is active.
LegalHoldBlocksFunding = 102,
/// Funding attempted while escrow is not in open status.
EscrowNotOpenForFunding = 103,
/// Allowlist gate active and investor address is not allowlisted.
InvestorNotAllowlisted = 104,
/// Adding funding would overflow the investor's stored contribution.
InvestorContributionOverflow = 105,
/// Funding would exceed configured `max_per_investor`.
InvestorContributionExceedsCap = 106,
/// A new investor would exceed configured `max_unique_investors`.
UniqueInvestorCapReached = 107,
/// [`LiquifactEscrow::fund_with_commitment`] called after investor already has principal.
///
/// Tier and lock selection are immutable after the first deposit leg. Once an investor
/// has a non-zero contribution recorded under [`DataKey::InvestorContribution`], the
/// yield rate and claim-lock timestamp are permanently fixed; calling
/// [`LiquifactEscrow::fund_with_commitment`] again would allow re-selecting a tier,
/// violating the fairness guarantee.
///
/// **Client action:** Use [`LiquifactEscrow::fund`] for all additional principal from
/// the same investor. `fund()` reads the stored effective yield set on the first leg
/// and does not allow tier re-selection.
///
/// **Code:** `108` — stable, append-only.
TieredSecondDeposit = 108,
/// Computing investor claim-not-before timestamp would overflow.
InvestorClaimTimeOverflow = 109,
/// Adding funding would overflow escrow `funded_amount`.
FundedAmountOverflow = 110,
/// Commitment lock would push `now + committed_lock_secs` past the escrow maturity.
/// Reject at deposit time so a settled escrow cannot hold an investor's payout
/// claim hostage beyond the point where principal is due.
CommitmentLockExceedsMaturity = 111,
/// [`LiquifactEscrow::settle`] blocked while a legal hold is active.
LegalHoldBlocksSettlement = 120,
/// [`LiquifactEscrow::settle`] called before escrow reached funded status.
SettlementNotFunded = 121,
/// [`LiquifactEscrow::settle`] called before configured maturity timestamp.
MaturityNotReached = 122,
/// [`LiquifactEscrow::withdraw`] blocked while a legal hold is active.
LegalHoldBlocksWithdrawal = 123,
/// [`LiquifactEscrow::withdraw`] called before escrow reached funded status.
WithdrawalNotFunded = 124,
/// [`LiquifactEscrow::claim_investor_payout`] blocked while a legal hold is active.
LegalHoldBlocksInvestorClaims = 125,
/// [`LiquifactEscrow::claim_investor_payout`] for an address with zero contribution.
NoContributionToClaim = 126,
/// [`LiquifactEscrow::claim_investor_payout`] before escrow is settled.
InvestorClaimNotSettled = 127,
/// [`LiquifactEscrow::claim_investor_payout`] before tier commitment lock expires.
InvestorCommitmentLockNotExpired = 128,
/// Checked arithmetic overflow in [`LiquifactEscrow::compute_investor_payout`].
ComputePayoutArithmeticOverflow = 129,
/// [`LiquifactEscrow::cancel_funding`] blocked while a legal hold is active.
LegalHoldBlocksCancelFunding = 140,
/// [`LiquifactEscrow::cancel_funding`] called while escrow is not open.
CancelFundingNotOpen = 141,
/// [`LiquifactEscrow::refund`] called while escrow is not cancelled.
RefundNotCancelled = 142,
/// [`LiquifactEscrow::refund`] for an address with zero contribution.
NoContributionToRefund = 143,
/// [`LiquifactEscrow::refund_batch`] received an empty investors vector.
RefundBatchEmpty = 144,
/// [`LiquifactEscrow::refund_batch`] exceeded [`MAX_REFUND_BATCH`].
RefundBatchTooLarge = 145,
/// `clear_legal_hold` was called without a prior `request_legal_hold_clear`.
LegalHoldClearRequestMissing = 150,
/// The two-phase legal-hold clear delay has not elapsed yet.
LegalHoldClearNotReady = 151,
/// Computing the legal-hold clear ready-at timestamp would overflow.
LegalHoldClearDelayOverflow = 152,
/// Funding deadline has passed, new deposits are rejected.
FundingDeadlinePassed = 164,
/// A legal hold blocks rotating the beneficiary (SME) address.
LegalHoldBlocksBeneficiaryRotation = 160,
/// Beneficiary rotation was attempted while the escrow was not in a
/// pre-settlement state (`status` must be 0 = open or 1 = funded).
RotationNotOpen = 161,
/// The proposed new SME address is identical to the current beneficiary.
NewSmeSameAsCurrent = 162,
/// Attempted to accept or cancel admin role when no pending admin exists.
NoPendingAdmin = 172,
/// The contract's funding-token balance is less than `funded_amount` at withdraw time.
/// Funds must be custodied in this contract before the SME can pull them.
InsufficientContractBalance = 165,
/// The maturity timestamp is in the past relative to the current ledger time.
MaturityInPast = 166,
/// The maturity timestamp exceeds the configured maximum horizon from the current ledger time.
MaturityExceedsMaxHorizon = 167,
/// `clear_sme_collateral_commitment` was called when no commitment pledge exists.
NoCollateralToClear = 169,
/// The computed investor payout is zero; nothing to transfer.
PayoutZero = 170,
/// `update_funding_deadline` was called on a non-open escrow (status != 0).
FundingDeadlineUpdateNotOpen = 171,
/// [`LiquifactEscrow::extend_funding_deadline`] did not strictly extend the stored deadline.
FundingDeadlineNotExtended = 206,
/// [`LiquifactEscrow::extend_funding_deadline`] would place the deadline at or beyond maturity.
FundingDeadlineBeyondMaturity = 204,
/// [`LiquifactEscrow::extend_funding_deadline`] called when no funding deadline is configured.
FundingDeadlineNotSet = 205,
/// [`LiquifactEscrow::lower_min_contribution_floor`] called while escrow is not open.
FloorLowerNotOpen = 173,
/// [`LiquifactEscrow::lower_min_contribution_floor`] did not strictly lower the floor.
NewFloorNotLower = 174,
/// [`LiquifactEscrow::lower_min_contribution_floor`] received a non-positive floor.
NewFloorNotPositive = 175,
/// Caller is not authorized to perform partial settlement.
/// Only the escrow's `sme_address` or `admin` may call [`LiquifactEscrow::partial_settle`].
PartialSettleUnauthorizedCaller = 200,
/// [`LiquifactEscrow::partial_settle`] blocked while a legal hold is active.
LegalHoldBlocksPartialSettle = 201,
/// [`LiquifactEscrow::partial_settle`] called while escrow is not in open status (`status != 0`).
PartialSettleNotOpen = 202,
MaxPerInvestorCapNotConfigured = 24, // new
MaxPerInvestorCapNotRaised = 25, // new
/// [`LiquifactEscrow::raise_maturity_max_horizon`] received a `new_horizon` that is
/// not strictly greater than the current stored horizon.
HorizonNotRaised = 214,
/// [`LiquifactEscrow::fund`] blocked while operational pause is active.
PausedBlocksFunding = 210,
/// [`LiquifactEscrow::settle`] blocked while operational pause is active.
PausedBlocksSettlement = 211,
/// [`LiquifactEscrow::withdraw`] blocked while operational pause is active.
PausedBlocksWithdrawal = 212,
/// [`LiquifactEscrow::claim_investor_payout`] blocked while operational pause is active.
PausedBlocksInvestorClaims = 213,
/// [`LiquifactEscrow::init`] rejected `protocol_fee_bps` outside `0..=10_000`.
ProtocolFeeBpsOutOfRange = 215,
/// Arithmetic overflow computing protocol fee at [`LiquifactEscrow::withdraw`].
WithdrawFeeArithmeticOverflow = 216,
/// Arithmetic underflow computing net SME payout at [`LiquifactEscrow::withdraw`].
WithdrawNetArithmeticUnderflow = 217,
/// [`LiquifactEscrow::init`] rejected a `funding_deadline` at or after maturity.
FundingDeadlineAtOrAfterMaturity = 218,
/// [`LiquifactEscrow::unfund`] called when [`InvoiceEscrow::status`] is not 0 (open).
/// Unfunding is only valid while the escrow is still accepting contributions.
UnfundEscrowNotOpen = 220,
/// [`LiquifactEscrow::unfund`] requested amount exceeds the investor's recorded contribution.
/// Never withdraw more than was contributed; checked via [`i128::checked_sub`].
OverWithdrawal = 221,
/// [`LiquifactEscrow::unfund`] blocked because a compliance/legal hold is active.
/// No fund movement is permitted until the hold is cleared by the admin.
UnfundLegalHoldActive = 222,
}
#[inline(always)]
pub(crate) fn fail(env: &Env, error: EscrowError) -> ! {
panic_with_error!(env, error)
}
#[inline(always)]
pub(crate) fn ensure(env: &Env, condition: bool, error: EscrowError) {
if !condition {
fail(env, error);
}
}
/// Assert that `actual_status == expected_status`, emitting `error` otherwise.
///
/// This is the shared primitive used by all status gate helpers. Callers that need a
/// specific named status check (e.g. [`require_funding_open`]) delegate here so the
/// exact error code is preserved at every call site.
#[inline(always)]
pub(crate) fn guard_status_eq(
env: &Env,
actual_status: u32,
expected_status: u32,
error: EscrowError,
) {
ensure(env, actual_status == expected_status, error);
}
/// Assert that `actual_status` is one of the values in `allowed`, emitting `error` otherwise.
///
/// Used for terminal-state checks where multiple valid statuses apply (e.g. sweep dust
/// is allowed in settled/withdrawn/cancelled).
#[allow(dead_code)]
#[inline(always)]
pub(crate) fn guard_status_in(env: &Env, actual_status: u32, allowed: &[u32], error: EscrowError) {
ensure(env, allowed.contains(&actual_status), error);
}
/// Shared guard: assert that the escrow is in the **open funding window** (status == 0).
///
/// Every entrypoint that accepts new principal — [`LiquifactEscrow::fund`],
/// [`LiquifactEscrow::fund_with_commitment`], [`LiquifactEscrow::fund_batch`],
/// [`LiquifactEscrow::update_funding_target`], [`LiquifactEscrow::lower_max_unique_investors`],
/// and [`LiquifactEscrow::lower_min_contribution_floor`] — must call this helper instead of
/// inlining the status comparison. Centralising the gate means adding a new open-window
/// operation cannot accidentally omit or diverge from the check.
///
/// # Errors
/// Panics with [`EscrowError::EscrowNotOpenForFunding`] when `escrow.status != 0`.
///
/// # Security notes
/// This helper is intentionally **read-only** (no storage writes). Callers must complete their
/// own `Address::require_auth()` before performing any storage mutation; this guard only
/// validates escrow state and cannot substitute for an authorization check.
#[inline(always)]
pub(crate) fn require_funding_open(env: &Env, status: u32) {
guard_status_eq(env, status, 0, EscrowError::EscrowNotOpenForFunding);
}
/// Shared guard: assert that no legal/compliance hold is currently active.
///
/// Replaces the repeated inline pattern
/// `ensure(&env, !Self::legal_hold_active(&env), EscrowError::LegalHoldBlocks*)` that previously
/// appeared at every risk-bearing entrypoint — `sweep_terminal_dust`, `rotate_beneficiary`,
/// `fund_impl`, `partial_settle`, `settle`, `withdraw`, `claim_investor_payout`, and
/// `cancel_funding`. By centralising the read of [`DataKey::LegalHold`] and the negation we
/// guarantee that adding a new risk-bearing entrypoint cannot accidentally forget the hold
/// check or pick the wrong `LegalHoldBlocks*` variant — the caller passes the typed error
/// variant that documents which entrypoint was blocked.
///
/// # Errors
/// Panics with the caller-supplied `error` (one of the `EscrowError::LegalHoldBlocks*`
/// variants) when [`DataKey::LegalHold`] is `true`.
///
/// # Security notes
/// - Read-only: performs a single instance-storage read with `unwrap_or(false)` (no panic on
/// missing key). Does not write or delete any storage key.
/// - This helper is **not** an authorization check. Callers must still call
/// `Address::require_auth()` for the entrypoint's bound role before any storage mutation
/// or token transfer, per [ADR-002](docs/adr/ADR-002-auth-boundaries.md).
/// - The `LegalHold` flag is independent of the operational pause ([`DataKey::Paused`]); an
/// entrypoint that needs both gates must compose `guard_not_legal_hold` with
/// `ensure(!paused_active(env), PausedBlocks*)` itself.
#[inline(always)]
pub(crate) fn guard_not_legal_hold(env: &Env, error: EscrowError) {
ensure(env, !LiquifactEscrow::legal_hold_active(env), error);
}
/// Predicate: `true` when `status` is one of the **terminal** escrow states
/// (`2` = settled, `3` = withdrawn, `4` = cancelled).
///
/// Used to gate entries that only make sense after the escrow has reached a final
/// disposition — e.g. [`LiquifactEscrow::sweep_terminal_dust`], which sweeps
/// rounding-residue / stray-transfer balances only in terminal states, or liability-floor
/// checks that must only run when no further principal inbound is possible.
///
/// Centralising this predicate keeps the `settled | withdrawn | cancelled` set definitionally
/// identical across every call site — adding a new status code (e.g. a future
/// `claimed` state) only requires editing this helper and a single call-site comment.
///
/// # Notes
/// Pure function: no storage access, no token interaction. Safe to call from
/// any context where a `status: u32` value is in hand (entrypoint, view function, test).
///
/// # Security notes
/// This is a **predicate**, not a guard — callers that need to *enforce* the terminal
/// precondition must wrap the call in `ensure(&env, is_terminal_status(status), error)`.
/// Mixing predicates and guards deliberately: predicates let view helpers and tests reuse
/// the definition without hiding a panic, while `guard_status_eq` /
/// `guard_status_in` keep the call-site `ensure` self-documenting at entrypoints.
#[inline(always)]
pub(crate) fn is_terminal_status(status: u32) -> bool {
matches!(status, 2..=4)
}
/// Predicate: `true` when `status` is one of the **pre-settlement** escrow states
/// (`0` = open, `1` = funded).
///
/// Used by entrypoints that must run after funding closed but before settlement
/// finalised — e.g. [`LiquifactEscrow::rotate_beneficiary`], which lets the SME/admin
/// re-point the payout destination only while the escrow is still open or funded.
///
/// Centralising the predicate keeps the `open | funded` set definitionally identical across
/// every call site.
///
/// # Notes
/// Pure function: no storage access, no token interaction.
///
/// # Security notes
/// This is a **predicate**, not a guard. Callers that need to *enforce* the pre-settlement
/// precondition must wrap it in
/// `ensure(&env, is_pre_settlement_status(status), error)`.
#[inline(always)]
pub(crate) fn is_pre_settlement_status(status: u32) -> bool {
matches!(status, 0 | 1)
}
pub(crate) fn validate_maturity_bounds(env: &Env, maturity: u64, max_horizon: u64) {
if maturity == 0 {
return;
}
let now = env.ledger().timestamp();
ensure(env, maturity >= now, EscrowError::MaturityInPast);
let max_allowed = now.saturating_add(max_horizon);
ensure(
env,
maturity <= max_allowed,
EscrowError::MaturityExceedsMaxHorizon,
);
}
// --- Storage keys ---
#[contracttype]
#[derive(Clone)]
/// Storage discriminator for persisted contract state.
///
/// Most variants live in **instance** storage (shared TTL with the contract instance, bounded
/// aggregate size). Per-investor variants
/// [`InvestorContribution`], [`InvestorEffectiveYield`], [`InvestorClaimNotBefore`], and
/// [`InvestorClaimed`] use **persistent** storage (independent per-address TTL; see ADR-007 and
/// `docs/escrow-gas-storage-notes.md`). [`InvestorAllowlisted`] also uses persistent storage.
///
/// Optional keys are always read with `.get(...).unwrap_or(default)` so that deployments predating
/// a key behave as “unset / default” without panicking.
///
/// ## Additive-key policy (see ADR-007)
///
/// Adding a new variant is **backward-compatible** when the new key is read with
/// `.unwrap_or(default)` and its absence does not change existing entrypoint semantics.
/// Renaming a variant, changing its XDR discriminant, or altering the stored type of an
/// existing key is **breaking** and requires a `migrate` path or a full redeploy.
///
/// Derive rationale:
/// - `Clone`: required because keys are passed by reference into storage APIs and reused
/// across lookups/sets in the same execution path.
pub enum DataKey {
/// Full escrow snapshot ([`InvoiceEscrow`]); rewritten atomically on every state transition.
Escrow,
/// Stored schema version; written once by [`LiquifactEscrow::init`] to [`SCHEMA_VERSION`]
/// and updated by [`LiquifactEscrow::migrate`] when a migration path is implemented.
/// Read with [`LiquifactEscrow::get_version`]. Never delete or rename this variant.
Version,
/// Per-investor contributed principal recorded during [`LiquifactEscrow::fund`].
/// **Persistent** storage. Absent ⇒ `0`. One entry per investor address.
InvestorContribution(Address),
/// When true, compliance/legal hold blocks payouts and settlement finalization.
/// Absent ⇒ `false` (no hold). Toggled by admin via [`LiquifactEscrow::set_legal_hold`].
LegalHold,
/// Optional minimum ledger timestamp when `LegalHold` may be cleared after a
/// [`LiquifactEscrow::request_clear_legal_hold`] call.
/// Absent ⇒ no clear request is pending.
LegalHoldClearableAt,
/// Configured minimum delay between [`LiquifactEscrow::request_clear_legal_hold`] and
/// [`LiquifactEscrow::set_legal_hold(env, false)`]. Absent ⇒ `0`.
LegalHoldClearDelay,
/// Optional SME collateral commitment metadata (record-only — not an on-chain asset lock).
/// Absent when no commitment has been recorded. Replaceable by the SME.
SmeCollateralPledge,
/// Set to `true` when an investor has exercised a claim after settlement.
/// **Persistent** storage. Absent ⇒ `false`. Written once; a second claim returns without re-emitting.
InvestorClaimed(Address),
/// SEP-41 funding asset for this invoice instance; set once in [`LiquifactEscrow::init`].
/// Immutable after init.
FundingToken,
/// Protocol treasury that may receive [`LiquifactEscrow::sweep_terminal_dust`]; set once in init.
/// Immutable after init.
Treasury,
/// Optional registry contract id for indexers; **hint only**, not authority (see module rustdoc).
/// Omitted from storage when unset at init. Absent ⇒ `None`.
RegistryRef,
/// Immutable tier table when configured at [`LiquifactEscrow::init`]; omitted when tiering is off.
/// Absent ⇒ no tiering (base `yield_bps` applies to all investors).
/// **Trust:** values are protocol-supplied at deploy; the contract never mutates this key after init.
YieldTierTable,
/// Set once when status first becomes **funded** (1); immutable thereafter (pro-rata denominator).
/// Absent until the escrow reaches `status == 1`. See [`FundingCloseSnapshot`].
FundingCloseSnapshot,
/// Effective annualized yield in bps chosen at this investor’s **first** deposit (see tiered yield).
/// **Persistent** storage. Absent ⇒ falls back to [`InvoiceEscrow::yield_bps`]. One entry per investor address.
InvestorEffectiveYield(Address),
/// Minimum [`Env::ledger`] timestamp before [`LiquifactEscrow::claim_investor_payout`] (0 = no extra gate).
/// **Persistent** storage. Absent ⇒ `0`. One entry per investor address; set on first deposit.
InvestorClaimNotBefore(Address),
/// Minimum [`LiquifactEscrow::fund`] / [`LiquifactEscrow::fund_with_commitment`] amount per call (0 = no floor).
/// Written as `0` even when unconfigured so reads always succeed.
MinContributionFloor,
/// When set at [`LiquifactEscrow::init`], caps distinct investor addresses that may contribute.
/// Absent ⇒ unlimited. Checked against [`DataKey::UniqueFunderCount`] on each new investor.
MaxUniqueInvestorsCap,
/// Optional immutable per-investor cap on total principal credited to a single address.
/// Absent ⇒ unlimited. Checked against [`DataKey::InvestorContribution`] on every deposit.
MaxPerInvestorCap,
/// Proposed successor admin waiting for [`LiquifactEscrow::accept_admin`].
/// Absent ⇒ no pending handover. Cleared after successful acceptance.
PendingAdmin,
/// Ledger timestamp (seconds) after which [`LiquifactEscrow::accept_admin`] rejects the
/// pending proposal. Written alongside [`DataKey::PendingAdmin`] on every
/// [`LiquifactEscrow::propose_admin`] call; cleared on acceptance or cancellation.
PendingAdminExpiry,
/// Count of distinct investor addresses that have a non-zero [`DataKey::InvestorContribution`].
/// Written as `0` at init; incremented once per new investor in `fund_impl`.
UniqueFunderCount,
/// Admin-only **single-set** off-chain attestation digest (e.g. SHA-256 of a legal/KYC bundle).
/// Absent until [`LiquifactEscrow::bind_primary_attestation_hash`] is called; single-set thereafter.
PrimaryAttestationHash,
/// Append-only audit chain of digests (bounded by [`MAX_ATTESTATION_APPEND_ENTRIES`]).
/// Absent ⇒ empty log. See [`LiquifactEscrow::append_attestation_digest`].
AttestationAppendLog,
/// Per-index revocation marker for [`DataKey::AttestationAppendLog`] entries.
/// Absent ⇒ not revoked. Written as `true` by [`LiquifactEscrow::revoke_attestation_digest`].
/// Preserves the original digest for auditability while signalling supersession.
AttestationRevoked(u32),
/// When true, only allowlisted addresses may call [`LiquifactEscrow::fund`] or [`LiquifactEscrow::fund_with_commitment`].
AllowlistActive,
/// Whether a specific address is permitted to fund when [`DataKey::AllowlistActive`] is true.
InvestorAllowlisted(Address),
/// Index of allowlisted addresses for paginated enumeration.
AllowlistIndex,
/// Set to `true` once an investor's principal has been refunded in a cancelled escrow.
/// Absent ⇒ `false`. Written once; prevents double-refund.
InvestorRefunded(Address),
/// Running total of principal already returned to investors via [`LiquifactEscrow::refund`].
/// Absent ⇒ `0`. Incremented atomically with each successful refund transfer.
/// Used by [`LiquifactEscrow::sweep_terminal_dust`] to compute outstanding liabilities:
/// `outstanding = funded_amount - distributed_principal`.
DistributedPrincipal,
/// Configured maximum maturity horizon in seconds from current ledger time.
/// Absent ⇒ falls back to [`DEFAULT_MATURITY_MAX_HORIZON_SECS`].
/// Set at init and updatable via [`LiquifactEscrow::update_maturity_max_horizon`].
MaturityMaxHorizon,
/// Optional funding deadline timestamp; absent ⇒ no deadline.
/// Written by [`LiquifactEscrow::init`] and extended by
/// [`LiquifactEscrow::extend_funding_deadline`]; checked during [`LiquifactEscrow::fund`].
FundingDeadline,
/// Ordered list of all investor addresses; used for pagination via [`LiquifactEscrow::get_investors`].
/// Absent ⇒ empty list (no investors yet funded).
InvestorIndex,
/// Ledger timestamp recorded when [`LiquifactEscrow::settle`] transitions status to 2.
/// Absent ⇒ not yet settled, or legacy instance. Read via [`LiquifactEscrow::get_settled_at`].
SettledAt,
/// When true, a lightweight **operational pause** blocks risk-bearing entrypoints
/// (`fund`, `settle`, `withdraw`, `claim_investor_payout`) for incident response.
/// Absent ⇒ `false` (not paused). Toggled by admin via [`LiquifactEscrow::set_paused`].
///
/// Orthogonal to [`DataKey::LegalHold`]: the pause has **no** compliance semantics and
/// **no** two-phase clear delay — it is a single-call admin switch for incidents such as a
/// suspected token bug. Either flag independently blocks the gated entrypoints.
Paused,
/// Immutable protocol fee in basis points (0..=10_000) applied to the SME disbursement
/// at [`LiquifactEscrow::withdraw`]; set once in [`LiquifactEscrow::init`].
/// Written as `0` even when unconfigured so reads always succeed (`.unwrap_or(0)`).
/// Stored as `i64` to match the [`InvoiceEscrow::yield_bps`] basis-point convention.
/// **Additive key (ADR-007):** absent on instances predating this key ⇒ read as `0`
/// (no fee), preserving legacy full-principal disbursement semantics.
ProtocolFeeBps,
}
// --- Data types ---
/// Full state of an invoice escrow persisted in contract storage (`DataKey::Escrow`).
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
/// Full escrow snapshot persisted at [`DataKey::Escrow`].
///
/// Derive rationale:
/// - `Debug`: improves failure diagnostics in tests.
/// - `PartialEq`: allows exact state assertions in tests.
///
/// `Clone` is intentionally omitted to avoid accidental full-state copies.
pub struct InvoiceEscrow {
pub invoice_id: Symbol,
pub admin: Address,
pub sme_address: Address,
pub amount: i128,
pub funding_target: i128,
pub funded_amount: i128,
pub yield_bps: i64,
pub maturity: u64,
/// 0 = open, 1 = funded, 2 = settled, 3 = withdrawn (SME pulled liquidity), 4 = cancelled (admin-gated; investors may refund)
pub status: u32,
}
/// SME-reported collateral metadata for off-chain risk review.
///
/// **Record-only:** this struct is stored for transparency and indexing. It does **not**
/// custody, escrow, transfer, freeze, reserve, or verify assets. It also does not alter funding,
/// settlement, SME withdrawal, investor-claim, compliance hold, or treasury-sweep behavior.
/// Future versions that enforce asset movement or custody must introduce explicit APIs and must
/// not treat historical records from this type as proof of locked assets.
///
/// # Fields
/// - `asset`: The off-chain asset symbol (cannot be empty).
/// - `amount`: The reported collateral amount (must be positive).
/// - `recorded_at`: The Soroban ledger timestamp when this record was written.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
/// SME collateral commitment metadata (record-only).
///
/// Derive rationale:
/// - `Clone`: required for `Option<SmeCollateralCommitment>` used in `EscrowSummary`.
/// - `Debug`: improves failure diagnostics in tests.
/// - `PartialEq`: allows deterministic assertion of stored/read values.
pub struct SmeCollateralCommitment {
pub asset: Symbol,
pub amount: i128,
pub recorded_at: u64,
}
/// One step in an optional tier ladder: investors who commit to at least `min_lock_secs` (on first
/// deposit via [`LiquifactEscrow::fund_with_commitment`]) may receive `yield_bps` for pro-rata /
/// off-chain coupon math. **Immutable** after `init`: the table is fixed for the escrow instance.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct YieldTier {
pub min_lock_secs: u64,
pub yield_bps: i64,
}
/// Captured exactly once at the first ledger transition to **funded** so settlement and claims can
/// use a stable total principal and target. If the threshold-crossing deposit overshoots
/// [`InvoiceEscrow::funding_target`], [`FundingCloseSnapshot::total_principal`] records the full
/// credited [`InvoiceEscrow::funded_amount`] at close and becomes the pro-rata denominator.
/// **Immutable** once written.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct FundingCloseSnapshot {
/// Sum of principal credited when the invoice became funded (`funded_amount` at close),
/// including over-funding past target.
pub total_principal: i128,
pub funding_target: i128,
pub closed_at_ledger_timestamp: u64,
pub closed_at_ledger_sequence: u32,
}
/// Custom option-like enum to represent the captured funding close snapshot.
/// Models standard option semantics as a contracttype to avoid standard library
/// blanket trait limitations in Soroban SDK testutils.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum EscrowCloseSnapshot {
None,
Some(FundingCloseSnapshot),
}
/// Custom option-like enum to represent the SME collateral commitment.
/// Models standard option semantics as a contracttype to avoid standard library
/// blanket trait limitations in Soroban SDK testutils.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum CollateralCommitmentSnapshot {
None,
Some(SmeCollateralCommitment),
}
/// Comprehensive summary of the escrow contract state.
/// Bundles multiple read-only values to allow a single host invocation
/// for off-chain indexers and client rendering.
#[contracttype]
#[derive(Debug, PartialEq)]
pub struct EscrowSummary {
/// Full escrow snapshot.
pub escrow: InvoiceEscrow,
/// True when `escrow.maturity > 0`; false means settlement has no maturity time lock.
pub has_maturity_lock: bool,
/// Active legal or compliance hold flag.
pub legal_hold: bool,
/// The captured funding close snapshot (Option).
pub funding_close_snapshot: EscrowCloseSnapshot,
/// Unique investors count who funded the escrow.
pub unique_funder_count: u32,
/// Whether the investor allowlist is active.
pub is_allowlist_active: bool,
/// Persisted schema version of the contract data.
pub schema_version: u32,
/// SME collateral commitment metadata (None when never recorded).
pub sme_collateral_commitment: CollateralCommitmentSnapshot,
/// Whether a primary attestation hash has been bound.
pub has_primary_attestation: bool,