-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathReceivableNote.sol
More file actions
288 lines (251 loc) · 12.7 KB
/
Copy pathReceivableNote.sol
File metadata and controls
288 lines (251 loc) · 12.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
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IIdentityRegistry} from "./interfaces/IIdentityRegistry.sol";
import {ObligationRegistry} from "./ObligationRegistry.sol";
import {FactoringEscrow} from "./FactoringEscrow.sol";
/// @title ReceivableNote
/// @notice Covenant's Compliant Receivable Note (CRN): one supplier's invoice on one
/// corporate buyer (the obligor), tokenized 1:1 as an ERC-721 — but only after
/// the obligor has confirmed the debt in `ObligationRegistry`. This is
/// confirmed/approved-payables financing (the Taulia/PrimeRevenue category),
/// not generic invoice factoring: the buyer's on-chain confirmation, not a
/// financier's trust in paper, is what de-risks the trade.
///
/// Lifecycle: confirm (ObligationRegistry, by the obligor) -> originate (here,
/// by the supplier, against that confirmation) -> fund (by a verified
/// financier, at an advance rate priced off the obligor's confirmed A-Pass
/// subTier as an honestly-labeled eligibility proxy, NOT a credit score) ->
/// settle (by the obligor, at maturity).
///
/// Compliance is a property of the token from block one: `_update` gates every
/// subsequent transfer against `IdentityRegistry`, so the note can never move
/// to a wallet that isn't A-Pass verified.
contract ReceivableNote is ERC721, Ownable {
struct Note {
bytes32 invoiceHash;
address obligor;
address supplier;
uint256 faceValue;
uint64 maturity;
uint8 minFinancierSubTier;
uint16 requiredJurisdiction; // 0 = any
uint256 advanceRateBps; // snapshotted at origination, out of 10_000
uint256 advanceAmount; // set at fund()
address financier; // set at fund()
bool funded;
bool settled;
}
IIdentityRegistry public immutable identityRegistry;
ObligationRegistry public immutable obligationRegistry;
FactoringEscrow public immutable escrow;
IERC20 public immutable asset;
/// @notice Supplier must hold at least this A-Pass subTier to originate — kills
/// fake/anonymous originators.
uint8 public minOriginatorSubTier;
/// @notice The advance-rate curve, as bands over the obligor's A-Pass subTier.
///
/// Bands rather than a per-value mapping because subTier is a 0-99
/// continuum that Cleanverse hands out, not a small enum Covenant controls.
/// An earlier version keyed a mapping on `tier` with entries for 1, 2 and 3
/// — which worked against seeded fixtures and reverted against every real
/// identity, because live passes carry tiers like 20 and 50 and Cleanverse
/// assigns them. `subTier` is the axis an integrator sets at issuance, so
/// it is the one Covenant can honestly price on.
///
/// Held strictly descending by `minSubTier`; the first band a subTier
/// clears wins. A subTier below every band prices at zero, which blocks
/// origination rather than silently defaulting to some rate.
struct RateBand {
uint8 minSubTier;
uint16 advanceRateBps;
}
RateBand[] private _rateBands;
mapping(uint256 => Note) public notes;
/// @notice Prevents minting a second note off the same obligor confirmation.
mapping(bytes32 => uint256) public tokenIdForKey;
uint256 private _nextTokenId = 1;
event Originated(
uint256 indexed tokenId,
bytes32 indexed key,
address indexed supplier,
address obligor,
uint256 faceValue,
uint256 advanceRateBps
);
event Funded(uint256 indexed tokenId, address indexed financier, uint256 advanceAmount);
event Settled(uint256 indexed tokenId, address indexed holder, uint256 faceValue);
event RateBandsChanged(uint256 bandCount);
error NotVerified(address subject);
error TierTooLow(address subject);
error ConfirmationNotFound(bytes32 key);
error AlreadyOriginated(bytes32 key);
error NoAdvanceRateForSubTier(uint8 subjectSubTier);
error BandsNotDescending();
error InvalidAdvanceRate(uint16 bps);
error NoteNotFound(uint256 tokenId);
error AlreadyFunded(uint256 tokenId);
error JurisdictionMismatch(address subject);
error NotFunded(uint256 tokenId);
error AlreadySettled(uint256 tokenId);
error Unauthorized(address caller);
error NotEligibleHolder(address to);
constructor(
string memory name_,
string memory symbol_,
address identityRegistryAddr,
address obligationRegistryAddr,
address escrowAddr,
address assetAddr,
uint8 minOriginatorSubTier_
) ERC721(name_, symbol_) Ownable(msg.sender) {
identityRegistry = IIdentityRegistry(identityRegistryAddr);
obligationRegistry = ObligationRegistry(obligationRegistryAddr);
escrow = FactoringEscrow(escrowAddr);
asset = IERC20(assetAddr);
minOriginatorSubTier = minOriginatorSubTier_;
}
// ---------------------------------------------------------------------
// Admin
// ---------------------------------------------------------------------
/// @notice Replaces the whole curve at once. Set as a unit rather than band by
/// band so the ordering invariant can be checked against the final state —
/// a curve that is briefly out of order would mis-price every note
/// originated in between.
function setRateBands(RateBand[] calldata bands) external onlyOwner {
delete _rateBands;
for (uint256 i = 0; i < bands.length; ++i) {
if (bands[i].advanceRateBps == 0 || bands[i].advanceRateBps > 10_000) {
revert InvalidAdvanceRate(bands[i].advanceRateBps);
}
if (i > 0 && bands[i].minSubTier >= bands[i - 1].minSubTier) revert BandsNotDescending();
_rateBands.push(bands[i]);
}
emit RateBandsChanged(bands.length);
}
function setMinOriginatorSubTier(uint8 minOriginatorSubTier_) external onlyOwner {
minOriginatorSubTier = minOriginatorSubTier_;
}
// ---------------------------------------------------------------------
// Views
// ---------------------------------------------------------------------
/// @notice Explicit struct accessor, easier to consume than the raw positional
/// tuple the auto-generated `notes(tokenId)` getter returns.
function getNote(uint256 tokenId) external view returns (Note memory) {
return notes[tokenId];
}
function rateBands() external view returns (RateBand[] memory) {
return _rateBands;
}
/// @notice The advance rate a given subTier would price at, in bps. Zero means no
/// band matches and origination would revert — the desk shows this before
/// a supplier spends gas finding out.
function advanceRateBpsFor(uint8 subjectSubTier) public view returns (uint256) {
uint256 len = _rateBands.length;
for (uint256 i = 0; i < len; ++i) {
if (subjectSubTier >= _rateBands[i].minSubTier) return _rateBands[i].advanceRateBps;
}
return 0;
}
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
/// @notice Supplier originates a note against an existing obligor confirmation.
/// The advance rate is read off the obligor's SUBTIER AS SNAPSHOTTED AT
/// CONFIRMATION TIME (see ObligationRegistry) — an eligibility/compliance
/// proxy for risk, not a credit score.
function originate(
bytes32 invoiceHash,
address obligor,
uint256 faceValue,
uint64 maturity,
uint8 minFinancierSubTier,
uint16 requiredJurisdiction
) external returns (uint256 tokenId) {
address supplier = msg.sender;
if (!identityRegistry.isVerified(supplier)) revert NotVerified(supplier);
if (identityRegistry.subTier(supplier) < minOriginatorSubTier) revert TierTooLow(supplier);
bytes32 key = obligationRegistry.keyFor(invoiceHash, obligor, supplier, faceValue, maturity);
if (!obligationRegistry.isConfirmed(key)) revert ConfirmationNotFound(key);
if (tokenIdForKey[key] != 0) revert AlreadyOriginated(key);
ObligationRegistry.Confirmation memory conf = obligationRegistry.getConfirmation(key);
uint256 advanceRateBps = advanceRateBpsFor(conf.obligorSubTier);
if (advanceRateBps == 0) revert NoAdvanceRateForSubTier(conf.obligorSubTier);
tokenId = _nextTokenId++;
notes[tokenId] = Note({
invoiceHash: invoiceHash,
obligor: obligor,
supplier: supplier,
faceValue: faceValue,
maturity: maturity,
minFinancierSubTier: minFinancierSubTier,
requiredJurisdiction: requiredJurisdiction,
advanceRateBps: advanceRateBps,
advanceAmount: 0,
financier: address(0),
funded: false,
settled: false
});
tokenIdForKey[key] = tokenId;
_safeMint(supplier, tokenId);
emit Originated(tokenId, key, supplier, obligor, faceValue, advanceRateBps);
}
/// @notice A verified financier funds the note: pays the advance to the supplier
/// and receives the note. `FactoringEscrow` independently re-checks both
/// parties before moving value (the CVA defense-in-depth layer).
///
/// Also re-checks the obligor's verification here, not just at `confirm`
/// and `settle`: `IdentityRegistry` entries self-expire, so the obligor
/// confirmed at one point in time is not guaranteed to still be compliant
/// when the money actually moves. Without this, a lapsed/revoked obligor's
/// confirmation could still be financed at full price.
function fund(uint256 tokenId) external returns (uint256 advanceAmount) {
address financier = msg.sender;
Note storage n = notes[tokenId];
if (n.faceValue == 0) revert NoteNotFound(tokenId);
if (n.funded) revert AlreadyFunded(tokenId);
if (!identityRegistry.isVerified(financier)) revert NotVerified(financier);
if (identityRegistry.subTier(financier) < n.minFinancierSubTier) revert TierTooLow(financier);
if (n.requiredJurisdiction != 0 && identityRegistry.jurisdiction(financier) != n.requiredJurisdiction) {
revert JurisdictionMismatch(financier);
}
if (!identityRegistry.isVerified(n.obligor)) revert NotVerified(n.obligor);
address supplier = ownerOf(tokenId);
advanceAmount = (n.faceValue * n.advanceRateBps) / 10_000;
n.funded = true;
n.advanceAmount = advanceAmount;
n.financier = financier;
escrow.fundAdvance(address(asset), financier, supplier, advanceAmount);
_transfer(supplier, financier, tokenId);
emit Funded(tokenId, financier, advanceAmount);
}
/// @notice At maturity, the obligor deposits the face value; it routes to whoever
/// currently holds the note, which burns.
function settle(uint256 tokenId) external {
Note storage n = notes[tokenId];
if (n.faceValue == 0) revert NoteNotFound(tokenId);
if (!n.funded) revert NotFunded(tokenId);
if (n.settled) revert AlreadySettled(tokenId);
if (msg.sender != n.obligor) revert Unauthorized(msg.sender);
address holder = ownerOf(tokenId);
n.settled = true;
escrow.settleRedemption(address(asset), n.obligor, holder, n.faceValue);
_burn(tokenId);
emit Settled(tokenId, holder, n.faceValue);
}
// ---------------------------------------------------------------------
// Compliance-embedded transfer hook
// ---------------------------------------------------------------------
/// @dev OZ v5's single mint/burn/transfer hook. Mint (from == 0) and burn
/// (to == 0) pass through untouched; any wallet-to-wallet move requires the
/// recipient to be A-Pass verified, else it reverts on-chain from block one.
function _update(address to, uint256 tokenId, address auth) internal override returns (address) {
address from = _ownerOf(tokenId);
if (from != address(0) && to != address(0)) {
if (!identityRegistry.isVerified(to)) revert NotEligibleHolder(to);
}
return super._update(to, tokenId, auth);
}
}