Skip to content

Commit

Permalink
Feat/add vesting backend and frontend (#134)
Browse files Browse the repository at this point in the history
* add new routing for getEvents

* add new logic for getEvents to return vesting

* add env.example file

* change operant

* refactoring

* add missing param

* add caching and start_block

* update package-lock.json

* add new logic

* add annotation for types

* update routes

* rework get vesting event

* change governance contract

* update logic to fetch all events

* refactoring

* add new package

* add new VestingTable

* add VestingTable

* add basic url

* fix api url

* refactoring caching

* add new table style

* unify BASE_API_URL

* add do .. while

* Refactor backend for readability

* Polish refactor

* remove IsVestingMilestones if it is vested

* refactoring getVestingEvents

* Enforce consistency and code quality

---------

Co-authored-by: Ondřej Sojka <[email protected]>
  • Loading branch information
djeck1432 and tensojka authored Sep 23, 2024
1 parent 47ed5b0 commit 0edf935
Show file tree
Hide file tree
Showing 10 changed files with 446 additions and 56 deletions.
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Env variables
STARKNET_RPC=#
# If network == `mainnet` it will use StarknetChainId.SN_MAIN
# otherwise it will use by default StarknetChainId.SN_SEPOLIA
NETWORK=#
167 changes: 134 additions & 33 deletions backend/package-lock.json

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions backend/src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { RpcProvider, hash } from "starknet";


interface CacheEntry {
events: any[],
lastBlock: number
}

const cache: Map<string, CacheEntry> = new Map();

export const getRawVestingEvents = async (contract: string, provider: RpcProvider, fromBlock: number): Promise<any[]> => {
const latestBlockPromise = provider.getBlockLatestAccepted();
let cached = getCachedVestingEvents(contract, fromBlock);

const newlyFetched = await fetchVestingEvents(contract, provider, cached.lastBlock);
const events = [...cached.events, ...newlyFetched];
console.log('saving ', newlyFetched.length, ' newly fetched to cache');
cache.set(`${contract}-${fromBlock}`, { events, lastBlock: (await latestBlockPromise).block_number });
return events;
};

const getCachedVestingEvents = (contract: string, fromBlock: number): CacheEntry => {
const cachedEntry = cache.get(`${contract}-${fromBlock}`)
if (cachedEntry === undefined) {
return { lastBlock: fromBlock, events: [] }
};
let nextEntry = getCachedVestingEvents(contract, cachedEntry.lastBlock);
return { lastBlock: nextEntry.lastBlock, events: [...cachedEntry.events, ...nextEntry.events] }
}

const fetchVestingEvents = async (contract: string, provider: RpcProvider, fromBlock: number, continuation_token?: string) => {
const eventFilter = {
from_block: { block_number: fromBlock },
chunk_size: 100,
address: contract,
keys: [[hash.getSelectorFromName('VestingEvent')]],
...(continuation_token && { continuation_token })
};

const events = await provider.getEvents(eventFilter);

let res = events.events

if (events.continuation_token) { // means we can continue, more to fetch
const nextEvents = await fetchVestingEvents(contract, provider, fromBlock, events.continuation_token);
res = [...res, ...nextEvents];
}

return res;
}
30 changes: 27 additions & 3 deletions backend/src/routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import { Router } from "express";
import { submitProposal } from "./ipfs";
import { Router } from 'express';
import { submitProposal } from './ipfs';
import { getVestingEvents } from './starknet';
import { Request, Response } from 'express';

const router = Router();

router.post("/submit", submitProposal);
// Existing route for submitting proposals
router.post('/submit', submitProposal);

// Vesting events for user
router.get('/vesting-events', async (req: Request, res: Response) => {
const { contract, address } = req.query;

// Ensure that contract and address are strings, not arrays or other types
if (typeof contract !== 'string' || typeof address !== 'string') {
return res.status(400).json({ error: 'Both contract and address must be valid strings.' });
}

console.log(`Received request for vesting events with contract: ${contract} and address: ${address}`);

try {
const events = await getVestingEvents(contract, address);
res.json(events);
} catch (error) {
console.error('Error fetching vesting events:', error);
res.status(500).json({ error: 'Error fetching vesting events.' });
}
});


export default router;
111 changes: 99 additions & 12 deletions backend/src/starknet.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,111 @@
import { StarknetIdNavigator } from "starknetid.js";
import { RpcProvider, constants } from "starknet";
import { RpcProvider, constants, hash } from "starknet";
import * as dotenv from "dotenv";
import { getRawVestingEvents } from "./events";

dotenv.config();

const NODE_URL: string = process.env.NODE_URL || "";
const STARKNET_RPC: string = process.env.STARKNET_RPC || "";
const START_BLOCK: number = process.env.NETWORK === 'mainnet' ? 720000 : 196000; // Started block number before vesting added
const CHAIN_ID: constants.StarknetChainId = process.env.NETWORK === 'mainnet'
? constants.StarknetChainId.SN_MAIN
: constants.StarknetChainId.SN_SEPOLIA;

const provider = new RpcProvider({
nodeUrl: STARKNET_RPC,
chainId: CHAIN_ID,
});

const starknetIdNavigator = new StarknetIdNavigator(
provider,
CHAIN_ID,
);


interface VestingEvent {
amount: number; // The amount vested
claimable_at: number; // The timestamp when the amount becomes claimable
is_claimable: boolean; // Whether the amount is claimable at the current time
is_claimed: boolean; // Whether the amount has already been claimed
}

export const getVestingEvents = async (contract: string, address: string): Promise<any> => {
const vesting_milestone_add_selector = hash.getSelectorFromName('VestingMilestoneAdded');
const vested_selector = hash.getSelectorFromName('Vested');

const rawEvents = await getRawVestingEvents(contract, provider, START_BLOCK);

try {
const events = rawEvents.reduce((acc: VestingEvent[], event: any) => {
try {
const grantee = event.data[0]; // Grantee (index 0)
const timestamp = Number(BigInt(event.data[1])); // Timestamp (index 1)
let amount = Number(BigInt(event.data[2])); // Amount as BigInt (index 2)

// Apply scaling if amount > 0
if (amount > 0) {
amount = amount / (10 ** 18);
}

// Process only if the grantee matches the address
if (grantee === address) {
const isVestingMilestone = event.keys.includes(vesting_milestone_add_selector);
const isVested = event.keys.includes(vested_selector);

if (isVestingMilestone) {
acc.push({
amount: amount,
claimable_at: timestamp,
is_claimable: Date.now() >= timestamp,
is_claimed: false,
});
} else if (isVested) {
acc.push({
amount: amount,
claimable_at: timestamp,
is_claimable: false,
is_claimed: true,
});
}
}

} catch (error) {
console.error('Error processing event, skipping this event:', error);
}

return acc;
}, []);

// Create a set of claimable_at values from vested events
const vestedClaimableAts = new Set(
events.filter(e => e.is_claimed).map(e => e.claimable_at)
);

// Filter events, removing is_claimable if there's a matching vested event with the same claimable_at
const filteredEvents = events.filter(event =>
!event.is_claimable || !vestedClaimableAts.has(event.claimable_at)
);

// Sort the filtered events by claimable_at in ascending order
const sortedEvents = filteredEvents.sort((a, b) => a.claimable_at - b.claimable_at);

return sortedEvents;

} catch (error) {
console.error('Error in getVestingEvents:', error);

if (error instanceof Error) {
throw new Error(`Error fetching events: ${error.message}`);
} else {
throw new Error('Unknown error occurred while fetching events');
}
}
};


export const getStarknetId = async (
address: string
): Promise<string | null> => {
const provider = new RpcProvider({
nodeUrl: NODE_URL,
chainId: constants.StarknetChainId.SN_MAIN
});

const starknetIdNavigator = new StarknetIdNavigator(
provider,
constants.StarknetChainId.SN_MAIN
);

try {
const domain = await starknetIdNavigator.getStarkName(address);
const id = await starknetIdNavigator.getStarknetId(domain);
Expand Down
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"tailwindcss": "^3.3.5"
"tailwindcss": "^3.3.5",
"@tanstack/react-query": "^5.0.1"
},
"devDependencies": {
"@types/react": "^18.0.27",
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import React from "react";
// import { useBlock } from "@starknet-react/core";
import Header from "./components/Header";
import { useContractRead, useAccount } from "@starknet-react/core";
//import Tokens from "./helpers/tokens";
import { abi } from "./lib/abi";
import Proposal from "./components/Proposal";
import { CONTRACT_ADDR } from "./lib/config";
// import { useAccount } from "@starknet-react/core";
import SubmitProposalModal from "./components/SubmitProposalModal";
import TreasuryStatus from "./components/TreasuryStatus";
import VotingPower from "./components/staking/VotingPower";
import StatusTransfer from './components/StatusTransfer'
import StatusTransfer from './components/StatusTransfer';
import VestingTable from './components/VestingTable';

function App() {
const [isModalOpen, setIsModalOpen] = React.useState(false);
Expand All @@ -31,7 +29,6 @@ function App() {
return <div>{error?.message}</div>;
}

// Display the proposals
return (
<main className="flex flex-col items-center min-h-screen gap-12 mt-16">
<Header />
Expand Down Expand Up @@ -72,6 +69,8 @@ function App() {

<TreasuryStatus />
<StatusTransfer />

{address && <VestingTable />}
{address && <VotingPower />}
</main>
);
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/components/VestingTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { CONTRACT_ADDR } from '../lib/config';
import axios from 'axios';
import { useAccount } from '@starknet-react/core';
import { useQuery } from '@tanstack/react-query';
import { BASE_API_URL } from "../lib/config";

interface VestingEvent {
amount: number;
claimable_at: number | null;
is_claimable: boolean;
is_claimed: boolean;
}

const ITEMS_PER_PAGE = 10; // Items per page

const VestingTable: React.FC = () => {
const { address } = useAccount();
const [currentPage, setCurrentPage] = useState(1); // Track the current page

const { data: events = [], isLoading, error } = useQuery({
queryKey: ['vesting-events', address],
queryFn: async () => {
if (!address) {
return [];
}
const response = await axios.get(`${BASE_API_URL}/vesting-events`, {
params: {
contract: CONTRACT_ADDR,
address: address,
}
});
return response.data;
},
enabled: !!address, // Only fetch if the address is available
retry: false, // Disable retries for this query
});

// Pagination logic
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const paginatedEvents = events.slice(startIndex, startIndex + ITEMS_PER_PAGE);
const totalPages = Math.ceil(events.length / ITEMS_PER_PAGE);

const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};

const handlePreviousPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};

if (isLoading) {
return <div>Loading...</div>;
}

if (error) {
return <p>{(error as Error).message || 'Failed to load vesting events.'}</p>;
}

return (
<div className="w-full max-w-[50rem] mt-4">
{/* Title */}
<h2 className="text-xl font-bold mb-2">Vesting Milestones</h2>

{/* Table */}
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="border p-2 text-left">Amount</th>
<th className="border p-2 text-left">Claimable At</th>
<th className="border p-2 text-left">Is Claimable</th>
<th className="border p-2 text-left">Is Claimed</th>
</tr>
</thead>
<tbody>
{paginatedEvents.map((event: VestingEvent, index: number) => (
<tr key={index} className="hover:bg-gray-50">
<td className="border p-2">{event.amount}</td>
<td className="border p-2">
{event.claimable_at ? new Date(event.claimable_at * 1000).toLocaleString() : 'N/A'}
</td>
<td className="border p-2">{event.is_claimable ? 'Yes' : 'No'}</td>
<td className="border p-2">{event.is_claimed ? 'Yes' : 'No'}</td>
</tr>
))}
</tbody>
</table>

{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-4">
<button
onClick={handlePreviousPage}
disabled={currentPage === 1}
className={`px-4 py-2 bg-gray-300 rounded ${currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-400'}`}
>
Previous
</button>

<span className="text-sm">
Page {currentPage} of {totalPages}
</span>

<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className={`px-4 py-2 bg-gray-300 rounded ${currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-400'}`}
>
Next
</button>
</div>
)}
</div>
);

};

export default VestingTable;
Loading

0 comments on commit 0edf935

Please sign in to comment.