diff --git a/contracts/v2/VaultV2.sol b/contracts/v2/VaultV2.sol index b0dd3fc..143a401 100644 --- a/contracts/v2/VaultV2.sol +++ b/contracts/v2/VaultV2.sol @@ -44,6 +44,9 @@ contract VaultV2 is ReentrancyGuard { address public admin; bool public paused; + /// @notice Platform treasury address โ€” authorized to call depositFor() + address public treasury; + /// @notice Internal ledger balances mapping(address => uint256) public balances; @@ -80,12 +83,14 @@ contract VaultV2 is ReentrancyGuard { event Paused(address indexed admin); event Unpaused(address indexed admin); event AdminTransferred(address indexed oldAdmin, address indexed newAdmin); + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); // ============================================================ // Errors // ============================================================ error NotAdmin(); + error NotTreasury(); error VaultPaused(); error InsufficientBalance(); error InsufficientSplitReserve(); @@ -128,6 +133,11 @@ contract VaultV2 is ReentrancyGuard { _; } + modifier onlyTreasury() { + if (msg.sender != treasury) revert NotTreasury(); + _; + } + // ============================================================ // Constructor // ============================================================ @@ -157,6 +167,31 @@ contract VaultV2 is ReentrancyGuard { emit LedgerTransfer(address(0), msg.sender, amount, LedgerReason.UserDeposit); } + /** + * @notice Deposit USDC on behalf of a recipient (platform treasury only) + * @param recipient Address whose vault balance is credited + * @param amount USDC amount (6 decimals) + * @dev Used by the Trial Market Program: treasury tops up the creator's vault + * balance so the factory can pull the $30 seed via pullForNewMarket(). + * Requires treasury to be configured via setTreasury() by admin. + * See docs/TRIAL_MARKET_PROGRAM.md ยง8.1 + */ + function depositFor(address recipient, uint256 amount) + external + nonReentrant + whenNotPaused + onlyTreasury + { + if (amount == 0) revert ZeroAmount(); + if (recipient == address(0)) revert ZeroAddress(); + + usdc.safeTransferFrom(msg.sender, address(this), amount); + balances[recipient] += amount; + totalBalances += amount; + + emit LedgerTransfer(address(0), recipient, amount, LedgerReason.PlatformSeed); + } + /** * @notice Withdraw USDC from the vault * @param amount USDC amount to withdraw @@ -381,6 +416,12 @@ contract VaultV2 is ReentrancyGuard { emit TrustedFactoryUpdated(_factory, false); } + function setTreasury(address _treasury) external onlyAdmin { + address old = treasury; + treasury = _treasury; + emit TreasuryUpdated(old, _treasury); + } + function setExchange(address _exchange) external onlyAdmin { exchange = _exchange; } diff --git a/contracts/v2/interfaces/Types.sol b/contracts/v2/interfaces/Types.sol index c193942..213e732 100644 --- a/contracts/v2/interfaces/Types.sol +++ b/contracts/v2/interfaces/Types.sol @@ -55,7 +55,8 @@ enum LedgerReason { AccumulateFee, WithdrawFeePool, TransferBetween, - MarketInitLiquidity + MarketInitLiquidity, + PlatformSeed // Treasury deposits seed on behalf of trial market creator } // ============================================================ diff --git a/test/VaultV2.t.sol b/test/VaultV2.t.sol index 0c862b0..190c02c 100644 --- a/test/VaultV2.t.sol +++ b/test/VaultV2.t.sol @@ -287,4 +287,148 @@ contract VaultV2Test is Test { uint256 accounting = vault.totalBalances() + vault.splitReserve() + vault.feePool(); assertEq(usdcBal, accounting, "invariant: USDC should exactly match accounting"); } + + // ============================================================ + // depositFor โ€” Trial Market Program treasury seeding + // ============================================================ + + address public treasury = makeAddr("treasury"); + + function _setupTreasury() internal { + // Admin sets the treasury address + vm.prank(admin); + vault.setTreasury(treasury); + + // Treasury mints and approves USDC + usdc.mint(treasury, 1_000_000_000); // 1000 USDC + vm.prank(treasury); + usdc.approve(address(vault), type(uint256).max); + } + + function test_depositFor_creditsRecipient() public { + _setupTreasury(); + + uint256 seed = 30_000_000; // $30 + vm.prank(treasury); + vault.depositFor(bob, seed); + + assertEq(vault.balances(bob), seed); + assertEq(vault.totalBalances(), seed); + assertEq(usdc.balanceOf(address(vault)), seed); + } + + function test_depositFor_invariantHolds() public { + _setupTreasury(); + + vm.prank(treasury); + vault.depositFor(bob, 30_000_000); + + uint256 usdcBal = usdc.balanceOf(address(vault)); + uint256 accounting = vault.totalBalances() + vault.splitReserve() + vault.feePool(); + assertEq(usdcBal, accounting, "invariant must hold after depositFor"); + } + + function test_depositFor_allowsSubsequentPullForNewMarket() public { + _setupTreasury(); + + // Treasury tops up bob's balance ($30 seed) + vm.prank(treasury); + vault.depositFor(bob, 30_000_000); + + // Factory pulls seed from bob to market (simulates trial market creation) + vm.prank(factory); + vault.pullForNewMarket(bob, market, 30_000_000); + + assertEq(vault.balances(bob), 0); + assertEq(vault.balances(market), 30_000_000); + // totalBalances unchanged (zero-sum pullForNewMarket) + assertEq(vault.totalBalances(), 30_000_000); + } + + function test_depositFor_revertsIfNotTreasury() public { + _setupTreasury(); + + vm.prank(alice); // not treasury + vm.expectRevert(VaultV2.NotTreasury.selector); + vault.depositFor(bob, 30_000_000); + } + + function test_depositFor_revertsIfTreasuryNotSet() public { + // No setTreasury call โ€” treasury is address(0) + usdc.mint(address(this), 30_000_000); + usdc.approve(address(vault), 30_000_000); + + vm.expectRevert(VaultV2.NotTreasury.selector); + vault.depositFor(bob, 30_000_000); + } + + function test_depositFor_revertsZeroAmount() public { + _setupTreasury(); + + vm.prank(treasury); + vm.expectRevert(VaultV2.ZeroAmount.selector); + vault.depositFor(bob, 0); + } + + function test_depositFor_revertsZeroAddress() public { + _setupTreasury(); + + vm.prank(treasury); + vm.expectRevert(VaultV2.ZeroAddress.selector); + vault.depositFor(address(0), 30_000_000); + } + + function test_depositFor_revertsWhenPaused() public { + _setupTreasury(); + + vm.prank(admin); + vault.pause(); + + vm.prank(treasury); + vm.expectRevert(VaultV2.VaultPaused.selector); + vault.depositFor(bob, 30_000_000); + } + + // ============================================================ + // setTreasury + // ============================================================ + + function test_setTreasury_byAdmin() public { + address newTreasury = makeAddr("newTreasury"); + + vm.prank(admin); + vault.setTreasury(newTreasury); + + assertEq(vault.treasury(), newTreasury); + } + + function test_setTreasury_emitsEvent() public { + address newTreasury = makeAddr("newTreasury"); + + vm.prank(admin); + vm.expectEmit(true, true, false, false); + emit VaultV2.TreasuryUpdated(address(0), newTreasury); + vault.setTreasury(newTreasury); + } + + function test_setTreasury_revertsIfNotAdmin() public { + vm.prank(alice); + vm.expectRevert(VaultV2.NotAdmin.selector); + vault.setTreasury(makeAddr("treasury")); + } + + function test_setTreasury_canClearTreasury() public { + _setupTreasury(); + + // Clear treasury (set to zero) + vm.prank(admin); + vault.setTreasury(address(0)); + + assertEq(vault.treasury(), address(0)); + + // depositFor should now revert (msg.sender != address(0)) + vm.prank(treasury); + vm.expectRevert(VaultV2.NotTreasury.selector); + vault.depositFor(bob, 30_000_000); + } }