SKIP TO CONTENT

Demo build — mock protocol data. No chain, no ABIs yet.

  • SYNCING: ……

K401BondDepository.sol

Bonds. price = max(twap * (1 - discount), nav()) — floored at NAV, so no sale is ever dilutive.

238 lines9.4 KBSolidity
Source of src/K401BondDepository.sol, 238 lines of Solidity
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3 
4import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
5import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
6import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8import {IK401, IK401Oracle, IK401Treasury} from "./interfaces/IK401Interfaces.sol";
9 
10/**
11 * @title K401BondDepository
12 *
13 * price = max(twap * (1 - BOND_DISCOUNT_BPS), nav())
14 *
15 * The floor is NAV, not 1 USDG, so every bond sale is NAV accretive and can never be
16 * dilutive. There is NO control variable and no debt ratio: capacity is a fixed
17 * percentage of supply per 8 hour epoch, which cannot be gamed into a death spiral.
18 *
19 * LP principal is valued by the treasury at the 1 USDG floor (geometric mean), never at
20 * market price. Vesting is 5 day linear.
21 */
22contract K401BondDepository is Ownable, ReentrancyGuard {
23 using SafeERC20 for IERC20;
24 
25 uint256 public constant WAD = 1e18;
26 uint256 public constant BPS = 10_000;
27 uint256 public constant EPOCH_LENGTH = 8 hours;
28 /// @notice 5 day linear vesting. Immutable.
29 uint256 public constant VESTING_TERM = 5 days;
30 /// @notice Hard ceiling on any market's discount.
31 uint256 public constant MAX_DISCOUNT_BPS = 2_000;
32 /// @notice Hard ceiling on any market's per-epoch capacity.
33 uint256 public constant MAX_CAPACITY_BPS = 500;
34 
35 IK401 public immutable k401;
36 IK401Oracle public immutable oracle;
37 IK401Treasury public immutable treasury;
38 
39 struct Market {
40 address principal;
41 bool isLp;
42 bool active;
43 uint16 discountBps;
44 uint16 capacityBpsPerEpoch;
45 uint256 epochStart;
46 uint256 soldThisEpoch;
47 uint256 totalSold;
48 uint256 totalPrincipal;
49 }
50 
51 struct Note {
52 uint256 payout; // 401K still owed
53 uint256 lastUpdate;
54 uint256 vestingEnd;
55 }
56 
57 Market[] public markets;
58 /// @notice user -> marketId -> position
59 mapping(address => mapping(uint256 => Note)) public notes;
60 
61 event MarketCreated(uint256 indexed id, address principal, bool isLp, uint16 discountBps, uint16 capacityBps);
62 event MarketToggled(uint256 indexed id, bool active);
63 event Bonded(
64 address indexed user, uint256 indexed id, uint256 amount, uint256 valueWad, uint256 payout, uint256 priceWad
65 );
66 event Redeemed(address indexed user, uint256 indexed id, uint256 payout);
67 
68 error BadMarket();
69 error MarketInactive();
70 error CapacityExceeded();
71 error ZeroAmount();
72 error NothingToRedeem();
73 error BadParams();
74 error ZeroAddress();
75 
76 constructor(address k401_, address oracle_, address treasury_, address owner_) Ownable(owner_) {
77 if (k401_ == address(0) || oracle_ == address(0) || treasury_ == address(0)) revert ZeroAddress();
78 k401 = IK401(k401_);
79 oracle = IK401Oracle(oracle_);
80 treasury = IK401Treasury(treasury_);
81 }
82 
83 /*//////////////////////////////////////////////////////////////
84 MARKETS
85 //////////////////////////////////////////////////////////////*/
86 
87 function createMarket(address principal, bool isLp, uint16 discountBps, uint16 capacityBpsPerEpoch)
88 external
89 onlyOwner
90 returns (uint256 id)
91 {
92 if (principal == address(0)) revert ZeroAddress();
93 if (discountBps > MAX_DISCOUNT_BPS || capacityBpsPerEpoch == 0 || capacityBpsPerEpoch > MAX_CAPACITY_BPS) {
94 revert BadParams();
95 }
96 id = markets.length;
97 markets.push(
98 Market({
99 principal: principal,
100 isLp: isLp,
101 active: true,
102 discountBps: discountBps,
103 capacityBpsPerEpoch: capacityBpsPerEpoch,
104 epochStart: block.timestamp,
105 soldThisEpoch: 0,
106 totalSold: 0,
107 totalPrincipal: 0
108 })
109 );
110 emit MarketCreated(id, principal, isLp, discountBps, capacityBpsPerEpoch);
111 }
112 
113 /// @notice Markets can be paused but their economic parameters can never be edited.
114 function setMarketActive(uint256 id, bool active) external onlyOwner {
115 if (id >= markets.length) revert BadMarket();
116 markets[id].active = active;
117 emit MarketToggled(id, active);
118 }
119 
120 function marketCount() external view returns (uint256) {
121 return markets.length;
122 }
123 
124 /*//////////////////////////////////////////////////////////////
125 PRICING
126 //////////////////////////////////////////////////////////////*/
127 
128 /// @notice USDG (18-dec) paid per 1e18 of 401K. Fail-closed on a stale oracle.
129 function bondPrice(uint256 id) public view returns (uint256) {
130 if (id >= markets.length) revert BadMarket();
131 uint256 twap = oracle.consult(); // reverts StaleOracle outside the valid window
132 uint256 discounted = (twap * (BPS - markets[id].discountBps)) / BPS;
133 uint256 navWad = treasury.nav();
134 return discounted > navWad ? discounted : navWad;
135 }
136 
137 /// @notice Non-reverting variant for the UI.
138 function bondPriceView(uint256 id) public view returns (uint256 price, bool ok) {
139 if (id >= markets.length) return (0, false);
140 (uint256 twap, bool valid) = oracle.peek();
141 if (!valid) return (0, false);
142 uint256 discounted = (twap * (BPS - markets[id].discountBps)) / BPS;
143 uint256 navWad = treasury.nav();
144 return (discounted > navWad ? discounted : navWad, true);
145 }
146 
147 /// @notice Remaining 401K that may be sold in the current epoch of `id`.
148 function capacityLeft(uint256 id) public view returns (uint256) {
149 if (id >= markets.length) return 0;
150 Market storage m = markets[id];
151 uint256 cap = (k401.totalSupply() * m.capacityBpsPerEpoch) / BPS;
152 if (block.timestamp >= m.epochStart + EPOCH_LENGTH) return cap;
153 return m.soldThisEpoch >= cap ? 0 : cap - m.soldThisEpoch;
154 }
155 
156 /// @notice 401K received for `amount` of the market's principal. 0 if the oracle is stale.
157 function payoutFor(uint256 id, uint256 amount) external view returns (uint256) {
158 (uint256 price, bool ok) = bondPriceView(id);
159 if (!ok || price == 0) return 0;
160 return (treasury.valueOf(markets[id].principal, amount) * WAD) / price;
161 }
162 
163 /*//////////////////////////////////////////////////////////////
164 BOND
165 //////////////////////////////////////////////////////////////*/
166 
167 function bond(uint256 id, uint256 amount) external nonReentrant returns (uint256 payout) {
168 if (id >= markets.length) revert BadMarket();
169 Market storage m = markets[id];
170 if (!m.active) revert MarketInactive();
171 if (amount == 0) revert ZeroAmount();
172 
173 uint256 price = bondPrice(id); // fail-closed
174 uint256 valueWad = treasury.valueOf(m.principal, amount);
175 payout = (valueWad * WAD) / price;
176 if (payout == 0) revert ZeroAmount();
177 
178 // Roll the epoch window if needed, then enforce the fixed capacity.
179 if (block.timestamp >= m.epochStart + EPOCH_LENGTH) {
180 m.epochStart = block.timestamp;
181 m.soldThisEpoch = 0;
182 }
183 uint256 cap = (k401.totalSupply() * m.capacityBpsPerEpoch) / BPS;
184 if (m.soldThisEpoch + payout > cap) revert CapacityExceeded();
185 m.soldThisEpoch += payout;
186 m.totalSold += payout;
187 m.totalPrincipal += amount;
188 
189 // Pull principal, route it into the treasury, mint exactly `payout`.
190 IERC20(m.principal).safeTransferFrom(msg.sender, address(this), amount);
191 IERC20(m.principal).forceApprove(address(treasury), amount);
192 uint256 minted = treasury.deposit(m.principal, amount, valueWad - payout);
193 
194 Note storage n = notes[msg.sender][id];
195 n.payout += minted;
196 n.lastUpdate = block.timestamp;
197 n.vestingEnd = block.timestamp + VESTING_TERM;
198 
199 emit Bonded(msg.sender, id, amount, valueWad, minted, price);
200 payout = minted;
201 }
202 
203 /*//////////////////////////////////////////////////////////////
204 REDEEM
205 //////////////////////////////////////////////////////////////*/
206 
207 /// @notice Fraction of the caller's position that has vested, 1e18 scaled.
208 function percentVested(address user, uint256 id) public view returns (uint256) {
209 Note storage n = notes[user][id];
210 if (n.payout == 0) return 0;
211 if (block.timestamp >= n.vestingEnd) return WAD;
212 uint256 total = n.vestingEnd - n.lastUpdate;
213 if (total == 0) return WAD;
214 return ((block.timestamp - n.lastUpdate) * WAD) / total;
215 }
216 
217 function pendingPayoutFor(address user, uint256 id) public view returns (uint256) {
218 Note storage n = notes[user][id];
219 uint256 pct = percentVested(user, id);
220 return (n.payout * pct) / WAD;
221 }
222 
223 function redeemBond(uint256 id) external nonReentrant returns (uint256 amount) {
224 Note storage n = notes[msg.sender][id];
225 if (n.payout == 0) revert NothingToRedeem();
226 
227 amount = pendingPayoutFor(msg.sender, id);
228 if (amount == 0) revert NothingToRedeem();
229 
230 n.payout -= amount;
231 n.lastUpdate = block.timestamp;
232 if (n.payout == 0) n.vestingEnd = block.timestamp;
233 
234 IERC20(address(k401)).safeTransfer(msg.sender, amount);
235 emit Redeemed(msg.sender, id, amount);
236 }
237}
238 

Click any line number to deep-link to it — the target line highlights on load.