From e27f4990ae926bb83ea1af285a24db1c0385b9bd Mon Sep 17 00:00:00 2001 From: rayeberechi Date: Mon, 16 Feb 2026 08:58:43 +0100 Subject: [PATCH] feat: Timelocked Vault submission --- rayeberechi/.gitignore | 20 +++++++ rayeberechi/README.md | 17 ++++++ rayeberechi/contracts/TimeLockedVault.sol | 71 +++++++++++++++++++++++ rayeberechi/hardhat.config.ts | 38 ++++++++++++ rayeberechi/ignition/modules/Counter.ts | 9 +++ rayeberechi/package.json | 28 +++++++++ rayeberechi/scripts/send-op-tx.ts | 22 +++++++ rayeberechi/test/Counter.ts | 36 ++++++++++++ rayeberechi/tsconfig.json | 13 +++++ 9 files changed, 254 insertions(+) create mode 100644 rayeberechi/.gitignore create mode 100644 rayeberechi/README.md create mode 100644 rayeberechi/contracts/TimeLockedVault.sol create mode 100644 rayeberechi/hardhat.config.ts create mode 100644 rayeberechi/ignition/modules/Counter.ts create mode 100644 rayeberechi/package.json create mode 100644 rayeberechi/scripts/send-op-tx.ts create mode 100644 rayeberechi/test/Counter.ts create mode 100644 rayeberechi/tsconfig.json diff --git a/rayeberechi/.gitignore b/rayeberechi/.gitignore new file mode 100644 index 00000000..991a319e --- /dev/null +++ b/rayeberechi/.gitignore @@ -0,0 +1,20 @@ +# Node modules +/node_modules + +# Compilation output +/dist + +# pnpm deploy output +/bundle + +# Hardhat Build Artifacts +/artifacts + +# Hardhat compilation (v2) support directory +/cache + +# Typechain output +/types + +# Hardhat coverage reports +/coverage diff --git a/rayeberechi/README.md b/rayeberechi/README.md new file mode 100644 index 00000000..4b35c161 --- /dev/null +++ b/rayeberechi/README.md @@ -0,0 +1,17 @@ +# Timelocked Savings Vault + +A smart contract system that forces users to save ETH until a specific date. + +## Features +- **Time Lock**: Users set an `unlockTime`. Funds cannot be withdrawn before this timestamp. +- **Factory Pattern**: Each user gets their own personal Vault contract. +- **Single Active Vault**: A user cannot create a second vault if they already have funds locked in an existing one. +- **Security**: + - Only the owner can withdraw. + - Direct ETH transfers to the vault are rejected (no `receive` function). + - Full balance is withdrawn at once to reset the vault. + +## Usage +1. Call `createVault(unlockTime)` on the Factory with ETH attached. +2. Wait until `block.timestamp >= unlockTime`. +3. Call `withdraw()` on your specific Vault address. \ No newline at end of file diff --git a/rayeberechi/contracts/TimeLockedVault.sol b/rayeberechi/contracts/TimeLockedVault.sol new file mode 100644 index 00000000..aa9f1e30 --- /dev/null +++ b/rayeberechi/contracts/TimeLockedVault.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// --- 1. THE CHILD CONTRACT (The Vault) --- +contract TimeLockedVault { + + address public owner; + uint256 public unlockTime; + + // Requirement: "Contract must reject direct ETH transfers" + + constructor(address _owner, uint256 _unlockTime) payable { + require(msg.value > 0, "Must deposit ETH to create vault"); + require(_unlockTime > block.timestamp, "Unlock time must be in the future"); + + owner = _owner; + unlockTime = _unlockTime; + } + + function withdraw() external { + // Requirement: "No one can withdraw another user's funds" + require(msg.sender == owner, "Only owner can withdraw"); + + // Requirement: "User can withdraw only after block.timestamp >= unlockTime" + require(block.timestamp >= unlockTime, "Vault is still locked"); + + uint256 amount = address(this).balance; + require(amount > 0, "Vault is empty"); + + // Requirement: "Full balance must be withdrawn at once" + (bool success, ) = owner.call{value: amount}(""); + require(success, "Transfer failed"); + } + + // Helper to check balance + function getBalance() external view returns (uint256) { + return address(this).balance; + } +} + +// --- 2. THE FACTORY CONTRACT (The Manager) --- +contract VaultFactory { + + // Requirement: "Each user can have only one active vault at a time" + mapping(address => TimeLockedVault) public userVaults; + + event VaultCreated(address indexed user, address vaultAddress, uint256 unlockTime, uint256 amount); + + function createVault(uint256 _unlockTime) external payable { + // 1. Check if user already has an ACTIVE vault (one with money in it) + if (address(userVaults[msg.sender]) != address(0)) { + uint256 existingBalance = userVaults[msg.sender].getBalance(); + require(existingBalance == 0, "You already have an active vault with funds"); + } + + require(msg.value > 0, "You must deposit ETH to create a vault"); + + // 2. Create the new vault and pass the ETH + TimeLockedVault newVault = new TimeLockedVault{value: msg.value}(msg.sender, _unlockTime); + + // 3. Update the registry + userVaults[msg.sender] = newVault; + + emit VaultCreated(msg.sender, address(newVault), _unlockTime, msg.value); + } + + // Helper to find your vault + function getMyVault() external view returns (address) { + return address(userVaults[msg.sender]); + } +} \ No newline at end of file diff --git a/rayeberechi/hardhat.config.ts b/rayeberechi/hardhat.config.ts new file mode 100644 index 00000000..7092b852 --- /dev/null +++ b/rayeberechi/hardhat.config.ts @@ -0,0 +1,38 @@ +import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers"; +import { configVariable, defineConfig } from "hardhat/config"; + +export default defineConfig({ + plugins: [hardhatToolboxMochaEthersPlugin], + solidity: { + profiles: { + default: { + version: "0.8.28", + }, + production: { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + }, + }, + networks: { + hardhatMainnet: { + type: "edr-simulated", + chainType: "l1", + }, + hardhatOp: { + type: "edr-simulated", + chainType: "op", + }, + sepolia: { + type: "http", + chainType: "l1", + url: configVariable("SEPOLIA_RPC_URL"), + accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], + }, + }, +}); diff --git a/rayeberechi/ignition/modules/Counter.ts b/rayeberechi/ignition/modules/Counter.ts new file mode 100644 index 00000000..042e61c8 --- /dev/null +++ b/rayeberechi/ignition/modules/Counter.ts @@ -0,0 +1,9 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; + +export default buildModule("CounterModule", (m) => { + const counter = m.contract("Counter"); + + m.call(counter, "incBy", [5n]); + + return { counter }; +}); diff --git a/rayeberechi/package.json b/rayeberechi/package.json new file mode 100644 index 00000000..5be4ae2b --- /dev/null +++ b/rayeberechi/package.json @@ -0,0 +1,28 @@ +{ + "name": "rayeberechi", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "devDependencies": { + "@nomicfoundation/hardhat-ethers": "^4.0.4", + "@nomicfoundation/hardhat-ignition": "^3.0.7", + "@nomicfoundation/hardhat-toolbox-mocha-ethers": "^3.0.2", + "@types/chai": "^4.3.20", + "@types/chai-as-promised": "^8.0.2", + "@types/mocha": "^10.0.10", + "@types/node": "^22.19.11", + "chai": "^5.3.3", + "ethers": "^6.16.0", + "forge-std": "github:foundry-rs/forge-std#v1.9.4", + "hardhat": "^3.1.8", + "mocha": "^11.7.5", + "typescript": "~5.8.0" + } +} diff --git a/rayeberechi/scripts/send-op-tx.ts b/rayeberechi/scripts/send-op-tx.ts new file mode 100644 index 00000000..c10a2360 --- /dev/null +++ b/rayeberechi/scripts/send-op-tx.ts @@ -0,0 +1,22 @@ +import { network } from "hardhat"; + +const { ethers } = await network.connect({ + network: "hardhatOp", + chainType: "op", +}); + +console.log("Sending transaction using the OP chain type"); + +const [sender] = await ethers.getSigners(); + +console.log("Sending 1 wei from", sender.address, "to itself"); + +console.log("Sending L2 transaction"); +const tx = await sender.sendTransaction({ + to: sender.address, + value: 1n, +}); + +await tx.wait(); + +console.log("Transaction sent successfully"); diff --git a/rayeberechi/test/Counter.ts b/rayeberechi/test/Counter.ts new file mode 100644 index 00000000..f8c38986 --- /dev/null +++ b/rayeberechi/test/Counter.ts @@ -0,0 +1,36 @@ +import { expect } from "chai"; +import { network } from "hardhat"; + +const { ethers } = await network.connect(); + +describe("Counter", function () { + it("Should emit the Increment event when calling the inc() function", async function () { + const counter = await ethers.deployContract("Counter"); + + await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n); + }); + + it("The sum of the Increment events should match the current value", async function () { + const counter = await ethers.deployContract("Counter"); + const deploymentBlockNumber = await ethers.provider.getBlockNumber(); + + // run a series of increments + for (let i = 1; i <= 10; i++) { + await counter.incBy(i); + } + + const events = await counter.queryFilter( + counter.filters.Increment(), + deploymentBlockNumber, + "latest", + ); + + // check that the aggregated events match the current value + let total = 0n; + for (const event of events) { + total += event.args.by; + } + + expect(await counter.x()).to.equal(total); + }); +}); diff --git a/rayeberechi/tsconfig.json b/rayeberechi/tsconfig.json new file mode 100644 index 00000000..9b1380cc --- /dev/null +++ b/rayeberechi/tsconfig.json @@ -0,0 +1,13 @@ +/* Based on https://github.com/tsconfig/bases/blob/501da2bcd640cf95c95805783e1012b992338f28/bases/node22.json */ +{ + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "target": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "node16", + "outDir": "dist" + } +}