Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The `Request` contract is the core contract managing funding requests with dual-

- **OfferReceiver**: Validates and processes signed offers using EIP-712 signatures
- **VaultController**: Manages PT/YT tokens with ERC4626-style redemptions
- **Ownable**: Restricts admin functions to the contract owner
- **OwnableRoles**: Restricts admin functions to the contract owner and allows for the "puller" role to pull funds from the contract

### Request Lifecycle

Expand All @@ -78,7 +78,7 @@ The `Request` contract is the core contract managing funding requests with dual-

1. DEPLOYMENT
└─> Factory creates Request + PT Vault + YT Vault (beacon proxies)
└─> Owner is set, contract initialized
└─> Owner and puller role are set, contract initialized

2. FUNDING PHASE (canWithdraw = false)
├─> Method A: consume() - Process signed offers from prime brokers
Expand All @@ -87,10 +87,12 @@ The `Request` contract is the core contract managing funding requests with dual-
└─> Owner authorizes → Prime broker calls mint() → PT/YT minted

3. FUND UTILIZATION
└─> Owner calls pullFunds() to transfer assets to borrower
└─> Puller calls pullFunds(amount, data) to transfer assets to themselves
└─> If data is provided, onPullFunds() callback is invoked on the puller

4. REPAYMENT
└─> Borrower transfers assets back to Request contract
└─> Puller (or borrower) transfers assets back to Request contract
└─> Optional: Use repay(amount) helper function

5. REDEMPTION PHASE (canWithdraw = true)
└─> Owner calls setRepaid()
Expand All @@ -110,7 +112,7 @@ The owner broadcasts a signed EIP-712 offer to the contract. This method is idea
2. Owner calls `consume(offer, signature, ptAmount)`
3. Contract validates the signature and offer parameters
4. Contract calls `onRequestConsumed()` callback on the maker's contract
5. Contract pulls `ptAmount` of underlying asset from the owner (msg.sender)
5. Contract pulls `ptAmount` of underlying asset from the maker (offer.maker)
6. PT and YT tokens are minted to the maker
7. The maker's offer nonce is updated

Expand Down Expand Up @@ -176,20 +178,52 @@ request.mint(); // Receives 1M PT + 100k YT

After offers are consumed or minting is complete:

1. **Pull Funds**: Owner calls `pullFunds(receiver, amount)` to transfer collected assets to the borrower
2. **Borrower Utilizes Funds**: The borrower uses the funds for their intended purpose
3. **Repayment**: Borrower transfers assets back to the Request contract
4. **Enable Redemptions**: Owner calls `setRepaid()` to unlock withdrawals
1. **Pull Funds**: Address with puller role calls `pullFunds(amount, data)` to transfer collected assets to themselves
2. **Callback Invocation**: If `data.length > 0`, the contract calls `onPullFunds(amount, data)` on the puller address
3. **Fund Utilization**: The puller uses the funds for their intended purpose
4. **Repayment**: Puller (or borrower) transfers assets back to the Request contract
5. **Enable Redemptions**: Owner calls `setRepaid()` to unlock withdrawals

```solidity
// After funding phase
request.pullFunds(borrowerAddress, totalFunded);
// After funding phase - puller pulls funds
// Funds are transferred to msg.sender (the puller)
request.pullFunds(totalFunded, ""); // No callback

// After borrower repays
// With callback data - useful for automated position management
bytes memory positionData = abi.encode(positionId, strategy);
request.pullFunds(totalFunded, positionData); // Calls onPullFunds() on puller

// After repayment (optional helper function)
request.repay(repaymentAmount);
// Or direct transfer:
asset.transfer(address(request), repaymentAmount);

// Enable redemptions
request.setRepaid(); // Enables PT/YT holders to redeem
```

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.
**Puller Role:**
- The puller role is set during contract initialization via the factory
- Only addresses with the puller role can call `pullFunds()`
- Funds are always transferred to `msg.sender` (the puller), not a separate recipient
- The puller can be a smart contract implementing `IPositionManagerRequestCallback` for automated fund management

**Callback Interface:**

Position managers implementing automated strategies can implement `IPositionManagerRequestCallback`:

```solidity
interface IPositionManagerRequestCallback {
function onPullFunds(uint256 amount, bytes calldata data) external;
}
```

The callback is invoked **after** funds are transferred, allowing the position manager to:
- Open positions in DeFi protocols
- Update internal accounting
- Execute automated strategies based on the provided data

The `pullFunds()` function can be called multiple times by the puller 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.

## RequestFactory

Expand Down Expand Up @@ -231,6 +265,7 @@ RequestFactory factory = new RequestFactory(beaconOwner);
// Create a new request with PT/YT vaults
(address request, address ptVault, address ytVault) = factory.createRequest(
owner, // Request owner (admin)
puller, // Address with puller role (can call pullFunds)
address(usdc), // Underlying asset
"USDC Request", // Base name (becomes "PT-USDC Request" / "YT-USDC Request")
"USDC-REQ" // Base symbol (becomes "PT-USDC-REQ" / "YT-USDC-REQ")
Expand Down
23 changes: 23 additions & 0 deletions src/interfaces/request/IPositionManagerRequest.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

/// @title IPositionManagerRequest
/// @notice Interface for the PositionManagerRequest contract that pulls funds from the contract.
interface IPositionManagerRequest {
/// @notice Transfers underlying assets from the contract to the puller.
/// @dev This function is used after offers are consumed to transfer the collected funds
/// to the borrower. The borrower then repays by transferring assets back to the
/// contract before `setRepaid()` is called to enable PT/YT holder withdrawals.
/// @dev This function should trigger a callback to the puller if data is provided.
/// This should only be called by the puller role.
/// @param amount The amount of underlying assets to transfer
/// @param data Additional data to be passed to the puller callback
function pullFunds(uint256 amount, bytes calldata data) external;

/// @notice Repays the request by transferring the underlying assets back to the contract.
/// @dev This function is used to repay the request by transferring the underlying assets back to the contract.
/// It assumes caller has enough allowance to transfer the underlying assets back to the contract.
/// Depending on the request contract implementation, this is optional and may be done via a simple transfer.
/// @param amount The amount of underlying assets to transfer
function repay(uint256 amount) external;
}
11 changes: 11 additions & 0 deletions src/interfaces/request/IPositionManagerRequestCallback.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

/// @title IPositionManagerRequestCallback
/// @notice Interface for contracts that want to receive callbacks when their requests are being consumed.
interface IPositionManagerRequestCallback {
/// @notice Called when the PositionManagerRequest contract pulls funds from the contract with non-null data.
/// @param amount The amount of underlying assets to transfer
/// @param data Additional data to be passed to the callback
function onPullFunds(uint256 amount, bytes calldata data) external;
}
11 changes: 2 additions & 9 deletions src/interfaces/request/IRequest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ pragma solidity ^0.8.20;

import {Offer} from "./IOfferReceiver.sol";
import {IVaultController} from "./IVaultController.sol";
import {IPositionManagerRequest} from "./IPositionManagerRequest.sol";

/// @title IRequest
/// @notice Interface for the Request contract that manages funding requests with dual-token (PT/YT) issuance.
interface IRequest is IVaultController {
interface IRequest is IVaultController, IPositionManagerRequest {
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* EVENTS */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
Expand All @@ -33,14 +34,6 @@ interface IRequest is IVaultController {
/// @param ytAmount The amount of YT tokens the address can mint
function authorizeMinting(address to, uint128 ptAmount, uint128 ytAmount) external;

/// @notice Transfers underlying assets from the contract to a specified receiver.
/// @dev This function is used after offers are consumed to transfer the collected funds
/// to the borrower. The borrower then repays by transferring assets back to the
/// contract before `setRepaid()` is called to enable PT/YT holder withdrawals.
/// @param receiver The address to receive the transferred assets
/// @param amount The amount of underlying assets to transfer
function pullFunds(address receiver, uint256 amount) external;

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* MINTING */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
Expand Down
56 changes: 44 additions & 12 deletions src/request/Request.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {TokenController} from "./abstract/tokens/TokenController.sol";
import {LibMintAuth} from "../libs/request/LibMintAuth.sol";
import {IERC20} from "../interfaces/integrations/IERC20.sol";
import {IRequest} from "../interfaces/request/IRequest.sol";
import {IPositionManagerRequest} from "../interfaces/request/IPositionManagerRequest.sol";
import {IRequestCallback} from "../interfaces/request/IRequestCallback.sol";
import {IPositionManagerRequestCallback} from "../interfaces/request/IPositionManagerRequestCallback.sol";
import {ITokenController} from "../interfaces/request/ITokenController.sol";
import {Offer} from "../interfaces/request/IOfferReceiver.sol";
import {Initializable} from "lib/solady/src/utils/Initializable.sol";
import {Ownable} from "lib/solady/src/auth/Ownable.sol";
import {OwnableRoles} from "lib/solady/src/auth/OwnableRoles.sol";
import {FixedPointMathLib} from "lib/solady/src/utils/FixedPointMathLib.sol";
import {ReentrancyGuardTransient} from "lib/solady/src/utils/ReentrancyGuardTransient.sol";
import {SafeTransferLib} from "lib/solady/src/utils/SafeTransferLib.sol";
Expand All @@ -23,7 +25,7 @@ import {EIP712} from "lib/solady/src/utils/EIP712.sol";
/// - **OfferReceiver**: Validates and processes signed offers using EIP-712 signatures
/// - **VaultController**: Manages PT/YT tokens with ERC4626-style redemptions
/// - **Initializable**: Supports initialization for proxy deployments
/// - **Ownable**: Restricts admin functions to the contract owner
/// - **OwnableRoles**: Restricts admin functions to the contract owner and allows for the "puller" role to pull funds from the contract.
/// - **ReentrancyGuard**: Prevents reentrancy attacks during offer consumption
///
/// Deployment Options:
Expand All @@ -44,16 +46,22 @@ import {EIP712} from "lib/solady/src/utils/EIP712.sol";
/// 2. Owner calls `consume()` with the offer, signature, and PT amount to fulfill
/// 3. The maker's `onRequestConsumed` callback is invoked to prepare funds
/// 4. Assets are transferred from owner and PT/YT tokens are minted to the maker
contract Request is IRequest, OfferReceiver, VaultController, Initializable, Ownable, ReentrancyGuardTransient {
contract Request is IRequest, OfferReceiver, VaultController, Initializable, OwnableRoles, ReentrancyGuardTransient {
using FixedPointMathLib for uint256;
using SafeTransferLib for address;
using LibMintAuth for address;

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* CONSTANTS */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

uint256 internal constant _ROLE_PULLER = _ROLE_0;

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* ERRORS */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

/// @dev The request has already been repaid, preventing further calls to `setRepaid()`.
/// @dev The request has already been repaid, preventing further calls to `setRepaid()`, `pullFunds()`, and `repay()`.
error AlreadyRepaid();

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
Expand All @@ -64,13 +72,15 @@ contract Request is IRequest, OfferReceiver, VaultController, Initializable, Own
/// @dev Uses ERC-7201 namespaced storage pattern for proxy compatibility. All fields are grouped
/// and accessed via a fixed storage slot to prevent collisions with inherited contracts.
/// @param asset The address of the underlying ERC20 asset (e.g., USDC)
/// @param repaymentDeadline The time at which repayments are unlocked regardless of whether repaid is true or false
/// @param repaid Whether the request has been repaid, enabling withdrawals
/// @param ptToken The address of the Principal Token contract
/// @param ytToken The address of the Yield Token contract
/// @param name The base name for the PT/YT tokens (prefixed with "PT-" / "YT-")
/// @param symbol The base symbol for the PT/YT tokens (prefixed with "PT-" / "YT-")
struct RequestStorage {
address asset;
uint64 repaymentDeadline;
bool repaid;
address ptToken;
address ytToken;
Expand Down Expand Up @@ -101,28 +111,34 @@ contract Request is IRequest, OfferReceiver, VaultController, Initializable, Own
/// @notice Initializes the Request contract with all required parameters.
/// @dev Can only be called once due to the `initializer` modifier. Sets up the contract owner,
/// underlying asset, PT/YT token addresses, and metadata. The contract starts in a non-repaid
/// state where withdrawals are disabled.
/// state where withdrawals are disabled until either setRepaid() is called or the repayment deadline passes.
/// @param owner_ The address that will own the contract and have admin privileges
/// @param puller_ The address that will have the puller role
/// @param asset_ The address of the underlying ERC20 asset (e.g., USDC)
/// @param ptToken_ The address of the deployed Principal Token contract
/// @param ytToken_ The address of the deployed Yield Token contract
/// @param name_ The base name for the tokens (will be prefixed with "PT-" / "YT-")
/// @param symbol_ The base symbol for the tokens (will be prefixed with "PT-" / "YT-")
/// @param repaymentDeadline_ The timestamp after which withdrawals are automatically enabled, regardless of repaid status
function initialize(
address owner_,
address puller_,
address asset_,
address ptToken_,
address ytToken_,
string memory name_,
string memory symbol_
string memory symbol_,
uint64 repaymentDeadline_
) public initializer {
RequestStorage storage req = _requestStorage();
req.asset = asset_;
req.repaymentDeadline = repaymentDeadline_;
req.ptToken = ptToken_;
req.ytToken = ytToken_;
req.name = name_;
req.symbol = symbol_;
_initializeOwner(owner_);
_setRoles(puller_, _ROLE_PULLER);
}

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
Expand All @@ -135,8 +151,11 @@ contract Request is IRequest, OfferReceiver, VaultController, Initializable, Own
}

/// @inheritdoc VaultController
/// @dev Returns true if either the request has been marked as repaid OR the repayment deadline has passed.
/// This allows withdrawals to be enabled automatically after a deadline, even if setRepaid() was never called.
function _canWithdraw() internal view override returns (bool) {
return _requestStorage().repaid;
RequestStorage storage req = _requestStorage();
return req.repaid || block.timestamp >= req.repaymentDeadline;
}

/// @inheritdoc ITokenController
Expand Down Expand Up @@ -189,16 +208,29 @@ contract Request is IRequest, OfferReceiver, VaultController, Initializable, Own
emit AuthorizedMinting(to, ptAmount, ytAmount);
}

/// @inheritdoc IRequest
/// @dev Only callable by the owner. This function is used after offers are consumed to
/// transfer the collected funds to the borrower (or any designated receiver). The borrower
/// @inheritdoc IPositionManagerRequest
/// @dev Only callable by the puller role. This function is used after offers are consumed to
/// transfer the collected funds to the puller. The puller
/// is then expected to repay by transferring assets back to the contract before
/// `setRepaid()` is called to enable PT/YT holder withdrawals.
/// Emits a Transfer event from the underlying asset contract.
/// @custom:reverts If the request has been repaid
function pullFunds(address receiver, uint256 amount) external onlyOwner {
function pullFunds(uint256 amount, bytes calldata data) external onlyRoles(_ROLE_PULLER) {
if (_canWithdraw()) revert AlreadyRepaid();
_asset().safeTransfer(msg.sender, amount);
if (data.length > 0) {
IPositionManagerRequestCallback(msg.sender).onPullFunds(amount, data);
}
}

/// @inheritdoc IPositionManagerRequest
/// @dev Transfers the underlying assets back to the contract. This is purely optional
/// with the given implementation and may be done via a simple transfer.
/// Cannot be called after the request has been repaid (when withdrawals are enabled).
/// @custom:reverts If the request has been repaid (canWithdraw is true)
function repay(uint256 amount) external {
if (_canWithdraw()) revert AlreadyRepaid();
_asset().safeTransfer(receiver, amount);
_asset().safeTransferFrom(msg.sender, address(this), amount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe emit an event?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just an helper for the future if repayment needs to have flashloan repayments or whatever. Plus a repayment in this case can simply be an ERC20 transfer which won't trigger the event anyway.

Comment thread
maxencerb marked this conversation as resolved.
}

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
Expand Down
Loading