-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathInvariant.t.sol
More file actions
434 lines (390 loc) · 18.4 KB
/
Copy pathInvariant.t.sol
File metadata and controls
434 lines (390 loc) · 18.4 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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {IdentityRegistry} from "../src/IdentityRegistry.sol";
import {ObligationRegistry} from "../src/ObligationRegistry.sol";
import {FactoringEscrow} from "../src/FactoringEscrow.sol";
import {CompliancePolicy} from "../src/CompliancePolicy.sol";
import {MockAToken} from "../src/MockAToken.sol";
import {ReceivableNote} from "../src/ReceivableNote.sol";
import {CovenantDefaults} from "../src/CovenantDefaults.sol";
/// @notice Drives the desk the way a chain would: nine wallets calling the lifecycle in
/// whatever order Foundry picks, including orders no user story describes.
///
/// Every action is wrapped in try/catch on purpose. A handler that reverts is a
/// wasted run; the interesting sequences are the ones that get deep — settle
/// before fund, fund a note whose obligor was revoked halfway, transfer a note
/// to a wallet that lost its A-Pass between two calls. Those must be *attempted*
/// and then either succeed legally or be refused, and the invariants must hold
/// across all of it.
contract CovenantHandler is Test {
IdentityRegistry public identityRegistry;
ObligationRegistry public obligationRegistry;
FactoringEscrow public escrow;
MockAToken public asset;
ReceivableNote public note;
address[] public actors;
uint256[] public tokenIds;
struct Pending {
bytes32 invoiceHash;
address obligor;
address supplier;
uint256 faceValue;
uint64 maturity;
}
Pending[] public pending;
uint256 public confirmed;
uint256 public originated;
uint256 public funded;
uint256 public settled;
uint256 public transferred;
uint256 public revoked;
uint256 public revokedNow;
uint256 private _salt;
uint64 internal constant FAR_FUTURE = 4_102_444_800; // 2100-01-01
constructor() {
identityRegistry = new IdentityRegistry(address(this));
obligationRegistry = new ObligationRegistry(address(identityRegistry));
escrow = new FactoringEscrow(address(identityRegistry));
CompliancePolicy policy = new CompliancePolicy(address(identityRegistry));
asset = new MockAToken("Covenant Devnet A-Token", "dvaUSD", 6, address(identityRegistry), address(policy));
note = new ReceivableNote(
"Covenant Compliant Receivable Note",
"CRN",
address(identityRegistry),
address(obligationRegistry),
address(escrow),
address(asset),
CovenantDefaults.MIN_ORIGINATOR_SUBTIER
);
escrow.setNote(address(note));
note.setRateBands(CovenantDefaults.rateBands());
// Spread across every band plus both edges: one wallet below the origination
// floor, one never verified at all.
_seed("inst-a", 90);
_seed("inst-b", 80);
_seed("mid-a", 60);
_seed("mid-b", 50);
_seed("basic-a", 30);
_seed("basic-b", 20);
_seed("thin", 5);
_seed("floor-edge", 19);
actors.push(makeAddr("never-verified"));
}
function _seed(string memory label, uint8 subTier) private {
address a = makeAddr(label);
identityRegistry.adminSetIdentity(a, true, 50, subTier, 1, FAR_FUTURE);
asset.mint(a, 5_000_000e6);
actors.push(a);
}
function actorCount() external view returns (uint256) {
return actors.length;
}
function tokenCount() external view returns (uint256) {
return tokenIds.length;
}
function _actor(uint256 seed) private view returns (address) {
return actors[seed % actors.length];
}
// ---------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------
function confirm(uint256 obligorSeed, uint256 supplierSeed, uint96 faceValue, uint32 maturityOffset) external {
address obligor = _actor(obligorSeed);
address supplier = _actor(supplierSeed);
uint256 face = bound(faceValue, 1e6, 250_000e6);
uint64 maturity = uint64(block.timestamp + bound(maturityOffset, 1 days, 730 days));
bytes32 invoiceHash = keccak256(abi.encodePacked("inv", _salt++));
vm.prank(obligor);
try obligationRegistry.confirmObligation(invoiceHash, supplier, face, maturity) {
pending.push(
Pending({
invoiceHash: invoiceHash,
obligor: obligor,
supplier: supplier,
faceValue: face,
maturity: maturity
})
);
confirmed++;
} catch {}
}
function originate(uint256 pendingSeed, uint8 minFinancierSubTier) external {
if (pending.length == 0) return;
uint256 i = pendingSeed % pending.length;
Pending memory p = pending[i];
// Bounded to the range the seeded wallets actually occupy. Left raw, half of
// every run demanded a subTier no wallet on the chain holds, so `fund` could
// never succeed and the invariants passed without a single trade completing.
uint8 floor = uint8(bound(minFinancierSubTier, 0, 90));
vm.prank(p.supplier);
try note.originate(p.invoiceHash, p.obligor, p.faceValue, p.maturity, floor, 0) returns (
uint256 tokenId
) {
tokenIds.push(tokenId);
originated++;
_evict(i);
} catch {
// A confirmation whose supplier can never originate — below the floor,
// unverified, or revoked — stays in the pool forever otherwise, and the
// pool fills with them. Left in, random picks mostly landed on dead
// entries and a typical run originated twice in ninety-six calls.
_evict(i);
}
}
function _evict(uint256 i) private {
pending[i] = pending[pending.length - 1];
pending.pop();
}
/// The seed picks among notes that are actually fundable, not among all notes ever
/// minted. Picking from the whole list, most calls landed on something already
/// funded and returned without touching the contract — the sequences stayed
/// shallow and almost no run ever reached settlement. Which note, and which
/// financier, are still the fuzzer's choice; only "a note that could be funded at
/// all" is filtered.
function fund(uint256 tokenSeed, uint256 financierSeed) external {
uint256 count = _countWhere(false);
if (count == 0) return;
uint256 tokenId = _nthWhere(false, tokenSeed % count);
address financier = _actor(financierSeed);
ReceivableNote.Note memory n = note.getNote(tokenId);
uint256 advance = (n.faceValue * n.advanceRateBps) / 10_000;
vm.prank(financier);
asset.approve(address(escrow), advance);
vm.prank(financier);
try note.fund(tokenId) {
funded++;
} catch {}
// Whatever happened, do not leave a standing allowance behind — that is
// docs/REVIEW.md Finding 3's exposure, and leaving it here would let the
// conservation invariant pass for the wrong reason.
vm.prank(financier);
asset.approve(address(escrow), 0);
}
function settle(uint256 tokenSeed) external {
uint256 count = _countWhere(true);
if (count == 0) return;
uint256 tokenId = _nthWhere(true, tokenSeed % count);
ReceivableNote.Note memory n = note.getNote(tokenId);
vm.prank(n.obligor);
asset.approve(address(escrow), n.faceValue);
vm.prank(n.obligor);
try note.settle(tokenId) {
settled++;
} catch {}
vm.prank(n.obligor);
asset.approve(address(escrow), 0);
}
/// `wantFunded == true` selects funded-and-unsettled notes (settle candidates);
/// `false` selects unfunded ones (fund candidates).
function _countWhere(bool wantFunded) private view returns (uint256 count) {
for (uint256 i = 0; i < tokenIds.length; ++i) {
ReceivableNote.Note memory n = note.getNote(tokenIds[i]);
if (n.settled) continue;
if (n.funded == wantFunded) count++;
}
}
function _nthWhere(bool wantFunded, uint256 n) private view returns (uint256) {
uint256 seen;
for (uint256 i = 0; i < tokenIds.length; ++i) {
ReceivableNote.Note memory x = note.getNote(tokenIds[i]);
if (x.settled) continue;
if (x.funded != wantFunded) continue;
if (seen == n) return tokenIds[i];
seen++;
}
revert("no candidate");
}
function transferNote(uint256 tokenSeed, uint256 toSeed) external {
if (tokenIds.length == 0) return;
uint256 tokenId = tokenIds[tokenSeed % tokenIds.length];
address to = _actor(toSeed);
try note.ownerOf(tokenId) returns (address owner) {
vm.prank(owner);
try note.transferFrom(owner, to, tokenId) {
transferred++;
} catch {}
} catch {}
}
/// Identity is not permanent. A wallet can lose its A-Pass between confirming a
/// debt and being paid for it, and the desk has to stay coherent when it does.
///
/// Capped at two wallets at once. Uncapped, revocation outran restoration and the
/// whole roster ended up unverified within a few calls — every subsequent action
/// reverted, and the invariants held over a chain where nothing ever happened.
function revokeIdentity(uint256 actorSeed) external {
if (revokedNow >= 2) return;
address a = _actor(actorSeed);
if (!identityRegistry.isVerified(a)) return;
identityRegistry.adminSetIdentity(a, false, 50, 0, 1, FAR_FUTURE);
revokedNow++;
revoked++;
}
/// Restores into the priced range. Restoring at a raw fuzzed subTier meant most
/// wallets came back at 0 — verified but unable to originate or be priced, which
/// is a revocation wearing a different hat.
function restoreIdentity(uint256 actorSeed, uint8 subTier) external {
address a = _actor(actorSeed);
if (identityRegistry.isVerified(a)) return;
identityRegistry.adminSetIdentity(a, true, 50, uint8(bound(subTier, 20, 99)), 1, FAR_FUTURE);
if (revokedNow > 0) revokedNow--;
}
/// Expiry is a second, independent way to stop being verified — `isVerified` reads
/// `verifiedUntil` against `block.timestamp`, so time alone can revoke a wallet.
function warp(uint32 secondsForward) external {
vm.warp(block.timestamp + bound(secondsForward, 1 hours, 60 days));
}
}
/// @notice What must be true of Covenant after any sequence of calls, not just the
/// sequences a test author imagined.
///
/// The characterisation suite pins known behaviour and the fuzz suite pins
/// properties of single functions. These are properties of the SYSTEM, checked
/// after every step of thousands of randomly ordered runs. Three of them are
/// the product's actual promises restated as machine-checked assertions: the
/// escrow never custodies anyone's money, no value is created or destroyed, and
/// a note is never priced off the published curve.
contract InvariantTest is StdInvariant, Test {
CovenantHandler handler;
function setUp() public {
handler = new CovenantHandler();
targetContract(address(handler));
}
/// The strongest structural claim the submission makes. `FactoringEscrow` moves
/// value with `safeTransferFrom` between two counterparties and never takes
/// custody — so its balance is zero at every point in every run, not merely at the
/// end of the happy path.
function invariant_escrowCustodiesNothing() public view {
assertEq(handler.asset().balanceOf(address(handler.escrow())), 0);
}
/// Nor does the note contract. Nothing in the lifecycle parks funds anywhere.
function invariant_noContractHoldsSettlementAsset() public view {
assertEq(handler.asset().balanceOf(address(handler.note())), 0);
assertEq(handler.asset().balanceOf(address(handler.obligationRegistry())), 0);
}
/// Factoring moves money between parties; it never creates or destroys it. Summed
/// over every wallet the handler can reach, the total is the supply that was minted
/// at construction.
function invariant_valueIsConserved() public view {
MockAToken asset = handler.asset();
uint256 total;
uint256 n = handler.actorCount();
for (uint256 i = 0; i < n; ++i) {
total += asset.balanceOf(handler.actors(i));
}
assertEq(total, asset.totalSupply());
}
/// Every note ever originated carries one of the three published rates. A note
/// priced off-curve would mean the desk charged something it never advertised.
function invariant_everyNoteIsPricedOnTheCurve() public view {
ReceivableNote note = handler.note();
uint256 n = handler.tokenCount();
for (uint256 i = 0; i < n; ++i) {
uint256 rate = note.getNote(handler.tokenIds(i)).advanceRateBps;
assertTrue(
rate == CovenantDefaults.RATE_INSTITUTIONAL || rate == CovenantDefaults.RATE_ESTABLISHED
|| rate == CovenantDefaults.RATE_BASIC,
"note priced off the published curve"
);
}
}
/// The advance is always a discount on face, and once funded it is exactly the
/// snapshotted rate applied to face — not recomputed later against a curve or an
/// identity that may have moved in the meantime.
function invariant_fundedNotesPriceExactly() public view {
ReceivableNote note = handler.note();
uint256 n = handler.tokenCount();
for (uint256 i = 0; i < n; ++i) {
ReceivableNote.Note memory x = note.getNote(handler.tokenIds(i));
assertLe(x.advanceAmount, x.faceValue, "advance exceeded face");
if (x.funded) {
assertEq(x.advanceAmount, (x.faceValue * x.advanceRateBps) / 10_000, "advance is not rate x face");
assertTrue(x.financier != address(0), "funded note has no financier");
} else {
assertEq(x.advanceAmount, 0, "unfunded note carries an advance");
assertEq(x.financier, address(0), "unfunded note names a financier");
}
}
}
/// Settlement burns the note, and only settlement does. A settled note that still
/// had an owner could be re-sold after the debt was already paid.
function invariant_settledNotesAreBurnedAndOthersAreNot() public view {
ReceivableNote note = handler.note();
uint256 n = handler.tokenCount();
uint256 live;
for (uint256 i = 0; i < n; ++i) {
uint256 tokenId = handler.tokenIds(i);
bool exists = _exists(note, tokenId);
if (note.getNote(tokenId).settled) {
assertFalse(exists, "settled note still exists");
} else {
assertTrue(exists, "unsettled note was burned");
live++;
}
}
uint256 held;
uint256 actors = handler.actorCount();
for (uint256 i = 0; i < actors; ++i) {
held += note.balanceOf(handler.actors(i));
}
assertEq(held, live, "live notes are not all held by a known wallet");
}
/// One confirmation, one note. The key mapping is what stops a single confirmed
/// debt being financed twice, so it must stay injective across every run.
function invariant_oneConfirmationMintsAtMostOneNote() public view {
ReceivableNote note = handler.note();
ObligationRegistry registry = handler.obligationRegistry();
uint256 n = handler.tokenCount();
for (uint256 i = 0; i < n; ++i) {
uint256 tokenId = handler.tokenIds(i);
ReceivableNote.Note memory x = note.getNote(tokenId);
bytes32 key = registry.keyFor(x.invoiceHash, x.obligor, x.supplier, x.faceValue, x.maturity);
assertEq(note.tokenIdForKey(key), tokenId, "key does not map back to its own note");
}
}
/// Nothing may exist that the handler did not originate — no path mints a note
/// outside `originate`.
function invariant_noteSupplyIsAccountedFor() public view {
assertEq(handler.originated(), handler.tokenCount());
}
function _exists(ReceivableNote note, uint256 tokenId) private view returns (bool) {
try note.ownerOf(tokenId) returns (address) {
return true;
} catch {
return false;
}
}
/// The first version of this suite passed all nine invariants over 6,144 calls
/// while completing exactly zero trades: two bad bounds meant nothing ever got past
/// `confirm`, and "no note was ever mispriced" is trivially true on a chain with no
/// notes. Green meant nothing.
///
/// This is the guard against that, and it is deliberately NOT an `afterInvariant`
/// assertion. That fires after every randomised sequence, so it would demand that
/// all 128 of them reach settlement and would fail on the unlucky one — a flaky
/// test, which is worse than no test. A fixed sequence through the same handler
/// proves the thing that actually matters: the handler can reach every stage, so a
/// campaign that never does is a fuzzing problem and not an inert harness.
function test_theHandlerCanCompleteATrade() public {
CovenantHandler h = new CovenantHandler();
for (uint256 i = 0; i < 6; ++i) {
h.confirm(i, i + 2, uint96(10_000e6 + i * 1e6), uint32(30 days));
h.originate(i, 20);
h.fund(i, i + 2);
h.settle(i);
}
assertGt(h.originated(), 0, "handler cannot originate");
assertGt(h.funded(), 0, "handler cannot fund");
assertGt(h.settled(), 0, "handler cannot settle");
}
/// A run summary for the reader.
function invariant_callSummary() public view {
console.log("confirmed ", handler.confirmed());
console.log("originated ", handler.originated());
console.log("funded ", handler.funded());
console.log("settled ", handler.settled());
console.log("transferred", handler.transferred());
console.log("revoked ", handler.revoked());
}
}