// SPDX-License-Identifier: MIT pragma solidity 0.8.24; /// @dev Minimal ERC-20 surface Vesta needs. USDC returns `true` on success. interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); } /// @title VestaPayments — non-custodial USDC invoice payments (testnet MVP) /// @notice A customer pays an invoice; USDC moves in ONE transaction directly from the /// customer to the merchant (gross − fee) and to the Vesta fee wallet (fee). /// This contract never holds a USDC balance and has no withdraw function. /// /// Trust model (approved in docs/architecture-usdc-flow.md): /// - The caller supplies `merchant` and `amount`. A tampered call can only /// misroute the caller's own USDC; the off-chain indexer only marks an /// invoice CONFIRMED when the emitted event matches the invoice record. /// - Expiry is enforced off-chain (soft expiry). /// - Fee and fee wallet are fixed at deployment; there is no owner/admin. contract VestaPayments { /// @notice The only token this contract will ever move. IERC20 public immutable usdc; /// @notice Receives the Vesta fee on every payment. address public immutable feeWallet; /// @notice Fee in basis points (300 = 3%). uint256 public immutable feeBps; uint256 public constant BPS_DENOMINATOR = 10_000; /// @notice invoiceId => paid. Set before any transfer; blocks double payment. mapping(bytes32 => bool) public paid; event PaymentReceived( bytes32 indexed invoiceId, address indexed merchant, address indexed payer, uint256 grossAmount, uint256 feeAmount, uint256 merchantAmount ); error ZeroAddress(); error FeeTooHigh(); error ZeroAmount(); error AlreadyPaid(bytes32 invoiceId); error TransferFailed(); constructor(address usdc_, address feeWallet_, uint256 feeBps_) { if (usdc_ == address(0) || feeWallet_ == address(0)) revert ZeroAddress(); if (feeBps_ >= BPS_DENOMINATOR) revert FeeTooHigh(); usdc = IERC20(usdc_); feeWallet = feeWallet_; feeBps = feeBps_; } /// @notice Pure fee split, mirrored in web/lib/fees.ts. Fee rounds down; dust stays with merchant. function split(uint256 grossAmount) public view returns (uint256 feeAmount, uint256 merchantAmount) { feeAmount = (grossAmount * feeBps) / BPS_DENOMINATOR; merchantAmount = grossAmount - feeAmount; } /// @notice Pay `amount` USDC (6-decimal units) for `invoiceId` to `merchant`. /// @dev Caller must have approved this contract for at least `amount` on USDC. /// Reverts (and moves nothing) on insufficient balance/allowance or duplicate invoiceId. function pay(bytes32 invoiceId, address merchant, uint256 amount) external { if (merchant == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); if (paid[invoiceId]) revert AlreadyPaid(invoiceId); paid[invoiceId] = true; // effects before interactions (uint256 feeAmount, uint256 merchantAmount) = split(amount); if (!usdc.transferFrom(msg.sender, merchant, merchantAmount)) revert TransferFailed(); if (feeAmount > 0) { if (!usdc.transferFrom(msg.sender, feeWallet, feeAmount)) revert TransferFailed(); } emit PaymentReceived(invoiceId, merchant, msg.sender, amount, feeAmount, merchantAmount); } }