Ethereum Trust Fund
A simple guide on building a trust fund in Solidity & NextJS
All the code to follow along with the tutorial more easily can be found here. To get started follow these steps.
Project Structure
- Create a folder for the whole project (I called mine "ethereumtrustfund")
- Create a "solidity" folder. This will hold all of the Hardhat code.
Setup Solidity
- CD into the solidity folder. `cd solidity` from inside the ethereumtrustfund folder
- Inisde this folder run `npm init`
- Inside this folder run `npm i --save-dev hardhat`
- Run `npx hardhat init`
- Select "Create a TypeScript project"
- Select all the defaults for the rest of the wizard
Setting Up Amoy Testnet Account
- Setup an account at infura.io
- Create a new API key
- Click configure API key
- On the Polygon network ensure that both Mainnet and Amoy are checked.
- Create a .env file inside the solidity folder
- Take the API key at the top and past it into the .env file like so: AMOY_RPC_PROVIDER="https://polygon-amoy.infura.io/v3/YOUR_API_KEY"
Setting Up Amoy Metamask
- In Metamask click the network dropdown on the top left.
- Click the "Add network" button.
- Click "Add a network manually"
- Paste in the RPC URL you got above.
- Put in Chain ID 80002
- Currency symbol can be MATIC
Creating a Metamask account
- In Metamask click the account dropdown at the top center.
- Click the "Add account or hardware wallet" button.
- Click "Add a new account"
- You can name the account anything.
- Once the account is created you can click the accounts dropdown again and then select the more options button on the account you created.
- Click "Account details"
- Click "Show private key"
- Paste this private key into your .env file under the ACCOUNT_SECRET_KEY_1 variable.
- Paste the account address for this key under ACCOUNT_ADDRESS_1
- You'll also need to fund this account on both the Polygon and Amoy networks. The only way to fund on Polygon is to send real MATIC. However on a testnet like Amoy you can search Google for a Amoy faucet and they will send you MATIC for free.
Update contracts/Lock.sol
Replace contracts/Lock.sol with contracts/TrustFund.sol
// SPDX-License-Identifier: No-license
pragma solidity 0.8.24;
/**
@title TrustFund
@author Jonathan Emig
*/
contract TrustFund {
struct Deposit {
uint256 depositId;
address depositor;
address beneficiary;
uint256 amount;
uint256 withdrawalDate;
}
uint256 public depositCounter; // Counter for unique deposit IDs
mapping(uint256 => Deposit) private depositsById; // Mapping deposit ID to Deposit
mapping(address => uint256[]) private depositIdsByAddress; // Mapping beneficiary & depositor to an array of deposit IDs
event FundsDeposited(uint256 depositId, address indexed depositor, address indexed beneficiary, uint256 amount, uint256 withdrawalDate);
event FundsWithdrawn(uint256 depositId, address indexed beneficiary, uint256 amount);
// Function to deposit funds
function depositFunds(address beneficiary, uint256 withdrawalDate) external payable {
require(beneficiary != address(0), "Invalid beneficiary address");
require(withdrawalDate > block.timestamp, "Withdrawal date must be in the future");
require(msg.value > 0, "Amount must be greater than zero");
depositCounter++; // Increment the deposit ID counter
// Create a new deposit and store it by ID
depositsById[depositCounter] = Deposit({
depositId: depositCounter,
depositor: msg.sender,
beneficiary: beneficiary,
amount: msg.value,
withdrawalDate: withdrawalDate
});
// Store the deposit ID for both the depositor and the beneficiary
if (beneficiary == msg.sender) {
depositIdsByAddress[msg.sender].push(depositCounter);
} else {
depositIdsByAddress[beneficiary].push(depositCounter);
depositIdsByAddress[msg.sender].push(depositCounter);
}
emit FundsDeposited(depositCounter, msg.sender, beneficiary, msg.value, withdrawalDate);
}
// Function for beneficiaries to withdraw funds
function withdrawFunds(uint256 depositId) external {
Deposit storage deposit = depositsById[depositId];
require(deposit.amount > 0, "No funds available for withdrawal");
require(msg.sender == deposit.beneficiary || msg.sender == deposit.depositor, "You must either be the depositor or the beneficiary to withdraw funds");
if (msg.sender != deposit.depositor) {
require(block.timestamp >= deposit.withdrawalDate, "Withdrawal date has not yet passed");
}
uint256 amount = deposit.amount;
deposit.amount = 0; // Set the deposit amount to 0 to prevent re-entrancy
payable(msg.sender).transfer(amount);
emit FundsWithdrawn(depositId, msg.sender, amount);
}
// Function to return all the deposits of whoever is calling it
function getMyDeposits() external view returns (Deposit[] memory) {
uint256[] memory depositIds = depositIdsByAddress[msg.sender];
Deposit[] memory myDeposits = new Deposit[](depositIds.length);
for (uint256 i = 0; i < depositIds.length; i++) {
myDeposits[i] = depositsById[depositIds[i]];
}
return myDeposits;
}
// Function to retrieve the deposits for a specific address (beneficiary or depositor)
function getDepositsForAddress(address addr) external view returns (uint256[] memory) {
return depositIdsByAddress[addr];
}
// Function to get details for a specific deposit
function getDepositDetails(uint256 depositId) external view returns (Deposit memory) {
Deposit storage deposit = depositsById[depositId];
require(msg.sender == deposit.beneficiary || msg.sender == deposit.depositor, "You must be the beneficiary or the depositor to see these funds");
return depositsById[depositId];
}
}
Update test/Lock.ts
Replace test/Lock.ts with test/TrustFund.ts
import { expect } from "chai";
import hre from "hardhat";
function timeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe("TrustFund Tests", () => {
async function setup() {
// Contracts are deployed using the first signer/account by default
const [owner, otherAccount] = await hre.ethers.getSigners();
const TrustFund = await hre.ethers.getContractFactory("TrustFund");
const trustFund = await TrustFund.deploy();
return { trustFund, owner, otherAccount };
}
it("Should deposit funds and get amount equal to the same as deposited", async () => {
const { trustFund, owner } = await setup();
const timestamp = Math.round((new Date()).getTime() / 1000 + 10); // add 60 seconds
const txn = await trustFund.depositFunds(owner, timestamp, {
value: hre.ethers.parseUnits("0.01", "ether"),
});
const receipt = await txn.wait();
const unformattedDeposits = await trustFund.getMyDeposits();
const deposits = Object.values(unformattedDeposits);
const etherAmount = hre.ethers.formatEther(deposits[0][3]);
expect(etherAmount).to.equal("0.01");
});
it("Should deposit funds and withdraw funds", async () => {
const { trustFund, owner } = await setup();
const timestamp = Math.round((new Date()).getTime() / 1000 + 5);
const txn = await trustFund.depositFunds(owner, timestamp, {
value: hre.ethers.parseUnits("0.01", "ether")
});
const receipt = await txn.wait();
await timeout(5000);
const unformattedDeposits = await trustFund.getMyDeposits();
const deposits = Object.values(unformattedDeposits);
const firstDepositId = deposits[0][0];
const fundsTxn = await trustFund.withdrawFunds(firstDepositId);
const fundReceipts = await fundsTxn.wait();
expect(fundReceipts?.status).to.equal(1);
});
});
Update package.json
Add the following script to the package.json
"build": "npx hardhat compile --show-stack-traces",
"clean": "npx hardhat clean --show-stack-traces",
"test": "npx hardhat test test/TrustFund.ts --show-stack-traces",
"deploy-amoy": "npx hardhat ignition deploy ./ignition/modules/TrustFund.ts --network polygonAmoy",
"deploy-polygon": "npx hardhat ignition deploy ./ignition/modules/TrustFund.ts --network polygon",
"verify-amoy": "npx hardhat ignition verify chain-80002 --include-unrelated-contracts",
"verify-polygon": "npx hardhat ignition verify chain-137 --include-unrelated-contracts"
Now you can run `npm run build` to make sure the contract compiles.
You can also run `npm run test` to run the unit tests.
To deploy the smart contract to amoy run `npm run deploy-amoy`
Copy ABI
Once the smart contract has been deployed hardhat will generate an ABI for you in the ignition/deployments/chain-80002/artifacts folder
Copy and paste TrustFundModule#TrustFund.json into the `client/src/abis/` folder. You'll have to create the `abis` folder first.
Once the file is in there you can rename it to just TrustFund.json
Also, make sure to add a new property to the object called "address" with the address of the deployed contract. You can find this address in solidity/ignition/deployments/chain-80002/deployed_addresses.json
Now on to the building the client.
Setup NextJS
- CD out of the solidity folder `cd ..`
- Inside this folder run `npx create-next-app@latest`
- Name the project `client` (or whatever you think sounds good)
- I selected yes for TypeScript, ESLint, Tailwind, the src directory, App Router, and no to customized import alias.
Building the client
- CD into the client folder. `cd client` from inside the ethereumtrustfund folder or `cd ../client` if you're in the solidity folder
- Inisde this folder run `npx shadcn@latest init`
- Here I selected New York, Zinc, and yes to variable colors
- Next you're going to want to install the components. Run this command for every component listed here in the next line `npx shadcn@latest add button`
- button, calendar, card, form, input, label, popover, tabs, and, toast
- Run `npm i zod`
- Run `npm i ethers`
- Create an abis folder inside src and paste in the abi of the smart contract. I paste in the entire TrustFundModule#TrustFund.json file which also includes the contractName and sourceName as well as the abi array.
- Create a .env file inside the client folder and add two env vars. For the contract address you should paste in your own contract address but for convenience you can also use the one I've deployed to amoy if that's easier.
NEXT_PUBLIC_ETH_NETWORK="matic-amoy"
NEXT_PUBLIC_TRUST_FUND_ADDRESS="0x631CcED615eEb97FE630Cdc96F55f1D4822c73BE"
With that all taken care of we can now start building the app and components.
Create a custom folder inside of the components folder and then create a DepositForm.tsx and WithdrawForm.tsx
Here are what the two files look like you can just paste these in.
DepositForm.tsx
"use client"
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"
import { format } from "date-fns";
import { useForm } from "react-hook-form";
import { ethers } from "ethers";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import trustFundJson from '@/abis/TrustFund.json';
const trustFundAddress = process.env.NEXT_PUBLIC_TRUST_FUND_ADDRESS || '';
const validateSchema = z.object({
beneficiaryAddress: z.string().min(1, { message: "A beneficiary is required." }),
withdrawalDate: z.date({ required_error: "Please select a withdrawal date." }),
depositAmount: z.string().min(1, { message: "A deposit amount is required" }),
});
const depositFormSchema = z.object({
beneficiaryAddress: z.string(),
withdrawalDate: z.date().or(z.undefined()),
depositAmount: z.string(),
});
export default function DepositForm() {
const { toast } = useToast();
const form = useForm<z.infer<typeof depositFormSchema>>({
values: {
beneficiaryAddress: '',
withdrawalDate: undefined,
depositAmount: '',
},
resolver: zodResolver(validateSchema),
});
const onSubmit = async (data: z.infer<typeof validateSchema>) => {
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contract = new ethers.Contract(
trustFundAddress,
trustFundJson.abi,
signer,
);
const withdrawalTimestamp = data.withdrawalDate?.getTime() / 1000;
const txn = await contract.depositFunds(data.beneficiaryAddress, withdrawalTimestamp, {
value: ethers.parseEther(data.depositAmount),
});
toast({ title: "Transaction sent" });
form.reset();
await txn.wait();
toast({ title: "Transaction confirmed!", variant: "success" });
} catch (e: any) {
console.error(e);
toast({ title: e.message, variant: "destructive" });
}
}
return (
<Form {...form}>
{ /* @ts-ignore */ }
<form onSubmit={form.handleSubmit(onSubmit)} >
<Card>
<CardHeader>
<CardTitle>Deposit</CardTitle>
<CardDescription>
Deposit funds into the trust here.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="beneficiaryAddress"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Beneficiary Address</FormLabel>
<Input id="beneficiaryAddress" placeholder="0x5795E7...db038" value={field.value} onChange={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="withdrawalDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Withdrawal Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn("w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground")}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) => date < new Date() || date < new Date("1900-01-01")}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="depositAmount"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Deposit Amount</FormLabel>
<div className="relative">
<Input className="pr-20" id="depositAmount" type="number" placeholder="" value={field.value} onChange={field.onChange} />
<span className="absolute top-[10px] right-4 text-muted-foreground">MATIC</span>
</div>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit">Deposit</Button>
</CardFooter>
</Card>
</form>
</Form>
);
}
WithdrawForm.tsx
"use client"
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import { ethers } from "ethers";
import { useToast } from "@/hooks/use-toast";
import trustFundJson from "@/abis/TrustFund.json";
import { Label } from "../ui/label";
const trustFundAddress = process.env.NEXT_PUBLIC_TRUST_FUND_ADDRESS || '';
type Deposit = {
depositId: number;
beneficiary: string;
depositor: string;
amount: string;
withdrawalDate: string;
}
export default function WithdrawForm() {
const [withdrawalAddress, setWithdrawalAddress] = useState<string>('');
const [deposits, setDeposits] = useState<Deposit[]>([]);
const { toast } = useToast();
const onHandleWithdraw = async (depositId: number) => {
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contract = new ethers.Contract(
trustFundAddress,
trustFundJson.abi,
signer,
);
const txn = await contract.withdrawFunds(depositId);
toast({ title: "Transaction sent" });
await txn.wait();
toast({ title: "Transaction confirmed!" });
getWithdrawalAmounts();
} catch (e: any) {
if (e.message.includes('No funds available')) {
return toast({ title: "No funds available to withdraw.", variant: "destructive" });
}
toast({ title: e.message, variant: "destructive" });
}
}
const setAddress = async () => {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
setWithdrawalAddress(address);
}
const getWithdrawalAmounts = async () => {
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contract = new ethers.Contract(
trustFundAddress,
trustFundJson.abi,
signer,
);
const txn = await contract.getMyDeposits();
const deposits: Deposit[] = Object.values(txn).filter((d: any) => d[3] > 0).map((d: any) => ({
depositId: d[0],
depositor: d[1],
beneficiary: d[2],
amount: ethers.formatEther(d[3]),
withdrawalDate: new Date(parseInt(d[4]) * 1000).toLocaleDateString("en-US"),
}));
setDeposits(deposits);
} catch (e: any) {
console.error(e);
toast({ title: e.message, variant: "destructive" });
setDeposits([]);
}
}
useEffect(() => {
setAddress();
getWithdrawalAmounts();
window.ethereum.on('accountsChanged', function () {
setAddress();
getWithdrawalAmounts();
});
}, []);
return (
<Card>
<CardHeader>
<CardTitle>Withdraw</CardTitle>
<CardDescription>
Withdraw funds from the beneficiary address.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<Label htmlFor="withdrawalAddress">Withdrawal Address</Label>
<Input
id="withdrawalAddress"
placeholder="0x5795E7...db038"
value={withdrawalAddress}
onChange={(e) => setWithdrawalAddress(e.target.value)}
disabled
/>
<div className="text-muted-foreground">
To withdraw funds to a different address than the one seen here use Metamask to connect a different account.
</div>
</div>
{!!deposits.length && deposits.map((deposit, i) => (
<div key={i} className="flex items-center space-x-4 rounded-md border p-4">
<div className="flex-1 space-y-2 w-full">
<p className="text-sm text-muted-foreground text-nowrap text-ellipsis overflow-hidden">
<strong>Depositor: </strong> {deposit.depositor}
</p>
<p className="text-sm text-muted-foreground text-nowrap text-ellipsis overflow-hidden">
<strong>Beneficiary: </strong> {deposit.beneficiary}
</p>
<p className="text-sm text-muted-foreground text-nowrap text-ellipsis overflow-hidden">
<strong>Amount: </strong> {deposit.amount} MATIC
</p>
<p className="text-sm text-muted-foreground text-nowrap text-ellipsis overflow-hidden">
<strong>Withdrawal Date: </strong> {deposit.withdrawalDate}
</p>
<Button
className="!mt-4"
type="button"
onClick={() => onHandleWithdraw(deposit.depositId)}
>
Withdraw
</Button>
</div>
</div>
))}
</CardContent>
</Card>
);
}
To get rid of the window.ethereum does not exist error create a file called react-app.env.d.ts inside of the src folder then paste this:
/// <reference types="react-scripts" />
interface Window {
ethereum: any;
}
If you run into a TypeScript error feel free to remove "next/typescript" inside of the eslintrc.json file. Or, if you're feeling ambitious you can work through the TS errors that pop up.
For the layout.txs file paste in:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Ethereum Trustless Fund",
description: "A tool for sending money to beneficiaries at a later date.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<main>
{children}
</main>
<Toaster />
</body>
</html>
);
}
And for the page.tsx file paste in:
"use client"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import DepositForm from "@/components/custom/DepositForm";
import WithdrawForm from "@/components/custom/WithdrawForm";
import { useEffect } from "react";
import { ethers } from "ethers";
import { toast } from "@/hooks/use-toast";
const NEXT_PUBLIC_ETH_NETWORK = process.env.NEXT_PUBLIC_ETH_NETWORK;
export default function Home() {
const checkNetworkAndNotify = () => {
const provider = new ethers.BrowserProvider(window.ethereum);
provider.getNetwork()
.then((network) => {
if (network.name !== NEXT_PUBLIC_ETH_NETWORK) {
// remind the user that they need to be connected to the right network
toast({ title: `Please change your Metamask network to ${NEXT_PUBLIC_ETH_NETWORK}`, variant: "destructive" });
}
});
}
useEffect(() => {
checkNetworkAndNotify();
window.ethereum.on('chainChanged', function() {
checkNetworkAndNotify();
});
}, [])
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 w-full max-w-5xl items-center justify-center font-mono text-sm lg:flex">
<div className="flex flex-col items-center w-[400px]">
<h2 className="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0 mb-10 w-full">
Ethereum Trustless Fund
</h2>
<Tabs defaultValue="deposit" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="deposit">Deposit</TabsTrigger>
<TabsTrigger value="withdraw">Withdraw</TabsTrigger>
</TabsList>
<TabsContent value="deposit">
<DepositForm />
</TabsContent>
<TabsContent value="withdraw">
<WithdrawForm />
</TabsContent>
</Tabs>
</div>
</div>
</main>
);
}
If you're noticing some issues with the color scheme (mine happened to be dark) try pasting in the globals.css file here:
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
You'll also want to customize the shadcn toast notification to have a success variant. Inside toast.tsx add in the success variant with the following value: "success group border-green-500 bg-green-500 text-neutral-50"
With all those changes made you should be able to deposit and withdraw funds! If you're having any trouble following along there is also a YouTube video on my channel which goes into more detail.
Comments