-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFuzz.t.sol
More file actions
335 lines (287 loc) · 16.6 KB
/
Copy pathFuzz.t.sol
File metadata and controls
335 lines (287 loc) · 16.6 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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {CovenantTestBase} from "./Base.t.sol";
import {ReceivableNote} from "../src/ReceivableNote.sol";
import {ObligationRegistry} from "../src/ObligationRegistry.sol";
import {IdentityRegistry} from "../src/IdentityRegistry.sol";
import {CovenantDefaults} from "../src/CovenantDefaults.sol";
/// @notice Property-based tests over the parts of Covenant that take a number from a
/// stranger.
///
/// The rest of the suite pins specific cases: this subTier prices at that rate,
/// this caller is rejected. That proves the examples chosen, and nothing about
/// the ones not chosen. Every input asserted on below arrives from outside the
/// contract — a supplier picks `faceValue` and `maturity`, Cleanverse picks the
/// obligor's `subTier`, the desk owner picks the curve — so the interesting
/// question is not "does 90 price at 9700" but "is there ANY subTier that
/// prices above face", and that is a question about a domain, not a case.
///
/// Foundry runs each of these 256 times by default with a fresh input each run,
/// and shrinks any failure to a minimal counterexample.
contract FuzzTest is CovenantTestBase {
// ---------------------------------------------------------------------
// The rate curve
// ---------------------------------------------------------------------
/// Deeper verification must never price worse than shallower verification. This is
/// the entire pricing claim in one line — if it fails anywhere in 0..255, the
/// argument that subTier is a usable risk axis fails with it.
function testFuzz_rateIsMonotonicInSubTier(uint8 a, uint8 b) public view {
if (a > b) (a, b) = (b, a);
assertLe(note.advanceRateBpsFor(a), note.advanceRateBpsFor(b));
}
/// An advance rate above 10_000 bps would advance more than the invoice is worth.
function testFuzz_rateNeverExceedsFullFace(uint8 subTier) public view {
assertLe(note.advanceRateBpsFor(subTier), 10_000);
}
/// Zero means "no band matched, origination reverts". It must happen below the
/// floor and nowhere else — a silent zero inside the priced range would brick
/// origination for a counterparty the desk claims to serve.
function testFuzz_rateIsZeroExactlyBelowTheFloor(uint8 subTier) public view {
assertEq(note.advanceRateBpsFor(subTier) == 0, subTier < CovenantDefaults.SUBTIER_BASIC);
}
/// Whatever comes back is one of the three published bands, never an interpolation.
function testFuzz_rateIsAlwaysAPublishedBand(uint8 subTier) public view {
uint256 rate = note.advanceRateBpsFor(subTier);
assertTrue(
rate == 0 || rate == CovenantDefaults.RATE_INSTITUTIONAL || rate == CovenantDefaults.RATE_ESTABLISHED
|| rate == CovenantDefaults.RATE_BASIC
);
}
// ---------------------------------------------------------------------
// Curve administration
// ---------------------------------------------------------------------
/// A curve that is not strictly descending prices ambiguously — two bands could
/// match one subTier, and the winner would be whichever was pushed first. Rejected
/// for every such pair, not just the ones a test author thought of.
function testFuzz_setRateBandsRejectsNonDescending(uint8 first, uint8 second) public {
vm.assume(second >= first);
ReceivableNote.RateBand[] memory bands = new ReceivableNote.RateBand[](2);
bands[0] = ReceivableNote.RateBand({minSubTier: first, advanceRateBps: 9_000});
bands[1] = ReceivableNote.RateBand({minSubTier: second, advanceRateBps: 8_000});
vm.expectRevert(ReceivableNote.BandsNotDescending.selector);
note.setRateBands(bands);
}
function testFuzz_setRateBandsRejectsUnusableRate(uint16 bps) public {
vm.assume(bps == 0 || bps > 10_000);
ReceivableNote.RateBand[] memory bands = new ReceivableNote.RateBand[](1);
bands[0] = ReceivableNote.RateBand({minSubTier: 20, advanceRateBps: bps});
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.InvalidAdvanceRate.selector, bps));
note.setRateBands(bands);
}
/// Any curve the setter accepts is monotonic when read back. Guards the pair of
/// guards above: it is the read path, not the write path, that pricing depends on.
function testFuzz_acceptedCurvesReadBackMonotonic(uint8 high, uint8 low, uint8 probeA, uint8 probeB) public {
vm.assume(high > low);
ReceivableNote.RateBand[] memory bands = new ReceivableNote.RateBand[](2);
bands[0] = ReceivableNote.RateBand({minSubTier: high, advanceRateBps: 9_500});
bands[1] = ReceivableNote.RateBand({minSubTier: low, advanceRateBps: 8_000});
note.setRateBands(bands);
if (probeA > probeB) (probeA, probeB) = (probeB, probeA);
assertLe(note.advanceRateBpsFor(probeA), note.advanceRateBpsFor(probeB));
}
// ---------------------------------------------------------------------
// The confirmation key
// ---------------------------------------------------------------------
/// Two different obligations must never share a key. A collision would let one
/// confirmation be spent on a different invoice — the single worst failure the
/// registry could have.
function testFuzz_distinctObligationsGetDistinctKeys(
bytes32 invoiceA,
address obligorA,
address supplierA,
uint256 faceA,
uint64 maturityA,
bytes32 invoiceB,
address obligorB,
address supplierB,
uint256 faceB,
uint64 maturityB
) public view {
vm.assume(
invoiceA != invoiceB || obligorA != obligorB || supplierA != supplierB || faceA != faceB
|| maturityA != maturityB
);
assertTrue(
obligationRegistry.keyFor(invoiceA, obligorA, supplierA, faceA, maturityA)
!= obligationRegistry.keyFor(invoiceB, obligorB, supplierB, faceB, maturityB)
);
}
/// `supplier` is in the hash even though `keyFor`'s own comment omits it
/// (docs/REVIEW.md, Finding 6). Pinned here so the omission stays a documentation
/// defect and can never quietly become a code one.
function testFuzz_supplierIsPartOfTheKey(bytes32 invoiceHash, address supplierA, address supplierB, uint256 face)
public
view
{
vm.assume(supplierA != supplierB);
assertTrue(
obligationRegistry.keyFor(invoiceHash, obligorInstitutional, supplierA, face, 1)
!= obligationRegistry.keyFor(invoiceHash, obligorInstitutional, supplierB, face, 1)
);
}
// ---------------------------------------------------------------------
// Pricing arithmetic
// ---------------------------------------------------------------------
/// The advance is a discount, always. A financier who paid more than face would be
/// buying a guaranteed loss, and the supplier would be borrowing at a negative rate.
function testFuzz_advanceNeverExceedsFace(uint96 faceValue, uint8 subTier) public {
faceValue = uint96(bound(faceValue, 1e6, 1_000_000e6));
// 0–99 is the documented domain of subTier. Fuzzing it over the whole uint8 was
// asking the registry to price a value Cleanverse cannot issue, which both
// registries now answer with 0.
subTier = uint8(bound(subTier, 0, 99));
vm.assume(note.advanceRateBpsFor(subTier) > 0);
address obligor = makeAddr("fuzzObligor");
identityRegistry.adminSetIdentity(obligor, true, 50, subTier, 1, uint64(block.timestamp + 365 days));
asset.mint(obligor, faceValue);
uint64 maturity = uint64(block.timestamp + 30 days);
bytes32 invoiceHash = keccak256(abi.encodePacked("fuzz", faceValue, subTier));
_confirm(obligor, invoiceHash, faceValue, maturity);
uint256 tokenId = _originate(invoiceHash, obligor, faceValue, maturity);
uint256 advance = (uint256(faceValue) * note.getNote(tokenId).advanceRateBps) / 10_000;
assertLe(advance, faceValue);
}
// ---------------------------------------------------------------------
// Value conservation across a whole lifecycle
// ---------------------------------------------------------------------
/// Confirm, originate, fund, settle — over any face value and any maturity, the
/// three parties end exactly where the arithmetic says. Nothing is minted, nothing
/// is burned, and the escrow keeps none of it.
///
/// This is the claim the desk makes to a financier in one assertion: your return is
/// the discount, and it is the whole discount.
function testFuzz_lifecycleConservesValue(uint96 faceValue, uint32 maturityOffset) public {
faceValue = uint96(bound(faceValue, 1e6, 500_000e6));
uint64 maturity = uint64(block.timestamp + bound(maturityOffset, 1 days, 3650 days));
bytes32 invoiceHash = keccak256(abi.encodePacked("conserve", faceValue, maturityOffset));
_confirm(obligorInstitutional, invoiceHash, faceValue, maturity);
uint256 tokenId = _originate(invoiceHash, obligorInstitutional, faceValue, maturity);
uint256 supplier0 = asset.balanceOf(supplier);
uint256 financier0 = asset.balanceOf(financier);
uint256 obligor0 = asset.balanceOf(obligorInstitutional);
uint256 total0 = supplier0 + financier0 + obligor0;
uint256 advance = (uint256(faceValue) * note.getNote(tokenId).advanceRateBps) / 10_000;
_approveAndFund(tokenId, advance);
vm.prank(obligorInstitutional);
asset.approve(address(escrow), faceValue);
vm.prank(obligorInstitutional);
note.settle(tokenId);
assertEq(asset.balanceOf(supplier), supplier0 + advance, "supplier receives exactly the advance");
assertEq(
asset.balanceOf(financier), financier0 - advance + faceValue, "financier earns exactly the discount"
);
assertEq(asset.balanceOf(obligorInstitutional), obligor0 - faceValue, "obligor pays exactly face");
assertEq(
asset.balanceOf(supplier) + asset.balanceOf(financier) + asset.balanceOf(obligorInstitutional),
total0,
"no value created or destroyed"
);
assertEq(asset.balanceOf(address(escrow)), 0, "escrow custodies nothing");
}
// ---------------------------------------------------------------------
// Compliance, over the whole address space
// ---------------------------------------------------------------------
/// No unverified address can end up holding a note, for any address that is not one
/// of the seeded participants. The gate lives in `_update`, so this covers every
/// route into it — `transferFrom`, `safeTransferFrom`, and an approved operator.
function testFuzz_unverifiedWalletCanNeverHoldANote(address stranger) public {
vm.assume(stranger != address(0));
vm.assume(!identityRegistry.isVerified(stranger));
vm.assume(stranger.code.length == 0);
bytes32 invoiceHash = keccak256(abi.encodePacked("stranger", stranger));
uint64 maturity = uint64(block.timestamp + 30 days);
_confirm(obligorInstitutional, invoiceHash, 1_000e6, maturity);
uint256 tokenId = _originate(invoiceHash, obligorInstitutional, 1_000e6, maturity);
vm.prank(supplier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NotEligibleHolder.selector, stranger));
note.transferFrom(supplier, stranger, tokenId);
assertEq(note.ownerOf(tokenId), supplier);
}
/// Only the named obligor settles. Anyone else — including the supplier who
/// originated it and the financier who owns it — is refused.
function testFuzz_onlyTheObligorCanSettle(address caller) public {
vm.assume(caller != obligorInstitutional);
bytes32 invoiceHash = keccak256(abi.encodePacked("settler", caller));
uint64 maturity = uint64(block.timestamp + 30 days);
_confirm(obligorInstitutional, invoiceHash, 1_000e6, maturity);
uint256 tokenId = _originate(invoiceHash, obligorInstitutional, 1_000e6, maturity);
_approveAndFund(tokenId, 970e6);
vm.prank(caller);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.Unauthorized.selector, caller));
note.settle(tokenId);
}
/// A supplier below the origination floor is refused for every subTier below it,
/// not merely at the one value a hand-written test would pick.
function testFuzz_supplierBelowTheFloorCannotOriginate(uint8 supplierSubTier) public {
vm.assume(supplierSubTier < CovenantDefaults.MIN_ORIGINATOR_SUBTIER);
address thinSupplier = makeAddr("thinSupplier");
identityRegistry.adminSetIdentity(
thinSupplier, true, 50, supplierSubTier, 1, uint64(block.timestamp + 365 days)
);
bytes32 invoiceHash = keccak256(abi.encodePacked("thin", supplierSubTier));
uint64 maturity = uint64(block.timestamp + 30 days);
vm.prank(obligorInstitutional);
obligationRegistry.confirmObligation(invoiceHash, thinSupplier, 1_000e6, maturity);
vm.prank(thinSupplier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.TierTooLow.selector, thinSupplier));
note.originate(invoiceHash, obligorInstitutional, 1_000e6, maturity, 20, 0);
}
/// A financier below the note's own floor is refused, for every such value. The
/// floor is per-note and set by the supplier, so this is a different gate from the
/// one above and fails differently.
function testFuzz_financierBelowTheNoteFloorCannotFund(uint8 required, uint8 financierSubTier) public {
required = uint8(bound(required, 1, 255));
vm.assume(financierSubTier < required);
address thinFinancier = makeAddr("thinFinancier");
identityRegistry.adminSetIdentity(
thinFinancier, true, 50, financierSubTier, 1, uint64(block.timestamp + 365 days)
);
asset.mint(thinFinancier, STARTING_BALANCE);
bytes32 invoiceHash = keccak256(abi.encodePacked("thinFin", required, financierSubTier));
uint64 maturity = uint64(block.timestamp + 30 days);
_confirm(obligorInstitutional, invoiceHash, 1_000e6, maturity);
vm.prank(supplier);
uint256 tokenId = note.originate(invoiceHash, obligorInstitutional, 1_000e6, maturity, required, 0);
vm.prank(thinFinancier);
asset.approve(address(escrow), 1_000e6);
vm.prank(thinFinancier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.TierTooLow.selector, thinFinancier));
note.fund(tokenId);
}
// ---------------------------------------------------------------------
// Guards added after static analysis
// ---------------------------------------------------------------------
/// Every address except zero is a legal relayer, and zero never is. Zero would
/// freeze the registry permanently — `onlyRelayer` compares against it and no
/// transaction can have a zero sender — and nothing in the contract's reads would
/// say so. Slither flagged the missing check on both the constructor and the
/// setter; see docs/STATIC-ANALYSIS.md.
function testFuzz_relayerIsNeverZero(address newRelayer) public {
if (newRelayer == address(0)) {
vm.expectRevert(IdentityRegistry.ZeroRelayer.selector);
identityRegistry.setRelayer(newRelayer);
} else {
identityRegistry.setRelayer(newRelayer);
assertEq(identityRegistry.relayer(), newRelayer);
}
}
function test_registryCannotBeDeployedWithoutARelayer() public {
vm.expectRevert(IdentityRegistry.ZeroRelayer.selector);
new IdentityRegistry(address(0));
}
/// Both implementations of `IIdentityRegistry` must answer identically for a subTier
/// outside Cleanverse's documented 0–99: fold it to 0, which prices at nothing and
/// blocks origination. The devnet mirror used to return it verbatim while production
/// clamped, so a value refused on Monad was priced at the top band in tests.
function testFuzz_outOfRangeSubTierReadsAsZero(uint8 subTier) public {
address subject = makeAddr("clampProbe");
identityRegistry.adminSetIdentity(subject, true, 50, subTier, 1, uint64(block.timestamp + 365 days));
uint8 got = identityRegistry.subTier(subject);
if (subTier > 99) {
assertEq(got, 0, "out-of-range subTier must not be readable");
assertEq(note.advanceRateBpsFor(got), 0, "an unreadable identity must not be priced");
} else {
assertEq(got, subTier, "in-range subTier must round-trip");
}
}
}