Skip to content

Commit a5937ca

Browse files
maxencerbMaxime Brugel
andauthored
feat: Add Principal and Yield Token Vault System (#1)
* feat: add dual-token vault system with principal and yield tokens, including interfaces and libraries for token management * feat: added offerreceiver abstract + tests * chore: fmt * feat: transfer 128 bit manipulation to a lib * chore: fmt * feat: added mint auth lib * feat: initial request contract draft * fix: move setting nonce above * fix: update nonce before checking signature to avoid replay of the signature * fix: compare with min token allowance for emitting events * fix: test correctly checks expiry * feat: remove UUPS from request * chore: update docs on internal functions to have no notice * fix: emit withdraw events correctly * chore: docs remove notice on internal funcs * feat: crafted request interface * fix: no transfer to self * feat: added vault implementation * fix: invalid inherit docs * feat: created the request factory * feat: add pullFunds method + update readme * feat: added mint authorization view function * feat: added request tests * chore: fmt * chore: improve coverage * chore: fmt * feat: moved max 128 constant to constants file * chore: added coverage ignore + silence warnings on tests * feat: added interfaces * fix: pull funds from maker instead of owner * feat: reorganise file layout * chore: align solidity versions * chore: use token in request factory instead of vault for naming * feat: cannot call setRepaid more than once * doc: update readme --------- Co-authored-by: Maxime Brugel <maxime.brugel@kiln.fi>
1 parent ff61966 commit a5937ca

42 files changed

Lines changed: 7742 additions & 39 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitmodules‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "lib/forge-std"]
22
path = lib/forge-std
33
url = https://github.com/foundry-rs/forge-std
4+
[submodule "lib/solady"]
5+
path = lib/solady
6+
url = https://github.com/vectorized/solady

‎README.md‎

Lines changed: 271 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
```txt █████
2-
░░███
3-
███████ ████████ █████ ████ ████████ ███████
4-
███░░███ ░░███░░███ ░░███ ░███ ░░███░░███ ░░░███░
2+
░░███
3+
███████ ████████ █████ ████ ████████ ███████
4+
███░░███ ░░███░░███ ░░███ ░███ ░░███░░███ ░░░███░
55
░███ ░███ ░███ ░░░ ░███ ░███ ░███ ░███ ░███
66
░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ███
77
░░███████ █████ ░░████████ ████ █████ ░░█████
@@ -13,4 +13,271 @@
1313

1414
<p align="center" style="font-size: smaller;">
1515
<i>(wip)</i>
16-
</p>
16+
</p>
17+
18+
## Principal and Yield Token Vaults
19+
20+
The vault system implements a dual-token structure separating principal and yield:
21+
22+
### Deposit Phase
23+
24+
When depositing into the vault, users receive two types of shares:
25+
- **Principal Shares (PT)**: Equal to the amount of assets deposited
26+
- **Yield Shares (YT)**: Equal to the expected yield amount
27+
28+
**Example**: Depositing 1,000,000 USDC with an expected 10% return:
29+
- Receive: 1,000,000 PT + 100,000 YT
30+
31+
During the deposit phase, withdrawals are locked (`canWithdraw = false`).
32+
33+
### Redemption Phase
34+
35+
Once the vault receives assets and unlocks redemption (`canWithdraw = true`), the value of each share type is determined by the total assets in the vault.
36+
37+
#### Redemption Formula
38+
39+
```
40+
principalAssets = min(totalAssets, principalTokenSupply)
41+
yieldAssets = totalAssets - principalAssets
42+
43+
pricePerPrincipalShare = principalAssets / principalTokenSupply
44+
pricePerYieldShare = yieldAssets / yieldTokenSupply
45+
```
46+
47+
#### Redemption Examples
48+
49+
Given: 1,000,000 PT and 100,000 YT outstanding
50+
51+
| Total Assets | Principal Assets | Yield Assets | PT Price | YT Price |
52+
| ------------ | ---------------- | ------------ | -------- | -------- |
53+
| 900,000 | 900,000 | 0 | 0.9 | 0 |
54+
| 1,000,000 | 1,000,000 | 0 | 1.0 | 0 |
55+
| 1,050,000 | 1,000,000 | 50,000 | 1.0 | 0.5 |
56+
| 1,200,000 | 1,000,000 | 200,000 | 1.0 | 2.0 |
57+
58+
**Key Properties:**
59+
- Principal holders are prioritized: they receive up to 1:1 redemption of their shares
60+
- Yield holders receive any assets beyond the principal supply
61+
- If total assets < principal supply, principal holders share the loss proportionally
62+
- If total assets > principal supply, yield holders capture all upside
63+
64+
## Request Contract
65+
66+
The `Request` contract is the core contract managing funding requests with dual-token (PT/YT) issuance. It combines multiple functionalities:
67+
68+
- **OfferReceiver**: Validates and processes signed offers using EIP-712 signatures
69+
- **VaultController**: Manages PT/YT tokens with ERC4626-style redemptions
70+
- **Ownable**: Restricts admin functions to the contract owner
71+
72+
### Request Lifecycle
73+
74+
```
75+
┌─────────────────────────────────────────────────────────────────────────────┐
76+
│ REQUEST LIFECYCLE │
77+
└─────────────────────────────────────────────────────────────────────────────┘
78+
79+
1. DEPLOYMENT
80+
└─> Factory creates Request + PT Vault + YT Vault (beacon proxies)
81+
└─> Owner is set, contract initialized
82+
83+
2. FUNDING PHASE (canWithdraw = false)
84+
├─> Method A: consume() - Process signed offers from prime brokers
85+
│ └─> Callback triggers → Funds pulled → PT/YT minted to maker
86+
└─> Method B: authorizeMinting() + mint() - Whitelist approach
87+
└─> Owner authorizes → Prime broker calls mint() → PT/YT minted
88+
89+
3. FUND UTILIZATION
90+
└─> Owner calls pullFunds() to transfer assets to borrower
91+
92+
4. REPAYMENT
93+
└─> Borrower transfers assets back to Request contract
94+
95+
5. REDEMPTION PHASE (canWithdraw = true)
96+
└─> Owner calls setRepaid()
97+
└─> PT/YT holders can redeem their tokens for underlying assets
98+
```
99+
100+
### Funding Methods
101+
102+
The Request contract supports two methods for prime brokers to provide funds:
103+
104+
#### Method 1: Signed Offer Consumption (`consume`)
105+
106+
The owner broadcasts a signed EIP-712 offer to the contract. This method is ideal for prime brokers who implement automated fund management through smart contracts.
107+
108+
**Flow:**
109+
1. Prime broker creates and signs an `Offer` struct (off-chain)
110+
2. Owner calls `consume(offer, signature, ptAmount)`
111+
3. Contract validates the signature and offer parameters
112+
4. Contract calls `onRequestConsumed()` callback on the maker's contract
113+
5. Contract pulls `ptAmount` of underlying asset from the owner (msg.sender)
114+
6. PT and YT tokens are minted to the maker
115+
7. The maker's offer nonce is updated
116+
117+
```solidity
118+
// Offer struct
119+
struct Offer {
120+
address maker; // Prime broker address that receives tokens
121+
uint256 amount; // Reference principal amount
122+
uint256 expectedReturn; // Expected yield amount
123+
uint256 nonce; // Sequential nonce (must be > stored nonce)
124+
uint256 expiration; // Timestamp when offer expires
125+
}
126+
127+
// YT amount is calculated proportionally
128+
ytAmount = offer.expectedReturn * ptAmount / offer.amount
129+
130+
// The ptAmount parameter determines how many PT tokens are minted
131+
// and scales the YT amount proportionally from the offer's expectedReturn
132+
```
133+
134+
**Callback Interface:**
135+
136+
Prime brokers implementing automated strategies can implement `IRequestCallback`:
137+
138+
```solidity
139+
interface IRequestCallback {
140+
function onRequestConsumed(
141+
Offer calldata offer,
142+
bytes calldata signature,
143+
uint256 principal, // PT amount being minted
144+
uint256 yield // YT amount being minted
145+
) external;
146+
}
147+
```
148+
149+
The callback is invoked **before** funds are pulled, allowing the maker to:
150+
- Withdraw from DeFi positions
151+
- Move funds from internal accounting
152+
- Set ERC20 allowances for the Request contract
153+
154+
#### Method 2: Authorized Minting (`authorizeMinting` + `mint`)
155+
156+
The owner whitelists specific addresses to mint PT/YT tokens. This method is simpler and suitable for prime brokers who manage funds manually or through EOAs.
157+
158+
**Flow:**
159+
1. Owner calls `authorizeMinting(primebroker, ptAmount, ytAmount)`
160+
2. Prime broker approves the Request contract to spend their underlying asset
161+
3. Prime broker calls `mint()`
162+
4. Contract transfers `ptAmount` of underlying asset from the prime broker
163+
5. PT and YT tokens are minted to the prime broker
164+
6. Authorization is consumed (one-time use)
165+
166+
```solidity
167+
// Owner authorizes minting
168+
request.authorizeMinting(primeBroker, 1_000_000e6, 100_000e6);
169+
170+
// Prime broker mints (after approving underlying asset)
171+
asset.approve(address(request), 1_000_000e6);
172+
request.mint(); // Receives 1M PT + 100k YT
173+
```
174+
175+
### Fund Management
176+
177+
After offers are consumed or minting is complete:
178+
179+
1. **Pull Funds**: Owner calls `pullFunds(receiver, amount)` to transfer collected assets to the borrower
180+
2. **Borrower Utilizes Funds**: The borrower uses the funds for their intended purpose
181+
3. **Repayment**: Borrower transfers assets back to the Request contract
182+
4. **Enable Redemptions**: Owner calls `setRepaid()` to unlock withdrawals
183+
184+
```solidity
185+
// After funding phase
186+
request.pullFunds(borrowerAddress, totalFunded);
187+
188+
// After borrower repays
189+
request.setRepaid(); // Enables PT/YT holders to redeem
190+
```
191+
192+
The `pullFunds()` function can be called multiple times by the owner during the funding phase. The `setRepaid()` function toggles the withdrawal lock, enabling PT/YT holders to redeem their tokens for the underlying assets held by the contract.
193+
194+
## RequestFactory
195+
196+
The `RequestFactory` deploys Request instances using the **beacon proxy pattern** for gas-efficient and upgradeable deployments.
197+
198+
### Architecture
199+
200+
```
201+
┌─────────────────────────────────────────────────────────────────┐
202+
│ BEACON PROXY PATTERN │
203+
└─────────────────────────────────────────────────────────────────┘
204+
205+
┌──────────────────┐
206+
│ RequestFactory │
207+
│ │
208+
│ REQUEST_BEACON ──┼──> UpgradeableBeacon ──> Request Implementation
209+
│ PT_VAULT_BEACON ─┼──> UpgradeableBeacon ──> Vault(isPT=false)
210+
│ YT_VAULT_BEACON ─┼──> UpgradeableBeacon ──> Vault(isPT=true)
211+
└──────────────────┘
212+
│
213+
│ createRequest()
214+
▼
215+
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
216+
│ Request Proxy │ │ PT Vault Proxy │ │ YT Vault Proxy │
217+
│ (ERC1967) │ │ (ERC1967) │ │ (ERC1967) │
218+
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
219+
│ │ │
220+
└────────────────────────┼────────────────────────┘
221+
│
222+
delegates to beacon
223+
```
224+
225+
### Deployment
226+
227+
```solidity
228+
// Deploy factory with beacon owner
229+
RequestFactory factory = new RequestFactory(beaconOwner);
230+
231+
// Create a new request with PT/YT vaults
232+
(address request, address ptVault, address ytVault) = factory.createRequest(
233+
owner, // Request owner (admin)
234+
address(usdc), // Underlying asset
235+
"USDC Request", // Base name (becomes "PT-USDC Request" / "YT-USDC Request")
236+
"USDC-REQ" // Base symbol (becomes "PT-USDC-REQ" / "YT-USDC-REQ")
237+
);
238+
```
239+
240+
### Upgrades
241+
242+
The beacon owner can upgrade all proxies by updating the beacon's implementation:
243+
244+
```solidity
245+
// All existing Request proxies now use the new implementation
246+
UpgradeableBeacon(factory.REQUEST_BEACON()).upgradeTo(newImplementation);
247+
```
248+
249+
## Prime Broker Offers
250+
251+
### Offer Creation and Signing
252+
253+
Prime brokers create cryptographically signed offers to lend assets to the protocol. Each offer specifies:
254+
- **Amount**: The principal amount to lend
255+
- **Expected Return**: The absolute return expected (not a rate)
256+
- **Maker**: The address providing the funds
257+
- **Nonce**: A sequential number for offer management (must start at 1)
258+
- **Expiration**: Timestamp after which the offer is no longer valid
259+
260+
Offers are signed using either:
261+
- **EIP-712**: Standard typed data signing for EOA accounts
262+
- **EIP-1271**: Smart contract signature validation via `isValidSignature()`
263+
264+
### Nonce Management
265+
266+
Nonces enable flexible offer lifecycle management:
267+
268+
**Starting Value**: All nonces must start at 1 (nonce 0 is invalid)
269+
270+
**Offer Cancellation**:
271+
- **Soft Cancel**: Communicate with the off-chain server to mark offers as cancelled (no on-chain transaction)
272+
- **Hard Cancel**: Set nonce on-chain to invalidate all offers at or below that nonce
273+
274+
### Offer Validation
275+
276+
When an offer is consumed, the protocol validates:
277+
1. **Maker validity**: Maker address is not zero
278+
2. **Amount validity**: Amount and expectedReturn are non-zero
279+
3. **Expiration**: Current timestamp < expiration
280+
4. **Nonce freshness**: Offer nonce > current stored nonce for maker
281+
5. **Signature validity**: EIP-712 or EIP-1271 signature verification
282+
283+
Upon successful validation, the offer's nonce is stored, preventing replay attacks.

‎foundry.lock‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,11 @@
44
"name": "v1.11.0",
55
"rev": "8e40513d678f392f398620b3ef2b418648b33e89"
66
}
7+
},
8+
"lib/solady": {
9+
"tag": {
10+
"name": "v0.1.26",
11+
"rev": "acd959aa4bd04720d640bf4e6a5c71037510cc4b"
12+
}
713
}
814
}

‎foundry.toml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
[profile.solx]
1+
[profile.default]
22
src = "src"
33
out = "out"
44
libs = ["lib"]
5+
# Exclude mock contracts and scripts from coverage reports
6+
no_match_coverage = "mock|\\.s\\.sol$"
7+
8+
[fmt]
9+
tab_width = 2
10+
11+
[lint]
12+
ignore = ["test/**/*"]
13+
514

615
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

‎lib/solady‎

Submodule solady added at acd959a

‎script/Request.s.sol‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22
pragma solidity ^0.8.13;
33

44
import {Script} from "forge-std/Script.sol";
5-
import {Request} from "../src/request/Request.sol";
5+
// import {Request} from "../src/request/Request.sol";
66

77
contract RequestScript is Script {
8-
Request public request;
8+
// Request public request;
99

10-
function setUp() public {}
10+
function setUp() public {}
1111

12-
function run() public {
13-
vm.startBroadcast();
12+
function run() public {
13+
vm.startBroadcast();
1414

15-
request = new Request();
15+
// request = new Request();
1616

17-
vm.stopBroadcast();
18-
}
17+
vm.stopBroadcast();
18+
}
1919
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.6.2;
3+
4+
/// @dev Interface of the ERC20 standard as defined in the EIP.
5+
/// @dev This includes the optional name, symbol, and decimals metadata.
6+
interface IERC20 {
7+
/// @dev Emitted when `value` tokens are moved from one account (`from`) to another (`to`).
8+
event Transfer(address indexed from, address indexed to, uint256 value);
9+
10+
/// @dev Emitted when the allowance of a `spender` for an `owner` is set, where `value`
11+
/// is the new allowance.
12+
event Approval(address indexed owner, address indexed spender, uint256 value);
13+
14+
/// @notice Returns the amount of tokens in existence.
15+
function totalSupply() external view returns (uint256);
16+
17+
/// @notice Returns the amount of tokens owned by `account`.
18+
function balanceOf(address account) external view returns (uint256);
19+
20+
/// @notice Moves `amount` tokens from the caller's account to `to`.
21+
function transfer(address to, uint256 amount) external returns (bool);
22+
23+
/// @notice Returns the remaining number of tokens that `spender` is allowed
24+
/// to spend on behalf of `owner`
25+
function allowance(address owner, address spender) external view returns (uint256);
26+
27+
/// @notice Sets `amount` as the allowance of `spender` over the caller's tokens.
28+
/// @dev Be aware of front-running risks: https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
29+
function approve(address spender, uint256 amount) external returns (bool);
30+
31+
/// @notice Moves `amount` tokens from `from` to `to` using the allowance mechanism.
32+
/// `amount` is then deducted from the caller's allowance.
33+
function transferFrom(address from, address to, uint256 amount) external returns (bool);
34+
35+
/// @notice Returns the name of the token.
36+
function name() external view returns (string memory);
37+
38+
/// @notice Returns the symbol of the token.
39+
function symbol() external view returns (string memory);
40+
41+
/// @notice Returns the decimals places of the token.
42+
function decimals() external view returns (uint8);
43+
}
44+

0 commit comments

Comments
 (0)