// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title HabitProofX HabitRegistry /// @notice Decentralized, non-custodial, append-only registry of fiat-to-crypto /// habits. Holds no funds. After testing and completion this contract /// is deployed immutable: no proxy, no owner, no admin, no upgrade. /// Every function only ever touches `msg.sender`'s own records. /// Protocol metadata (source, ABI) is pinned to IPFS at freeze. contract HabitRegistry { enum Frequency { Weekly, Biweekly } struct Habit { uint96 amount; // habit size in whole USD Frequency frequency; uint32 chainId; // destination network id (see _validChain) uint64 createdAt; uint64 updatedAt; bool archived; string method; // "Venmo", "Zelle", ... string asset; // TOKEN:id for the DCA destination uint64[] cycles; // block timestamps of confirmed ramps } uint256 public constant MAX_HABITS = 16; uint64 public constant WEEK = 7 days; uint64 public constant BIWEEK = 14 days; mapping(address => Habit[]) private _habits; event HabitCreated( address indexed user, uint256 indexed habitId, uint96 amount, Frequency frequency, uint32 chainId, string method, string asset ); event HabitUpdated( address indexed user, uint256 indexed habitId, uint96 amount, Frequency frequency, uint32 chainId, string method, string asset ); event HabitArchived(address indexed user, uint256 indexed habitId, bool archived); event CycleConfirmed(address indexed user, uint256 indexed habitId, uint64 timestamp, uint256 totalCycles); error HabitNotFound(); error InvalidAmount(); error InvalidMethod(); error InvalidChain(); error InvalidAsset(); error TooManyHabits(); error CycleTooSoon(); error Archived(); modifier exists(uint256 habitId) { if (habitId >= _habits[msg.sender].length) revert HabitNotFound(); _; } function _validChain(uint32 chainId) private pure returns (bool) { return chainId == 1 || // Ethereum chainId == 369 || // PulseChain chainId == 8453 || // Base chainId == 501 || // Solana (sentinel) chainId == 784 || // Sui (sentinel) chainId == 999001; // Robinhood venue (sentinel) } function _validate(uint96 amount, uint32 chainId, string calldata method, string calldata asset) private pure { if (amount == 0) revert InvalidAmount(); if (!_validChain(chainId)) revert InvalidChain(); if (bytes(method).length == 0 || bytes(method).length > 32) revert InvalidMethod(); if (bytes(asset).length == 0 || bytes(asset).length > 128) revert InvalidAsset(); } function _period(Frequency frequency) private pure returns (uint64) { return frequency == Frequency.Weekly ? WEEK : BIWEEK; } /// @notice Commit a new habit for the caller. Returns its id. function createHabit( uint96 amount, Frequency frequency, uint32 chainId, string calldata method, string calldata asset ) external returns (uint256 habitId) { _validate(amount, chainId, method, asset); if (_habits[msg.sender].length >= MAX_HABITS) revert TooManyHabits(); habitId = _habits[msg.sender].length; Habit storage h = _habits[msg.sender].push(); h.amount = amount; h.frequency = frequency; h.chainId = chainId; h.createdAt = uint64(block.timestamp); h.updatedAt = uint64(block.timestamp); h.method = method; h.asset = asset; emit HabitCreated(msg.sender, habitId, amount, frequency, chainId, method, asset); } /// @notice Update an existing habit in place. Cycle history is preserved. function updateHabit( uint256 habitId, uint96 amount, Frequency frequency, uint32 chainId, string calldata method, string calldata asset ) external exists(habitId) { _validate(amount, chainId, method, asset); Habit storage h = _habits[msg.sender][habitId]; h.amount = amount; h.frequency = frequency; h.chainId = chainId; h.method = method; h.asset = asset; h.updatedAt = uint64(block.timestamp); emit HabitUpdated(msg.sender, habitId, amount, frequency, chainId, method, asset); } /// @notice Record a completed ramp. Enforces one confirmation per cadence period. function confirmCycle(uint256 habitId) external exists(habitId) { Habit storage h = _habits[msg.sender][habitId]; if (h.archived) revert Archived(); if (h.cycles.length > 0) { uint64 last = h.cycles[h.cycles.length - 1]; if (block.timestamp < last + _period(h.frequency)) revert CycleTooSoon(); } h.cycles.push(uint64(block.timestamp)); h.updatedAt = uint64(block.timestamp); emit CycleConfirmed(msg.sender, habitId, uint64(block.timestamp), h.cycles.length); } /// @notice Archive or restore a habit without deleting its proof history. function setArchived(uint256 habitId, bool archived) external exists(habitId) { _habits[msg.sender][habitId].archived = archived; _habits[msg.sender][habitId].updatedAt = uint64(block.timestamp); emit HabitArchived(msg.sender, habitId, archived); } function getHabits(address user) external view returns (Habit[] memory) { return _habits[user]; } function getHabit(address user, uint256 habitId) external view returns (Habit memory) { if (habitId >= _habits[user].length) revert HabitNotFound(); return _habits[user][habitId]; } function habitCount(address user) external view returns (uint256) { return _habits[user].length; } function cycleCount(address user, uint256 habitId) external view returns (uint256) { if (habitId >= _habits[user].length) return 0; return _habits[user][habitId].cycles.length; } function lastCycleTime(address user, uint256 habitId) external view returns (uint64) { if (habitId >= _habits[user].length) return 0; uint256 n = _habits[user][habitId].cycles.length; if (n == 0) return 0; return _habits[user][habitId].cycles[n - 1]; } function habitFrequency(address user, uint256 habitId) external view returns (Frequency) { if (habitId >= _habits[user].length) return Frequency.Weekly; return _habits[user][habitId].frequency; } }