Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add vesting backend and frontend #134

Merged
merged 30 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3f0b54a
add new routing for getEvents
djeck1432 Sep 6, 2024
58dff75
add new logic for getEvents to return vesting
djeck1432 Sep 6, 2024
9970767
add env.example file
djeck1432 Sep 6, 2024
c45474e
change operant
djeck1432 Sep 6, 2024
158854a
refactoring
djeck1432 Sep 9, 2024
470bf79
add missing param
djeck1432 Sep 9, 2024
c8acc16
add caching and start_block
djeck1432 Sep 12, 2024
01eabb6
update package-lock.json
djeck1432 Sep 12, 2024
9c0840d
add new logic
djeck1432 Sep 12, 2024
d443cc2
add annotation for types
djeck1432 Sep 12, 2024
692357f
Merge branch 'CarmineOptions:master' into feat/add-vesting-backend
djeck1432 Sep 21, 2024
d58de00
update routes
djeck1432 Sep 21, 2024
9ef5b1e
rework get vesting event
djeck1432 Sep 21, 2024
0032333
change governance contract
djeck1432 Sep 21, 2024
bbe9c7d
update logic to fetch all events
djeck1432 Sep 21, 2024
71c09ec
refactoring
djeck1432 Sep 21, 2024
86acad2
add new package
djeck1432 Sep 23, 2024
98bba22
add new VestingTable
djeck1432 Sep 23, 2024
cb2245d
add VestingTable
djeck1432 Sep 23, 2024
cd81d40
add basic url
djeck1432 Sep 23, 2024
d14954a
fix api url
djeck1432 Sep 23, 2024
842bba5
refactoring caching
djeck1432 Sep 23, 2024
d1f2bc7
add new table style
djeck1432 Sep 23, 2024
07187e0
unify BASE_API_URL
djeck1432 Sep 23, 2024
b9915cc
add do .. while
djeck1432 Sep 23, 2024
d40b022
Refactor backend for readability
tensojka Sep 23, 2024
2a46b2e
Polish refactor
tensojka Sep 23, 2024
d57651f
remove IsVestingMilestones if it is vested
djeck1432 Sep 23, 2024
f409902
refactoring getVestingEvents
djeck1432 Sep 23, 2024
b102cd6
Enforce consistency and code quality
tensojka Sep 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

54 changes: 54 additions & 0 deletions backend/src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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);
console.log("fetched ", newlyFetched.length, " events")
const events = [...cached.events, ...newlyFetched];
const cacheKey = { contract, fromBlock };
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
console.debug('continuing', events.continuation_token);
console.debug(res.length)
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;
94 changes: 82 additions & 12 deletions backend/src/starknet.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,94 @@
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 | null; // 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,
is_claimable: false,
claimable_at: null,
is_claimed: true,
});
}
}
} catch (error) {
console.error('Error processing event, skipping this event:', error);
}
return acc;
}, []);

return events;
} 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 */}
tensojka marked this conversation as resolved.
Show resolved Hide resolved
<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;
4 changes: 2 additions & 2 deletions frontend/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const CONTRACT_ADDR =
// mainnet: "0x001405ab78ab6ec90fba09e6116f373cda53b0ba557789a4578d8c1ec374ba0f";
"0x02ba6e05d06a9e7398ae71c330b018415f93710c58e99fb04fa761f97712ec76"; // sepolia with treasury
"0x0618f5457aca4d3c9e46ad07bae6d051234e308c39eb60d73bc461f4f8c1d3da"; // sepolia with treasury
export const VOTING_TOKEN_CONTRACT =
"0x4ff1af47bb9659aa83bbd33e13c25e8fb1b5ecf8359320251f03e1440e8890a";
export const FLOATING_TOKEN_CONTRACT = "0x31868056874ad7629055ddd00eb0931cb92167851702abf6b441cb8ea02d02b";
Expand All @@ -16,7 +16,7 @@ export const formatIpfsHash = (hash: string) => {
return hash.replace(/,/g, "");
};

export const BASE_API_URL = "https://konoha.vote/discussion-api/api/"
export const BASE_API_URL = "https://konoha.vote/discussion-api/api"
export const NETWORK: string = "sepolia";

export const ETH_ADDRESS = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"
Expand Down