diff --git a/.github/workflows/production-check.yml b/.github/workflows/production-check.yml deleted file mode 100644 index ca8738e..0000000 --- a/.github/workflows/production-check.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Twice Weekly Production API Tests - -on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 9 * * 1" # Every Monday at 09:00 UTC - - cron: "0 9 * * 4" # Every Thursday at 09:00 UTC - workflow_dispatch: # Allow manual trigger from GitHub UI - -jobs: - production-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: "18" - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Run Production Tests - run: npm test test/production.test.ts - - - name: Report Success - if: success() - run: echo "Production test suite completed." - - - name: Report Failure - if: failure() - run: echo "Production test suite failed!" diff --git a/README.md b/README.md index b5c8900..2176af8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# NEAR Token Swap API +# NEAR Treasury & Token API -A RESTful API service for token swaps and blockchain metadata on the NEAR network, built with Express.js and TypeScript. It leverages caching, rate limiting, and secure request handling to provide a robust service for interacting with NEAR blockchain data. +A comprehensive RESTful API service for NEAR blockchain interactions, treasury management, token operations, and validator information. Built with Express.js and TypeScript, featuring robust caching, rate limiting, and secure request handling. --- @@ -12,223 +12,335 @@ A RESTful API service for token swaps and blockchain metadata on the NEAR networ - [Environment Variables](#environment-variables) - [Running the Server](#running-the-server) - [API Endpoints](#api-endpoints) - - [Get Token Metadata](#get-token-metadata) - - [Whitelist Tokens](#whitelist-tokens) - - [Token Swap](#token-swap) - - [Get NEAR Price](#get-near-price) - - [Fetch FT Tokens](#fetch-ft-tokens) - - [Get All Token Balance History](#get-all-token-balance-history) - - [Clear Token Balance History](#clear-token-balance-history) - - [Transactions Transfer History](#transactions-transfer-history) + - [Token Operations](#token-operations) + - [Balance & History](#balance--history) + - [Treasury Management](#treasury-management) + - [Validator Information](#validator-information) + - [Search & Discovery](#search--discovery) + - [OneClick Treasury](#oneclick-treasury) - [Caching & Rate Limiting](#caching--rate-limiting) -- [RPC Requests & Fallback Logic](#rpc-requests--fallback-logic) +- [Testing](#testing) - [License](#license) --- ## Features -- **Token Metadata Retrieval:** Get metadata for any token from a pre-defined list. -- **Whitelist Tokens:** Retrieve tokens with associated balances and prices for a given account. -- **Token Swap Functionality:** Execute swap operations with validation and default slippage. -- **NEAR Price Retrieval:** Fetch the NEAR token price via external APIs with a database fallback. -- **FT Tokens Endpoint:** Retrieve fungible token balances with caching. -- **Token Balance History:** Get historical balance data bundled by period. -- **Clear Balance History:** Delete all token balance history entries from the database. -- **Transactions Transfer History:** Retrieve transfer history information from transactions. -- **Rate Limiting & CORS:** Protect endpoints using rate limits and CORS. -- **Security:** Utilize Helmet for secure HTTP headers. -- **RPC Caching:** Use internal caching and fallback mechanisms for RPC requests. +- **Token Operations:** Swap tokens, fetch metadata, prices, and balances +- **Balance History:** Historical token balance tracking with multiple time periods +- **Treasury Management:** DAO treasury analytics, reports, and transaction history +- **Validator Information:** Current validators list and detailed validator data +- **Search & Discovery:** Search for tokens and discover user DAOs +- **OneClick Treasury:** Simplified treasury proposal creation for cross-chain operations +- **Intents Balance History:** Track token balances held through intents contracts +- **Security & Performance:** Rate limiting, CORS, caching, and secure HTTP headers +- **Database Integration:** PostgreSQL with Prisma ORM for data persistence --- ## Requirements -- [Node.js](https://nodejs.org/en/) (v14+ recommended) +- [Node.js](https://nodejs.org/en/) (v16+ recommended) - [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/) -- A running PostgreSQL database instance +- PostgreSQL database instance +- API keys for external services (NEARBLOCKS, FASTNEAR, PIKESPEAK) --- ## Installation 1. **Clone the repository:** - ```bash - git clone https://github.com/your-username/near-token-swap-api.git - cd near-token-swap-api + git clone https://github.com/your-username/ref-sdk-api.git + cd ref-sdk-api ``` 2. **Install dependencies:** - ```bash - npm install + yarn install ``` 3. **Setup environment variables:** - - Copy the provided `.env.example` file to `.env` and configure the variables accordingly. - ```bash cp .env.example .env + # Edit .env with your configuration ``` -4. **Run migrations (if using Prisma):** - +4. **Run database migrations:** ```bash - npx prisma migrate dev + npx prisma migrate deploy + npx prisma generate ``` --- ## Environment Variables -Ensure you have a `.env` file with the following variables configured: - -``` +```env HOSTNAME=127.0.0.1 PORT=3000 -PIKESPEAK_KEY= -DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" -FASTNEAR_API_KEY= -NEARBLOCKS_API_KEY= +DATABASE_URL="postgresql://user:password@localhost:5432/ref_sdk_api?schema=public" +NEARBLOCKS_API_KEY=your_nearblocks_api_key +FASTNEAR_API_KEY=your_fastnear_api_key +PIKESPEAK_KEY=your_pikespeak_api_key ``` --- ## Running the Server -Start the server with: - ```bash -npm start +# Development +yarn dev + +# Production +yarn build +yarn start ``` -By default, the server will run on `http://127.0.0.1:3000` (or as specified by the `HOSTNAME` and `PORT` variables). +Server runs on `http://127.0.0.1:3000` by default. --- ## API Endpoints -### Whitelist Tokens +### Token Operations +#### Get Whitelist Tokens - **Endpoint:** `GET /api/whitelist-tokens` -- **Optional Query Parameter:** - - `account` (string): The NEAR account id to filter tokens. -- **Response:** JSON object containing whitelisted tokens along with balances and prices. +- **Description:** Returns whitelisted tokens with balances and prices for a specific account +- **Query Parameters:** + - `account` (string, optional): NEAR account ID to fetch token balances for +- **Response:** Array of token objects with balance and price information - **Example:** - ```http GET /api/whitelist-tokens?account=example.near ``` ---- - -### Token Swap - +#### Token Swap - **Endpoint:** `GET /api/swap` -- **Required Query Parameters:** - - `accountId` (string): The account executing the swap. - - `tokenIn` (string): The ID of the token to swap from. - - `tokenOut` (string): The token to swap to. - - `amountIn` (string): The amount of `tokenIn` being swapped. -- **Optional Query Parameter:** - - `slippage` (string): The allowable slippage (default: "0.01" for 1%). -- **Response:** JSON object containing swap details or error information. +- **Description:** Generate swap transactions for token exchanges +- **Query Parameters:** + - `accountId` (string, required): Account executing the swap + - `tokenIn` (string, required): Input token contract ID + - `tokenOut` (string, required): Output token contract ID + - `amountIn` (string, required): Amount of input token (in smallest units) + - `slippage` (string, optional): Slippage tolerance (default: "0.01" for 1%) +- **Response:** Swap transaction details and estimated output - **Example:** - ```http - GET /api/swap?accountId=example.near&tokenIn=near&tokenOut=usdt&amountIn=100&slippage=0.02 + GET /api/swap?accountId=example.near&tokenIn=wrap.near&tokenOut=usdt.tether-token.near&amountIn=1000000000000000000000000&slippage=0.01 ``` ---- - -### Get NEAR Price - +#### Get NEAR Price - **Endpoint:** `GET /api/near-price` -- **Description:** Retrieves the current NEAR token price. If external sources fail, it falls back to the latest price stored in the database. -- **Response:** NEAR price as a JSON value. +- **Description:** Current NEAR token price in USD with database fallback +- **Response:** Number (price in USD) - **Example:** - ```http GET /api/near-price ``` ---- +#### Get FT Token Price +- **Endpoint:** `GET /api/ft-token-price` +- **Description:** Get price for a specific fungible token +- **Query Parameters:** + - `account_id` (string, required): Token contract ID (use "near" for NEAR token) +- **Response:** Object with price information +- **Example:** + ```http + GET /api/ft-token-price?account_id=wrap.near + ``` -### Fetch FT Tokens +#### Get FT Token Metadata +- **Endpoint:** `GET /api/ft-token-metadata` +- **Description:** Fetch metadata for a fungible token +- **Query Parameters:** + - `account_id` (string, required): Token contract ID (use "near" for NEAR token) +- **Response:** Token metadata object (name, symbol, decimals, icon, etc.) +- **Example:** + ```http + GET /api/ft-token-metadata?account_id=wrap.near + ``` +#### Get FT Tokens - **Endpoint:** `GET /api/ft-tokens` +- **Description:** Get all fungible tokens held by an account with metadata and USD values - **Query Parameters:** - - `account_id` (string, required): The account id to fetch fungible token information. -- **Response:** JSON object with FT token details. + - `account_id` (string, required): NEAR account ID +- **Response:** Object with total USD value and array of tokens with metadata - **Example:** - ```http GET /api/ft-tokens?account_id=example.near ``` ---- - -### Get All Token Balance History +### Balance & History +#### Get Token Balance History - **Endpoint:** `GET /api/all-token-balance-history` +- **Description:** Historical balance data for a specific token across multiple time periods - **Query Parameters:** - - `account_id` (string, required): The account id whose token balance history is to be fetched. - - `token_id` (string, required): The token id for which balance history is required. - - `disableCache` (optional): When provided, bypasses the cached result (note: sensitive to frequent requests). -- **Response:** JSON object mapping each period to its corresponding balance history. + - `account_id` (string, required): NEAR account ID + - `token_id` (string, required): Token contract ID +- **Response:** Object mapping time periods to balance history arrays - **Example:** - ```http - GET /api/all-token-balance-history?account_id=example.near&token_id=near - GET /api/all-token-balance-history?account_id=example.near&token_id=near&disableCache=true + GET /api/all-token-balance-history?account_id=example.near&token_id=wrap.near ``` ---- +#### Get Intents Balance History +- **Endpoint:** `GET /api/intents-balance-history` +- **Description:** Historical balance data for tokens held through intents contracts +- **Query Parameters:** + - `account_id` (string, required): NEAR account ID +- **Response:** Object mapping time periods to intents token balance history +- **Example:** + ```http + GET /api/intents-balance-history?account_id=example.near + ``` -### Transactions Transfer History +### Treasury Management +#### Get Transactions Transfer History - **Endpoint:** `GET /api/transactions-transfer-history` +- **Description:** Transfer transaction history for a treasury DAO +- **Query Parameters:** + - `treasuryDaoID` (string, required): Treasury DAO contract ID +- **Response:** Object containing transfer history data +- **Example:** + ```http + GET /api/transactions-transfer-history?treasuryDaoID=example.sputnik-dao.near + ``` + +#### Store Treasuries +- **Endpoint:** `GET /db/store-treasuries` +- **Description:** Fetch and store treasury data from factory contract +- **Response:** Success message with operation results + +#### Insert Treasury +- **Endpoint:** `POST /db/insert-treasury` +- **Description:** Manually insert treasury data +- **Body:** Treasury object or array of treasury objects +- **Response:** Operation results for each treasury + +#### Treasuries Report +- **Endpoint:** `GET /db/treasuries-report` +- **Description:** Generate comprehensive treasury analytics report +- **Response:** Treasury metrics and Google Sheets update status + +#### Treasuries Transactions Report +- **Endpoint:** `GET /db/treasuries-transactions-report` +- **Description:** Generate treasury transaction analytics report +- **Response:** Transaction metrics and Google Sheets update status + +### Validator Information + +#### Get Validators +- **Endpoint:** `GET /api/validators` +- **Description:** List of current NEAR validators with formatted fee information +- **Response:** Array of validator objects with pool_id and fee percentage +- **Example:** + ```http + GET /api/validators + ``` + +#### Get Validator Details +- **Endpoint:** `GET /api/validator-details` +- **Description:** Detailed information for a specific validator +- **Query Parameters:** + - `account_id` (string, required): Validator account ID +- **Response:** Detailed validator information object +- **Example:** + ```http + GET /api/validator-details?account_id=validator.near + ``` + +### Search & Discovery + +#### Search FT Tokens +- **Endpoint:** `GET /api/search-ft` +- **Description:** Search for fungible tokens by name or symbol +- **Query Parameters:** + - `query` (string, required): Search term +- **Response:** First matching token object +- **Example:** + ```http + GET /api/search-ft?query=near + ``` + +#### Get User DAOs +- **Endpoint:** `GET /api/user-daos` +- **Description:** List of DAOs that a user is a member of - **Query Parameters:** - - `treasuryDaoID` (string, required): The treasury DAO ID to filter transfer transactions. -- **Response:** JSON object containing the transfer history data. + - `account_id` (string, required): NEAR account ID +- **Response:** Array of DAO contract IDs - **Example:** + ```http + GET /api/user-daos?account_id=example.near + ``` +### OneClick Treasury + +#### OneClick Quote +- **Endpoint:** `POST /api/treasury/oneclick-quote` +- **Description:** Generate treasury proposal for cross-chain token operations +- **Body Parameters:** + - `treasuryDaoID` (string, required): Treasury DAO contract ID (must end with .sputnik-dao.near) + - `inputToken` (object, required): Input token details + - `outputToken` (object, required): Output token details + - `amountIn` (string, required): Input amount + - `slippageTolerance` (string, required): Slippage tolerance + - `networkOut` (string, optional): Output network +- **Response:** Formatted proposal payload for DAO submission +- **Example:** ```http - GET /api/transactions-transfer-history?treasuryDaoID=dao.near + POST /api/treasury/oneclick-quote + Content-Type: application/json + + { + "treasuryDaoID": "example.sputnik-dao.near", + "inputToken": {"id": "wrap.near", "symbol": "WNEAR"}, + "outputToken": {"id": "usdc", "blockchain": "ethereum"}, + "amountIn": "1000000000000000000000000", + "slippageTolerance": "100" + } ``` --- ## Caching & Rate Limiting -- **Caching:** - - - The API uses [NodeCache](https://www.npmjs.com/package/node-cache) to store short-term responses (e.g., NEAR prices and token balance histories) to reduce external API calls and recomputation. - - RPC calls (in `src/utils/fetch-from-rpc.ts`) also cache responses and skip endpoints temporarily on rate-limited (HTTP 429) responses. +### Caching +- **NodeCache:** 2-minute TTL for most endpoints +- **Specialized caching:** Longer TTL for validator data (7 days) and search results (1 day) +- **RPC caching:** Request-based caching with error handling for rate limits -- **Rate Limiting:** - - All `/api/*` endpoints are limited to 180 requests per 30 seconds per IP (or forwarded IP when available). - - This helps protect against abuse and ensures service stability. +### Rate Limiting +- **Limit:** 180 requests per 30 seconds per IP +- **Scope:** All `/api/*` endpoints +- **Headers:** Standard rate limit headers included in responses --- -## RPC Requests & Fallback Logic +## Testing -- The API makes use of multiple RPC endpoints for querying the NEAR blockchain. -- In `src/utils/fetch-from-rpc.ts`, the request is: - - Cached based on a hash of the request body. - - Attempted sequentially across a list of primary or archival endpoints. - - The response is stored using Prisma if successful. - - In the event of a known error (e.g., non-existent account), the system caches the fact to avoid unnecessary calls. +```bash +# Run all tests +yarn test + +# Run specific test suites +yarn test test/server.test.ts # Unit tests (mocked) +yarn test test/server.integration.test.ts # Integration tests (local) +yarn test test/intents-graph.test.ts # Intents unit tests +yarn test test/intents-graph.integration.test.ts # Intents integration tests +``` --- ## License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for more details. +MIT License - see [LICENSE](LICENSE) for details. --- -_For additional questions or contributions, please open an issue or submit a PR on GitHub._ +_For questions or contributions, please open an issue or submit a PR on GitHub._ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b9164a5..f163b99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,19 @@ model TokenBalanceHistory { @@unique([account_id, token_id, period], name: "account_id_token_id_period") } +model IntentsBalanceHistory { + id String @id @default(uuid()) + account_id String + period String // e.g., "1Y", "1M", "1W", etc. + balance_history Json // Array of { timestamp, date, tokens: [{token_id, balance}], totalTokens } + fromBlock Int // oldest block in the history + toBlock Int // latest block stored for this period + timestamp DateTime @default(now()) + + @@index([account_id, period]) + @@unique([account_id, period], name: "account_id_period") +} + model RpcRequest { id String @id @default(uuid()) diff --git a/src/all-token-balance-history.ts b/src/all-token-balance-history.ts index 8ef3f87..8d4055d 100644 --- a/src/all-token-balance-history.ts +++ b/src/all-token-balance-history.ts @@ -4,53 +4,12 @@ import prisma from "./prisma"; import { tokens } from "./constants/tokens"; import { periodMap } from "./constants/period-map"; import { getUserStakeBalances } from "./utils/lib"; - -const BLOCKS_PER_HOUR = 3200; - -function formatLabel(timestamp: number, period: string): string { - const date = new Date(timestamp); - switch (period) { - case "1Y": - return date.toLocaleDateString("en-US", { - month: "short", - year: "numeric", - }); - - case "1M": - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - - case "1W": - return date.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - }); - - case "1D": - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - hour12: true, - }); - - case "1H": - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - - default: - return date.toLocaleDateString("en-US", { - month: "short", - year: "numeric", - }); - } -} +import { + BLOCKS_PER_HOUR, + formatLabel, + groupByPeriod, + BalanceHistoryCache, +} from "./utils/balance-history-common"; type BalanceHistoryEntry = { timestamp: number; @@ -58,27 +17,8 @@ type BalanceHistoryEntry = { balance: string; }; -type AllTokenBalanceHistoryCache = { - get: (key: string) => any; - set: (key: string, value: any, ttl?: number) => void; - del: (key: string) => void; -}; - -const groupByPeriod = (history: BalanceHistoryEntry[]) => { - const grouped = new Map(); - - for (const entry of history) { - const key = entry.date; - if (!grouped.has(key) || entry.timestamp > grouped.get(key)!.timestamp) { - grouped.set(key, entry); - } - } - - return Object.fromEntries(grouped); -}; - export async function getAllTokenBalanceHistory( - cache: AllTokenBalanceHistoryCache, + cache: BalanceHistoryCache, cacheKey: string, account_id: string, token_id: string diff --git a/src/intents-graph.ts b/src/intents-graph.ts new file mode 100644 index 0000000..5308a65 --- /dev/null +++ b/src/intents-graph.ts @@ -0,0 +1,335 @@ +import { fetchFromRPC } from "./utils/fetch-from-rpc"; +import prisma from "./prisma"; +import { periodMap } from "./constants/period-map"; +import axios from "axios"; +import Big from "big.js"; +import { + BLOCKS_PER_HOUR, + formatLabel, + groupByPeriod, + decodeUint8Array, + BalanceHistoryCache, +} from "./utils/balance-history-common"; + +const INTENTS_CONTRACT_ID = "intents.near"; + +async function getTokensMetadata() { + try { + const { data: tokensResponse } = await axios.get( + "https://api-mng-console.chaindefuser.com/api/tokens" + ); + return tokensResponse?.items || []; + } catch (error) { + console.error("Failed to fetch token metadata:", error); + return []; + } +} + +type IntentsBalanceEntry = { + timestamp: number; + date: string; + tokens: Array<{ + token_id: string; + symbol: string; + icon?: string; + balance: string; + parsedBalance: string; + }>; + totalTokens: number; +}; + +export async function getIntentsBalanceHistory( + cache: BalanceHistoryCache, + cacheKey: string, + account_id: string +): Promise> { + let rpcCallCount = 0; + + try { + const currentBlockData = await fetchFromRPC( + { + jsonrpc: "2.0", + id: 1, + method: "block", + params: { finality: "final" }, + }, + true, + false + ); + rpcCallCount++; + const currentBlock = currentBlockData.result.header.height; + + const tokensAccountHoldResp = await fetchFromRPC( + { + jsonrpc: "2.0", + id: "dontcare", + method: "query", + params: { + request_type: "call_function", + finality: "final", + account_id: INTENTS_CONTRACT_ID, + method_name: "mt_tokens_for_owner", + args_base64: Buffer.from( + JSON.stringify({ + account_id, + }) + ).toString("base64"), + }, + }, + false + ); + + if (!tokensAccountHoldResp?.result?.result) { + console.log(`Account ${account_id} has no intents tokens`); + return {}; // Return empty result if no tokens + } + + const tokensAccountHold = decodeUint8Array( + tokensAccountHoldResp.result.result + ); + + if (tokensAccountHold.length === 0) { + console.log(`Account ${account_id} has no intents tokens`); + return {}; // Return empty result if no tokens + } + + // Get token metadata + const tokensMetadata = await getTokensMetadata(); + const tokenMetadataMap = new Map( + tokensMetadata.map((token: any) => [token.defuse_asset_id, token]) + ); + + console.log( + `Account ${account_id} has intents tokens, fetching historical data...` + ); + + const existingHistories = await prisma.intentsBalanceHistory.findMany({ + where: { account_id }, + }); + + const existingMap = Object.fromEntries( + existingHistories + .filter((e: any) => Array.isArray(e.balance_history)) + .map((e: any) => [e.period, e]) + ); + + const allPeriodHistories = await Promise.all( + periodMap.map(async ({ period, value, interval }) => { + const useArchival = ["1Y", "1M", "1W", "All"].includes(period); + const hoursPerStep = value / interval; + const blocksPerStep = Math.floor(BLOCKS_PER_HOUR * hoursPerStep); + + const prev = existingMap[period]; + const lastStoredBlock = + typeof prev?.toBlock === "number" ? prev.toBlock : 0; + + if (currentBlock <= lastStoredBlock) { + console.log(`[${period}] No new blocks since last stored. Skipping.`); + return { + period, + data: Array.isArray(prev?.balance_history) + ? prev.balance_history + : [], + }; + } + + let totalSteps = Math.min( + interval, + Math.floor((currentBlock - lastStoredBlock) / blocksPerStep) + ); + + if (totalSteps <= 0) { + console.log(`[${period}] [${account_id}] No new steps to fetch.`); + totalSteps = 1; + } + + const blockHeights = Array.from( + { length: totalSteps }, + (_, i) => currentBlock - blocksPerStep * (totalSteps - 1 - i) + ).filter((block) => block > lastStoredBlock && block > 1_000_000); + + if (blockHeights.length === 0) { + console.log( + `[${period}] Filtered block heights are empty. Skipping.` + ); + return { + period, + data: Array.isArray(prev?.balance_history) + ? prev.balance_history + : [], + }; + } + + const timestamps = await Promise.all( + blockHeights.map(async (block_id) => { + const data = await fetchFromRPC( + { + jsonrpc: "2.0", + id: block_id, + method: "block", + params: { block_id }, + }, + false, + useArchival + ); + rpcCallCount++; + return data.result.header.timestamp / 1e6; + }) + ); + + // For each block, we'll check balances for tokens the account holds + const tokensByBlock = blockHeights.map(() => tokensAccountHold); + + // Get balances for all tokens at each block height + const balancesByBlock = await Promise.all( + blockHeights.map(async (block_id, index) => { + const tokensForBlock = tokensByBlock[index]; + if (!tokensForBlock || tokensForBlock.length === 0) { + return []; + } + + rpcCallCount++; + try { + const balancesResponse = await fetchFromRPC( + { + jsonrpc: "2.0", + id: "dontcare", + method: "query", + params: { + request_type: "call_function", + block_id, + account_id: INTENTS_CONTRACT_ID, + method_name: "mt_batch_balance_of", + args_base64: Buffer.from( + JSON.stringify({ + account_id, + token_ids: tokensForBlock.map((t: any) => t.token_id), + }) + ).toString("base64"), + }, + }, + false, + useArchival + ); + + const balancesResult = balancesResponse?.result?.result; + if (!balancesResult) return []; + + const balances = decodeUint8Array(balancesResult); + if (!balances) return []; + + // Combine token IDs with their balances and metadata + return tokensForBlock + .map((tokenInfo: any, i: number) => { + const balance = balances[i] || "0"; + const token_id = tokenInfo.token_id; + const tokenMeta = tokenMetadataMap.get(token_id) as any; + + if (Big(balance).eq(0)) { + return null; // Filter out tokens with zero balance + } + + const parsedBalance = Big(balance) + .div(Big(10).pow(tokenMeta?.decimals || 0)) + .toFixed(); + + return { + token_id, + symbol: tokenMeta?.symbol || tokenMeta?.name || token_id, + icon: tokenMeta?.icon || tokenMeta?.image, + balance, + parsedBalance, + }; + }) + .filter(Boolean); // Remove null entries + } catch (error) { + console.error( + `Error fetching balances for block ${block_id}:`, + error + ); + return []; + } + }) + ); + + const newHistory = blockHeights.map((_, index) => { + const tokens = balancesByBlock[index] || []; + const ts = timestamps[index]; + + return { + timestamp: ts, + date: formatLabel(ts, period), + tokens, + totalTokens: tokens.length, + }; + }); + + const groupedHistory = groupByPeriod(newHistory); + + const mergedHistory = [ + ...((prev?.balance_history as IntentsBalanceEntry[]) || []), + ...Object.values(groupedHistory), + ]; + + const finalHistory = Object.values(groupByPeriod(mergedHistory)).slice( + -interval + ); + + if (prev) { + prisma.intentsBalanceHistory + .update({ + where: { + account_id_period: { + account_id, + period, + }, + }, + data: { + balance_history: finalHistory, + toBlock: currentBlock, + }, + }) + .catch((e: any) => console.error("DB write failed:", e.message)); + } else { + prisma.intentsBalanceHistory + .create({ + data: { + account_id, + period, + balance_history: finalHistory, + fromBlock: blockHeights[0], + toBlock: currentBlock, + }, + }) + .catch((e: any) => console.error("DB write failed:", e.message)); + } + + return { period, data: finalHistory }; + }) + ); + + const resp = allPeriodHistories.reduce((acc, { period, data }) => { + acc[period] = data as IntentsBalanceEntry[]; + return acc; + }, {} as Record); + + cache.set(cacheKey, resp, 60 * 5); + console.log(`Total RPC calls made: ${rpcCallCount}`); + return resp; + } catch (err) { + console.error( + "Fatal error in intents balance history. Using DB fallback:", + err + ); + const fallback = await prisma.intentsBalanceHistory.findMany({ + where: { account_id }, + }); + + return fallback.reduce((acc: any, entry: any) => { + acc[entry.period] = Array.isArray(entry.balance_history) + ? entry.balance_history + : []; + return acc; + }, {} as Record); + } +} diff --git a/src/server.ts b/src/server.ts index 8160b30..bb9f73c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import { getSwap, SwapParams } from "./swap"; import { getNearPrice } from "./near-price"; import { getFTTokens } from "./ft-tokens"; import { getAllTokenBalanceHistory } from "./all-token-balance-history"; +import { getIntentsBalanceHistory } from "./intents-graph"; import { getTransactionsTransferHistory, TransferHistoryParams, @@ -184,6 +185,26 @@ app.get( } ); +app.get("/api/intents-balance-history", async (req: Request, res: Response) => { + const { account_id } = req.query; + + if (!account_id || typeof account_id !== "string") { + return res.status(400).json({ + error: "Missing required parameter: account_id", + }); + } + + const cacheKey = `intents:${account_id}`; + + try { + const result = await getIntentsBalanceHistory(cache, cacheKey, account_id); + return res.json(result); + } catch (error) { + console.error("Unhandled error in /api/intents-balance-history:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}); + app.get( "/api/transactions-transfer-history", async (req: Request, res: Response) => { diff --git a/src/utils/balance-history-common.ts b/src/utils/balance-history-common.ts new file mode 100644 index 0000000..b830dd3 --- /dev/null +++ b/src/utils/balance-history-common.ts @@ -0,0 +1,119 @@ +export const BLOCKS_PER_HOUR = 3200; + +export function formatLabel(timestamp: number, period: string): string { + const date = new Date(timestamp); + const timeZone = "UTC"; + // All dates formatted in UTC timezone + switch (period) { + case "1Y": + return date.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + timeZone: timeZone, + }); + + case "1M": + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: timeZone, + }); + + case "1W": + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + timeZone: timeZone, + }); + + case "1D": + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + hour12: true, + timeZone: timeZone, + }); + + case "1H": + return ( + date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: timeZone, + }) + " UTC" + ); + + default: + return date.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + timeZone: timeZone, + }); + } +} + +export type BalanceHistoryCache = { + get: (key: string) => any; + set: (key: string, value: any, ttl?: number) => void; + del: (key: string) => void; +}; + +export function groupByPeriod( + history: T[] +): Record { + const grouped = new Map(); + + for (const entry of history) { + const key = entry.date; + if (!grouped.has(key) || entry.timestamp > grouped.get(key)!.timestamp) { + grouped.set(key, entry); + } + } + + return Object.fromEntries(grouped); +} + +// Helper function to decode Uint8Array response +export function decodeUint8Array(uint8Array: number[]): any { + const jsonString = uint8Array.map((c) => String.fromCharCode(c)).join(""); + try { + return JSON.parse(jsonString); + } catch (error) { + console.error("Failed to parse JSON from Uint8Array:", error); + return null; + } +} + +// Common block height calculation logic +export function calculateBlockHeights( + currentBlock: number, + lastStoredBlock: number, + blocksPerStep: number, + interval: number +): number[] { + let totalSteps = Math.min( + interval, + Math.floor((currentBlock - lastStoredBlock) / blocksPerStep) + ); + + if (totalSteps <= 0) { + totalSteps = 1; + } + + return Array.from( + { length: totalSteps }, + (_, i) => currentBlock - blocksPerStep * (totalSteps - 1 - i) + ).filter((block) => block > lastStoredBlock && block > 1_000_000); +} + +// Common period configuration logic +export function shouldUseArchival(period: string): boolean { + return ["1Y", "1M", "1W", "All"].includes(period); +} + +export function calculateBlocksPerStep(hoursPerStep: number): number { + return Math.floor(BLOCKS_PER_HOUR * hoursPerStep); +} diff --git a/src/utils/lib.ts b/src/utils/lib.ts index a30ed92..5c6d88e 100644 --- a/src/utils/lib.ts +++ b/src/utils/lib.ts @@ -319,16 +319,15 @@ export async function getUserStakeBalances( ); const { data: stakingData } = await axios.get( - `https://staking-pools-api.neartreasury.com/v1/account/${account_id}/staking`, - + `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( diff --git a/test/fetch-from-rpc.test.ts b/test/fetch-from-rpc.test.ts deleted file mode 100644 index 3b152b5..0000000 --- a/test/fetch-from-rpc.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import axios from "axios"; -import { fetchFromRPC } from "../src/utils/fetch-from-rpc"; -import prisma from "../src/prisma"; - -jest.mock("axios"); -jest.mock("../src/prisma", () => ({ - __esModule: true, - default: { - rpcRequest: { - findFirst: jest.fn(), - create: jest.fn(), - }, - accountBlockExistence: { - findFirst: jest.fn(), - create: jest.fn(), - }, - }, -})); - -const mockedAxios = axios as jest.Mocked; -const mockedPrisma = prisma as jest.Mocked; - -describe("fetchFromRPC", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset Prisma mocks - (mockedPrisma.rpcRequest.findFirst as jest.Mock).mockResolvedValue(null); - (mockedPrisma.rpcRequest.create as jest.Mock).mockImplementation( - async ({ data }) => data - ); - ( - mockedPrisma.accountBlockExistence.findFirst as jest.Mock - ).mockResolvedValue(null); - (mockedPrisma.accountBlockExistence.create as jest.Mock).mockImplementation( - async ({ data }) => data - ); - - // Reset axios mock for each test - mockedAxios.post.mockReset(); - }); - - beforeAll(() => { - process.env.FASTNEAR_API_KEY = "dummy-key"; - }); - - test("returns result from first successful RPC call and caches the result", async () => { - const body = { test: "cacheTest" }; - const successResponse = { result: "cachedData" }; - - // First call: simulate success from the first RPC endpoint - mockedAxios.post.mockResolvedValueOnce({ data: successResponse }); - (mockedPrisma.rpcRequest.findFirst as jest.Mock).mockResolvedValue(null); - - const result = await fetchFromRPC(body); - expect(result).toEqual(successResponse); - expect(mockedAxios.post).toHaveBeenCalledTimes(1); - - // Second call with the same body should return the cached result - (mockedPrisma.rpcRequest.findFirst as jest.Mock).mockResolvedValue({ - responseBody: successResponse, - }); - const cachedResult = await fetchFromRPC(body); - expect(cachedResult).toEqual(successResponse); - expect(mockedAxios.post).toHaveBeenCalledTimes(1); // Only the first call hit axios.post - }); - - test("tries next RPC endpoints if the first fails", async () => { - const body = { test: "tryNextEndpoint" }; - const successResponse = { result: "secondEndpointSuccess" }; - - // Single endpoint fails with a non-UNKNOWN_ACCOUNT error - mockedAxios.post.mockRejectedValueOnce(new Error("First endpoint failed")); - - const result = await fetchFromRPC(body); - expect(result).toBe(0); // Should return 0 since there's only one endpoint - expect(mockedAxios.post).toHaveBeenCalledTimes(1); - }); - - test("returns 0 if all RPC endpoints fail", async () => { - const body = { test: "allFail" }; - - // Single endpoint fails - mockedAxios.post.mockRejectedValueOnce(new Error("First endpoint failed")); - - const result = await fetchFromRPC(body); - expect(result).toBe(0); - expect(mockedAxios.post).toHaveBeenCalledTimes(1); - }); - - test("handles responses with an error property and retries", async () => { - const body = { test: "errorResponse" }; - - // Single endpoint returns error response - mockedAxios.post.mockResolvedValueOnce({ - data: { - error: { - cause: { name: "RPCError", info: { block_height: 12345 } }, - data: "some error", - }, - }, - }); - - const result = await fetchFromRPC(body); - expect(result).toBe(0); // Should return 0 since there's only one endpoint - expect(mockedAxios.post).toHaveBeenCalledTimes(1); - }); - - test("respects the disableCache flag by not using the cached response", async () => { - const body = { test: "disableCache" }; - const successResponse = { result: "freshData" }; - - // Even with cache available, it should make new requests - (mockedPrisma.rpcRequest.findFirst as jest.Mock).mockResolvedValue({ - responseBody: { result: "cachedData" }, - }); - mockedAxios.post.mockResolvedValue({ data: successResponse }); - - // Call with disableCache set to true — this should bypass the cache - const result1 = await fetchFromRPC(body, true); - expect(result1).toEqual(successResponse); - - // Even with the same body, a new axios.post should be made - const result2 = await fetchFromRPC(body, true); - expect(result2).toEqual(successResponse); - - // Total axios calls should be 2 since caching was bypassed - expect(mockedAxios.post).toHaveBeenCalledTimes(2); - }); -}); diff --git a/test/intents-graph.test.ts b/test/intents-graph.test.ts new file mode 100644 index 0000000..e2d1918 --- /dev/null +++ b/test/intents-graph.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import request from "supertest"; +import app from "../src/server"; + +// Mock the module for unit tests +jest.mock("../src/intents-graph"); +import * as intentsGraph from "../src/intents-graph"; + +// Mock NodeCache to prevent cache interference +jest.mock("node-cache", () => { + return jest.fn().mockImplementation(() => ({ + get: jest.fn().mockReturnValue(undefined), // Always return undefined (cache miss) + set: jest.fn(), + del: jest.fn(), + })); +}); + +describe("GET /api/intents-balance-history", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return 400 when account_id is missing", async () => { + const response = await request(app) + .get("/api/intents-balance-history") + .expect(400); + + expect(response.body).toEqual({ + error: "Missing required parameter: account_id", + }); + }); + + it("should return empty result for account with no intents tokens", async () => { + const accountId = "mgoel.sputnik-dao.near"; + + ( + intentsGraph.getIntentsBalanceHistory as jest.MockedFunction< + typeof intentsGraph.getIntentsBalanceHistory + > + ).mockResolvedValue({}); + + const response = await request(app) + .get("/api/intents-balance-history") + .query({ account_id: accountId }) + .expect(200); + + expect(response.body).toEqual({}); + expect(intentsGraph.getIntentsBalanceHistory).toHaveBeenCalledWith( + expect.any(Object), + `intents:${accountId}`, + accountId + ); + }); + + it("should return historical data for account with intents tokens", async () => { + const accountId = "webassemblymusic-treasury.sputnik-dao.near"; + const mockHistoryData = { + "1H": [ + { + timestamp: 1694678400000, + date: "Sep 14, 2:00 PM", + tokens: [ + { + token_id: "btc.defuse.near", + symbol: "BTC", + icon: "https://example.com/btc.png", + balance: "100000000", + parsedBalance: "1.00000000", + }, + ], + totalTokens: 1, + }, + ], + "1D": [ + { + timestamp: 1694592000000, + date: "Sep 13", + tokens: [ + { + token_id: "eth.defuse.near", + symbol: "ETH", + icon: "https://example.com/eth.png", + balance: "1000000000000000000", + parsedBalance: "1.000000000000000000", + }, + ], + totalTokens: 1, + }, + ], + }; + + ( + intentsGraph.getIntentsBalanceHistory as jest.MockedFunction< + typeof intentsGraph.getIntentsBalanceHistory + > + ).mockResolvedValue(mockHistoryData); + + const response = await request(app) + .get("/api/intents-balance-history") + .query({ account_id: accountId }) + .expect(200); + + expect(response.body).toEqual(mockHistoryData); + expect(response.body).toHaveProperty("1H"); + expect(response.body).toHaveProperty("1D"); + expect(Array.isArray(response.body["1H"])).toBe(true); + expect(response.body["1H"][0]).toHaveProperty("tokens"); + expect(response.body["1H"][0]).toHaveProperty("totalTokens"); + expect(intentsGraph.getIntentsBalanceHistory).toHaveBeenCalledWith( + expect.any(Object), // cache + `intents:${accountId}`, + accountId + ); + }); + + it("should handle errors gracefully", async () => { + const accountId = "test.near"; + const errorMessage = "RPC call failed"; + + ( + intentsGraph.getIntentsBalanceHistory as jest.MockedFunction< + typeof intentsGraph.getIntentsBalanceHistory + > + ).mockRejectedValue(new Error(errorMessage)); + + const response = await request(app) + .get("/api/intents-balance-history") + .query({ account_id: accountId }) + .expect(500); + + expect(response.body).toEqual({ + error: "Internal server error", + }); + }); + + it("should accept numeric strings as valid account_id", async () => { + // Express converts query params to strings, so 123 becomes "123" + const mockHistoryData = { + "1H": [ + { + timestamp: 1694678400000, + date: "Sep 14, 2:00 PM", + tokens: [], + totalTokens: 0, + }, + ], + }; + + ( + intentsGraph.getIntentsBalanceHistory as jest.MockedFunction< + typeof intentsGraph.getIntentsBalanceHistory + > + ).mockResolvedValue(mockHistoryData); + + const response = await request(app) + .get("/api/intents-balance-history") + .query({ account_id: 123 }) // Express converts to "123" + .expect(200); + + expect(response.body).toEqual(mockHistoryData); + expect(intentsGraph.getIntentsBalanceHistory).toHaveBeenCalledWith( + expect.any(Object), + "intents:123", // account_id becomes "123" + "123" + ); + }); + + it("should handle empty account_id", async () => { + const response = await request(app) + .get("/api/intents-balance-history") + .query({ account_id: "" }) // Empty string + .expect(400); + + expect(response.body).toEqual({ + error: "Missing required parameter: account_id", + }); + }); + + it("should validate response structure for historical data", async () => { + const accountId = "test.near"; + const mockHistoryData = { + "1H": [ + { + timestamp: 1694678400000, + date: "Sep 14, 2:00 PM", + tokens: [ + { + token_id: "usdc.defuse.near", + symbol: "USDC", + icon: "https://example.com/usdc.png", + balance: "1000000", + parsedBalance: "1.000000", + }, + ], + totalTokens: 1, + }, + ], + }; + + ( + intentsGraph.getIntentsBalanceHistory as jest.MockedFunction< + typeof intentsGraph.getIntentsBalanceHistory + > + ).mockResolvedValue(mockHistoryData); + + const response = await request(app) + .get("/api/intents-balance-history") + .query({ account_id: accountId }) + .expect(200); + + // Validate the structure + expect(typeof response.body).toBe("object"); + expect(response.body["1H"]).toBeDefined(); + expect(Array.isArray(response.body["1H"])).toBe(true); + + const firstEntry = response.body["1H"][0]; + expect(firstEntry).toHaveProperty("timestamp"); + expect(firstEntry).toHaveProperty("date"); + expect(firstEntry).toHaveProperty("tokens"); + expect(firstEntry).toHaveProperty("totalTokens"); + + expect(typeof firstEntry.timestamp).toBe("number"); + expect(typeof firstEntry.date).toBe("string"); + expect(Array.isArray(firstEntry.tokens)).toBe(true); + expect(typeof firstEntry.totalTokens).toBe("number"); + + // Validate token structure + const firstToken = firstEntry.tokens[0]; + expect(firstToken).toHaveProperty("token_id"); + expect(firstToken).toHaveProperty("symbol"); + expect(firstToken).toHaveProperty("balance"); + expect(firstToken).toHaveProperty("parsedBalance"); + + expect(typeof firstToken.token_id).toBe("string"); + expect(typeof firstToken.symbol).toBe("string"); + expect(typeof firstToken.balance).toBe("string"); + expect(typeof firstToken.parsedBalance).toBe("string"); + }); +}); diff --git a/test/production.test.ts b/test/production.test.ts deleted file mode 100644 index 9efa415..0000000 --- a/test/production.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import axios from "axios"; -import https from "https"; - -axios.defaults.httpsAgent = new https.Agent({ keepAlive: false }); -axios.defaults.timeout = 60_000; // Increase timeout to 60 seconds for CI - -const BASE_URL = "https://ref-sdk-api-2.fly.dev"; - -// List of accounts to test -const accounts = [ - "devdao.sputnik-dao.near", - "infinex.sputnik-dao.near", - "shitzu.sputnik-dao.near", - "templar.sputnik-dao.near", -]; - -describe("Production API Status Tests", () => { - test.each(accounts)( - "GET /api/whitelist-tokens for %s returns 200", - async (account) => { - const res = await axios.get(`${BASE_URL}/api/whitelist-tokens`, { - params: { account }, - }); - expect(res.status).toBe(200); - }, - 60000 // 60 second timeout per test - ); - - test.each(accounts)( - "GET /api/ft-tokens for %s returns 200", - async (account_id) => { - const res = await axios.get(`${BASE_URL}/api/ft-tokens`, { - params: { account_id }, - }); - expect(res.status).toBe(200); - } - ); - - test.each(accounts)("GET /api/swap for %s returns 200", async (accountId) => { - const res = await axios.get(`${BASE_URL}/api/swap`, { - params: { - accountId, - tokenIn: "wrap.near", - tokenOut: "usdt.tether-token.near", - amountIn: "1000000000000000000000000", - slippage: "0.01", - }, - }); - expect(res.status).toBe(200); - }); - - test.each(accounts)( - "GET /api/transactions-transfer-history for %s returns 200", - async (treasuryDaoID) => { - const res = await axios.get( - `${BASE_URL}/api/transactions-transfer-history`, - { - params: { treasuryDaoID }, - } - ); - expect(res.status).toBe(200); - }, - 60000 // 60 second timeout for this endpoint - ); - - test("GET /api/near-price returns 200", async () => { - const res = await axios.get(`${BASE_URL}/api/near-price`); - expect(res.status).toBe(200); - }); - - test.each(accounts)( - "GET /api/all-token-balance-history for %s returns 200", - async (account_id) => { - const token_id = "usdt.tether-token.near"; - const res = await axios.get(`${BASE_URL}/api/all-token-balance-history`, { - params: { account_id, token_id }, - }); - expect(res.status).toBe(200); - } - ); - - test.each(accounts)( - "GET /api/ft-token-price for %s returns 200", - async (account_id) => { - const res = await axios.get(`${BASE_URL}/api/ft-token-price`, { - params: { account_id }, - }); - expect(res.status).toBe(200); - } - ); -}); diff --git a/test/server.test.ts b/test/server.test.ts index af610de..24a11b2 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -7,12 +7,20 @@ import * as nearPrice from "../src/near-price"; import * as ftTokens from "../src/ft-tokens"; import * as allTokenBalanceHistory from "../src/all-token-balance-history"; import * as transactionsTransferHistory from "../src/transactions-transfer-history"; -import { tokens } from "../src/constants/tokens"; import axios from "axios"; jest.mock("axios"); const mockedAxios = axios as jest.Mocked; +// Mock NodeCache to prevent cache interference +jest.mock("node-cache", () => { + return jest.fn().mockImplementation(() => ({ + get: jest.fn().mockReturnValue(undefined), // Always return undefined (cache miss) + set: jest.fn(), + del: jest.fn(), + })); +}); + // Mock all external dependencies jest.mock("../src/prisma", () => ({ __esModule: true, @@ -52,11 +60,20 @@ jest.mock("../src/constants/tokens", () => ({ describe("API Endpoints", () => { beforeEach(() => { jest.clearAllMocks(); + // Reset axios mock + mockedAxios.get.mockReset(); }); describe("GET /api/whitelist-tokens", () => { - it("should return whitelist tokens", async () => { - const mockTokens = [{ token_id: "token1" }, { token_id: "token2" }]; + it("should return whitelist tokens for valid account", async () => { + const mockTokens = [ + { token_id: "wrap.near", symbol: "wNEAR", balance: "1000000" }, + { + token_id: "usdt.tether-token.near", + symbol: "USDt", + balance: "500000", + }, + ]; (whitelistTokens.getWhitelistTokens as jest.Mock).mockResolvedValue( mockTokens ); @@ -68,11 +85,48 @@ describe("API Endpoints", () => { expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body).toEqual(mockTokens); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty("token_id"); + expect(response.body[0]).toHaveProperty("symbol"); + }); + + it("should return empty array when no tokens found", async () => { + (whitelistTokens.getWhitelistTokens as jest.Mock).mockResolvedValue([]); + + const response = await request(app) + .get("/api/whitelist-tokens") + .query({ account: "empty.near" }); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(0); + }); + + it("should handle missing account parameter gracefully", async () => { + (whitelistTokens.getWhitelistTokens as jest.Mock).mockResolvedValue([]); + + const response = await request(app).get("/api/whitelist-tokens"); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + }); + + it("should handle service errors gracefully", async () => { + (whitelistTokens.getWhitelistTokens as jest.Mock).mockRejectedValue( + new Error("Service error") + ); + + const response = await request(app) + .get("/api/whitelist-tokens") + .query({ account: "test.near" }); + + expect(response.status).toBe(500); + expect(response.body).toHaveProperty("error"); }); }); describe("GET /api/swap", () => { - it("should handle swap request", async () => { + beforeEach(() => { // Mock the searchToken function jest .spyOn(require("../src/utils/search-token"), "searchToken") @@ -86,9 +140,11 @@ describe("API Endpoints", () => { } return null; }); + }); + it("should handle swap request with valid parameters", async () => { const mockSwapResult = { - transactions: [{ some: "transaction" }], + transactions: [{ type: "FunctionCall", method: "swap" }], outEstimate: "1000000", }; (swap.getSwap as jest.Mock).mockResolvedValue(mockSwapResult); @@ -103,6 +159,9 @@ describe("API Endpoints", () => { expect(response.status).toBe(200); expect(response.body).toEqual(mockSwapResult); + expect(response.body).toHaveProperty("transactions"); + expect(response.body).toHaveProperty("outEstimate"); + expect(Array.isArray(response.body.transactions)).toBe(true); }); it("should return 400 when required parameters are missing", async () => { @@ -118,21 +177,43 @@ describe("API Endpoints", () => { "Missing required parameters. Required: accountId, tokenIn, tokenOut, amountIn", }); }); + + it("should handle swap service errors gracefully", async () => { + (swap.getSwap as jest.Mock).mockRejectedValue(new Error("Swap failed")); + + const response = await request(app).get("/api/swap").query({ + accountId: "test.near", + tokenIn: "wrap.near", + tokenOut: "usdc.near", + amountIn: "1000000000000000000000000", + slippage: "0.01", + }); + + expect(response.status).toBe(500); + expect(response.body).toHaveProperty("error"); + expect(response.body.error).toBe("Swap failed"); + }); }); describe("GET /api/near-price", () => { - it("should return NEAR price", async () => { - const mockPrice = 1.5; + it("should return NEAR price as number", async () => { + const mockPrice = 2.51; (nearPrice.getNearPrice as jest.Mock).mockResolvedValue(mockPrice); const response = await request(app).get("/api/near-price"); expect(response.status).toBe(200); + expect(typeof response.body).toBe("number"); expect(response.body).toBe(mockPrice); + expect(response.body).toBeGreaterThan(0); }); - it("should fallback to database on error", async () => { - const mockDbPrice = { price: 1.5, timestamp: new Date(), source: "test" }; + it("should fallback to database on API error", async () => { + const mockDbPrice = { + price: 2.25, + timestamp: new Date(), + source: "fallback", + }; (nearPrice.getNearPrice as jest.Mock).mockRejectedValue( new Error("API Error") ); @@ -141,13 +222,42 @@ describe("API Endpoints", () => { const response = await request(app).get("/api/near-price"); expect(response.status).toBe(200); + expect(typeof response.body).toBe("number"); expect(response.body).toBe(mockDbPrice.price); }); + + it("should handle complete failure gracefully", async () => { + (nearPrice.getNearPrice as jest.Mock).mockRejectedValue( + new Error("API Error") + ); + (prisma.nearPrice.findFirst as jest.Mock).mockRejectedValue( + new Error("DB Error") + ); + + const response = await request(app).get("/api/near-price"); + + expect(response.status).toBe(500); + expect(response.body).toHaveProperty("error"); + }); }); describe("GET /api/ft-tokens", () => { it("should return FT tokens for valid account", async () => { - const mockTokens = { tokens: [] }; + const mockTokens = { + totalCumulativeAmt: 1500.5, + fts: [ + { + contract: "wrap.near", + amount: "1000000", + ft_meta: { symbol: "wNEAR" }, + }, + { + contract: "usdt.tether-token.near", + amount: "500000", + ft_meta: { symbol: "USDt" }, + }, + ], + }; (ftTokens.getFTTokens as jest.Mock).mockResolvedValue(mockTokens); const response = await request(app) @@ -156,18 +266,54 @@ describe("API Endpoints", () => { expect(response.status).toBe(200); expect(response.body).toEqual(mockTokens); + expect(response.body).toHaveProperty("totalCumulativeAmt"); + expect(response.body).toHaveProperty("fts"); + expect(Array.isArray(response.body.fts)).toBe(true); }); it("should return 400 when account_id is missing", async () => { const response = await request(app).get("/api/ft-tokens"); expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + it("should handle service errors with database fallback", async () => { + const mockDbResult = { + totalCumulativeAmt: 100.0, + fts: [], + timestamp: "2025-09-14T17:44:03.576Z", // String format as returned by API + }; + + (ftTokens.getFTTokens as jest.Mock).mockRejectedValue( + new Error("Service error") + ); + (prisma.fTToken.findFirst as jest.Mock).mockResolvedValue({ + ...mockDbResult, + timestamp: new Date(mockDbResult.timestamp), + }); + + const response = await request(app) + .get("/api/ft-tokens") + .query({ account_id: "test.near" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockDbResult); }); }); describe("GET /api/all-token-balance-history", () => { it("should return token balance history for valid parameters", async () => { - const mockHistory = { balances: [] }; + const mockHistory = { + "1H": [ + { + timestamp: 1694678400000, + balance: "1000000", + date: "Sep 14, 2:00 PM", + }, + ], + "1D": [{ timestamp: 1694592000000, balance: "950000", date: "Sep 13" }], + }; ( allTokenBalanceHistory.getAllTokenBalanceHistory as jest.Mock ).mockResolvedValue(mockHistory); @@ -181,28 +327,79 @@ describe("API Endpoints", () => { expect(response.status).toBe(200); expect(response.body).toEqual(mockHistory); + expect(typeof response.body).toBe("object"); + expect(response.body).toHaveProperty("1H"); + expect(response.body).toHaveProperty("1D"); + }); + + it("should return 400 when account_id is missing", async () => { + const response = await request(app) + .get("/api/all-token-balance-history") + .query({ token_id: "wrap.near" }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + it("should return 400 when token_id is missing", async () => { + const response = await request(app) + .get("/api/all-token-balance-history") + .query({ account_id: "test.near" }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); }); - it("should return 400 when parameters are missing", async () => { + it("should return 400 when both parameters are missing", async () => { const response = await request(app).get("/api/all-token-balance-history"); expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + it("should handle service errors gracefully", async () => { + ( + allTokenBalanceHistory.getAllTokenBalanceHistory as jest.Mock + ).mockRejectedValue(new Error("Service error")); + + const response = await request(app) + .get("/api/all-token-balance-history") + .query({ + account_id: "test.near", + token_id: "wrap.near", + }); + + expect(response.status).toBe(500); + expect(response.body).toHaveProperty("error"); }); }); describe("GET /api/transactions-transfer-history", () => { it("should return transfer history for valid treasury DAO", async () => { - const mockHistory = { transfers: [] }; + const mockHistory = { + transfers: [ + { + transaction_hash: "abc123", + amount: "1000000", + token_id: "wrap.near", + timestamp: "2023-09-14T10:00:00Z", + }, + ], + total: 1, + }; ( transactionsTransferHistory.getTransactionsTransferHistory as jest.Mock ).mockResolvedValue(mockHistory); const response = await request(app) .get("/api/transactions-transfer-history") - .query({ treasuryDaoID: "test-dao.near" }); + .query({ treasuryDaoID: "test-dao.sputnik-dao.near" }); expect(response.status).toBe(200); expect(response.body).toEqual({ data: mockHistory }); + expect(response.body).toHaveProperty("data"); + expect(response.body.data).toHaveProperty("transfers"); + expect(Array.isArray(response.body.data.transfers)).toBe(true); }); it("should return 400 when treasuryDaoID is missing", async () => { @@ -211,12 +408,26 @@ describe("API Endpoints", () => { ); expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + it("should handle service errors gracefully", async () => { + ( + transactionsTransferHistory.getTransactionsTransferHistory as jest.Mock + ).mockRejectedValue(new Error("Service error")); + + const response = await request(app) + .get("/api/transactions-transfer-history") + .query({ treasuryDaoID: "test-dao.sputnik-dao.near" }); + + expect(response.status).toBe(500); + expect(response.body).toHaveProperty("error"); }); }); describe("GET /api/ft-token-price", () => { - it("should return token price of near", async () => { - const mockPrice = 2; + it("should return token price for NEAR", async () => { + const mockPrice = 2.51; const mockResponse = { data: { contracts: [{ price: mockPrice.toString() }], @@ -231,6 +442,8 @@ describe("API Endpoints", () => { expect(response.status).toBe(200); expect(response.body).toEqual({ price: mockPrice }); + expect(typeof response.body.price).toBe("number"); + expect(response.body.price).toBeGreaterThan(0); expect(axios.get).toHaveBeenCalledWith( "https://api.nearblocks.io/v1/fts/wrap.near", @@ -248,5 +461,291 @@ describe("API Endpoints", () => { expect(response.status).toBe(400); expect(response.body).toEqual({ error: "account_id is required" }); }); + + it("should handle API errors gracefully", async () => { + mockedAxios.get.mockRejectedValue(new Error("API Error")); + + const response = await request(app) + .get("/api/ft-token-price") + .query({ account_id: "near" }); + + expect(response.status).toBe(500); + expect(response.body).toHaveProperty("error"); + }); + }); + + describe("GET /api/ft-token-metadata", () => { + it("should return token metadata for valid account", async () => { + const mockMetadata = { + name: "Wrapped NEAR", + symbol: "wNEAR", + decimals: 24, + icon: "https://example.com/icon.png", + }; + const mockResponse = { + data: { contracts: [mockMetadata] }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/ft-token-metadata") + .query({ account_id: "wrap.near" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockMetadata); + expect(response.body).toHaveProperty("name"); + expect(response.body).toHaveProperty("symbol"); + expect(response.body).toHaveProperty("decimals"); + }); + + it("should convert 'near' to 'wrap.near'", async () => { + const mockMetadata = { name: "Wrapped NEAR", symbol: "wNEAR" }; + const mockResponse = { data: { contracts: [mockMetadata] } }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/ft-token-metadata") + .query({ account_id: "near" }); + + expect(response.status).toBe(200); + expect(axios.get).toHaveBeenCalledWith( + "https://api.nearblocks.io/v1/fts/wrap.near", + expect.any(Object) + ); + }); + + it("should return 400 when account_id is missing", async () => { + const response = await request(app).get("/api/ft-token-metadata"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "account_id is required" }); + }); + + it("should handle API errors gracefully", async () => { + mockedAxios.get.mockRejectedValue(new Error("API Error")); + + const response = await request(app) + .get("/api/ft-token-metadata") + .query({ account_id: "wrap.near" }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: "Failed to fetch token metadata", + }); + }); + }); + + describe("GET /api/user-daos", () => { + it("should return user DAOs for valid account", async () => { + const mockDaos = ["dao1.sputnik-dao.near", "dao2.sputnik-dao.near"]; + const mockResponse = { + data: { + "test.near": { daos: mockDaos }, + }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/user-daos") + .query({ account_id: "test.near" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockDaos); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + }); + + it("should return empty array when user has no DAOs", async () => { + const mockResponse = { + data: { + "test.near": { daos: [] }, + }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/user-daos") + .query({ account_id: "test.near" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + expect(Array.isArray(response.body)).toBe(true); + }); + + it("should return 400 when account_id is missing", async () => { + const response = await request(app).get("/api/user-daos"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "account_id is required" }); + }); + + it("should handle API errors gracefully", async () => { + mockedAxios.get.mockRejectedValue(new Error("API Error")); + + const response = await request(app) + .get("/api/user-daos") + .query({ account_id: "test.near" }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: "Failed to fetch user daos" }); + }); + }); + + describe("GET /api/validator-details", () => { + it("should return validator details for valid account", async () => { + const mockDetails = { + account_id: "validator.near", + stake: "1000000000000000000000000", + delegators_count: 100, + fee: { numerator: 5, denominator: 100 }, + }; + + mockedAxios.get.mockResolvedValue({ data: mockDetails }); + + const response = await request(app) + .get("/api/validator-details") + .query({ account_id: "validator.near" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockDetails); + expect(response.body).toHaveProperty("account_id"); + expect(response.body).toHaveProperty("stake"); + }); + + it("should return 400 when account_id is missing", async () => { + const response = await request(app).get("/api/validator-details"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "account_id is required" }); + }); + + it("should handle API errors gracefully", async () => { + mockedAxios.get.mockRejectedValue(new Error("API Error")); + + const response = await request(app) + .get("/api/validator-details") + .query({ account_id: "validator.near" }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: "Failed to fetch validator details", + }); + }); + }); + + describe("GET /api/validators", () => { + it("should return formatted validators list", async () => { + const mockValidators = [ + { + account_id: "validator1.near", + fees: { numerator: 5, denominator: 100 }, + }, + { + account_id: "validator2.near", + fees: { numerator: 1000, denominator: 10000 }, // 10% + }, + ]; + + mockedAxios.get.mockResolvedValue({ data: mockValidators }); + + const response = await request(app).get("/api/validators"); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body).toEqual([ + { pool_id: "validator1.near", fee: "5" }, // whole number, no decimals + { pool_id: "validator2.near", fee: "10" }, // actual behavior: whole numbers don't have decimals + ]); + expect(response.body[0]).toHaveProperty("pool_id"); + expect(response.body[0]).toHaveProperty("fee"); + }); + + it("should handle missing fees gracefully", async () => { + const mockValidators = [ + { + account_id: "validator.near", + fees: {}, // missing numerator/denominator + }, + ]; + + mockedAxios.get.mockResolvedValue({ data: mockValidators }); + + const response = await request(app).get("/api/validators"); + + expect(response.status).toBe(200); + expect(response.body[0].fee).toBe("0"); + }); + + it("should handle API errors gracefully", async () => { + mockedAxios.get.mockRejectedValue(new Error("API Error")); + + const response = await request(app).get("/api/validators"); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: "Failed to fetch validators" }); + }); + }); + + describe("GET /api/search-ft", () => { + it("should return search results for valid query", async () => { + const mockToken = { + contract: "token.near", + name: "Test Token", + symbol: "TEST", + decimals: 18, + }; + const mockResponse = { + data: { tokens: [mockToken] }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/search-ft") + .query({ query: "test" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockToken); + expect(response.body).toHaveProperty("contract"); + expect(response.body).toHaveProperty("name"); + }); + + it("should return 400 when query is missing", async () => { + const response = await request(app).get("/api/search-ft"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "query is required" }); + }); + + it("should handle API errors gracefully", async () => { + mockedAxios.get.mockRejectedValue(new Error("API Error")); + + const response = await request(app) + .get("/api/search-ft") + .query({ query: "test" }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: "Failed to search FT" }); + }); + + it("should handle empty search results", async () => { + const mockResponse = { + data: { tokens: [] }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/search-ft") + .query({ query: "nonexistent" }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({}); // API returns empty object when no results + }); }); });