diff --git a/self-created.json b/self-created.json index 371203b..f35df98 100644 --- a/self-created.json +++ b/self-created.json @@ -89,5 +89,26 @@ "createdBy": "manual", "instanceAccount": "treasury-near-india.near", "daoAccount": "near-india.sputnik-dao.near" + }, + { + "name": "ref-finance", + "createdAt": "2025-08-29T12:00:00Z", + "createdBy": "manual", + "instanceAccount": "treasury-ref-finance.near", + "daoAccount": "ref-finance.sputnik-dao.near" + }, + { + "name": "ref-community-board", + "createdAt": "2025-08-29T12:00:00Z", + "createdBy": "manual", + "instanceAccount": "treasury-ref-community-board.near", + "daoAccount": "ref-community-board.sputnik-dao.near" + }, + { + "name": "daocubator", + "createdAt": "2025-09-21T12:00:00Z", + "createdBy": "manual", + "instanceAccount": "treasury-daocubator.near", + "daoAccount": "daocubator.sputnik-dao.near" } ] diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts index ab60ccf..c0e08d8 100644 --- a/src/routes/metrics.ts +++ b/src/routes/metrics.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from "express"; import axios from "axios"; import prisma from "../prisma"; import { + updateDashboardSheet, updateReportSheet, updateTransactionsReportSheet, } from "../utils/google-sheet"; @@ -122,7 +123,7 @@ async function insertTreasuryToDb({ const fetchNearBalances = async (account_id: string) => { try { const { data } = await axios.get( - `https://ref-sdk-test-cold-haze-1300-2.fly.dev/api/all-token-balance-history`, + `https://ref-sdk-api-2.fly.dev/api/all-token-balance-history`, { params: { account_id, token_id: "near" }, } @@ -137,7 +138,7 @@ const fetchNearBalances = async (account_id: string) => { const fetchFTBalances = async (account_id: string) => { try { const { data } = await axios.get( - `https://ref-sdk-test-cold-haze-1300-2.fly.dev/api/ft-tokens`, + `https://ref-sdk-api-2.fly.dev/api/ft-tokens`, { params: { account_id }, } @@ -324,12 +325,204 @@ async function getTreasuiresForReport() { } } +function decodeUint8Array(uint8Array: number[]) { + const string = String.fromCharCode(...uint8Array); + return JSON.parse(string); +} + +async function getFTLockedBalance(ftContracts: string[], daoAccount: string) { + if (!ftContracts || ftContracts.length === 0) { + return { + formattedString: "-", + totalUSD: 0, + }; + } + try { + const balances = await Promise.all( + ftContracts.map(async (contract) => { + // Fetch account-specific FT data + const accountMetadataResp = await fetchFromRPC( + { + jsonrpc: "2.0", + id: "dontcare", + method: "query", + params: { + request_type: "call_function", + finality: "final", + account_id: contract, + method_name: "get_account", + args_base64: Buffer.from( + JSON.stringify({ account_id: daoAccount }) + ).toString("base64"), + }, + }, + false + ); + + // Fetch contract metadata + const contractMetadataResp = await fetchFromRPC( + { + jsonrpc: "2.0", + id: "dontcare", + method: "query", + params: { + request_type: "call_function", + finality: "final", + account_id: contract, + method_name: "contract_metadata", + args_base64: "", + }, + }, + false + ); + const contractMetadata = decodeUint8Array( + contractMetadataResp.result.result + ); + const ftMetadata = await getTokenMetadata( + contractMetadata.token_account_id + ); + const accountMetadata = decodeUint8Array( + accountMetadataResp.result.result + ); + + const remainingTokens = Big(accountMetadata?.session_num ?? 0) + .mul(accountMetadata?.release_per_session ?? 0) + .minus(accountMetadata?.claimed_amount ?? 0) + .div(Big(10).pow(ftMetadata?.decimals)); + + const usdValue = remainingTokens.mul(ftMetadata?.price ?? 0); + + return { + symbol: ftMetadata?.symbol, + amount: remainingTokens, + usdValue, + }; + }) + ); + + // Format string for the sheet + const formattedString = balances + .map( + (b) => + `Token: ${ + b.symbol + } | Amount: ${b.amount.toFixed()} | USD Value: $${b.usdValue.toFixed( + 2 + )}` + ) + .join("\n"); + + // Sum total USD + const totalUSD = balances.reduce((acc, b) => acc.plus(b.usdValue), Big(0)); + + return { + formattedString: formattedString || "-", + totalUSD: totalUSD.toNumber(), + }; + } catch (error) { + console.error(`Error fetching FT locked balance for ${daoAccount}`, error); + return { formattedString: "-", totalUSD: 0 }; + } +} + +async function getIntentsBalance(daoAccount: string) { + try { + const { data: tokensResponse } = await axios.get( + "https://api-mng-console.chaindefuser.com/api/tokens" + ); + + if (!tokensResponse?.items || tokensResponse.items.length === 0) { + return { formattedString: "-", totalUSD: 0 }; + } + + const initialTokens = tokensResponse.items; + const tokenIds = initialTokens.map((t: any) => t.defuse_asset_id); + + const balancesResp = await fetchFromRPC( + { + jsonrpc: "2.0", + id: "dontcare", + method: "query", + params: { + request_type: "call_function", + finality: "final", + account_id: "intents.near", + method_name: "mt_batch_balance_of", + args_base64: Buffer.from( + JSON.stringify({ + account_id: daoAccount, + token_ids: tokenIds, + }) + ).toString("base64"), + }, + }, + false + ); + + if (!balancesResp?.result?.result) { + console.error("Failed to fetch balances from intents.near"); + return { formattedString: "-", totalUSD: 0 }; + } + + const balances = decodeUint8Array(balancesResp.result.result); + + // Map tokens with balances + const tokensWithBalances = initialTokens.map((token: any, i: number) => ({ + ...token, + amount: balances[i] || "0", + })); + + const filteredTokens = tokensWithBalances.filter( + (token: any) => token.amount && Big(token.amount).gt(0) + ); + + if (filteredTokens.length === 0) { + return { formattedString: "-", totalUSD: 0 }; + } + + const tokensWithMetadata = await Promise.all( + filteredTokens.map(async (token: any) => ({ + ...token, + usdValue: Big(token.amount) + .div(Big(10).pow(token.decimals || 0)) + .mul(token.price || 0) + .toFixed(), + })) + ); + + // Format each token on a new line + const formattedString = tokensWithMetadata + .map((token: any) => { + const amount = Big(token.amount).div(Big(10).pow(token.decimals || 0)); + const usdValue = Big(token.usdValue); + return `Token: ${ + token.symbol || token.name + } | Amount: ${amount.toFixed()} | USD Value: $${usdValue.toFixed(2)}`; + }) + .join("\n"); + + // Total USD value + const totalUSD = tokensWithMetadata.reduce( + (acc: Big, token: any) => acc.plus(Big(token.usdValue)), + Big(0) + ); + + return { + formattedString: formattedString || "-", + totalUSD: totalUSD.toNumber(), + }; + } catch (error) { + console.error("Error fetching intents balance:", error); + return { formattedString: "-", totalUSD: 0 }; + } +} + router.get("/db/treasuries-report", async (_req, res) => { try { const treasuries = await getTreasuiresForReport(); const { data: nearPriceResp } = await axios.get( - "https://ref-sdk-test-cold-haze-1300-2.fly.dev/api/near-price" + "https://ref-sdk-api-2.fly.dev/api/near-price" ); const nearPrice = Big(nearPriceResp || 0); @@ -360,10 +553,18 @@ router.get("/db/treasuries-report", async (_req, res) => { treasury.lockupContract ? fetchNearBalances(treasury.lockupContract) : Promise.resolve(null), + getFTLockedBalance(treasury.ftLockedContracts, treasury.daoAccount), + getIntentsBalance(treasury.daoAccount), ]; - const [ftBalance, nearBalanceResp, policyResp, lockupBalanceResp] = - await Promise.all(promises); + const [ + ftBalance, + nearBalanceResp, + policyResp, + lockupBalanceResp, + ftLockups, + intentsBalance, + ] = await Promise.all(promises); const nearAmount = Big( nearBalanceResp?.["1H"]?.[nearBalanceResp?.["1H"]?.length - 1] @@ -375,7 +576,10 @@ router.get("/db/treasuries-report", async (_req, res) => { const totalAssetsUSD = ftAssetsUSD.plus(nearUSD); daoBalance = daoBalance.plus(totalAssetsUSD); + totalAssets = totalAssets.plus(totalAssetsUSD); + totalAssets = totalAssets.plus(ftLockups.totalUSD); + totalAssets = totalAssets.plus(intentsBalance.totalUSD); const usdcToken = (ftBalance?.fts ?? []).find( (i: FtToken) => @@ -396,15 +600,39 @@ router.get("/db/treasuries-report", async (_req, res) => { i.contract !== "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" ); - const otherTokensTotal = otherTokens.reduce( - (acc: any, token: FtToken) => { - const parsedAmount = getParsedTokenAmount(token); - return acc.plus(parsedAmount); - }, + + const otherTokensWithUSD = otherTokens + .map((token: FtToken) => { + const amount = getParsedTokenAmount(token); // your existing function + const usdValue = Big(amount).mul(token.ft_meta.price || 0); + return { + token, + amount, + usdValue, + }; + }) + .filter((t: any) => t.usdValue.gt(1)); // only keep tokens with USD > 1 + + // Format as string: "Token: SYMBOL | Amount: X | USD Value: $Y" + const otherTokensFormatted = otherTokensWithUSD + .map( + (t: any) => + `Token: ${ + t.token.ft_meta.symbol || t.token.ft_meta.name + } | Amount: ${t.amount.toFixed()} | USD Value: $${t.usdValue.toFixed( + 2 + )}` + ) + .join("\n"); + + // Total USD value of all included tokens + const otherTokensTotalUSD = otherTokensWithUSD.reduce( + (acc: any, t: any) => acc.plus(t.usdValue), Big(0) ); - const otherAmount = Number(otherTokensTotal.toFixed()); + const otherAmountFormatted = otherTokensFormatted || "-"; + const otherTotalUSD = otherTokensTotalUSD.toNumber(); const rawPolicyString = policyResp?.result?.result @@ -443,7 +671,12 @@ router.get("/db/treasuries-report", async (_req, res) => { nearAmount: Number(nearAmount.toFixed()), usdcAmount, usdtAmount, - otherAmount, + ftTokens: otherAmountFormatted, + ftTokensUSD: otherTotalUSD, + ftLockups: ftLockups.formattedString, + ftLockupsUSD: ftLockups.totalUSD, + intentsTokens: intentsBalance.formattedString, + intentsTotalUSD: intentsBalance.totalUSD, }; } catch (error) { console.warn(`⚠️ Skipping treasury ${treasury.name}:`, error); @@ -462,7 +695,7 @@ router.get("/db/treasuries-report", async (_req, res) => { }); // time for transactions report -const startTime = 1749031920000000000; +const startTime = 1753986600000000000; async function getTokenMetadata(tokenId: string) { const { data: meta } = await axios.get( @@ -471,15 +704,26 @@ async function getTokenMetadata(tokenId: string) { return meta; } -async function getTokenAmountInUSD(tokenId: string, rawAmount: string) { +async function getTokenAmountInUSD( + tokenId: string, + rawAmount: string, + isParsedAmount: boolean = false +) { const actualTokenId = tokenId?.trim() || "near"; const meta = await getTokenMetadata(actualTokenId); - const amount = Big(rawAmount).div(Big(10).pow(meta.decimals)); - const usdValue = amount.times(meta.price); + const amount = isParsedAmount + ? Big(rawAmount) + : Big(rawAmount).div(Big(10).pow(meta.decimals)); + + // Ensure price is a Big number and default to 0 if missing + const price = meta.price ? Big(meta.price) : Big(0); + + const usdValue = amount.times(price); + return { - amount: amount, - usdValue: usdValue, + amount, + usdValue, symbol: actualTokenId === "near" ? "NEAR" : meta.symbol, }; } @@ -489,11 +733,12 @@ async function getPaymentStats(daoAccount: string): Promise<{ tokenTotals: Record; }> { try { - const { data: proposals }: { data: TransferProposal[] } = await axios.get( - `https://sputnik-indexer-divine-fog-3863.fly.dev/proposals/${daoAccount}?proposal_type=Transfer&keyword=title` - ); + const { data: proposals }: { data: { proposals: TransferProposal[] } } = + await axios.get( + `https://sputnik-indexer.fly.dev/proposals/${daoAccount}?category=payments` + ); - const filtered = proposals.filter((p) => + const filtered = proposals.proposals.filter((p: TransferProposal) => Big(p.submission_time).gt(startTime) ); @@ -539,12 +784,12 @@ async function getStakeStats(daoAccount: string): Promise<{ }> { let stakeProposalsCount = 0; try { - const { data: proposals }: { data: FunctionCallProposal[] } = + const { data: proposals }: { data: { proposals: FunctionCallProposal[] } } = await axios.get( - `https://sputnik-indexer-divine-fog-3863.fly.dev/proposals/${daoAccount}?proposal_type=FunctionCall&keyword=stake` + `https://sputnik-indexer.fly.dev/proposals/${daoAccount}?category=stake-delegation` ); - const filtered = proposals.filter((p) => + const filtered = proposals.proposals.filter((p: FunctionCallProposal) => Big(p.submission_time).gt(startTime) ); @@ -603,12 +848,11 @@ async function getAssetExchangeStats(daoAccount: string): Promise<{ }> { try { let exchangeProposalsCount = 0; - const { data: proposals }: { data: FunctionCallProposal[] } = + const { data: proposals }: { data: { proposals: FunctionCallProposal[] } } = await axios.get( - `https://sputnik-indexer-divine-fog-3863.fly.dev/proposals/${daoAccount}?proposal_type=FunctionCall&keyword=asset-exchange` + `https://sputnik-indexer.fly.dev/proposals/${daoAccount}?category=asset-exchange` ); - - const filtered = proposals.filter((p) => + const filtered = proposals.proposals.filter((p: FunctionCallProposal) => Big(p.submission_time).gt(startTime) ); @@ -631,8 +875,16 @@ async function getAssetExchangeStats(daoAccount: string): Promise<{ const amountIn = Big(amountInMatch[1]); const amountOut = Big(amountOutMatch[1]); exchangeProposalsCount++; - const inUSD = await getTokenAmountInUSD(tokenIn, amountIn.toFixed()); - const outUSD = await getTokenAmountInUSD(tokenOut, amountOut.toFixed()); + const inUSD = await getTokenAmountInUSD( + tokenIn, + amountIn.toFixed(), + true + ); + const outUSD = await getTokenAmountInUSD( + tokenOut, + amountOut.toFixed(), + true + ); totalExchangeValueUSD = totalExchangeValueUSD .plus(inUSD.usdValue) @@ -670,12 +922,12 @@ async function getLockupStats(daoAccount: string): Promise<{ }> { try { let lockupProposalsCount = 0; - const { data: proposals }: { data: FunctionCallProposal[] } = + const { data: proposals }: { data: { proposals: FunctionCallProposal[] } } = await axios.get( - `https://sputnik-indexer-divine-fog-3863.fly.dev/proposals/${daoAccount}?proposal_type=FunctionCall&keyword=lockup` + `https://sputnik-indexer.fly.dev/proposals/${daoAccount}?category=lockup` ); - const filtered = proposals.filter((p) => + const filtered = proposals.proposals.filter((p: FunctionCallProposal) => Big(p.submission_time).gt(startTime) ); @@ -778,4 +1030,13 @@ router.get("/db/treasuries-transactions-report", async (_req, res) => { } }); +router.get("/db/treasuries-insights", async (_req, res) => { + try { + const insights = await updateDashboardSheet(); + return res.status(200).json(insights); + } catch (err) { + console.error("Error generating insights:", err); + res.status(500).json({ error: "Failed to generate insights" }); + } +}); export default router; diff --git a/src/utils/google-sheet.ts b/src/utils/google-sheet.ts index 42fcade..9425c9a 100644 --- a/src/utils/google-sheet.ts +++ b/src/utils/google-sheet.ts @@ -4,6 +4,8 @@ import * as path from "path"; const SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]; const CREDS_PATH = path.join(__dirname, "./near-treasury-metrics.json"); +const spreadsheetId = "1XtAWMXAeMUEo74ZtSclq1krERQPmyNztmHj3j9obsY4"; +const writeSpreadsheet = "17sw-bWxV-OpYTvvYN0dHrA3YnFrGFHKkZE1bm_alS7A"; async function authenticateGoogleSheets() { const auth = new google.auth.GoogleAuth({ @@ -188,7 +190,6 @@ async function getOrCreateSheetByTitle( export async function updateReportSheet(reportData: any[]) { const sheets = await authenticateGoogleSheets(); - const spreadsheetId = "1XtAWMXAeMUEo74ZtSclq1krERQPmyNztmHj3j9obsY4"; const reportLength = reportData.length + 2; // Add 2 because of timestamp and header row @@ -223,9 +224,14 @@ export async function updateReportSheet(reportData: any[]) { "NEAR", "USDC", "USDt", - "Other FTs", + "FT Tokens", + "FT Tokens (USD)", + "Intents Tokens", + "Intents Value (USD)", + "FT Lockups", + "FT Lockups (USD)", "DAO Assets (USD)", - "Lockup Assets (USD)", + "NEAR Lockup (USD)", "Total Assets (USD)", ], ...reportData.map((row) => [ @@ -237,25 +243,35 @@ export async function updateReportSheet(reportData: any[]) { row.nearAmount, row.usdcAmount, row.usdtAmount, - row.otherAmount, + row.ftTokens, + row.ftTokensUSD, + row.intentsTokens, + row.intentsTotalUSD, + row.ftLockups, + row.ftLockupsUSD, row.daoAssetsValueUSD, row.lockupValueUSD, row.totalAssetsValueUSD, ]), [ "TOTAL", - "", - `${reportData.length} treasuries`, - "", - "", + "", // Created By + `${reportData.length} treasuries`, // Treasury URL + "", // Lockup Account ...generateSumFormulasFromLetters(reportLength, [ - "F", - "G", - "H", - "I", - "J", - "K", - "L", + "E", // DAO Users + "F", // NEAR + "G", // USDC + "H", // USDt + "", // I -> FT Tokens (text) skip + "J", // FT Tokens (USD) + "", // K -> Intents Tokens (text) skip + "L", // Intents Value (USD) + "", // M -> FT Lockups (text) skip + "N", // FT Lockups (USD) + "O", // DAO Assets (USD) + "P", // NEAR Lockup (USD) + "Q", // Total Assets (USD) ]), ], ]; @@ -268,27 +284,30 @@ export async function updateReportSheet(reportData: any[]) { ); const requests = [ - ...formatHeader(sheetId), // now returns an array + ...formatHeader(sheetId), formatTotalRow(sheetId, reportLength), - // Format NEAR to Other FTs as numbers - ...Array.from({ length: 4 }, (_, i) => - formatColumnNumber(sheetId, 2, reportLength, 5 + i, "NUMBER") + + // Format number columns: DAO Users, NEAR, USDC, USDt + ...["E", "F", "G", "H"].map((col) => + formatColumnNumber( + sheetId, + 2, + reportLength, + col.charCodeAt(0) - 65, + "NUMBER" + ) ), - // Format DAO/Lockup/Total Assets as USD - ...Array.from({ length: 3 }, (_, i) => - formatColumnNumber(sheetId, 2, reportLength, 9 + i, "CURRENCY") + // Format currency columns: FT Tokens (USD), Intents Value (USD), FT Lockups (USD), DAO Assets, NEAR Lockup, Total Assets + ...["J", "L", "N", "O", "P", "Q"].map((col) => + formatColumnNumber( + sheetId, + 2, + reportLength, + col.charCodeAt(0) - 65, + "CURRENCY" + ) ), - { - autoResizeDimensions: { - dimensions: { - sheetId, - dimension: "COLUMNS", - startIndex: 0, - endIndex: 12, - }, - }, - }, ]; await updateSheet(sheets, spreadsheetId, values, requests, sheetTitle); @@ -296,7 +315,6 @@ export async function updateReportSheet(reportData: any[]) { export async function updateTransactionsReportSheet(reportData: any[]) { const sheets = await authenticateGoogleSheets(); - const spreadsheetId = "1XtAWMXAeMUEo74ZtSclq1krERQPmyNztmHj3j9obsY4"; // Timestamp (UTC) const now = new Date(); @@ -521,3 +539,771 @@ export async function updateTransactionsReportSheet(reportData: any[]) { console.error(`❌ Failed to update "${sheetTitle}" sheet:`, error); } } + +async function readSheetData( + sheets: any, + spreadsheetId: string, + sheetTitle: string, + sheetType: string +) { + // Fix: Read more columns to include "Total Assets (USD)" + const range = `'${sheetTitle}'!A:Q`; // Changed from A:O to A:Q + const response = await sheets.spreadsheets.values.get({ + spreadsheetId, + range, + }); + const rows = response.data.values || []; + return parseSheetData(rows, sheetType); +} + +// --- Utility Functions --- +function parseFtLockups(ftString: string) { + if (!ftString || ftString.trim() === "-" || ftString.trim() === "") return []; + + const cleanStr = ftString.replace(/^"+|"+$/g, ""); + const lines = cleanStr + .split(/\r?\n|(?=Token:)/g) + .map((l) => l.trim()) + .filter(Boolean); + + return lines.map((line) => { + const parts = line.split("|").map((p) => p.trim()); + const symbol = parts[0]?.split(":")[1]?.trim() || ""; + const usdValue = parts[2] + ? Number(parts[2].replace("USD Value: $", "").trim()) + : 0; + return { symbol, usdValue }; + }); +} + +function cleanNumber(value: any) { + if (value === null || value === undefined) return "0"; + const cleaned = String(value).replace(/[^0-9.-]/g, ""); + return cleaned === "" ? "0" : cleaned; +} + +const toBig = (v: any) => Big(cleanNumber(v)); +const toInt = (v: any) => parseInt(cleanNumber(v), 10) || 0; + +function parseSheetData(rows: any[], sheetType: string) { + if (!rows || rows.length === 0) return []; + + const headerRowIndex = rows.findIndex((row: any) => { + const filledCells = row.filter( + (cell: any) => cell && cell.trim() !== "" + ).length; + return ( + filledCells > 1 && + row.some((cell: any) => + [ + "Created At", + "Treasury URL", + "Lockup Account", + "Payment Proposals", + ].includes(cell) + ) + ); + }); + if (headerRowIndex === -1) return []; + + const headers = rows[headerRowIndex]; + const dataStartIndex = headerRowIndex + 1; + + const dataRows = rows + .slice(dataStartIndex) + .filter((row: any) => row[0] && row[0].toUpperCase() !== "TOTAL"); + + const numericFields = + sheetType === "holdings" + ? [ + "DAO Assets (USD)", + "Lockup Assets (USD)", + "NEAR Lockup (USD)", + "Total Assets (USD)", + ] + : [ + "Tokens Paid Value (USD)", + "Asset Exchange Value (USD)", + "Lockup Value (USD)", + "Total Transactions Value (USD)", + "Amount (NEAR)", + "Lockup Amount (NEAR)", + ]; + + return dataRows.map((row: any) => + headers.reduce((acc: any, header: any, idx: number) => { + let value = row[idx] || null; + if (numericFields.includes(header) && value) value = cleanNumber(value); + acc[header] = value; + return acc; + }, {}) + ); +} + +const monthsToCheck = [ + "April 2025", + "May 2025", + "June 2025", + "July 2025", + "August 2025", +]; + +export async function calculateInsights(month: string) { + const sheets = await authenticateGoogleSheets(); + const holdingsTitle = `${month} Metrics Report`; + const transactionsTitle = `${month} Txn Report`; + + const holdings = await readSheetData( + sheets, + spreadsheetId, + holdingsTitle, + "holdings" + ); + const transactions = await readSheetData( + sheets, + spreadsheetId, + transactionsTitle, + "transactions" + ); + + if (!holdings.length || !transactions.length) + throw new Error("Missing data!"); + + // --- Holdings Calculations --- + const totalDaoAssets = holdings.reduce( + (sum: any, h: any) => sum.plus(toBig(h["DAO Assets (USD)"])), + Big(0) + ); + + // Fix: Include both NEAR Lockup and FT Lockups in total lockup assets + const totalLockupAssets = holdings.reduce((sum: any, h: any) => { + // Check for both possible column names + const nearLockupValue = h["NEAR Lockup (USD)"] || h["Lockup Assets (USD)"] || "0"; + const nearLockup = toBig(nearLockupValue); + const ftLockups = parseFtLockups(h["FT Lockups"]); + const ftLockupTotal = ftLockups.reduce((acc, t) => acc + t.usdValue, 0); + return sum.plus(nearLockup).plus(ftLockupTotal); + }, Big(0)); + + const totalAssets = totalDaoAssets.plus(totalLockupAssets); + + // Fix: Count treasuries created in the specific month + const totalTreasuriesThisMonth = holdings.filter((h: any) => + isInCurrentMonth(h["Created At"], month) + ).length; + + // --- Top 5 Treasuries --- + const topTreasuries = holdings + .map((h: any) => { + return { + treasuryUrl: h["Treasury URL"], + value: h["Total Assets (USD)"], + }; + }) + .sort((a, b) => { + // Convert to numbers for sorting + const aVal = parseFloat(cleanNumber(a.value)); + const bVal = parseFloat(cleanNumber(b.value)); + return bVal - aVal; + }) + .slice(0, 5) + .map((t) => { + // Convert to currency format and add line break + const numericValue = parseFloat(cleanNumber(t.value)); + const currencyValue = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(numericValue); + return `${t.treasuryUrl} (${currencyValue})`; + }) + .join("\n\n"); // Add line breaks between treasuries + + // --- Transactions Calculations --- + const proposalCols = [ + "Payment Proposals", + "Asset Exchange Proposals", + "Stake Delegation Proposals", + "Lockup Proposals", + ]; + const totalTransactionsCount = transactions.reduce( + (sum: number, row: any) => + sum + proposalCols.reduce((s, col) => s + toInt(row[col]), 0), + 0 + ); + const totalTransactionsValue = transactions.reduce( + (sum: any, row: any) => + sum.plus(toBig(row["Total Transactions Value (USD)"])), + Big(0) + ); + + // --- Transaction Share Calculations --- + const transactionShares = { + payment: transactions.reduce( + (sum: number, row: any) => sum + toInt(row["Payment Proposals"]), + 0 + ), + exchange: transactions.reduce( + (sum: number, row: any) => sum + toInt(row["Asset Exchange Proposals"]), + 0 + ), + stake: transactions.reduce( + (sum: number, row: any) => sum + toInt(row["Stake Delegation Proposals"]), + 0 + ), + lockup: transactions.reduce( + (sum: number, row: any) => sum + toInt(row["Lockup Proposals"]), + 0 + ), + }; + + const transactionSharePercentages = { + payment: + totalTransactionsCount > 0 + ? ((transactionShares.payment / totalTransactionsCount) * 100).toFixed( + 1 + ) + : "0.0", + exchange: + totalTransactionsCount > 0 + ? ((transactionShares.exchange / totalTransactionsCount) * 100).toFixed( + 1 + ) + : "0.0", + stake: + totalTransactionsCount > 0 + ? ((transactionShares.stake / totalTransactionsCount) * 100).toFixed(1) + : "0.0", + lockup: + totalTransactionsCount > 0 + ? ((transactionShares.lockup / totalTransactionsCount) * 100).toFixed(1) + : "0.0", + }; + + // --- Top 5 Transaction Tokens by USD Value --- + const tokenUsdTransactions: Record = {}; + + transactions.forEach((row: any) => { + // 1. Tokens Paid - parse and add USD values + const tokensPaid = parseFtLockups(row["Tokens Paid"]); + tokensPaid.forEach((token) => { + if (token.symbol && token.symbol !== "undefined" && token.usdValue > 0) { + tokenUsdTransactions[token.symbol] = + (tokenUsdTransactions[token.symbol] || 0) + token.usdValue; + } + }); + + // 2. Stake Delegation - NEAR tokens (use Amount (NEAR) and Value (USD)) + if (row["Amount (NEAR)"] && row["Value (USD)"]) { + const stakeAmount = Number(cleanNumber(row["Amount (NEAR)"])); + const stakeValue = Number(cleanNumber(row["Value (USD)"])); + if ( + !isNaN(stakeAmount) && + !isNaN(stakeValue) && + stakeAmount > 0 && + stakeValue > 0 + ) { + tokenUsdTransactions["NEAR"] = + (tokenUsdTransactions["NEAR"] || 0) + stakeValue; + } + } + + // 3. Lockup Proposals - NEAR tokens (use Lockup Amount (NEAR) and Lockup Value (USD)) + if (row["Lockup Amount (NEAR)"] && row["Lockup Value (USD)"]) { + const lockupAmount = Number(cleanNumber(row["Lockup Amount (NEAR)"])); + const lockupValue = Number(cleanNumber(row["Lockup Value (USD)"])); + if ( + !isNaN(lockupAmount) && + !isNaN(lockupValue) && + lockupAmount > 0 && + lockupValue > 0 + ) { + tokenUsdTransactions["NEAR"] = + (tokenUsdTransactions["NEAR"] || 0) + lockupValue; + } + } + }); + + const topTokens = Object.entries(tokenUsdTransactions) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([token, usd]) => ({ token, usd })); + + // --- FT Lockups & NEAR Lockups --- + let totalFTUsd = Big(0); + let totalFTLockupCount = 0; + let totalNearLockupsCount = 0; + + holdings.forEach((h: any) => { + const fts = parseFtLockups(h["FT Lockups"]); + totalFTUsd = totalFTUsd.plus(fts.reduce((acc, t) => acc + t.usdValue, 0)); + totalFTLockupCount += fts.length; + if (h["Lockup Account"] && h["Lockup Account"] !== "-") + totalNearLockupsCount += 1; + }); + + // --- Top 5 Tokens Held by USD Value --- + const tokenUsdHoldings: Record = {}; + + // Fetch NEAR price from the internal API + let nearPrice = 2.5; // fallback price + try { + const nearPriceResponse = await fetch( + "http://localhost:3000/api/near-price" + ); + if (nearPriceResponse.ok) { + nearPrice = await nearPriceResponse.json(); + } + } catch (error) { + console.log("Failed to fetch NEAR price, using fallback:", error); + } + + holdings.forEach((h: any) => { + // 1. NEAR tokens - fetch price and calculate USD value + if (h["NEAR"]) { + const nearAmount = Number(cleanNumber(h["NEAR"])); + if (!isNaN(nearAmount) && nearAmount > 0) { + const nearUsdValue = nearAmount * nearPrice; + tokenUsdHoldings["NEAR"] = + (tokenUsdHoldings["NEAR"] || 0) + nearUsdValue; + } + } + + // 2. USDC - 1:1 with USD + if (h["USDC"]) { + const usdcAmount = Number(cleanNumber(h["USDC"])); + if (!isNaN(usdcAmount) && usdcAmount > 0) { + tokenUsdHoldings["USDC"] = (tokenUsdHoldings["USDC"] || 0) + usdcAmount; + } + } + + // 3. USDt - 1:1 with USD + if (h["USDt"]) { + const usdtAmount = Number(cleanNumber(h["USDt"])); + if (!isNaN(usdtAmount) && usdtAmount > 0) { + tokenUsdHoldings["USDT"] = (tokenUsdHoldings["USDT"] || 0) + usdtAmount; + } + } + + // 4. FT Tokens - parse and add USD values + const ftTokens = parseFtLockups(h["FT Tokens"]); + ftTokens.forEach((ft) => { + if (ft.symbol && ft.symbol !== "undefined" && ft.usdValue > 0) { + tokenUsdHoldings[ft.symbol] = + (tokenUsdHoldings[ft.symbol] || 0) + ft.usdValue; + } + }); + + // 5. Intents Tokens - parse and add USD values + const intentsTokens = parseFtLockups(h["Intents Tokens"]); + intentsTokens.forEach((token) => { + if (token.symbol && token.symbol !== "undefined" && token.usdValue > 0) { + tokenUsdHoldings[token.symbol] = + (tokenUsdHoldings[token.symbol] || 0) + token.usdValue; + } + }); + }); + + const topTokensHeld = Object.entries(tokenUsdHoldings) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([token, usd]) => ({ token, usd })); + + // --- Top 5 Tokens Held Through Intents --- + const intentsTokenHoldings: Record = {}; + + holdings.forEach((h: any) => { + const intentsTokens = parseFtLockups(h["Intents Tokens"]); + intentsTokens.forEach((token) => { + if (token.symbol && token.symbol !== "undefined" && token.usdValue > 0) { + intentsTokenHoldings[token.symbol] = + (intentsTokenHoldings[token.symbol] || 0) + token.usdValue; + } + }); + }); + + const topIntentsTokens = Object.entries(intentsTokenHoldings) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([token, usd]) => ({ token, usd })); + + // --- Top 5 Active Treasuries (by transaction count) --- + const activeTreasuries = transactions + .map((row: any) => { + const totalTxnCount = proposalCols.reduce( + (sum, col) => sum + toInt(row[col]), + 0 + ); + return { + treasuryUrl: row["Treasury URL"], + transactionCount: totalTxnCount, + }; + }) + .sort((a, b) => b.transactionCount - a.transactionCount) + .slice(0, 5) + .map((t) => `${t.treasuryUrl} (${t.transactionCount} transactions)`) + .join("\n\n"); + + // --- Transaction Count Ranges --- + const transactionRanges = { + zeroToFive: transactions.filter((row: any) => { + const count = proposalCols.reduce((sum, col) => sum + toInt(row[col]), 0); + return count >= 0 && count <= 5; + }).length, + fiveToTen: transactions.filter((row: any) => { + const count = proposalCols.reduce((sum, col) => sum + toInt(row[col]), 0); + return count > 5 && count <= 10; + }).length, + moreThanTen: transactions.filter((row: any) => { + const count = proposalCols.reduce((sum, col) => sum + toInt(row[col]), 0); + return count > 10; + }).length, + }; + + // --- Transactions by USD Amount (ranges) --- + const transactionsByUsd = { + under1k: transactions.filter((row: any) => { + const value = parseFloat( + cleanNumber(row["Total Transactions Value (USD)"]) + ); + return value > 0 && value < 1000; + }).length, + oneToTenK: transactions.filter((row: any) => { + const value = parseFloat( + cleanNumber(row["Total Transactions Value (USD)"]) + ); + return value >= 1000 && value < 10000; + }).length, + tenToHundredK: transactions.filter((row: any) => { + const value = parseFloat( + cleanNumber(row["Total Transactions Value (USD)"]) + ); + return value >= 10000 && value < 100000; + }).length, + overHundredK: transactions.filter((row: any) => { + const value = parseFloat( + cleanNumber(row["Total Transactions Value (USD)"]) + ); + return value >= 100000; + }).length, + }; + + // --- Treasury Lists by Transaction Count --- + const treasuriesByTransactionCount = { + moreThanTen: transactions + .filter((row: any) => { + const count = proposalCols.reduce( + (sum, col) => sum + toInt(row[col]), + 0 + ); + return count > 10; + }) + .map((row: any) => { + const count = proposalCols.reduce( + (sum, col) => sum + toInt(row[col]), + 0 + ); + return `${row["Treasury URL"]} (${count} transactions)`; + }), + }; + + // --- Treasury Lists by USD Value --- + const treasuriesByUsdValue = { + overHundredK: transactions + .filter((row: any) => { + const value = parseFloat( + cleanNumber(row["Total Transactions Value (USD)"]) + ); + return value >= 100000; + }) + .map((row: any) => { + const value = parseFloat( + cleanNumber(row["Total Transactions Value (USD)"]) + ); + const currencyValue = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); + return `${row["Treasury URL"]} (${currencyValue})`; + }), + }; + + // --- Total Treasuries Count --- + const totalTreasuries = holdings.length; + + return { + totalTreasuriesThisMonth, + totalDaoAssets: totalDaoAssets.toFixed(2), + totalLockupAssets: totalLockupAssets.toFixed(2), + totalAssets: totalAssets.toFixed(2), + topTreasuries, + totalTransactionsCount, + totalTransactionsValue: totalTransactionsValue.toFixed(2), + transactionShares, + transactionSharePercentages, + topTokens, + totalFTUsd: totalFTUsd.toFixed(2), + totalFTLockupCount, + totalNearLockupsCount, + topTokensHeld, + topIntentsTokens, + activeTreasuries, + transactionRanges, + transactionsByUsd, + totalTreasuries, + treasuriesByTransactionCount, + treasuriesByUsdValue, + }; +} + +// Create a new function to calculate insights for all months +export async function calculateMultiMonthInsights() { + const allInsights = []; + + for (const month of monthsToCheck) { + try { + const insights = await calculateInsights(month); + allInsights.push({ + month, + ...insights, + }); + } catch (error) { + console.log(`No data found for ${month}:`, (error as Error).message); + // Add empty data for missing months + allInsights.push({ + month, + totalTreasuries: 0, + totalTreasuriesThisMonth: 0, + totalDaoAssets: "0.00", + totalLockupAssets: "0.00", + totalAssets: "0.00", + topTreasuries: "", + totalTransactionsCount: 0, + totalTransactionsValue: "0.00", + transactionShares: { payment: 0, exchange: 0, stake: 0, lockup: 0 }, + transactionSharePercentages: { + payment: "0.0", + exchange: "0.0", + stake: "0.0", + lockup: "0.0", + }, + topTokens: [], + totalFTUsd: "0.00", + totalFTLockupCount: 0, + totalNearLockupsCount: 0, + topTokensHeld: [], + topIntentsTokens: [], + activeTreasuries: "", + transactionRanges: { zeroToFive: 0, fiveToTen: 0, moreThanTen: 0 }, + transactionsByUsd: { + under1k: 0, + oneToTenK: 0, + tenToHundredK: 0, + overHundredK: 0, + }, + treasuriesByTransactionCount: { moreThanTen: [] }, + treasuriesByUsdValue: { overHundredK: [] }, + }); + } + } + + return allInsights; +} + +// Fix the isInCurrentMonth function to accept month parameter +function isInCurrentMonth(dateStr: string, monthToCheck: string) { + if (!dateStr || dateStr.trim() === "") return false; + + const [monthName, yearStr] = monthToCheck.split(" "); + const year = parseInt(yearStr, 10); + const monthIndex = new Date(`${monthName} 1, ${year}`).getMonth(); + + const startOfMonth = new Date(year, monthIndex, 1); + const endOfMonth = new Date(year, monthIndex + 1, 0); + + // Try to parse the date string + let date: Date; + try { + date = new Date(dateStr); + // Check if the date is valid + if (isNaN(date.getTime())) { + console.log(`Invalid date format: ${dateStr}`); + return false; + } + } catch (error) { + console.log(`Error parsing date: ${dateStr}`, error); + return false; + } + + return date >= startOfMonth && date <= endOfMonth; +} + +// Create separate focused tables +export async function updateDashboardSheet() { + const allInsights = await calculateMultiMonthInsights(); + const sheets = await authenticateGoogleSheets(); + + // Helper function to format currency + const formatCurrency = (value: string | number) => { + const numericValue = typeof value === "string" ? parseFloat(value) : value; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(numericValue); + }; + + // 1. Treasury Overview Table + await createTreasuryOverviewTable(sheets, allInsights, formatCurrency); + + // 2. Asset Holdings Table + await createAssetHoldingsTable(sheets, allInsights, formatCurrency); + + // 3. Transaction Activity Table + await createTransactionActivityTable(sheets, allInsights, formatCurrency); + + // 4. Top Performers Table + await createTopPerformersTable(sheets, allInsights, formatCurrency); + + // 5. Transaction Value Analysis Table + await createTransactionValueTable(sheets, allInsights, formatCurrency); +} + +// 1. Treasury Overview Table +async function createTreasuryOverviewTable(sheets: any, allInsights: any[], formatCurrency: Function) { + const sheetTitle = "Treasury Overview"; + + const values = [ + ["Metric", "April 2025", "May 2025", "June 2025", "July 2025", "August 2025"], + ["Total Treasuries", ...allInsights.map(i => i.totalTreasuries)], + ["Treasuries Created This Month", ...allInsights.map(i => i.totalTreasuriesThisMonth)], + ["Number of NEAR Lockup Accounts", ...allInsights.map(i => i.totalNearLockupsCount)], + ["Number of FT Lockups", ...allInsights.map(i => i.totalFTLockupCount)], + ]; + + await createOrUpdateSheet(sheets, sheetTitle, values); +} + +// 2. Asset Holdings Table +async function createAssetHoldingsTable(sheets: any, allInsights: any[], formatCurrency: Function) { + const sheetTitle = "Asset Holdings"; + + const values = [ + ["Metric", "April 2025", "May 2025", "June 2025", "July 2025", "August 2025"], + ["DAO Assets (USD)", ...allInsights.map(i => formatCurrency(i.totalDaoAssets))], + ["NEAR Lockup Assets (USD)", ...allInsights.map(i => formatCurrency(i.totalLockupAssets))], + ["FT Lockup Assets (USD)", ...allInsights.map(i => formatCurrency(i.totalFTUsd))], + ["Total Treasury Assets (USD)", ...allInsights.map(i => formatCurrency(i.totalAssets))], + ]; + + await createOrUpdateSheet(sheets, sheetTitle, values); +} + +// 3. Transaction Activity Table +async function createTransactionActivityTable(sheets: any, allInsights: any[], formatCurrency: Function) { + const sheetTitle = "Transaction Activity"; + + const values = [ + ["Metric", "April 2025", "Apr Details", "May 2025", "May Details", "June 2025", "Jun Details", "July 2025", "Jul Details", "August 2025", "Aug Details"], + ["Total Transactions", ...allInsights.flatMap(i => [i.totalTransactionsCount, ""])], + ["Payment Proposals", ...allInsights.flatMap(i => [`${i.transactionShares.payment} (${i.transactionSharePercentages.payment}%)`, ""])], + ["Asset Exchange Proposals", ...allInsights.flatMap(i => [`${i.transactionShares.exchange} (${i.transactionSharePercentages.exchange}%)`, ""])], + ["Stake Delegation Proposals", ...allInsights.flatMap(i => [`${i.transactionShares.stake} (${i.transactionSharePercentages.stake}%)`, ""])], + ["Lockup Proposals", ...allInsights.flatMap(i => [`${i.transactionShares.lockup} (${i.transactionSharePercentages.lockup}%)`, ""])], + ["Treasuries with 0-5 Transactions", ...allInsights.flatMap(i => [i.transactionRanges.zeroToFive, ""])], + ["Treasuries with 5-10 Transactions", ...allInsights.flatMap(i => [i.transactionRanges.fiveToTen, ""])], + ["Treasuries with 10+ Transactions", ...allInsights.flatMap(i => [i.transactionRanges.moreThanTen, i.treasuriesByTransactionCount.moreThanTen.join("\n\n")])], + ]; + + await createOrUpdateSheet(sheets, sheetTitle, values); +} + +// 4. Top Performers Table +async function createTopPerformersTable(sheets: any, allInsights: any[], formatCurrency: Function) { + const sheetTitle = "Top Performers"; + + const values = [ + ["Category", "April 2025", "May 2025", "June 2025", "July 2025", "August 2025"], + ["Top 5 Treasuries by Assets", ...allInsights.map(i => i.topTreasuries)], + [], + ["Top 5 Active Treasuries", ...allInsights.map(i => i.activeTreasuries)], + [], + ["Top 5 Transacting Tokens", ...allInsights.map(i => + i.topTokens.map((t: any) => `${t.token} (${formatCurrency(t.usd)})`).join("\n\n") + )], + [], + ["Top 5 Tokens Held", ...allInsights.map(i => + i.topTokensHeld.map((t: any) => `${t.token} (${formatCurrency(t.usd)})`).join("\n\n") + )], + [], + ["Top 5 Intents Tokens", ...allInsights.map(i => + i.topIntentsTokens.map((t: any) => `${t.token} (${formatCurrency(t.usd)})`).join("\n\n") + )], + ]; + + await createOrUpdateSheet(sheets, sheetTitle, values); +} + +// 5. Transaction Value Analysis Table +async function createTransactionValueTable(sheets: any, allInsights: any[], formatCurrency: Function) { + const sheetTitle = "Transaction Value Analysis"; + + const values = [ + ["Metric", "April 2025", "Apr Details", "May 2025", "May Details", "June 2025", "Jun Details", "July 2025", "Jul Details", "August 2025", "Aug Details"], + ["Total Transaction Value (USD)", ...allInsights.flatMap(i => [formatCurrency(i.totalTransactionsValue), ""])], + ["Treasuries Under $1K", ...allInsights.flatMap(i => [i.transactionsByUsd.under1k, ""])], + ["Treasuries $1K-$10K", ...allInsights.flatMap(i => [i.transactionsByUsd.oneToTenK, ""])], + ["Treasuries $10K-$100K", ...allInsights.flatMap(i => [i.transactionsByUsd.tenToHundredK, ""])], + ["Treasuries Over $100K", ...allInsights.flatMap(i => [i.transactionsByUsd.overHundredK, i.treasuriesByUsdValue.overHundredK.join("\n\n")])], + ]; + + await createOrUpdateSheet(sheets, sheetTitle, values); +} + +// Helper function to create or update a sheet +async function createOrUpdateSheet(sheets: any, sheetTitle: string, values: any[][]) { + const sheetId = await getOrCreateSheetByTitle( + sheets, + writeSpreadsheet, + sheetTitle + ); + + // Only add formatting requests if we have values to format + const requests: any[] = []; + + // Add basic formatting for the header row + if (values.length > 0) { + requests.push({ + repeatCell: { + range: { + sheetId, + startRowIndex: 0, + endRowIndex: 1, + }, + cell: { + userEnteredFormat: { + textFormat: { bold: true }, + horizontalAlignment: "CENTER", + verticalAlignment: "MIDDLE", + }, + }, + fields: "userEnteredFormat(textFormat,horizontalAlignment,verticalAlignment)", + }, + }); + } + + console.log(`Creating/updating sheet: ${sheetTitle}`); + + // If we have requests, use batchUpdate, otherwise just update values + if (requests.length > 0) { + await updateSheet(sheets, writeSpreadsheet, values, requests, sheetTitle); + } else { + // Just update values without formatting + await sheets.spreadsheets.values.update({ + spreadsheetId: writeSpreadsheet, + range: `'${sheetTitle}'!A1:Z`, + valueInputOption: "USER_ENTERED", + requestBody: { values }, + }); + } + + console.log(`✅ Sheet "${sheetTitle}" updated successfully!`); +} diff --git a/src/utils/lib.ts b/src/utils/lib.ts index 1901b18..5c6d88e 100644 --- a/src/utils/lib.ts +++ b/src/utils/lib.ts @@ -318,13 +318,22 @@ export async function getUserStakeBalances( } ); - const stakedPools = (data?.pools ?? [])?.map((i: any) => i.pool_id); + const { data: stakingData } = await axios.get( + `https://staking-pools-api.neartreasury.com/v1/account/${account_id}/staking` + ); + + // Combine pools from both API calls and get unique pool IDs + const fastnearPools = (data?.pools ?? []).map((i: any) => i.pool_id); + const treasuryPools = (stakingData?.pools ?? []).map((i: any) => i.pool_id); + + const uniqueStakedPools = [...new Set([...fastnearPools, ...treasuryPools])]; + const results: number[] = new Array(blockHeights.length).fill(0); // Store total balance per blockHeight await Promise.all( blockHeights.map(async (block_id, index) => { const balances = await Promise.all( - stakedPools.map(async (pool: string) => { + uniqueStakedPools.map(async (pool: string) => { const cacheKey = `${account_id}-${block_id}-${pool}`; const cachedBalance = cache.get(cacheKey);