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
6690 lines (6065 loc) · 267 KB
/
Copy pathlib.rs
File metadata and controls
6690 lines (6065 loc) · 267 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
<<<<<<< HEAD
<<<<<<< HEAD
#![no_std]
=======
#![cfg_attr(not(test), no_std)]
=======
#![cfg_attr(not(test), no_std)]
>>>>>>> 973c262 (feat(collateral): add admin parameter setter)
//! 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`].
<<<<<<< HEAD
>>>>>>> pr-982
=======
>>>>>>> 973c262 (feat(collateral): add admin parameter setter)
#![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,
};
<<<<<<< HEAD
<<<<<<< HEAD
=======
pub mod external_calls;
pub mod keys;
pub use keys::{collateral_pledge_key, DataKey};
=======
pub mod external_calls;
>>>>>>> 973c262 (feat(collateral): add admin parameter setter)
/// 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.
<<<<<<< HEAD
>>>>>>> pr-982
=======
>>>>>>> 973c262 (feat(collateral): add admin parameter setter)
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;
/// Maximum number of digests that can be appended in a single batch call via
/// [`LiquifactEscrow::append_attestation_digests`].
pub const MAX_ATTESTATION_APPEND_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;
<<<<<<< HEAD
/// Upper bound on pause record read page size.
pub const MAX_PAUSE_READ_PAGE: u32 = 50;
/// Upper bound on collateral record read page size.
pub const MAX_COLLATERAL_READ_PAGE: u32 = 50;
/// Minimum pause max duration (seconds) for auto-expiry.
pub const MIN_PAUSE_MAX_DURATION_SECS: u64 = 300;
/// Maximum pause max duration (seconds) for auto-expiry.
pub const MAX_PAUSE_MAX_DURATION_SECS: u64 = 2_592_000;
/// Minimum pause toggle limit (number of toggles per window).
pub const MIN_PAUSE_TOGGLE_LIMIT: u32 = 1;
/// Maximum pause toggle limit (number of toggles per window).
pub const MAX_PAUSE_TOGGLE_LIMIT: u32 = 1000;
/// Minimum pause toggle window (seconds).
pub const MIN_PAUSE_TOGGLE_WINDOW_SECS: u64 = 60;
/// Maximum pause toggle window (seconds).
pub const MAX_PAUSE_TOGGLE_WINDOW_SECS: u64 = 86_400;
pub const DEFAULT_SETTLEMENT_LIMIT: u32 = 50;
pub const MIN_SETTLEMENT_LIMIT: u32 = 1;
pub const MAX_SETTLEMENT_LIMIT: u32 = 100;
=======
>>>>>>> 973c262 (feat(collateral): add admin parameter setter)
/// Upper bound on attestation digest read page size.
pub const MAX_ATTESTATION_READ_PAGE: u32 = 20;
/// Upper bound on [`LiquifactEscrow::get_collateral_records`] page size.
pub const MAX_COLLATERAL_READ_PAGE: u32 = 50;
/// Default number of entries processed per call when no explicit
/// [`LiquifactEscrow::set_settlement_limit`] has been configured.
pub const DEFAULT_SETTLEMENT_LIMIT: u32 = 50;
/// Lower bound accepted by [`LiquifactEscrow::set_settlement_limit`].
pub const MIN_SETTLEMENT_LIMIT: u32 = 1;
/// Upper bound accepted by [`LiquifactEscrow::set_settlement_limit`].
pub const MAX_SETTLEMENT_LIMIT: u32 = 100;
/// 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::append_attestation_digests`] received an empty digests list.
AttestationAppendBatchEmpty = 57,
/// [`LiquifactEscrow::append_attestation_digests`] exceeded [`MAX_ATTESTATION_APPEND_BATCH`].
AttestationAppendBatchTooLarge = 58,
/// [`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_collateral_limit`] received a non-positive limit.
CollateralLimitNotPositive = 63,
/// [`LiquifactEscrow::record_sme_collateral_commitment`] received an amount exceeding the admin-configured limit.
CollateralLimitExceeded = 64,
/// [`LiquifactEscrow::set_collateral_limit`] received a limit exceeding [`MAX_INVOICE_AMOUNT`].
CollateralLimitExceedsMax = 65,
/// [`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,
/// [`LiquifactEscrow::set_settlement_limit`] received a limit outside
/// `[MIN_SETTLEMENT_LIMIT, MAX_SETTLEMENT_LIMIT]`.
SettlementLimitOutOfRange = 300,
}
#[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 the lightweight **operational pause** is active.
///
/// Reads [`DataKey::Paused`] and checks if it has auto-expired (if configured).
/// Returns `false` if the pause has expired or if not paused.
///
/// Used internally by entrypoints to gate `fund`, `settle`, `withdraw`, and
/// `claim_investor_payout` when an operational pause is active.
pub(crate) fn paused_active(env: &Env) -> bool {
let paused: bool = env
.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false);
if !paused {
return false;
}
// Check auto-expiry
let paused_at: u64 = match env.storage().instance().get(&DataKey::PausedAt) {
Some(at) => at,
None => return false, // Paused=true but PausedAt missing - inconsistent, fail safe
};
let max_duration: u64 = env
.storage()
.instance()
.get(&DataKey::PauseMaxDuration)
.unwrap_or(0);
if max_duration == 0 {
// No auto-expiry configured - legacy behavior
return true;
}
let expiry = match paused_at.checked_add(max_duration) {
Some(exp) => exp,
None => {
// Overflow - fail safe by treating as still active
return true;
}
};
env.ledger().timestamp() < expiry
}
/// 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,
);
}
<<<<<<< HEAD
// --- 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