diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..f225f8e --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,107 @@ +# CashflowVaults (Avalanche Hackathon MVP) + +CashflowVaults is a Solidity-first MVP that tokenizes **simulated future cashflows** using an ERC-4626 style vault. + +- Investors deposit mock USDC and receive fungible vault shares. +- A trusted `creator` periodically contributes cashflows to the vault via `CashflowSchedule`. +- Investors redeem shares for underlying assets. When the creator pays, assets increase while share supply stays fixed, so PPS increases. + +> Trust model (explicit): this MVP is intentionally offchain-light. There is no oracle, KYC, legal enforcement, or payment guarantee. The creator is trusted to keep paying. + +## Contracts + +- `src/MockUSDC.sol`: 6-decimal mintable ERC20 for local testing. +- `src/CashflowVault.sol`: ERC-4626 style vault implementation with deposit/mint/withdraw/redeem. +- `src/CashflowSchedule.sol`: single agreement state machine (`ACTIVE/PAUSED/ENDED`) and periodic `pay()`. +- `src/CashflowShareNFT.sol`: optional ERC721 investor participation receipt (demo/storytelling only). + +## Architecture (ASCII) + +```text + Creator Investors + | | + | approve + pay() | deposit()/mint() + v v ++-------------------+ transfer USDC +-------------------+ +| CashflowSchedule | -------------------> | CashflowVault | +| - cadence | | (share ERC20) | +| - paymentAmount | | - totalAssets | +| - startTime | | - totalSupply | +| - state | +-------------------+ ++-------------------+ | + | holds + v + +-----------+ + | MockUSDC | + +-----------+ + +Optional: +CashflowVault -> mintReceipt() -> CashflowShareNFT (1st deposit receipt) +``` + +## Local Commands + +```bash +cd contracts +forge build +forge test -vv +``` + +### Deploy script + +```bash +cd contracts +CREATOR_ADDRESS= \ +PAYMENT_CADENCE=604800 \ +PAYMENT_AMOUNT=100000000 \ +START_TIME= \ +forge script script/Deploy.s.sol:DeployCashflowVaults --broadcast +``` + +## Scripted Demo (cast + scripts) + +### A) One-command scripted flow with `Demo.s.sol` + +```bash +cd contracts +CREATOR_PK= \ +INVESTOR_PK= \ +forge script script/Demo.s.sol:DemoCashflowVaults --broadcast +``` + +### B) Manual `cast` walkthrough (assuming deployed addresses) + +```bash +# Environment +RPC_URL= +CREATOR_PK= +INVESTOR_PK= +USDC= +VAULT= +SCHEDULE= +INVESTOR=$(cast wallet address --private-key $INVESTOR_PK) + +# 1) Mint investor + creator balances (demo token is permissionless mint) +cast send $USDC "mint(address,uint256)" $INVESTOR 1000000000 --private-key $INVESTOR_PK --rpc-url $RPC_URL +cast send $USDC "mint(address,uint256)" $(cast wallet address --private-key $CREATOR_PK) 10000000000 --private-key $CREATOR_PK --rpc-url $RPC_URL + +# 2) Investor deposits 1,000 USDC +cast send $USDC "approve(address,uint256)" $VAULT 1000000000 --private-key $INVESTOR_PK --rpc-url $RPC_URL +cast send $VAULT "deposit(uint256,address)" 1000000000 $INVESTOR --private-key $INVESTOR_PK --rpc-url $RPC_URL + +# 3) Creator pays twice +cast send $USDC "approve(address,uint256)" $SCHEDULE 200000000 --private-key $CREATOR_PK --rpc-url $RPC_URL +cast send $SCHEDULE "pay()" --private-key $CREATOR_PK --rpc-url $RPC_URL +# wait >= cadence +cast send $SCHEDULE "pay()" --private-key $CREATOR_PK --rpc-url $RPC_URL + +# 4) Investor redeems all shares +SHARES=$(cast call $VAULT "balanceOf(address)(uint256)" $INVESTOR --rpc-url $RPC_URL) +cast send $VAULT "redeem(uint256,address,address)" $SHARES $INVESTOR $INVESTOR --private-key $INVESTOR_PK --rpc-url $RPC_URL +``` + +## Notes for Avalanche readiness + +- Contracts use standard EVM Solidity (`^0.8.24`) only. +- No chain-specific precompiles/opcodes. +- Works on Avalanche C-Chain or any EVM-compatible network. diff --git a/contracts/foundry.toml b/contracts/foundry.toml index a4d6df3..8dc57b2 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -2,6 +2,6 @@ src = "src" out = "out" libs = ["lib"] -solc_version = "0.8.20" +solc_version = "0.8.24" optimizer = true optimizer_runs = 200 diff --git a/contracts/script/Demo.s.sol b/contracts/script/Demo.s.sol new file mode 100644 index 0000000..d07d5b3 --- /dev/null +++ b/contracts/script/Demo.s.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; + +import {MockUSDC} from "../src/MockUSDC.sol"; +import {CashflowVault} from "../src/CashflowVault.sol"; +import {CashflowSchedule} from "../src/CashflowSchedule.sol"; + +/// @title DemoCashflowVaults +/// @notice Scripted local demo: mint USDC -> deposit -> creator pays twice -> investor redeems. +contract DemoCashflowVaults is Script { + /// @notice Executes the scripted MVP flow against freshly deployed contracts. + function run() external { + uint256 creatorPk = vm.envUint("CREATOR_PK"); + uint256 investorPk = vm.envUint("INVESTOR_PK"); + + address creator = vm.addr(creatorPk); + address investor = vm.addr(investorPk); + + vm.startBroadcast(creatorPk); + MockUSDC usdc = new MockUSDC(); + CashflowVault vault = new CashflowVault(usdc); + CashflowSchedule schedule = new CashflowSchedule(creator, vault, 7 days, 100e6, block.timestamp); + usdc.mint(creator, 10_000e6); + usdc.approve(address(schedule), type(uint256).max); + vm.stopBroadcast(); + + vm.startBroadcast(investorPk); + usdc.mint(investor, 1_000e6); + usdc.approve(address(vault), type(uint256).max); + vault.deposit(1_000e6, investor); + vm.stopBroadcast(); + + vm.startBroadcast(creatorPk); + schedule.pay(); + vm.warp(block.timestamp + 7 days); + schedule.pay(); + vm.stopBroadcast(); + + vm.startBroadcast(investorPk); + uint256 shares = vault.balanceOf(investor); + vault.redeem(shares, investor, investor); + vm.stopBroadcast(); + } +} diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index 7bf3251..372e03a 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -1,14 +1,33 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import "forge-std/Script.sol"; -import "../src/PriceAlertRegistry.sol"; -contract DeployPriceAlertRegistry is Script { - function run() external returns (PriceAlertRegistry) { +import {MockUSDC} from "../src/MockUSDC.sol"; +import {CashflowVault} from "../src/CashflowVault.sol"; +import {CashflowSchedule} from "../src/CashflowSchedule.sol"; +import {CashflowShareNFT} from "../src/CashflowShareNFT.sol"; + +/// @title DeployCashflowVaults +/// @notice Deployment entrypoint for CashflowVaults MVP contracts. +contract DeployCashflowVaults is Script { + /// @notice Deploys asset, vault, schedule, and optional receipt NFT. + /// @return usdc Deployed MockUSDC instance. + /// @return vault Deployed cashflow vault. + /// @return schedule Deployed payment schedule. + /// @return nft Deployed participation receipt NFT. + function run() external returns (MockUSDC usdc, CashflowVault vault, CashflowSchedule schedule, CashflowShareNFT nft) { + address creator = vm.envAddress("CREATOR_ADDRESS"); + uint256 cadence = vm.envUint("PAYMENT_CADENCE"); + uint256 amount = vm.envUint("PAYMENT_AMOUNT"); + uint256 start = vm.envUint("START_TIME"); + vm.startBroadcast(); - PriceAlertRegistry registry = new PriceAlertRegistry(); + usdc = new MockUSDC(); + vault = new CashflowVault(usdc); + schedule = new CashflowSchedule(creator, vault, cadence, amount, start); + nft = new CashflowShareNFT("Cashflow Receipt", "CFR", address(vault), 1); + vault.setShareReceiptNft(nft); vm.stopBroadcast(); - return registry; } } diff --git a/contracts/src/CashflowSchedule.sol b/contracts/src/CashflowSchedule.sol new file mode 100644 index 0000000..2196594 --- /dev/null +++ b/contracts/src/CashflowSchedule.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {MockUSDC} from "./MockUSDC.sol"; +import {CashflowVault} from "./CashflowVault.sol"; + +/// @title CashflowSchedule +/// @notice Manages a single simulated cashflow agreement and periodic creator payments. +/// @dev Trust model: creator is trusted to keep approving and funding payments; no oracle or enforcement layer. +contract CashflowSchedule { + enum State { + ACTIVE, + PAUSED, + ENDED + } + + address public immutable creator; + CashflowVault public immutable vault; + MockUSDC public immutable asset; + uint256 public immutable cadence; + uint256 public immutable paymentAmount; + uint256 public immutable startTime; + + State public state; + uint256 public lastPaymentTime; + + event Paid(address indexed payer, uint256 amount, uint256 paidAt); + event StateChanged(State indexed newState); + + /// @notice Creates a new cashflow agreement. + /// @param _creator Creator responsible for scheduled payments. + /// @param _vault Vault receiving cashflows. + /// @param _cadence Minimum seconds between two `pay` calls. + /// @param _paymentAmount Fixed payment size per period. + /// @param _startTime Timestamp at which payments become valid. + constructor(address _creator, CashflowVault _vault, uint256 _cadence, uint256 _paymentAmount, uint256 _startTime) { + require(_creator != address(0), "ZERO_CREATOR"); + require(address(_vault) != address(0), "ZERO_VAULT"); + require(_cadence > 0, "ZERO_CADENCE"); + require(_paymentAmount > 0, "ZERO_PAYMENT"); + + creator = _creator; + vault = _vault; + asset = _vault.asset(); + cadence = _cadence; + paymentAmount = _paymentAmount; + startTime = _startTime; + state = State.ACTIVE; + } + + /// @notice Executes one scheduled payment from creator to vault. + /// @dev We intentionally donate assets directly to the vault instead of calling vault.deposit. + /// If we called `deposit`, new shares would be minted and PPS would remain mostly unchanged. + /// Direct donation increases assets backing existing shares, so investors capture upside. + function pay() external { + require(state == State.ACTIVE, "NOT_ACTIVE"); + require(block.timestamp >= startTime, "NOT_STARTED"); + if (lastPaymentTime != 0) { + require(block.timestamp >= lastPaymentTime + cadence, "TOO_EARLY"); + } + + require(asset.transferFrom(creator, address(vault), paymentAmount), "PAYMENT_TRANSFER_FAILED"); + lastPaymentTime = block.timestamp; + emit Paid(creator, paymentAmount, block.timestamp); + } + + /// @notice Pauses future payments. + function pause() external onlyCreator { + require(state == State.ACTIVE, "BAD_STATE"); + state = State.PAUSED; + emit StateChanged(state); + } + + /// @notice Resumes payments after a pause. + function resume() external onlyCreator { + require(state == State.PAUSED, "BAD_STATE"); + state = State.ACTIVE; + emit StateChanged(state); + } + + /// @notice Permanently ends the agreement. + function end() external onlyCreator { + require(state != State.ENDED, "ALREADY_ENDED"); + state = State.ENDED; + emit StateChanged(state); + } + + /// @notice Restricts calls to creator. + modifier onlyCreator() { + require(msg.sender == creator, "ONLY_CREATOR"); + _; + } +} diff --git a/contracts/src/CashflowShareNFT.sol b/contracts/src/CashflowShareNFT.sol new file mode 100644 index 0000000..421872e --- /dev/null +++ b/contracts/src/CashflowShareNFT.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title CashflowShareNFT +/// @notice Optional ERC721 receipt that mints one token per investor on first vault participation. +contract CashflowShareNFT { + string public name; + string public symbol; + address public immutable vault; + uint256 public immutable scheduleId; + + uint256 public totalSupply; + + mapping(uint256 => address) public ownerOf; + mapping(address => uint256) public balanceOf; + mapping(uint256 => address) public getApproved; + mapping(address => mapping(address => bool)) public isApprovedForAll; + mapping(address => bool) public hasReceipt; + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed spender, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /// @notice Creates a new receipt NFT collection bound to one schedule/vault. + /// @param _name NFT collection name. + /// @param _symbol NFT collection symbol. + /// @param _vault Authorized vault that may mint receipts. + /// @param _scheduleId Associated schedule id used in metadata. + constructor(string memory _name, string memory _symbol, address _vault, uint256 _scheduleId) { + require(_vault != address(0), "ZERO_VAULT"); + name = _name; + symbol = _symbol; + vault = _vault; + scheduleId = _scheduleId; + } + + /// @notice Mints one non-transferability-unrestricted receipt per investor. + /// @param to Investor account receiving receipt on first deposit. + function mintReceipt(address to) external { + require(msg.sender == vault, "ONLY_VAULT"); + if (hasReceipt[to]) return; + + hasReceipt[to] = true; + uint256 tokenId = ++totalSupply; + ownerOf[tokenId] = to; + balanceOf[to] += 1; + emit Transfer(address(0), to, tokenId); + } + + /// @notice Approves an operator for a specific token id. + /// @param spender Approved spender. + /// @param tokenId Token id being approved. + function approve(address spender, uint256 tokenId) external { + address owner = ownerOf[tokenId]; + require(owner != address(0), "NOT_MINTED"); + require(msg.sender == owner || isApprovedForAll[owner][msg.sender], "NOT_AUTHORIZED"); + getApproved[tokenId] = spender; + emit Approval(owner, spender, tokenId); + } + + /// @notice Approves/revokes operator for all sender tokens. + /// @param operator Operator address. + /// @param approved Boolean approved flag. + function setApprovalForAll(address operator, bool approved) external { + isApprovedForAll[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + /// @notice Transfers a token id from `from` to `to`. + /// @param from Current owner. + /// @param to Recipient. + /// @param tokenId Token id. + function transferFrom(address from, address to, uint256 tokenId) public { + address owner = ownerOf[tokenId]; + require(owner == from, "WRONG_FROM"); + require(to != address(0), "ZERO_TO"); + require( + msg.sender == owner || msg.sender == getApproved[tokenId] || isApprovedForAll[owner][msg.sender], + "NOT_AUTHORIZED" + ); + + delete getApproved[tokenId]; + ownerOf[tokenId] = to; + balanceOf[from] -= 1; + balanceOf[to] += 1; + emit Transfer(from, to, tokenId); + } + + /// @notice Safe transfer alias for demo simplicity. + /// @param from Current owner. + /// @param to Recipient. + /// @param tokenId Token id. + function safeTransferFrom(address from, address to, uint256 tokenId) external { + transferFrom(from, to, tokenId); + } + + /// @notice Safe transfer alias with unused data payload. + /// @param from Current owner. + /// @param to Recipient. + /// @param tokenId Token id. + /// @param data Arbitrary bytes payload. + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external { + data; + transferFrom(from, to, tokenId); + } + + /// @notice Returns on-chain JSON metadata URI embedding the schedule id. + /// @param tokenId The NFT id. + /// @return uri Data URI string for demo metadata. + function tokenURI(uint256 tokenId) external view returns (string memory uri) { + require(ownerOf[tokenId] != address(0), "NOT_MINTED"); + uri = string( + abi.encodePacked( + "data:application/json,{\"name\":\"Cashflow Receipt #", + _toString(tokenId), + "\",\"description\":\"Investor participation receipt for CashflowVaults MVP\",", + "\"attributes\":[{\"trait_type\":\"scheduleId\",\"value\":", + _toString(scheduleId), + "}]}" + ) + ); + } + + /// @notice Converts uint to decimal string. + /// @param value Integer input. + /// @return str Decimal string output. + function _toString(uint256 value) internal pure returns (string memory str) { + if (value == 0) return "0"; + uint256 tmp = value; + uint256 digits; + while (tmp != 0) { + digits++; + tmp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + str = string(buffer); + } +} diff --git a/contracts/src/CashflowVault.sol b/contracts/src/CashflowVault.sol new file mode 100644 index 0000000..5463e40 --- /dev/null +++ b/contracts/src/CashflowVault.sol @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {MockUSDC} from "./MockUSDC.sol"; + +interface ICashflowShareNFT { + function mintReceipt(address to) external; +} + +/// @title CashflowVault +/// @notice ERC-4626 style vault over MockUSDC for tokenized future cashflow participation. +/// @dev The vault issues fungible share tokens and supports deposit/mint/withdraw/redeem flows. +contract CashflowVault { + /// @notice Vault share token metadata name. + string public constant name = "Cashflow Vault Share"; + /// @notice Vault share token metadata symbol. + string public constant symbol = "cvSHARE"; + + MockUSDC public immutable asset; + ICashflowShareNFT public shareReceiptNft; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + uint256 public totalSupply; + + event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares); + event Withdraw( + address indexed caller, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); + event Transfer(address indexed from, address indexed to, uint256 amount); + event Approval(address indexed owner, address indexed spender, uint256 amount); + event ShareReceiptNftUpdated(address indexed nft); + + /// @notice Initializes the vault with the underlying MockUSDC asset. + /// @param _asset The ERC20 asset backing shares. + constructor(MockUSDC _asset) { + asset = _asset; + } + + /// @notice Returns share token decimal precision aligned with the underlying token. + /// @return The number of decimals (6). + function decimals() external pure returns (uint8) { + return 6; + } + + /// @notice Returns underlying asset token address. + /// @return The asset token contract address. + function assetAddress() external view returns (address) { + return address(asset); + } + + /// @notice Sets the optional ERC721 receipt minter used for storytelling UX. + /// @param nft The NFT contract address. + function setShareReceiptNft(ICashflowShareNFT nft) external { + shareReceiptNft = nft; + emit ShareReceiptNftUpdated(address(nft)); + } + + /// @notice Returns total underlying assets currently held by the vault. + /// @return The MockUSDC amount in vault custody. + function totalAssets() public view returns (uint256) { + return asset.balanceOf(address(this)); + } + + /// @notice Converts assets to shares using ERC-4626 style ratio math (round down). + /// @param assets Amount of underlying assets. + /// @return shares Equivalent shares. + function convertToShares(uint256 assets) public view returns (uint256 shares) { + uint256 supply = totalSupply; + uint256 managedAssets = totalAssets(); + if (supply == 0 || managedAssets == 0) return assets; + return (assets * supply) / managedAssets; + } + + /// @notice Converts shares to assets using ERC-4626 style ratio math (round down). + /// @param shares Amount of vault shares. + /// @return assets Equivalent underlying assets. + function convertToAssets(uint256 shares) public view returns (uint256 assets) { + uint256 supply = totalSupply; + uint256 managedAssets = totalAssets(); + if (supply == 0 || managedAssets == 0) return shares; + return (shares * managedAssets) / supply; + } + + /// @notice Preview shares minted for a deposit amount. + /// @param assets Amount of underlying assets. + /// @return shares Shares expected to be minted. + function previewDeposit(uint256 assets) external view returns (uint256 shares) { + return convertToShares(assets); + } + + /// @notice Preview assets required to mint a target share amount. + /// @param shares Desired number of shares. + /// @return assets Assets required, rounded up. + function previewMint(uint256 shares) public view returns (uint256 assets) { + uint256 supply = totalSupply; + uint256 managedAssets = totalAssets(); + if (supply == 0 || managedAssets == 0) return shares; + return _mulDivUp(shares, managedAssets, supply); + } + + /// @notice Preview shares burned for a target asset withdrawal. + /// @param assets Desired assets to withdraw. + /// @return shares Shares required, rounded up. + function previewWithdraw(uint256 assets) public view returns (uint256 shares) { + uint256 supply = totalSupply; + uint256 managedAssets = totalAssets(); + if (supply == 0 || managedAssets == 0) return assets; + return _mulDivUp(assets, supply, managedAssets); + } + + /// @notice Preview assets returned for a share redemption. + /// @param shares Shares to redeem. + /// @return assets Assets expected to be returned. + function previewRedeem(uint256 shares) external view returns (uint256 assets) { + return convertToAssets(shares); + } + + /// @notice Maximum assets `owner` can deposit. + /// @param owner The account that would receive shares. + /// @return maxAssets The max deposit amount. + function maxDeposit(address owner) external pure returns (uint256 maxAssets) { + owner; + return type(uint256).max; + } + + /// @notice Maximum shares `owner` can mint. + /// @param owner The account that would receive shares. + /// @return maxShares The max mint amount. + function maxMint(address owner) external pure returns (uint256 maxShares) { + owner; + return type(uint256).max; + } + + /// @notice Maximum assets withdrawable by `owner`. + /// @param owner Share holder account. + /// @return maxAssets The max withdraw amount in assets. + function maxWithdraw(address owner) external view returns (uint256 maxAssets) { + return convertToAssets(balanceOf[owner]); + } + + /// @notice Maximum shares redeemable by `owner`. + /// @param owner Share holder account. + /// @return maxShares The max share redemption. + function maxRedeem(address owner) external view returns (uint256 maxShares) { + return balanceOf[owner]; + } + + /// @notice Deposits assets and mints proportional shares to `receiver`. + /// @param assets Asset amount transferred into vault. + /// @param receiver Account receiving new shares. + /// @return shares Number of shares minted. + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + require(assets > 0, "ZERO_ASSETS"); + shares = convertToShares(assets); + require(shares > 0, "ZERO_SHARES"); + + require(asset.transferFrom(msg.sender, address(this), assets), "ASSET_TRANSFER_FAILED"); + _mint(receiver, shares); + + emit Deposit(msg.sender, receiver, assets, shares); + } + + /// @notice Mints exact shares to `receiver` by pulling required assets from caller. + /// @param shares Share amount to mint. + /// @param receiver Account receiving new shares. + /// @return assets Required assets transferred in. + function mint(uint256 shares, address receiver) external returns (uint256 assets) { + require(shares > 0, "ZERO_SHARES"); + assets = previewMint(shares); + require(assets > 0, "ZERO_ASSETS"); + + require(asset.transferFrom(msg.sender, address(this), assets), "ASSET_TRANSFER_FAILED"); + _mint(receiver, shares); + + emit Deposit(msg.sender, receiver, assets, shares); + } + + /// @notice Withdraws exact assets to `receiver` by burning owner's shares. + /// @param assets Asset amount to send out. + /// @param receiver Recipient of underlying assets. + /// @param owner Share owner whose balance is burned. + /// @return shares Shares burned. + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) { + require(assets > 0, "ZERO_ASSETS"); + shares = previewWithdraw(assets); + _spendAllowanceIfNeeded(owner, shares); + _burn(owner, shares); + + require(asset.transfer(receiver, assets), "ASSET_TRANSFER_FAILED"); + emit Withdraw(msg.sender, receiver, owner, assets, shares); + } + + /// @notice Redeems exact shares from owner for proportional assets. + /// @param shares Share amount to burn. + /// @param receiver Recipient of underlying assets. + /// @param owner Share owner whose balance is burned. + /// @return assets Assets returned. + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) { + require(shares > 0, "ZERO_SHARES"); + assets = convertToAssets(shares); + require(assets > 0, "ZERO_ASSETS"); + _spendAllowanceIfNeeded(owner, shares); + _burn(owner, shares); + + require(asset.transfer(receiver, assets), "ASSET_TRANSFER_FAILED"); + emit Withdraw(msg.sender, receiver, owner, assets, shares); + } + + /// @notice Approves `spender` to transfer caller shares. + /// @param spender Approved address. + /// @param amount Allowance amount. + /// @return True when approval succeeds. + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + /// @notice Transfers shares from caller to `to`. + /// @param to Recipient of shares. + /// @param amount Shares to transfer. + /// @return True when transfer succeeds. + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + /// @notice Transfers shares from `from` to `to` using share allowance. + /// @param from Share owner. + /// @param to Recipient. + /// @param amount Shares to transfer. + /// @return True when transfer succeeds. + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= amount, "ERC20: insufficient allowance"); + allowance[from][msg.sender] = allowed - amount; + } + _transfer(from, to, amount); + return true; + } + + /// @notice Internal share minting helper. + /// @param to Recipient. + /// @param amount Minted share amount. + function _mint(address to, uint256 amount) internal { + require(to != address(0), "ERC20: mint to zero"); + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + if (address(shareReceiptNft) != address(0)) { + shareReceiptNft.mintReceipt(to); + } + } + + /// @notice Internal share burn helper. + /// @param from Account to burn from. + /// @param amount Burn amount. + function _burn(address from, uint256 amount) internal { + uint256 fromBal = balanceOf[from]; + require(fromBal >= amount, "ERC20: burn exceeds balance"); + balanceOf[from] = fromBal - amount; + totalSupply -= amount; + emit Transfer(from, address(0), amount); + } + + /// @notice Internal share transfer helper. + /// @param from Sender. + /// @param to Recipient. + /// @param amount Share amount. + function _transfer(address from, address to, uint256 amount) internal { + require(to != address(0), "ERC20: transfer to zero"); + uint256 fromBal = balanceOf[from]; + require(fromBal >= amount, "ERC20: insufficient balance"); + balanceOf[from] = fromBal - amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } + + /// @notice Spends share allowance when caller is not owner. + /// @param owner Share owner. + /// @param shares Shares requested for burn. + function _spendAllowanceIfNeeded(address owner, uint256 shares) internal { + if (msg.sender == owner) return; + uint256 allowed = allowance[owner][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= shares, "ERC20: insufficient allowance"); + allowance[owner][msg.sender] = allowed - shares; + } + } + + /// @notice Computes ceil(x*y/denominator) for preview functions. + /// @param x Left operand. + /// @param y Right operand. + /// @param denominator Divisor. + /// @return result Ceil division multiplication result. + function _mulDivUp(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + result = (x * y) / denominator; + if ((x * y) % denominator != 0) { + result += 1; + } + } +} diff --git a/contracts/src/MockUSDC.sol b/contracts/src/MockUSDC.sol new file mode 100644 index 0000000..968f67e --- /dev/null +++ b/contracts/src/MockUSDC.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title MockUSDC +/// @notice Minimal mintable ERC20 used to simulate USDC in local tests and demos. +contract MockUSDC { + /// @notice Token name. + string public constant name = "Mock USDC"; + /// @notice Token symbol. + string public constant symbol = "mUSDC"; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + uint256 public totalSupply; + + event Transfer(address indexed from, address indexed to, uint256 amount); + event Approval(address indexed owner, address indexed spender, uint256 amount); + + /// @notice Returns the fixed decimal precision used by USDC-like assets. + /// @return The decimals count (6). + function decimals() external pure returns (uint8) { + return 6; + } + + /// @notice Approves a spender to transfer caller funds up to `amount`. + /// @param spender The address allowed to spend caller tokens. + /// @param amount The maximum allowance. + /// @return True when approval succeeds. + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + /// @notice Transfers tokens from caller to `to`. + /// @param to The recipient account. + /// @param amount The token amount. + /// @return True when transfer succeeds. + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + /// @notice Transfers tokens from `from` to `to` using allowance. + /// @param from The token owner. + /// @param to The recipient account. + /// @param amount The token amount. + /// @return True when transfer succeeds. + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= amount, "ERC20: insufficient allowance"); + allowance[from][msg.sender] = allowed - amount; + } + _transfer(from, to, amount); + return true; + } + + /// @notice Mints tokens to an arbitrary address for testing/demo setup. + /// @param to The recipient of freshly minted tokens. + /// @param amount The token amount to mint. + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + /// @notice Internal token movement primitive with balance checks. + /// @param from The source account. + /// @param to The destination account. + /// @param amount The token amount. + function _transfer(address from, address to, uint256 amount) internal { + require(to != address(0), "ERC20: transfer to zero"); + uint256 fromBal = balanceOf[from]; + require(fromBal >= amount, "ERC20: insufficient balance"); + balanceOf[from] = fromBal - amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/contracts/test/CashflowVault.t.sol b/contracts/test/CashflowVault.t.sol new file mode 100644 index 0000000..62055fe --- /dev/null +++ b/contracts/test/CashflowVault.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import {MockUSDC} from "../src/MockUSDC.sol"; +import {CashflowVault} from "../src/CashflowVault.sol"; +import {CashflowSchedule} from "../src/CashflowSchedule.sol"; +import {CashflowShareNFT} from "../src/CashflowShareNFT.sol"; + +contract CashflowVaultTest is Test { + MockUSDC internal usdc; + CashflowVault internal vault; + CashflowSchedule internal schedule; + CashflowShareNFT internal receipt; + + address internal creator = address(0xC0FFEE); + address internal investorA = address(0xA11CE); + address internal investorB = address(0xB0B); + + uint256 internal constant ONE_USDC = 1e6; + uint256 internal constant WEEK = 7 days; + + function setUp() public { + usdc = new MockUSDC(); + vault = new CashflowVault(usdc); + schedule = new CashflowSchedule(creator, vault, WEEK, 100 * ONE_USDC, block.timestamp + 1 days); + receipt = new CashflowShareNFT("Cashflow Receipt", "CFR", address(vault), 1); + vault.setShareReceiptNft(receipt); + + usdc.mint(investorA, 5_000 * ONE_USDC); + usdc.mint(investorB, 5_000 * ONE_USDC); + usdc.mint(creator, 5_000 * ONE_USDC); + + vm.prank(investorA); + usdc.approve(address(vault), type(uint256).max); + vm.prank(investorB); + usdc.approve(address(vault), type(uint256).max); + vm.prank(creator); + usdc.approve(address(schedule), type(uint256).max); + } + + function testDepositAndShares() public { + vm.prank(investorA); + uint256 sharesA = vault.deposit(1_000 * ONE_USDC, investorA); + vm.prank(investorB); + uint256 sharesB = vault.deposit(500 * ONE_USDC, investorB); + + assertEq(sharesA, 1_000 * ONE_USDC); + assertEq(sharesB, 500 * ONE_USDC); + assertEq(vault.balanceOf(investorA), 1_000 * ONE_USDC); + assertEq(vault.balanceOf(investorB), 500 * ONE_USDC); + assertEq(receipt.balanceOf(investorA), 1); + assertEq(receipt.balanceOf(investorB), 1); + } + + function testCashflowPaymentIncreasesPPS() public { + vm.prank(investorA); + vault.deposit(1_000 * ONE_USDC, investorA); + + uint256 ppsBefore = (vault.totalAssets() * 1e18) / vault.totalSupply(); + + vm.warp(block.timestamp + 1 days); + schedule.pay(); + + uint256 ppsAfter = (vault.totalAssets() * 1e18) / vault.totalSupply(); + assertGt(ppsAfter, ppsBefore); + } + + function testRedeemAfterPayments() public { + vm.prank(investorA); + vault.deposit(1_000 * ONE_USDC, investorA); + + vm.warp(block.timestamp + 1 days); + schedule.pay(); + vm.warp(block.timestamp + WEEK); + schedule.pay(); + + uint256 shares = vault.balanceOf(investorA); + uint256 balBefore = usdc.balanceOf(investorA); + + vm.prank(investorA); + uint256 assetsOut = vault.redeem(shares, investorA, investorA); + + uint256 balAfter = usdc.balanceOf(investorA); + assertEq(balAfter - balBefore, assetsOut); + assertGt(assetsOut, 1_000 * ONE_USDC); + } + + function testEdgeCases() public { + vm.expectRevert("ZERO_PAYMENT"); + new CashflowSchedule(creator, vault, WEEK, 0, block.timestamp + 1 days); + + vm.prank(creator); + schedule.pause(); + vm.warp(block.timestamp + 1 days); + vm.expectRevert("NOT_ACTIVE"); + schedule.pay(); + + CashflowSchedule futureSchedule = + new CashflowSchedule(creator, vault, WEEK, 10 * ONE_USDC, block.timestamp + 10 days); + vm.prank(creator); + usdc.approve(address(futureSchedule), type(uint256).max); + vm.expectRevert("NOT_STARTED"); + futureSchedule.pay(); + + CashflowSchedule allowanceSchedule = + new CashflowSchedule(creator, vault, WEEK, 10 * ONE_USDC, block.timestamp); + vm.prank(creator); + usdc.approve(address(allowanceSchedule), 0); + vm.expectRevert("ERC20: insufficient allowance"); + allowanceSchedule.pay(); + } +}