-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathKnownBehaviour.t.sol
More file actions
256 lines (209 loc) · 11.7 KB
/
Copy pathKnownBehaviour.t.sol
File metadata and controls
256 lines (209 loc) · 11.7 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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {CovenantTestBase} from "./Base.t.sol";
import {ReceivableNote} from "../src/ReceivableNote.sol";
import {CovenantDefaults} from "../src/CovenantDefaults.sol";
/// @notice Pins the behaviour recorded in docs/REVIEW.md.
///
/// The findings in that document were deliberately not fixed: the contracts are
/// deployed and verified on Sourcify at `exact_match`, and editing the source —
/// even a comment — un-matches the deployed bytecode. Leaving them merely
/// *described* is weaker than it needs to be, though. These tests assert what
/// the deployed contracts actually do at each of those edges, so the finding is
/// a characterised property rather than a claim in prose, and so a later fix has
/// a failing test to flip rather than a paragraph to re-read.
///
/// Every assertion here runs against the same bytecode that is live on Monad.
contract KnownBehaviourTest is CovenantTestBase {
uint256 constant FACE = 100_000e6;
function _maturity() internal view returns (uint64) {
return uint64(block.timestamp + 60 days);
}
// -----------------------------------------------------------------
// Finding 1 — an empty curve is accepted and bricks origination
// -----------------------------------------------------------------
function test_finding1_emptyCurveIsAcceptedAndBlocksEveryOrigination() public {
ReceivableNote.RateBand[] memory none = new ReceivableNote.RateBand[](0);
note.setRateBands(none);
assertEq(note.rateBands().length, 0, "curve should now be empty");
assertEq(note.advanceRateBpsFor(90), 0, "no band can match");
bytes32 invoice = keccak256("finding1");
_confirm(obligorInstitutional, invoice, FACE, _maturity());
vm.prank(supplier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NoAdvanceRateForSubTier.selector, SUBTIER_INSTITUTIONAL));
note.originate(invoice, obligorInstitutional, FACE, _maturity(), CovenantDefaults.MIN_ORIGINATOR_SUBTIER, 0);
}
function test_setRateBands_rejectsAnAscendingCurve() public {
ReceivableNote.RateBand[] memory bands = new ReceivableNote.RateBand[](2);
bands[0] = ReceivableNote.RateBand({minSubTier: 20, advanceRateBps: 8_800});
bands[1] = ReceivableNote.RateBand({minSubTier: 80, advanceRateBps: 9_700});
vm.expectRevert(ReceivableNote.BandsNotDescending.selector);
note.setRateBands(bands);
}
function test_setRateBands_rejectsAZeroOrOversizedRate() public {
ReceivableNote.RateBand[] memory zero = new ReceivableNote.RateBand[](1);
zero[0] = ReceivableNote.RateBand({minSubTier: 20, advanceRateBps: 0});
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.InvalidAdvanceRate.selector, uint16(0)));
note.setRateBands(zero);
ReceivableNote.RateBand[] memory over = new ReceivableNote.RateBand[](1);
over[0] = ReceivableNote.RateBand({minSubTier: 20, advanceRateBps: 10_001});
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.InvalidAdvanceRate.selector, uint16(10_001)));
note.setRateBands(over);
}
function test_onlyOwnerMayChangeTheCurveOrTheOriginatorFloor() public {
vm.prank(supplier);
vm.expectRevert();
note.setRateBands(CovenantDefaults.rateBands());
vm.prank(supplier);
vm.expectRevert();
note.setMinOriginatorSubTier(0);
}
// -----------------------------------------------------------------
// Finding 2 — the deployed curve floors at 20, so subTier 0 prices at nothing
// -----------------------------------------------------------------
function test_finding2_subTierBelowEveryBandPricesAtZero() public view {
assertEq(note.advanceRateBpsFor(19), 0, "19 is below the lowest band");
assertEq(note.advanceRateBpsFor(0), 0, "an unreadable identity prices at nothing");
assertEq(note.advanceRateBpsFor(20), 8_800, "20 sits exactly on the floor");
assertEq(note.advanceRateBpsFor(99), 9_700, "the top of the documented range");
}
// -----------------------------------------------------------------
// Finding 5 — three unguarded inputs in `originate`
// -----------------------------------------------------------------
/// A zero face value mints a real ERC-721 that `fund` and `settle` then treat as
/// non-existent, because both use `faceValue == 0` as their existence check. The
/// token exists to `ownerOf` and not to the lifecycle.
function test_finding5_zeroFaceValueMintsANoteTheLifecycleCannotSee() public {
bytes32 invoice = keccak256("finding5-zero");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, 0, maturity);
vm.prank(supplier);
uint256 tokenId = note.originate(invoice, obligorInstitutional, 0, maturity, 20, 0);
assertEq(note.ownerOf(tokenId), supplier, "the NFT is real and owned");
vm.prank(financier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NoteNotFound.selector, tokenId));
note.fund(tokenId);
vm.prank(obligorInstitutional);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NoteNotFound.selector, tokenId));
note.settle(tokenId);
}
/// `originate` does not re-read the obligor's *current* standing, only the subTier
/// snapshotted at confirmation — so a revoked obligor still yields a mintable note.
/// No money can move on it: `fund` does re-read, and reverts.
function test_finding5_revokedObligorStillMintsButCanNeverBeFunded() public {
bytes32 invoice = keccak256("finding5-revoked");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
identityRegistry.adminSetIdentity(obligorInstitutional, false, 50, SUBTIER_INSTITUTIONAL, 1, maturity);
vm.prank(supplier);
uint256 tokenId = note.originate(invoice, obligorInstitutional, FACE, maturity, 20, 0);
assertEq(note.ownerOf(tokenId), supplier, "the note mints against a revoked obligor");
vm.prank(financier);
asset.approve(address(escrow), FACE);
vm.prank(financier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NotVerified.selector, obligorInstitutional));
note.fund(tokenId);
}
/// "At maturity" is narrative, not enforced: the obligor may settle immediately.
/// Benign — early payment by the debtor harms nobody — but worth pinning so the
/// documentation and the contract cannot drift apart unnoticed.
function test_finding5_settlementBeforeMaturityIsAllowed() public {
bytes32 invoice = keccak256("finding5-early");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
uint256 tokenId = _originate(invoice, obligorInstitutional, FACE, maturity);
uint256 advance = (FACE * 9_700) / 10_000;
_approveAndFund(tokenId, advance);
vm.prank(obligorInstitutional);
asset.approve(address(escrow), FACE);
vm.prank(obligorInstitutional);
note.settle(tokenId);
vm.expectRevert();
note.ownerOf(tokenId);
}
// -----------------------------------------------------------------
// Guards that are enforced — the other half of the picture
// -----------------------------------------------------------------
function test_fund_rejectsAFinancierBelowTheNotesOwnFloor() public {
bytes32 invoice = keccak256("floor");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
vm.prank(supplier);
uint256 tokenId = note.originate(invoice, obligorInstitutional, FACE, maturity, 80, 0);
vm.prank(financier); // subTier 60, note demands 80
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.TierTooLow.selector, financier));
note.fund(tokenId);
}
function test_fund_rejectsAJurisdictionMismatch() public {
bytes32 invoice = keccak256("jurisdiction");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
// financier's jurisdiction is 1; the note demands 2
vm.prank(supplier);
uint256 tokenId = note.originate(invoice, obligorInstitutional, FACE, maturity, 20, 2);
vm.prank(financier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.JurisdictionMismatch.selector, financier));
note.fund(tokenId);
}
function test_settle_rejectsAnyCallerWhoIsNotTheObligor() public {
bytes32 invoice = keccak256("settle-auth");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
uint256 tokenId = _originate(invoice, obligorInstitutional, FACE, maturity);
_approveAndFund(tokenId, (FACE * 9_700) / 10_000);
vm.prank(financier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.Unauthorized.selector, financier));
note.settle(tokenId);
}
function test_settle_rejectsANoteThatWasNeverFunded() public {
bytes32 invoice = keccak256("settle-unfunded");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
uint256 tokenId = _originate(invoice, obligorInstitutional, FACE, maturity);
vm.prank(obligorInstitutional);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NotFunded.selector, tokenId));
note.settle(tokenId);
}
function test_fund_rejectsASecondFunding() public {
bytes32 invoice = keccak256("double-fund");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
uint256 tokenId = _originate(invoice, obligorInstitutional, FACE, maturity);
uint256 advance = (FACE * 9_700) / 10_000;
_approveAndFund(tokenId, advance);
vm.prank(financier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.AlreadyFunded.selector, tokenId));
note.fund(tokenId);
}
function test_fund_rejectsANoteThatDoesNotExist() public {
vm.prank(financier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.NoteNotFound.selector, uint256(999)));
note.fund(999);
}
function test_originate_rejectsASupplierBelowTheOriginatorFloor() public {
// Drop the supplier under the floor rather than raising the floor, so the
// deployed constant stays the thing under test.
identityRegistry.adminSetIdentity(supplier, true, 50, 19, 1, uint64(block.timestamp + 365 days));
bytes32 invoice = keccak256("originator-floor");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
vm.prank(supplier);
vm.expectRevert(abi.encodeWithSelector(ReceivableNote.TierTooLow.selector, supplier));
note.originate(invoice, obligorInstitutional, FACE, maturity, 20, 0);
}
function test_getNote_returnsTheSnapshottedEnvelope() public {
bytes32 invoice = keccak256("envelope");
uint64 maturity = _maturity();
_confirm(obligorInstitutional, invoice, FACE, maturity);
uint256 tokenId = _originate(invoice, obligorInstitutional, FACE, maturity);
ReceivableNote.Note memory n = note.getNote(tokenId);
assertEq(n.obligor, obligorInstitutional);
assertEq(n.supplier, supplier);
assertEq(n.faceValue, FACE);
assertEq(n.maturity, maturity);
assertEq(n.advanceRateBps, 9_700, "priced off the snapshotted subTier, not today's");
assertEq(n.financier, address(0));
assertFalse(n.funded);
assertFalse(n.settled);
}
}