├── .env.example ├── .eslintrc.js ├── .github └── workflows │ ├── codeql.yml │ └── test-build.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── Dockerfile ├── Makefile ├── Procfile ├── README.md ├── backend ├── app.ts ├── bigQuery.ts ├── ledgers.ts ├── lumens.ts ├── redis.ts ├── routes.ts └── v2v3 │ └── lumens.ts ├── common ├── lumens.d.ts └── lumens.js ├── frontend ├── app.js ├── common │ ├── known_accounts.js │ └── time.js ├── components │ ├── AccountBadge.js │ ├── AccountBalance.js │ ├── AmountWidget.js │ ├── App.js │ ├── AppBar.js │ ├── AssetLink.js │ ├── FailedTransactionsChart.js │ ├── FeeStats.js │ ├── Incidents.js │ ├── LedgerCloseChart.js │ ├── LiquidityPoolBadge.js │ ├── ListAccounts.js │ ├── LumensCirculating.js │ ├── LumensDistributed.js │ ├── LumensNonCirculating.js │ ├── NetworkStatus.js │ ├── PublicNetworkLedgersHistoryChart.js │ ├── RecentOperations.js │ ├── ScheduledMaintenance.js │ ├── TotalCoins.js │ └── TransactionsChart.js ├── events.js ├── index.html ├── scss │ ├── _force.scss │ ├── _main.scss │ └── index.scss └── utilities │ └── sanitizeHtml.js ├── gcloud └── service-account-example.json ├── gulpfile.babel.js ├── package.json ├── test ├── mocha.opts ├── test-setup.ts └── tests │ ├── integration │ └── backend.ts │ └── unit │ └── backend.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | BQ_PROJECT_ID=myBigQueryProject-123 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@stellar/eslint-config"], 3 | rules: { 4 | "no-console": "off", 5 | "import/no-unresolved": "off", 6 | "no-await-in-loop": "off", 7 | "no-constant-condition": "off", 8 | "@typescript-eslint/naming-convention": ["warn"], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: '26 17 * * 6' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze (${{ matrix.language }}) 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 360 16 | permissions: 17 | # required for all workflows 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - language: javascript-typescript 25 | build-mode: none 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | # Initializes the CodeQL tools for scanning. 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v3 34 | with: 35 | languages: ${{ matrix.language }} 36 | build-mode: ${{ matrix.build-mode }} 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: "/language:${{matrix.language}}" 42 | -------------------------------------------------------------------------------- /.github/workflows/test-build.yml: -------------------------------------------------------------------------------- 1 | name: Test and build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | services: 12 | redis: 13 | image: redis 14 | # Set health checks to wait until redis has started 15 | options: >- 16 | --health-cmd "redis-cli ping" 17 | --health-interval 10s 18 | --health-timeout 5s 19 | --health-retries 5 20 | ports: 21 | - 6379:6379 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-node@v2 25 | with: 26 | node-version: 16 27 | - run: yarn install 28 | - run: yarn test 29 | - run: yarn build 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.tmp 3 | /dist 4 | *.eslintcache 5 | *service-account.json 6 | *.env 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn pre-commit 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | MAINTAINER SDF Ops Team 4 | 5 | ADD . /app/src 6 | WORKDIR /app/src 7 | 8 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ 9 | gpg curl ca-certificates git apt-transport-https && \ 10 | curl -sSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key|gpg --dearmor >/etc/apt/trusted.gpg.d/nodesource-key.gpg && \ 11 | echo "deb https://deb.nodesource.com/node_16.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ 12 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg |gpg --dearmor >/etc/apt/trusted.gpg.d/yarnpkg.gpg && \ 13 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ 14 | apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs yarn && \ 15 | yarn install && /app/src/node_modules/gulp/bin/gulp.js build 16 | 17 | ENV PORT=80 UPDATE_DATA=false 18 | EXPOSE 80 19 | 20 | RUN node_modules/typescript/bin/tsc 21 | 22 | ENTRYPOINT ["/usr/bin/node"] 23 | CMD ["./backend/app.js"] 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Check if we need to prepend docker commands with sudo 2 | SUDO := $(shell docker version >/dev/null 2>&1 || echo "sudo") 3 | 4 | # If TAG is not provided set default value 5 | TAG ?= stellar/stellar-dashboard:$(shell git rev-parse --short HEAD)$(and $(shell git status -s),-dirty-$(shell id -u -n)) 6 | # https://github.com/opencontainers/image-spec/blob/master/annotations.md 7 | BUILD_DATE := $(shell date -u +%FT%TZ) 8 | 9 | docker-build: 10 | $(SUDO) docker build --pull --label org.opencontainers.image.created="$(BUILD_DATE)" -t $(TAG) . 11 | 12 | docker-push: 13 | $(SUDO) docker push $(TAG) 14 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node app.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dashboard 2 | 3 | ## Dependencies 4 | 5 | To build this project, you must have the following dependencies installed: 6 | 7 | - node 10.16.3 8 | - yarn 9 | 10 | ## Installation 11 | 12 | ```sh 13 | yarn 14 | ``` 15 | 16 | ## Developing 17 | 18 | ```sh 19 | yarn start 20 | ``` 21 | 22 | ### If you wish to use backend server API, you need to have redis running locally on port 6379 (default for redis) 23 | 24 | (If you do not have redis installed) If on a mac, install redis using homebrew 25 | 26 | ```sh 27 | brew install redis 28 | ``` 29 | 30 | (Other install directions can be found here: https://redis.io/download) 31 | 32 | Make sure it's running 33 | 34 | ```sh 35 | brew services start redis 36 | ``` 37 | 38 | Once you have redis installed, start this command 39 | 40 | ```sh 41 | yarn run start:backend 42 | ``` 43 | 44 | It will create a proxy to `browser-sync` server started by gulp at 45 | `http://localhost:5000` 46 | 47 | ### Connecting to Big Query 48 | Connecting to Big Query is not required for running the backend (if you run with UPDATE_DATA=false), but is required for things like catching up ledger data in redis. 49 | 50 | This project is pulling from SDF's `crypto-stellar` public data set, so no special credentials are required. However you will need a Google Cloud Platform project with a service account to be able to access Big Query. 51 | 52 | Directions for creating a service account [can be found here](https://cloud.google.com/docs/authentication/getting-started). 53 | 54 | Once you've created a service account, add the service account key json file to the `gcloud` folder under the name `service-account.json`. An example json file shows what the file structure should look like. 55 | -------------------------------------------------------------------------------- /backend/app.ts: -------------------------------------------------------------------------------- 1 | // need to manually import regeneratorRuntime for babel w/ async 2 | // https://github.com/babel/babel/issues/9849#issuecomment-487040428 3 | // require("regenerator-runtime/runtime"); 4 | import "regenerator-runtime/runtime"; 5 | 6 | import "dotenv/config"; 7 | 8 | // Run backend with cache updates. 9 | import { updateLumensCache } from "./routes"; 10 | import { updateLedgers } from "./ledgers"; 11 | 12 | async function beginCacheUpdates() { 13 | if (process.env.UPDATE_DATA === "true") { 14 | setInterval(updateLumensCache, 10 * 60 * 1000); 15 | console.log("starting lumens cache update"); 16 | await updateLumensCache(); 17 | 18 | console.log("starting ledgers cache update"); 19 | await updateLedgers(); 20 | } 21 | } 22 | 23 | beginCacheUpdates(); 24 | -------------------------------------------------------------------------------- /backend/bigQuery.ts: -------------------------------------------------------------------------------- 1 | import { BigQuery } from "@google-cloud/bigquery"; 2 | 3 | let options; 4 | if (process.env.DEV) { 5 | options = { 6 | keyFilename: "gcloud/service-account.json", 7 | projectId: process.env.BQ_PROJECT_ID, 8 | }; 9 | } else { 10 | options = { 11 | keyFilename: "../../../gcloud/service-account.json", 12 | projectId: "hubble-261722", 13 | }; 14 | } 15 | 16 | export const bqClient = new BigQuery(options); 17 | 18 | // TODO - drop the _2 when Hubble 2.0 is live 19 | const BQHistoryLedgersTable = "crypto-stellar.crypto_stellar_2.history_ledgers"; 20 | 21 | export function get30DayOldLedgerQuery() { 22 | const today = new Date(); 23 | const before = new Date(today.setDate(today.getDate() - 32)); 24 | const bqDate = `${before.getFullYear()}-${ 25 | before.getUTCMonth() + 1 26 | }-${before.getUTCDate()}`; 27 | return `SELECT * FROM \`${BQHistoryLedgersTable}\` WHERE closed_at >= "${bqDate}" ORDER BY sequence LIMIT 1;`; 28 | } 29 | 30 | export interface BQHistoryLedger { 31 | sequence: number; 32 | ledger_hash: string; 33 | previous_ledger_hash: string; 34 | transaction_count: number; 35 | operation_count: number; 36 | closed_at: Date; 37 | id: number; 38 | total_coins: number; 39 | fee_pool: number; 40 | base_fee: number; 41 | base_reserve: number; 42 | max_tx_set_size: number; 43 | protocol_version: number; 44 | ledger_header: string; 45 | successful_transaction_count: number; 46 | failed_transaction_count: number; 47 | tx_set_operation_count: number; 48 | batch_id: string; 49 | batch_run_date: Date; 50 | batch_insert_ts: Date; 51 | } 52 | -------------------------------------------------------------------------------- /backend/ledgers.ts: -------------------------------------------------------------------------------- 1 | import stellarSdk from "stellar-sdk"; 2 | import { findIndex } from "lodash"; 3 | import { Response, NextFunction } from "express"; 4 | 5 | import { redisClient, getOrThrow } from "./redis"; 6 | 7 | import { get30DayOldLedgerQuery, bqClient, BQHistoryLedger } from "./bigQuery"; 8 | import { QueryRowsResponse } from "@google-cloud/bigquery"; 9 | 10 | const LEDGER_DAY_COUNT = 30; 11 | 12 | interface LedgerStat { 13 | date: string; 14 | transaction_count: number; 15 | operation_count: number; 16 | } 17 | 18 | // TODO - import Horizon type once https://github.com/stellar/js-stellar-sdk/issues/731 resolved 19 | export type LedgerRecord = { 20 | closed_at: string; 21 | paging_token: string; 22 | sequence: number; 23 | successful_transaction_count: number; 24 | failed_transaction_count: number; 25 | operation_count: number; 26 | }; 27 | 28 | const REDIS_LEDGER_KEY = "ledgers"; 29 | const REDIS_PAGING_TOKEN_KEY = "paging_token"; 30 | const CURSOR_NOW = "now"; 31 | 32 | export async function handler(_: any, res: Response, next: NextFunction) { 33 | try { 34 | const cachedData = await getOrThrow(redisClient, REDIS_LEDGER_KEY); 35 | const ledgers: LedgerStat[] = JSON.parse(cachedData); 36 | res.json(ledgers); 37 | } catch (e) { 38 | next(e); 39 | } 40 | } 41 | 42 | export async function updateLedgers() { 43 | const cachedData = (await redisClient.get(REDIS_LEDGER_KEY)) || "[]"; 44 | const cachedLedgers: LedgerStat[] = JSON.parse(cachedData); 45 | 46 | // if missing data in last 30 days, catchup from last 30 days 47 | let pagingToken = ""; 48 | if (cachedLedgers.length < LEDGER_DAY_COUNT) { 49 | try { 50 | const query = get30DayOldLedgerQuery(); 51 | const [job] = await bqClient.createQueryJob(query); 52 | console.log("running bq query:", query); 53 | const [ledgers]: QueryRowsResponse = await job.getQueryResults(); 54 | const ledger: BQHistoryLedger = ledgers[0]; 55 | pagingToken = String(ledger.id); 56 | } catch (err) { 57 | console.error("BigQuery error", err); 58 | pagingToken = CURSOR_NOW; 59 | } 60 | await redisClient.del(REDIS_LEDGER_KEY); 61 | } else { 62 | pagingToken = (await redisClient.get(REDIS_PAGING_TOKEN_KEY)) || CURSOR_NOW; 63 | } 64 | 65 | await catchup(REDIS_LEDGER_KEY, pagingToken, REDIS_PAGING_TOKEN_KEY, 0); 66 | 67 | const horizon = new stellarSdk.Server("https://horizon.stellar.org"); 68 | horizon 69 | .ledgers() 70 | .cursor(CURSOR_NOW) 71 | .limit(200) 72 | .stream({ 73 | onmessage: async (ledger: LedgerRecord) => { 74 | await updateCache([ledger], REDIS_LEDGER_KEY, REDIS_PAGING_TOKEN_KEY); 75 | }, 76 | }); 77 | } 78 | 79 | export async function catchup( 80 | ledgersKey: string, 81 | pagingTokenStart: string, 82 | pagingTokenKey: string, 83 | limit: number, // if 0, catchup until now 84 | ) { 85 | const horizon = new stellarSdk.Server("https://horizon.stellar.org"); 86 | let ledgers: LedgerRecord[] = []; 87 | let total = 0; 88 | let pagingToken = pagingTokenStart; 89 | 90 | while (true) { 91 | const resp = await horizon.ledgers().cursor(pagingToken).limit(200).call(); 92 | ledgers = resp.records; 93 | total += resp.records.length; 94 | if (ledgers.length === 0 || (limit && total > limit)) { 95 | break; 96 | } 97 | 98 | pagingToken = ledgers[ledgers.length - 1].paging_token; 99 | await updateCache(ledgers, ledgersKey, pagingTokenKey); 100 | } 101 | } 102 | 103 | export async function updateCache( 104 | ledgers: LedgerRecord[], 105 | ledgersKey: string, 106 | pagingTokenKey: string, 107 | ) { 108 | if (!ledgers.length) { 109 | console.log("no ledgers to update"); 110 | return; 111 | } 112 | const json = (await redisClient.get(ledgersKey)) || "[]"; 113 | const cachedStats: LedgerStat[] = JSON.parse(json); 114 | let pagingToken = ""; 115 | 116 | ledgers.forEach((ledger: LedgerRecord) => { 117 | const date: string = formatDate(ledger.closed_at); 118 | const index: number = findIndex(cachedStats, { date }); 119 | if (index === -1) { 120 | cachedStats.push({ 121 | date, 122 | transaction_count: 123 | ledger.successful_transaction_count + ledger.failed_transaction_count, 124 | operation_count: ledger.operation_count, 125 | }); 126 | } else { 127 | cachedStats.splice(index, 1, { 128 | date, 129 | transaction_count: 130 | cachedStats[index].transaction_count + 131 | ledger.successful_transaction_count + 132 | ledger.failed_transaction_count, 133 | operation_count: 134 | cachedStats[index].operation_count + ledger.operation_count, 135 | }); 136 | } 137 | pagingToken = ledger.paging_token; 138 | }); 139 | cachedStats.sort(dateSorter); 140 | 141 | // only store latest 30 days 142 | await redisClient.set( 143 | ledgersKey, 144 | JSON.stringify(cachedStats.slice(0, LEDGER_DAY_COUNT)), 145 | ); 146 | await redisClient.set(pagingTokenKey, pagingToken); 147 | 148 | console.log("ledgers updated to:", ledgers[ledgers.length - 1].closed_at); 149 | } 150 | 151 | function dateSorter(a: LedgerStat, b: LedgerStat) { 152 | const dateA = new Date(a.date); 153 | const dateB = new Date(b.date); 154 | 155 | if (dateA.getMonth() === 11) { 156 | dateA.setFullYear(dateA.getFullYear() - 1); 157 | } 158 | if (dateB.getMonth() === 11) { 159 | dateB.setFullYear(dateB.getFullYear() - 1); 160 | } 161 | 162 | return dateB.getTime() - dateA.getTime(); 163 | } 164 | 165 | // MM-DD 166 | function formatDate(s: string): string { 167 | const d = new Date(s); 168 | const month = `0${d.getUTCMonth() + 1}`; 169 | const day = `0${d.getUTCDate()}`; 170 | return `${month.slice(-2)}-${day.slice(-2)}`; 171 | } 172 | -------------------------------------------------------------------------------- /backend/lumens.ts: -------------------------------------------------------------------------------- 1 | import { Response, NextFunction } from "express"; 2 | import { redisClient, getOrThrow } from "./redis"; 3 | import * as commonLumens from "../common/lumens.js"; 4 | 5 | interface CachedData { 6 | updatedAt: Date; 7 | totalCoins: string; 8 | availableCoins: string; 9 | programs: { 10 | directDevelopment: string; 11 | ecosystemSupport: string; 12 | useCaseInvestment: string; 13 | userAcquisition: string; 14 | }; 15 | } 16 | 17 | export async function v1Handler(_: any, res: Response, next: NextFunction) { 18 | try { 19 | const cachedData = await getOrThrow(redisClient, "lumensV1"); 20 | const obj: CachedData = JSON.parse(cachedData); 21 | res.json(obj); 22 | } catch (e) { 23 | next(e); 24 | } 25 | } 26 | 27 | export function updateApiLumens() { 28 | return Promise.all([ 29 | commonLumens.totalSupply(), 30 | commonLumens.circulatingSupply(), 31 | commonLumens.directDevelopmentAll(), 32 | commonLumens.distributionEcosystemSupport(), 33 | commonLumens.distributionUseCaseInvestment(), 34 | commonLumens.distributionUserAcquisition(), 35 | ]) 36 | .then( 37 | async ([ 38 | totalCoins, 39 | availableCoins, 40 | directDevelopment, 41 | ecosystemSupport, 42 | useCaseInvestment, 43 | userAcquisition, 44 | ]) => { 45 | const cachedData = { 46 | updatedAt: new Date(), 47 | totalCoins, 48 | availableCoins, 49 | programs: { 50 | directDevelopment, 51 | ecosystemSupport, 52 | useCaseInvestment, 53 | userAcquisition, 54 | }, 55 | }; 56 | await redisClient.set("lumensV1", JSON.stringify(cachedData)); 57 | console.log("/api/lumens data saved!"); 58 | }, 59 | ) 60 | .catch((err) => { 61 | console.error(err); 62 | return err; 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /backend/redis.ts: -------------------------------------------------------------------------------- 1 | import * as redis from "redis"; 2 | 3 | // recommended to use typeof for RedisClientType: 4 | // https://github.com/redis/node-redis/issues/1673#issuecomment-979866376 5 | export type RedisClientType = typeof redisClient; 6 | 7 | const redisUrl = process.env.DEV 8 | ? "redis://127.0.0.1:6379" 9 | : process.env.REDIS_URL; 10 | 11 | export const redisClient = redis.createClient({ url: redisUrl }); 12 | 13 | (async () => { 14 | redisClient.on("error", (err: Error) => 15 | console.error("Redis Client Error", err), 16 | ); 17 | await redisClient.connect(); 18 | console.log("connected to redis"); 19 | 20 | process.on("exit", async () => { 21 | console.log("closed redis connection"); 22 | await redisClient.quit(); 23 | }); 24 | })(); 25 | 26 | export async function getOrThrow(rc: RedisClientType, key: string) { 27 | const cachedData = await rc.get(key); 28 | if (cachedData == null) { 29 | throw new Error("redis key not found"); 30 | } 31 | return cachedData; 32 | } 33 | -------------------------------------------------------------------------------- /backend/routes.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import proxy from "express-http-proxy"; 3 | import logger from "morgan"; 4 | 5 | import * as lumens from "./lumens"; 6 | import * as lumensV2V3 from "./v2v3/lumens"; 7 | import * as ledgers from "./ledgers"; 8 | 9 | export const app = express(); 10 | app.set("port", process.env.PORT || 5000); 11 | app.set("json spaces", 2); 12 | 13 | app.use(logger("combined")); 14 | 15 | if (process.env.DEV) { 16 | app.use( 17 | "/", 18 | proxy("localhost:3000", { 19 | filter: (req, _) => 20 | req.path === "/" || 21 | req.path.indexOf(".js") >= 0 || 22 | req.path.indexOf(".html") >= 0 || 23 | req.path.indexOf(".css") >= 0, 24 | }), 25 | ); 26 | } else { 27 | app.use(express.static("dist")); 28 | } 29 | 30 | app.get("/api/ledgers/public", ledgers.handler); 31 | app.get("/api/lumens", lumens.v1Handler); 32 | 33 | app.get("/api/v2/lumens", lumensV2V3.v2Handler); 34 | /* For CoinMarketCap */ 35 | app.get("/api/v2/lumens/total-supply", lumensV2V3.v2TotalSupplyHandler); 36 | app.get( 37 | "/api/v2/lumens/circulating-supply", 38 | lumensV2V3.v2CirculatingSupplyHandler, 39 | ); 40 | 41 | app.get("/api/v3/lumens", lumensV2V3.v3Handler); 42 | app.get("/api/v3/lumens/all", lumensV2V3.totalSupplyCheckHandler); 43 | /* For CoinMarketCap */ 44 | app.get("/api/v3/lumens/total-supply", lumensV2V3.v3TotalSupplyHandler); 45 | app.get( 46 | "/api/v3/lumens/circulating-supply", 47 | lumensV2V3.v3CirculatingSupplyHandler, 48 | ); 49 | 50 | app.listen(app.get("port"), () => { 51 | console.log("Listening on port", app.get("port")); 52 | }); 53 | 54 | export async function updateLumensCache() { 55 | await lumens.updateApiLumens(); 56 | await lumensV2V3.updateApiLumens(); 57 | } 58 | -------------------------------------------------------------------------------- /backend/v2v3/lumens.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from "bignumber.js"; 2 | import { Response, NextFunction } from "express"; 3 | import { redisClient, getOrThrow } from "../redis"; 4 | import * as commonLumens from "../../common/lumens.js"; 5 | 6 | const LUMEN_SUPPLY_METRICS_URL = 7 | "https://www.stellar.org/developers/guides/lumen-supply-metrics.html"; 8 | 9 | // v2: 10 | interface LumensDataV2 { 11 | updatedAt: Date; 12 | originalSupply: string; 13 | inflationLumens: string; 14 | burnedLumens: string; 15 | totalSupply: string; 16 | upgradeReserve: string; 17 | feePool: string; 18 | sdfMandate: string; 19 | circulatingSupply: string; 20 | _details: string; 21 | } 22 | 23 | export async function v2Handler(_: any, res: Response, next: NextFunction) { 24 | try { 25 | const cachedData = await getOrThrow(redisClient, "lumensV2"); 26 | const obj: LumensDataV2 = JSON.parse(cachedData); 27 | res.json(obj); 28 | } catch (e) { 29 | next(e); 30 | } 31 | } 32 | export async function v2TotalSupplyHandler( 33 | _: any, 34 | res: Response, 35 | next: NextFunction, 36 | ) { 37 | try { 38 | const cachedData = await getOrThrow(redisClient, "lumensV2"); 39 | const obj: LumensDataV2 = JSON.parse(cachedData); 40 | // for CoinMarketCap returning Number 41 | res.json(Number(obj.totalSupply)); 42 | } catch (e) { 43 | next(e); 44 | } 45 | } 46 | export async function v2CirculatingSupplyHandler( 47 | _: any, 48 | res: Response, 49 | next: NextFunction, 50 | ) { 51 | try { 52 | const cachedData = await getOrThrow(redisClient, "lumensV2"); 53 | const obj: LumensDataV2 = JSON.parse(cachedData); 54 | // for CoinMarketCap returning Number 55 | res.json(Number(obj.circulatingSupply)); 56 | } catch (e) { 57 | next(e); 58 | } 59 | } 60 | 61 | // v3: 62 | interface LumensDataV3 { 63 | updatedAt: Date; 64 | originalSupply: string; 65 | inflationLumens: string; 66 | burnedLumens: string; 67 | totalSupply: BigNumber; 68 | upgradeReserve: string; 69 | feePool: string; 70 | sdfMandate: string; 71 | circulatingSupply: BigNumber; 72 | _details: string; 73 | } 74 | 75 | interface TotalSupplyCheckResponse { 76 | updatedAt: Date; 77 | totalSupply: BigNumber; 78 | inflationLumens: string; 79 | burnedLumens: string; 80 | totalSupplySum: BigNumber; 81 | upgradeReserve: string; 82 | feePool: string; 83 | sdfMandate: string; 84 | circulatingSupply: BigNumber; 85 | } 86 | 87 | export async function v3Handler(_: any, res: Response, next: NextFunction) { 88 | try { 89 | const cachedData = await getOrThrow(redisClient, "lumensV2"); 90 | const obj: LumensDataV3 = JSON.parse(cachedData); 91 | res.json(obj); 92 | } catch (e) { 93 | next(e); 94 | } 95 | } 96 | export async function totalSupplyCheckHandler( 97 | _: any, 98 | res: Response, 99 | next: NextFunction, 100 | ) { 101 | try { 102 | const cachedData = await getOrThrow( 103 | redisClient, 104 | "totalSupplyCheckResponse", 105 | ); 106 | const obj: TotalSupplyCheckResponse = JSON.parse(cachedData); 107 | res.json(obj); 108 | } catch (e) { 109 | next(e); 110 | } 111 | } 112 | 113 | /* For CoinMarketCap */ 114 | export async function v3TotalSupplyHandler( 115 | _: any, 116 | res: Response, 117 | next: NextFunction, 118 | ) { 119 | try { 120 | const cachedData = await getOrThrow( 121 | redisClient, 122 | "totalSupplyCheckResponse", 123 | ); 124 | const obj: TotalSupplyCheckResponse = JSON.parse(cachedData); 125 | res.json(obj.totalSupplySum); 126 | } catch (e) { 127 | next(e); 128 | } 129 | } 130 | export async function v3CirculatingSupplyHandler( 131 | _: any, 132 | res: Response, 133 | next: NextFunction, 134 | ) { 135 | try { 136 | const cachedData = await getOrThrow( 137 | redisClient, 138 | "totalSupplyCheckResponse", 139 | ); 140 | const obj: TotalSupplyCheckResponse = JSON.parse(cachedData); 141 | res.json(obj.circulatingSupply); 142 | } catch (e) { 143 | next(e); 144 | } 145 | } 146 | 147 | export function updateApiLumens() { 148 | return Promise.all([ 149 | commonLumens.ORIGINAL_SUPPLY_AMOUNT, 150 | commonLumens.inflationLumens(), 151 | commonLumens.burnedLumens(), 152 | commonLumens.totalSupply(), 153 | commonLumens.getUpgradeReserve(), 154 | commonLumens.feePool(), 155 | commonLumens.sdfAccounts(), 156 | commonLumens.circulatingSupply(), 157 | ]) 158 | .then( 159 | async ([ 160 | originalSupply, 161 | inflationLumens, 162 | burnedLumens, 163 | totalSupply, 164 | upgradeReserve, 165 | feePool, 166 | sdfMandate, 167 | circulatingSupply, 168 | ]) => { 169 | const lumensDataV2 = { 170 | updatedAt: new Date(), 171 | originalSupply, 172 | inflationLumens, 173 | burnedLumens, 174 | totalSupply, 175 | upgradeReserve, 176 | feePool, 177 | sdfMandate, 178 | circulatingSupply, 179 | _details: LUMEN_SUPPLY_METRICS_URL, 180 | }; 181 | await redisClient.set("lumensV2", JSON.stringify(lumensDataV2)); 182 | 183 | console.log("/api/v2/lumens data saved!"); 184 | 185 | const totalSupplyCalculate: BigNumber = new BigNumber(originalSupply) 186 | .plus(inflationLumens as BigNumber) 187 | .minus(burnedLumens); 188 | 189 | const circulatingSupplyCalculate = totalSupplyCalculate 190 | .minus(upgradeReserve) 191 | .minus(feePool) 192 | .minus(sdfMandate as BigNumber); 193 | 194 | const totalSupplySum = circulatingSupplyCalculate 195 | .plus(upgradeReserve) 196 | .plus(feePool) 197 | .plus(sdfMandate as BigNumber); 198 | 199 | const lumensDataV3 = { 200 | updatedAt: new Date(), 201 | originalSupply, 202 | inflationLumens, 203 | burnedLumens, 204 | totalSupply: totalSupplyCalculate, 205 | upgradeReserve, 206 | feePool, 207 | sdfMandate, 208 | circulatingSupply: circulatingSupplyCalculate, 209 | _details: LUMEN_SUPPLY_METRICS_URL, 210 | }; 211 | const totalSupplyCheckResponse = { 212 | updatedAt: new Date(), 213 | totalSupply: totalSupplyCalculate, 214 | inflationLumens, 215 | burnedLumens, 216 | totalSupplySum, 217 | upgradeReserve, 218 | feePool, 219 | sdfMandate, 220 | circulatingSupply: circulatingSupplyCalculate, 221 | }; 222 | await redisClient.set("lumensV3", JSON.stringify(lumensDataV3)); 223 | await redisClient.set( 224 | "totalSupplyCheckResponse", 225 | JSON.stringify(totalSupplyCheckResponse), 226 | ); 227 | 228 | console.log("/api/v3/lumens data saved!"); 229 | }, 230 | ) 231 | .catch((err) => { 232 | console.error(err); 233 | return err; 234 | }); 235 | } 236 | -------------------------------------------------------------------------------- /common/lumens.d.ts: -------------------------------------------------------------------------------- 1 | export var ORIGINAL_SUPPLY_AMOUNT: string; 2 | export function getLumenBalance(horizonURL: string, accountId: string): string; 3 | export function totalLumens(horizonURL: string): string; 4 | export function inflationLumens(): Promise; 5 | export function feePool(): string; 6 | export function burnedLumens(): string; 7 | export function directDevelopmentAll(): Promise; 8 | export function distributionEcosystemSupport(): Promise; 9 | export function distributionUseCaseInvestment(): Promise; 10 | export function distributionUserAcquisition(): Promise; 11 | export function getUpgradeReserve(): string; 12 | export function sdfAccounts(): Promise; 13 | export function totalSupply(): Promise; 14 | export function noncirculatingSupply(): Promise; 15 | export function circulatingSupply(): Promise; 16 | -------------------------------------------------------------------------------- /common/lumens.js: -------------------------------------------------------------------------------- 1 | // This file contains functions used both in backend and frontend code. 2 | // Will be helpful to build distribution stats API. 3 | const axios = require("axios"); 4 | const BigNumber = require("bignumber.js"); 5 | const map = require("lodash/map"); 6 | const reduce = require("lodash/reduce"); 7 | const find = require("lodash/find"); 8 | 9 | const horizonLiveURL = "https://horizon.stellar.org"; 10 | 11 | const voidAccount = "GALAXYVOIDAOPZTDLHILAJQKCVVFMD4IKLXLSZV5YHO7VY74IWZILUTO"; 12 | const networkUpgradeReserveAccount = 13 | "GBEZOC5U4TVH7ZY5N3FLYHTCZSI6VFGTULG7PBITLF5ZEBPJXFT46YZM"; 14 | const accounts = { 15 | // escrowJan2021: "GBA6XT7YBQOERXT656T74LYUVJ6MEIOC5EUETGAQNHQHEPUFPKCW5GYM", 16 | escrowJan2022: "GD2D6JG6D3V52ZMPIYSVHYFKVNIMXGYVLYJQ3HYHG5YDPGJ3DCRGPLTP", 17 | escrowJan2023: "GA2VRL65L3ZFEDDJ357RGI3MAOKPJZ2Z3IJTPSC24I4KDTNFSVEQURRA", 18 | developerSupportHot: 19 | "GCKJZ2YVECFGLUDJ5T7NZMJPPWERBNYHCXT2MZPXKELFHUSYQR5TVHJQ", 20 | developerSupportHot2: 21 | "GC3ITNZSVVPOWZ5BU7S64XKNI5VPTRSBEXXLS67V4K6LEUETWBMTE7IH", 22 | directDevelopment: "GB6NVEN5HSUBKMYCE5ZOWSK5K23TBWRUQLZY3KNMXUZ3AQ2ESC4MY4AQ", 23 | // directDevelopmentHot1: 24 | // "GCEZYB47RSSSR6RMHQDTBWL4L6RY5CY2SPJU3QHP3YPB6ALPVRLPN7OQ", 25 | directDevelopmentHot2: 26 | "GATL3ETTZ3XDGFXX2ELPIKCZL7S5D2HY3VK4T7LRPD6DW5JOLAEZSZBA", 27 | // directDevelopmentHot3: 28 | // "GCVLWV5B3L3YE6DSCCMHLCK7QIB365NYOLQLW3ZKHI5XINNMRLJ6YHVX", 29 | directDevelopmentHot4: 30 | "GAKGC35HMNB7A3Q2V5SQU6VJC2JFTZB6I7ZW77SJSMRCOX2ZFBGJOCHH", 31 | directDevelopmentHot5: 32 | "GAPV2C4BTHXPL2IVYDXJ5PUU7Q3LAXU7OAQDP7KVYHLCNM2JTAJNOQQI", 33 | infrastructureGrants: 34 | "GCVJDBALC2RQFLD2HYGQGWNFZBCOD2CPOTN3LE7FWRZ44H2WRAVZLFCU", 35 | currencySupport: "GAMGGUQKKJ637ILVDOSCT5X7HYSZDUPGXSUW67B2UKMG2HEN5TPWN3LQ", 36 | currencySupportHot: 37 | "GANII5Y2LABEBK74NWNKS4NREX2T52YTBGQDRDKVBFRIIF5VE4ORYOVY", 38 | enterpriseFund: "GDUY7J7A33TQWOSOQGDO776GGLM3UQERL4J3SPT56F6YS4ID7MLDERI4", 39 | newProducts: "GCPWKVQNLDPD4RNP5CAXME4BEDTKSSYRR4MMEL4KG65NEGCOGNJW7QI2", 40 | inAppDistribution: "GDKIJJIKXLOM2NRMPNQZUUYK24ZPVFC6426GZAEP3KUK6KEJLACCWNMX", 41 | inAppDistributionHot: 42 | "GAX3BRBNB5WTJ2GNEFFH7A4CZKT2FORYABDDBZR5FIIT3P7FLS2EFOZZ", 43 | inAppDistributionHot2: 44 | "GDWXQOTIIDO2EUK4DIGIBLEHLME2IAJRNU6JDFS5B2ZTND65P7J36WQZ", 45 | marketingSupport: "GBEVKAYIPWC5AQT6D4N7FC3XGKRRBMPCAMTO3QZWMHHACLHTMAHAM2TP", 46 | marketingSupportHot: 47 | "GBI5PADO5TEDY3R6WFAO2HEKBTTZS4LGR77XM4AHGN52H45ENBWGDFOH", 48 | }; 49 | 50 | const ORIGINAL_SUPPLY_AMOUNT = "100000000000"; 51 | 52 | exports.ORIGINAL_SUPPLY_AMOUNT = ORIGINAL_SUPPLY_AMOUNT; 53 | 54 | exports.getLumenBalance = getLumenBalance; 55 | function getLumenBalance(horizonURL, accountId) { 56 | return axios 57 | .get(`${horizonURL}/accounts/${accountId}`) 58 | .then((response) => { 59 | var xlmBalance = find( 60 | response.data.balances, 61 | (b) => b.asset_type == "native", 62 | ); 63 | return xlmBalance.balance; 64 | }) 65 | .catch((error) => { 66 | if (error.response && error.response.status == 404) { 67 | return "0.0"; // consider the balance of an account zero if the account does not exist or has been deleted from the network 68 | } else throw error; // something else happened, and at this point we shouldn't trust the computed balance 69 | }); 70 | } 71 | 72 | function sumRelevantAccounts(accounts) { 73 | return Promise.all( 74 | accounts.map((acct) => getLumenBalance(horizonLiveURL, acct)), 75 | ).then((data) => 76 | data 77 | .reduce( 78 | (sum, currentBalance) => new BigNumber(currentBalance).plus(sum), 79 | new BigNumber(0), 80 | ) 81 | .toString(), 82 | ); 83 | } 84 | 85 | exports.totalLumens = totalLumens; 86 | function totalLumens(horizonURL) { 87 | return axios 88 | .get(`${horizonURL}/ledgers/?order=desc&limit=1`) 89 | .then((response) => { 90 | return response.data._embedded.records[0].total_coins; 91 | }); 92 | } 93 | 94 | exports.inflationLumens = inflationLumens; 95 | function inflationLumens() { 96 | return Promise.all([ 97 | totalLumens(horizonLiveURL), 98 | ORIGINAL_SUPPLY_AMOUNT, 99 | ]).then((result) => { 100 | let [totalLumens, originalSupply] = result; 101 | return new BigNumber(totalLumens).minus(originalSupply); 102 | }); 103 | } 104 | 105 | exports.feePool = feePool; 106 | function feePool() { 107 | return axios 108 | .get(`${horizonLiveURL}/ledgers/?order=desc&limit=1`) 109 | .then((response) => { 110 | return response.data._embedded.records[0].fee_pool; 111 | }); 112 | } 113 | 114 | exports.burnedLumens = burnedLumens; 115 | function burnedLumens() { 116 | return axios 117 | .get(`${horizonLiveURL}/accounts/${voidAccount}`) 118 | .then((response) => { 119 | var xlmBalance = find( 120 | response.data.balances, 121 | (b) => b.asset_type == "native", 122 | ); 123 | return xlmBalance.balance; 124 | }); 125 | } 126 | 127 | exports.directDevelopmentAll = directDevelopmentAll; 128 | function directDevelopmentAll() { 129 | const { 130 | directDevelopment, 131 | // directDevelopmentHot1, 132 | directDevelopmentHot2, 133 | // directDevelopmentHot3, 134 | directDevelopmentHot4, 135 | directDevelopmentHot5, 136 | } = accounts; 137 | return sumRelevantAccounts([ 138 | directDevelopment, 139 | // directDevelopmentHot1, 140 | directDevelopmentHot2, 141 | // directDevelopmentHot3, 142 | directDevelopmentHot4, 143 | directDevelopmentHot5, 144 | ]); 145 | } 146 | 147 | exports.distributionEcosystemSupport = distributionEcosystemSupport; 148 | function distributionEcosystemSupport() { 149 | const { 150 | infrastructureGrants, 151 | currencySupport, 152 | currencySupportHot, 153 | developerSupportHot, 154 | developerSupportHot2, 155 | } = accounts; 156 | return sumRelevantAccounts([ 157 | infrastructureGrants, 158 | currencySupport, 159 | currencySupportHot, 160 | developerSupportHot, 161 | developerSupportHot2, 162 | ]); 163 | } 164 | 165 | exports.distributionUseCaseInvestment = distributionUseCaseInvestment; 166 | function distributionUseCaseInvestment() { 167 | const { enterpriseFund, newProducts } = accounts; 168 | return sumRelevantAccounts([enterpriseFund, newProducts]); 169 | } 170 | 171 | exports.distributionUserAcquisition = distributionUserAcquisition; 172 | function distributionUserAcquisition() { 173 | const { 174 | inAppDistribution, 175 | inAppDistributionHot, 176 | inAppDistributionHot2, 177 | marketingSupport, 178 | marketingSupportHot, 179 | } = accounts; 180 | 181 | return sumRelevantAccounts([ 182 | inAppDistribution, 183 | inAppDistributionHot, 184 | inAppDistributionHot2, 185 | marketingSupport, 186 | marketingSupportHot, 187 | ]); 188 | } 189 | 190 | exports.getUpgradeReserve = getUpgradeReserve; 191 | function getUpgradeReserve() { 192 | return getLumenBalance(horizonLiveURL, networkUpgradeReserveAccount); 193 | } 194 | 195 | exports.sdfAccounts = sdfAccounts; 196 | function sdfAccounts() { 197 | var balanceMap = map(accounts, (id) => getLumenBalance(horizonLiveURL, id)); 198 | return Promise.all(balanceMap).then((balances) => { 199 | return reduce( 200 | balances, 201 | (sum, balance) => sum.plus(balance), 202 | new BigNumber(0), 203 | ); 204 | }); 205 | } 206 | 207 | exports.totalSupply = totalSupply; 208 | function totalSupply() { 209 | return Promise.all([inflationLumens(), burnedLumens()]).then((result) => { 210 | let [inflationLumens, burnedLumens] = result; 211 | 212 | return new BigNumber(ORIGINAL_SUPPLY_AMOUNT) 213 | .plus(inflationLumens) 214 | .minus(burnedLumens); 215 | }); 216 | } 217 | 218 | exports.noncirculatingSupply = noncirculatingSupply; 219 | function noncirculatingSupply() { 220 | return Promise.all([getUpgradeReserve(), feePool(), sdfAccounts()]).then( 221 | (balances) => { 222 | return reduce( 223 | balances, 224 | (sum, balance) => sum.plus(balance), 225 | new BigNumber(0), 226 | ); 227 | }, 228 | ); 229 | } 230 | 231 | exports.circulatingSupply = circulatingSupply; 232 | function circulatingSupply() { 233 | return Promise.all([totalSupply(), noncirculatingSupply()]).then((result) => { 234 | let [totalLumens, noncirculatingSupply] = result; 235 | 236 | return new BigNumber(totalLumens).minus(noncirculatingSupply); 237 | }); 238 | } 239 | -------------------------------------------------------------------------------- /frontend/app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./components/App.js"; 4 | 5 | require("./index.html"); 6 | require("./scss/index.scss"); 7 | 8 | ReactDOM.render(, document.getElementById("app")); 9 | -------------------------------------------------------------------------------- /frontend/common/known_accounts.js: -------------------------------------------------------------------------------- 1 | export const knownAccounts = { 2 | GCKX3XVTPVNFXQWLQCIBZX6OOPOIUT7FOAZVNOFCNEIXEZFRFSPNZKZT: "Coins base", 3 | GBUQWP3BOUZX34TOND2QV7QQ7K7VJTG6VSE7WMLBTMDJLLAW7YKGU6EP: "Coins iss.", 4 | GCGNWKCJ3KHRLPM3TM6N7D3W5YKDJFL6A2YCXFXNMRTZ4Q66MEMZ6FI2: "Poloniex", 5 | GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKTM: "Kraken", 6 | GC2BQYBXFOVPRDH35D5HT2AFVCDGXJM5YVTAF5THFSAISYOWAJQKRESK: "Tempo base.", 7 | GAP5LETOV6YIE62YAM56STDANPRDO7ZFDBGSNHJQIYGGKSMOZAHOOS2S: "Tempo iss.", 8 | GB7GRJ5DTE3AA2TCVHQS2LAD3D7NFG7YLTOEWEBVRNUUI2Q3TJ5UQIFM: "Btc38", 9 | GB2Y4SUXOWSTXHTL7QVNGGK3Y6IOMUGHDB3ZICWJN2EJ2PJHORWS3LG4: "Btc38", 10 | GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ65JJLDHKHRUZI3EUEKMTCH: "NaoBTC", 11 | GB6YPGW5JFMMP2QB2USQ33EUWTXVL4ZT5ITUNCY3YKVWOJPP57CANOF3: "Bittrex", 12 | GC4KAS6W2YCGJGLP633A6F6AKTCV4WSLMTMIQRSEQE5QRRVKSX7THV6S: "btcid", 13 | GBV4ZDEPNQ2FKSPKGJP2YKDAIZWQ2XKRQD4V4ACH3TCTFY6KPY3OAVS7: "Changelly", 14 | GCO2IP3MJNUOKS4PUDI4C7LGGMQDJGXG3COYX3WSB4HHNAHKYV5YL3VC: "Binance", 15 | GDCIUCGL7VEMMF6VYJOW75KQ5ZCLHAQBRM6EPFTKCRWUYVUOOYQCKC5A: { 16 | name: "Mobius.network", 17 | url: "https://mobius.network", 18 | icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAICAYAAAAftBSpAAABp0lEQVR42mWRu2sUURTG5x+xjX0aIUgKbcTCRrBIihSCyIJIMEQsBNPkoUFcEWwkplCCgogsGIzBQIKIhKyiRHQX8zK7M7uZTOZxJzPXmXt/3h0NBv3gFPc8vu/c71iA7kSgtK4nuV6Lcy0yrQ/y/4bMtU5NXR/KWZ2HpxTlIOHKekSp6jO45DP9QeCKnKLdwAzz3kmZ+igYnfcpv/ax/ayoWa7pui9TrrkxQ7WIvsU9Tsy0OVa26R1v8sAMrLYklUbC1BfB7Xchwy88Tk/anLtp40U51mJL6kqQ4uWKIFPUDfuE2aR7tEnXpU2O9K/Tc32byTcBjTAjTBV7seJrQzL02KW28xPr1UqsT11tMP8p5oeX0TJfcOKMufo+Z+84dJ3f4OjlLbrHmvQ926WymVATGTWZ8yiRtI2w5QulL9x1OH5jm9JTl4m3IQ+/CZZ20oJw5XtKySj23HM4+aTNmYVdBlZDLrYFzzOJ8RmrcNywzZpNppcjZtf22TBrHxjagTkcVVtyqxox/DlkZCvmZfyboDD2z5kKFHf7O/sfOsSp0kgTh0V+ARai44A02yZhAAAAAElFTkSuQmCC", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/common/time.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | let _offset = 0; 4 | 5 | //set offset between a (presumably) accurate time and local time 6 | export function setTimeOffset(offset) { 7 | _offset = offset; 8 | } 9 | 10 | // calculate the time delta between now and ts in seconds 11 | export function agoSeconds(ts) { 12 | return moment().diff(ts, "seconds") - _offset; 13 | } 14 | 15 | // calculate and format the human-readable time delta between now and ts 16 | export function ago(a) { 17 | let diff = moment().diff(a, "seconds") - _offset; 18 | if (diff < 60) { 19 | return `${diff}s`; 20 | } else if (diff < 60 * 60) { 21 | diff = moment().diff(a, "minutes"); 22 | return `${diff}m`; 23 | } else if (diff < 24 * 60 * 60) { 24 | diff = moment().diff(a, "hours"); 25 | return `${diff}h`; 26 | } else { 27 | diff = moment().diff(a, "days"); 28 | return `${diff}d`; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/components/AccountBadge.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import isObject from "lodash/isObject"; 3 | import { knownAccounts } from "../common/known_accounts"; 4 | 5 | export default class AccountBadge extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 14 | {this.props.id.substr(0, 4)} 15 | 16 | 17 | {knownAccounts[this.props.id] && this.props.id != this.props.known ? ( 18 | isObject(knownAccounts[this.props.id]) ? ( 19 | 24 | 25 | 26 | ) : ( 27 | {knownAccounts[this.props.id]} 28 | ) 29 | ) : ( 30 | "" 31 | )} 32 | 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/components/AccountBalance.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AmountWidget from "./AmountWidget"; 3 | import Panel from "muicss/lib/react/panel"; 4 | import axios from "axios"; 5 | import find from "lodash/find"; 6 | 7 | export default class AccountBalance extends AmountWidget { 8 | constructor(props) { 9 | super(props); 10 | this.url = `${this.props.horizonURL}/accounts/${this.props.id}`; 11 | } 12 | 13 | componentDidMount() { 14 | this.timerID = setInterval(() => this.updateBalance(), 5 * 60 * 1000); 15 | this.updateBalance(); 16 | } 17 | 18 | componentWillUnmount() { 19 | clearInterval(this.timerID); 20 | } 21 | 22 | updateBalance() { 23 | axios.get(this.url).then((response) => { 24 | let xlmBalance = find( 25 | response.data.balances, 26 | (b) => b.asset_type == "native", 27 | ); 28 | let amount = xlmBalance.balance; 29 | let code = "XLM"; 30 | this.setState({ amount, code, loading: false }); 31 | }); 32 | } 33 | 34 | renderName() { 35 | return ( 36 | 37 | {this.props.name}:{" "} 38 | 39 | 40 | {this.props.id.substr(0, 4)} 41 | 42 | 43 | 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/components/AmountWidget.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import BigNumber from "bignumber.js"; 4 | 5 | export default class AmountWidget extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { loading: true }; 9 | } 10 | 11 | renderName() { 12 | return null; 13 | } 14 | 15 | render() { 16 | let amountBig; 17 | let amount; 18 | if (this.state.loading) { 19 | amountBig = "Loading..."; 20 | } else { 21 | if (this.state.amount >= 1000000000) { 22 | amountBig = Math.floor(this.state.amount / 10000000) / 100 + "B"; 23 | } else if (this.state.amount >= 1000000) { 24 | amountBig = Math.floor(this.state.amount / 10000) / 100 + "M"; 25 | } else if (this.state.amount < 1000000 && this.state.amount >= 100000) { 26 | amountBig = Math.floor(this.state.amount / 1000) + "k"; 27 | } else { 28 | amountBig = Math.floor(this.state.amount); 29 | } 30 | 31 | if (this.state.code) { 32 | amountBig += ` ${this.state.code}`; 33 | } 34 | 35 | amount = new BigNumber(this.state.amount).toFormat(7); 36 | } 37 | 38 | return ( 39 | 40 |
{this.renderName()}
41 |
{amountBig}
42 |
43 | {this.state.loading ? ( 44 | "" 45 | ) : ( 46 | 47 | {amount} {this.state.code} 48 | 49 | )} 50 |
51 |
52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/components/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import { EventEmitter } from "fbemitter"; 4 | import axios from "axios"; 5 | import moment from "moment"; 6 | import { Server } from "stellar-sdk"; 7 | 8 | import AppBar from "./AppBar"; 9 | import AccountBalance from "./AccountBalance"; 10 | import FeeStats from "./FeeStats"; 11 | import NetworkStatus from "./NetworkStatus"; 12 | import Incidents from "./Incidents"; 13 | import LedgerCloseChart from "./LedgerCloseChart"; 14 | import LumensCirculating from "./LumensCirculating"; 15 | import LumensNonCirculating from "./LumensNonCirculating"; 16 | import PublicNetworkLedgersHistoryChart from "./PublicNetworkLedgersHistoryChart"; 17 | import RecentOperations from "./RecentOperations"; 18 | import TotalCoins from "./TotalCoins"; 19 | import TransactionsChart from "./TransactionsChart"; 20 | import FailedTransactionsChart from "./FailedTransactionsChart"; 21 | import { LIVE_NEW_LEDGER, TEST_NEW_LEDGER } from "../events"; 22 | import { setTimeOffset } from "../common/time"; 23 | import { ScheduledMaintenance } from "./ScheduledMaintenance"; 24 | import sanitizeHtml from "../utilities/sanitizeHtml.js"; 25 | 26 | const horizonLive = "https://horizon.stellar.org"; 27 | const horizonTest = "https://horizon-testnet.stellar.org"; 28 | 29 | export default class App extends React.Component { 30 | constructor(props) { 31 | super(props); 32 | this.chrome57 = navigator.userAgent.toLowerCase().indexOf("chrome/57") > -1; 33 | this.emitter = new EventEmitter(); 34 | this.sleepDetector(); 35 | 36 | // Add an axios response interceptor to setup a timestamp offset between 37 | // local time and horizon time if a date header is present 38 | // this will be used to settle clock discrepancies 39 | axios.interceptors.response.use( 40 | function (response) { 41 | let headerDate = response.headers.date; 42 | if (headerDate) { 43 | setTimeOffset( 44 | Math.round((new Date() - new Date(response.headers.date)) / 1000), 45 | ); 46 | } 47 | return response; 48 | }, 49 | function (error) { 50 | return Promise.reject(error); 51 | }, 52 | ); 53 | 54 | // forceTheme is our way to celebrate May, 4th. 55 | var forceTheme = false; 56 | var may4 = false; 57 | 58 | var now = new Date(); 59 | var d = now.getDate(); 60 | var m = now.getMonth() + 1; 61 | var y = now.getFullYear(); 62 | 63 | if (d == 4 && m == 5) { 64 | forceTheme = true; 65 | may4 = true; 66 | } 67 | 68 | // TLJ 69 | if (d == 9 && m == 12 && y == 2017) { 70 | forceTheme = true; 71 | } 72 | 73 | // TRS 74 | if (d == 20 && m == 12 && y == 2019) { 75 | forceTheme = true; 76 | } 77 | 78 | // For testing 79 | if (localStorage.getItem("forceTheme") != null) { 80 | forceTheme = true; 81 | may4 = true; 82 | } 83 | 84 | this.state = { forceTheme, may4 }; 85 | } 86 | 87 | componentDidMount() { 88 | this.streamLedgers(horizonLive, LIVE_NEW_LEDGER); 89 | this.streamLedgers(horizonTest, TEST_NEW_LEDGER); 90 | 91 | this.getStatusPageData(); 92 | this.statusPageUpdateInterval = setInterval( 93 | () => this.getStatusPageData(), 94 | 30 * 1000, 95 | ); 96 | } 97 | 98 | componentWillUnmount() { 99 | clearInterval(this.statusPageUpdateInterval); 100 | } 101 | 102 | getStatusPageData() { 103 | axios 104 | .get("https://9sl3dhr1twv1.statuspage.io/api/v2/summary.json") 105 | .then((response) => { 106 | this.setState({ statusPage: response.data }); 107 | }); 108 | } 109 | 110 | reloadOnConnection() { 111 | return axios 112 | .get("https://s3-us-west-1.amazonaws.com/stellar-heartbeat/index.html", { 113 | timeout: 5 * 1000, 114 | }) 115 | .then(() => location.reload()) 116 | .catch(() => setTimeout(this.reloadOnConnection.bind(this), 1000)); 117 | } 118 | 119 | sleepDetector() { 120 | if (!this.lastTime) { 121 | this.lastTime = new Date(); 122 | } 123 | 124 | let currentTime = new Date(); 125 | if (currentTime - this.lastTime > 10 * 60 * 1000) { 126 | this.setState({ sleeping: true }); 127 | this.reloadOnConnection(); 128 | return; 129 | } 130 | 131 | this.lastTime = new Date(); 132 | setTimeout(this.sleepDetector.bind(this), 5000); 133 | } 134 | 135 | streamLedgers(horizonURL, eventName) { 136 | // Get last ledger 137 | axios.get(`${horizonURL}/ledgers?order=desc&limit=1`).then((response) => { 138 | let lastLedger = response.data._embedded.records[0]; 139 | 140 | new Server(horizonURL) 141 | .ledgers() 142 | .cursor(lastLedger.paging_token) 143 | .limit(200) 144 | .stream({ 145 | onmessage: (ledger) => this.emitter.emit(eventName, ledger), 146 | }); 147 | }); 148 | } 149 | 150 | turnOffForceTheme() { 151 | this.setState({ forceTheme: false }); 152 | return false; 153 | } 154 | 155 | render() { 156 | return ( 157 |
158 | 162 | 163 | { 164 | /* Incidents */ 165 | this.state.statusPage 166 | ? this.state.statusPage.incidents.map((m) => { 167 | return ( 168 | 169 |
170 | 171 | {m.name} 172 | {" "} 173 | (started: {moment(m.started_at).fromNow()} 174 | {m.incident_updates.length > 0 175 | ? ", last update: " + 176 | moment(m.incident_updates[0].created_at).fromNow() 177 | : null} 178 | )
179 | 180 | Affected: {m.components.map((c) => c.name).join(", ")} 181 | 182 |
183 | {m.incident_updates.length > 0 ? ( 184 | {sanitizeHtml(m.incident_updates[0].body)} 185 | ) : null} 186 |
187 |
188 | ); 189 | }) 190 | : null 191 | } 192 | { 193 | /* Scheduled maintenances */ 194 | this.state.statusPage && 195 | this.state.statusPage.scheduled_maintenances.length ? ( 196 | 201 | ) : null 202 | } 203 | {this.chrome57 ? ( 204 | 205 |
206 | You are using Chrome 57. There is a{" "} 207 | 211 | known bug 212 | {" "} 213 | that makes the Dashboard app consume extensive amounts of memory. 214 | Please switch to any other browser or wait for a fix by a Chromium 215 | team. 216 |
217 |
218 | ) : null} 219 | {this.state.sleeping ? ( 220 | 221 |
222 | System sleep detected. Waiting for internet connection... 223 |
224 |
225 | ) : null} 226 | {this.state.forceTheme && this.state.may4 ? ( 227 |

228 | May the 4th be with you! 229 |

230 | ) : null} 231 |
232 |
233 |

Live network status

234 |
235 |
236 | 242 | 243 | 244 | 250 |
251 |
252 | 259 | 266 | 273 | 274 |
275 |
276 |
277 | 278 |
279 |

LUMEN SUPPLY

280 |
281 | 282 |
283 | 284 |
285 | 286 |
287 | 288 |
289 | 290 |
291 |

292 | 296 | Lumen Supply Metrics 297 | 298 |

299 |
300 | 301 |
302 |

Network Nodes

303 |

304 | View network nodes on Stellarbeat and visualize consensus. 305 |
306 | 307 | Explore Nodes 308 | 309 |

310 |
311 | 312 |
313 |

Test network status

314 |
315 | 321 | 327 |
328 |
329 | 336 | 343 | 350 |
351 |
352 | 357 |
358 |
359 |
360 |
361 | ); 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /frontend/components/AppBar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class AppBar extends React.Component { 4 | render() { 5 | return ( 6 |
7 |
8 |
9 |
10 | « Stellar.org 11 |
12 |
Stellar.org Dashboard
13 |
14 |
15 | 20 | 28 | 33 | {this.props.forceTheme ? ( 34 | 41 | ) : null} 42 |
43 |
44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/components/AssetLink.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AccountBadge from "./AccountBadge"; 3 | 4 | export default class AssetLink extends React.Component { 5 | render() { 6 | return ( 7 | 8 | {this.props.code} 9 | {this.props.issuer ? ( 10 | 14 | ) : ( 15 | "" 16 | )} 17 | 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/components/FailedTransactionsChart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import { scale, format } from "d3"; 5 | import BarChart from "react-d3-components/lib/BarChart"; 6 | import clone from "lodash/clone"; 7 | import each from "lodash/each"; 8 | 9 | export default class FailedTransactionsChart extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.panel = null; 13 | this.colorScale = scale.category10(); 14 | this.state = { 15 | loading: true, 16 | chartWidth: 400, 17 | chartHeigth: this.props.chartHeigth || 120, 18 | }; 19 | this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; 20 | } 21 | 22 | componentDidMount() { 23 | this.getLedgers(); 24 | // Update chart width 25 | this.updateSize(); 26 | setInterval(() => this.updateSize(), 5000); 27 | } 28 | 29 | updateSize() { 30 | let value = this.panel.offsetWidth - 20; 31 | if (this.state.chartWidth != value) { 32 | this.setState({ chartWidth: value }); 33 | } 34 | } 35 | 36 | onNewLedger(ledger) { 37 | let data = clone(this.state.data); 38 | data[0].values.push({ 39 | x: ledger.sequence.toString(), 40 | y: ledger.successful_transaction_count, 41 | }); 42 | data[1].values.push({ 43 | x: ledger.sequence.toString(), 44 | y: ledger.failed_transaction_count, 45 | }); 46 | data[0].values.shift(); 47 | data[1].values.shift(); 48 | this.setState({ loading: false, data }); 49 | } 50 | 51 | getLedgers() { 52 | axios.get(this.url).then((response) => { 53 | let data = [ 54 | { 55 | label: "Success", 56 | values: [], 57 | }, 58 | { 59 | label: "Fail", 60 | values: [], 61 | }, 62 | ]; 63 | each(response.data._embedded.records, (ledger) => { 64 | data[0].values.unshift({ 65 | x: ledger.sequence.toString(), 66 | y: ledger.successful_transaction_count, 67 | }); 68 | data[1].values.unshift({ 69 | x: ledger.sequence.toString(), 70 | y: ledger.failed_transaction_count, 71 | }); 72 | }); 73 | this.setState({ loading: false, data }); 74 | // Start listening to events 75 | this.props.emitter.addListener( 76 | this.props.newLedgerEventName, 77 | this.onNewLedger.bind(this), 78 | ); 79 | }); 80 | } 81 | 82 | render() { 83 | return ( 84 |
{ 86 | this.panel = el; 87 | }} 88 | > 89 | 90 |
91 | 92 | Successful 93 | {" "} 94 | &{" "} 95 | Failed{" "} 96 | Txs in the last {this.props.limit} ledgers: {this.props.network} 97 | 98 | API 99 | 100 |
101 | {this.state.loading ? ( 102 | "Loading..." 103 | ) : ( 104 | 112 | )} 113 |
114 |
115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /frontend/components/FeeStats.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import moment from "moment"; 5 | import clone from "lodash/clone"; 6 | import each from "lodash/each"; 7 | import defaults from "lodash/defaults"; 8 | import get from "lodash/get"; 9 | import AccountBadge from "./AccountBadge"; 10 | import AssetLink from "./AssetLink"; 11 | import BigNumber from "bignumber.js"; 12 | import { ago } from "../common/time"; 13 | 14 | export default class FeeStats extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = { loading: true, stats: {} }; 18 | this.url = `${this.props.horizonURL}/fee_stats`; 19 | this.nameMap = [ 20 | { id: "ledger_capacity_usage", name: "Capacity Usage" }, 21 | { id: "max_fee.max", name: "Max Accepted Fee" }, 22 | { id: "max_fee.min", name: "Min Accepted Fee" }, 23 | { id: "max_fee.mode", name: "Mode Accepted Fee" }, 24 | { id: "max_fee.p10", name: "10th Percentile Accepted Fee" }, 25 | { id: "max_fee.p20", name: "20th Percentile Accepted Fee" }, 26 | { id: "max_fee.p30", name: "30th Percentile Accepted Fee" }, 27 | { id: "max_fee.p40", name: "40th Percentile Accepted Fee" }, 28 | { id: "max_fee.p50", name: "50th Percentile Accepted Fee" }, 29 | { id: "max_fee.p60", name: "60th Percentile Accepted Fee" }, 30 | { id: "max_fee.p70", name: "70th Percentile Accepted Fee" }, 31 | { id: "max_fee.p80", name: "80th Percentile Accepted Fee" }, 32 | { id: "max_fee.p90", name: "90th Percentile Accepted Fee" }, 33 | { id: "max_fee.p95", name: "95th Percentile Accepted Fee" }, 34 | { id: "max_fee.p99", name: "99th Percentile Accepted Fee" }, 35 | ]; 36 | } 37 | 38 | getStats() { 39 | if (this.statsLoading) { 40 | return; 41 | } 42 | this.statsLoading = true; 43 | 44 | axios.get(this.url).then((response) => { 45 | this.setState({ loading: false, stats: response.data }); 46 | this.statsLoading = false; 47 | }); 48 | } 49 | 50 | componentDidMount() { 51 | this.getStats(); 52 | this.timerID = setInterval(() => this.getStats(), 5 * 1000); 53 | } 54 | 55 | componentWillUnmount() { 56 | clearInterval(this.timerID); 57 | } 58 | 59 | capacityStyle(cap) { 60 | if (cap <= 0.5) { 61 | return { color: "green" }; 62 | } else if (cap > 0.5 && cap <= 0.7) { 63 | return { color: "orange" }; 64 | } else if (cap > 0.7 && cap <= 0.9) { 65 | return { color: "red", fontWeight: "bold" }; 66 | } else if (cap > 0.9) { 67 | return { color: "brown", fontWeight: "bold" }; 68 | } 69 | } 70 | 71 | feeStyle(fee) { 72 | if (fee <= 200) { 73 | return { color: "green" }; 74 | } else if (fee > 200 && fee <= 500) { 75 | return { color: "orange" }; 76 | } else if (fee > 500 && fee <= 1000) { 77 | return { color: "red", fontWeight: "bold" }; 78 | } else if (fee > 1000) { 79 | return { color: "brown", fontWeight: "bold" }; 80 | } 81 | } 82 | 83 | render() { 84 | return ( 85 | 86 |
87 | Fee stats (last 5 ledgers) 88 | 89 | API 90 | 91 |
92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | {this.state.loading ? ( 101 | 102 | 103 | 104 | ) : ( 105 | this.nameMap.map((field) => { 106 | let styleFn = this.feeStyle; 107 | let val = get(this.state.stats, field.id); 108 | let displayVal = get(this.state.stats, field.id); 109 | 110 | if (field.id === "ledger_capacity_usage") { 111 | styleFn = this.capacityStyle; 112 | displayVal = `${Math.round(val * 100)}%`; 113 | } 114 | 115 | return ( 116 | 117 | 118 | 119 | 120 | ); 121 | }) 122 | )} 123 | 124 |
MetricValue
Loading...
{field.name}{displayVal}
125 |
126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /frontend/components/Incidents.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import moment from "moment"; 5 | 6 | export default class Incidents extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { loading: true }; 10 | } 11 | 12 | getIncidents() { 13 | if (this.loading) { 14 | return; 15 | } 16 | this.loading = true; 17 | 18 | axios 19 | .get("https://9sl3dhr1twv1.statuspage.io/api/v2/incidents.json?limit=10") 20 | .then((response) => { 21 | this.setState({ loading: false, incidents: response.data.incidents }); 22 | this.loading = false; 23 | }); 24 | } 25 | 26 | componentDidMount() { 27 | this.getIncidents(); 28 | this.timerID = setInterval(() => this.getIncidents(), 60 * 1000); 29 | } 30 | 31 | componentWillUnmount() { 32 | clearInterval(this.timerID); 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 |
Incidents
39 | {this.state.loading ? ( 40 | Loading... 41 | ) : ( 42 |
    43 | {this.state.incidents.map((m) => { 44 | return ( 45 |
  • 46 | 47 | {m.name} 48 | {" "} 49 | ({moment(m.started_at).fromNow()}) 50 |
  • 51 | ); 52 | })} 53 |
54 | )} 55 |
56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /frontend/components/LedgerCloseChart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import { scale } from "d3"; 5 | import BarChart from "react-d3-components/lib/BarChart"; 6 | import each from "lodash/each"; 7 | import clone from "lodash/clone"; 8 | 9 | export default class LedgerChartClose extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.panel = null; 13 | this.colorScale = scale.category10(); 14 | this.state = { 15 | loading: true, 16 | chartWidth: 400, 17 | chartHeigth: this.props.chartHeigth || 120, 18 | }; 19 | this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; 20 | } 21 | 22 | componentDidMount() { 23 | this.getLedgers(); 24 | // Update chart width 25 | this.updateSize(); 26 | setInterval(() => this.updateSize(), 5000); 27 | } 28 | 29 | updateSize() { 30 | let value = this.panel.offsetWidth - 20; 31 | if (this.state.chartWidth != value) { 32 | this.setState({ chartWidth: value }); 33 | } 34 | } 35 | 36 | getLedgers() { 37 | axios.get(this.url).then((response) => { 38 | let data = [ 39 | { 40 | label: "Ledger Close", 41 | values: [], 42 | }, 43 | ]; 44 | this.lastLedgerClosedAt = null; 45 | each(response.data._embedded.records, (ledger) => { 46 | let closedAt = new Date(ledger.closed_at); 47 | if (this.lastLedgerClosedAt == null) { 48 | this.lastLedgerClosedAt = closedAt; 49 | this.frontLedgerClosedAt = closedAt; // used in onNewLedger 50 | return; 51 | } 52 | let diff = (this.lastLedgerClosedAt - closedAt) / 1000; 53 | data[0].values.unshift({ x: ledger.sequence.toString(), y: diff }); 54 | this.lastLedgerClosedAt = closedAt; 55 | }); 56 | this.setState({ loading: false, data }); 57 | // Start listening to events 58 | this.props.emitter.addListener( 59 | this.props.newLedgerEventName, 60 | this.onNewLedger.bind(this), 61 | ); 62 | }); 63 | } 64 | 65 | onNewLedger(ledger) { 66 | let closedAt = new Date(ledger.closed_at); 67 | if (this.frontLedgerClosedAt) { 68 | let data = clone(this.state.data); 69 | let diff = (closedAt - this.frontLedgerClosedAt) / 1000; 70 | data[0].values.push({ x: ledger.sequence.toString(), y: diff }); 71 | if (data[0].values.length > this.props.limit) { 72 | data[0].values.shift(); 73 | } 74 | this.setState({ data }); 75 | } 76 | 77 | this.frontLedgerClosedAt = closedAt; 78 | } 79 | 80 | render() { 81 | return ( 82 |
{ 84 | this.panel = el; 85 | }} 86 | > 87 | 88 |
89 | Last {this.props.limit} ledgers close times: {this.props.network} 90 | 91 | API 92 | 93 |
94 | {this.state.loading ? ( 95 | "Loading..." 96 | ) : ( 97 | 104 | )} 105 |
106 |
107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /frontend/components/LiquidityPoolBadge.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class LiquidityPoolBadge extends React.Component { 4 | render() { 5 | return ( 6 | 7 | 8 | 12 | {this.props.id.substr(0, 4)} 13 | 14 | 15 | 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/components/ListAccounts.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import clone from "lodash/clone"; 5 | import find from "lodash/find"; 6 | import reduce from "lodash/reduce"; 7 | import AccountBadge from "./AccountBadge"; 8 | import BigNumber from "bignumber.js"; 9 | 10 | export default class ListAccounts extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { balances: {} }; 14 | } 15 | 16 | loadBalances() { 17 | let balances = {}; 18 | 19 | Promise.all( 20 | this.props.accounts.map((accountId) => { 21 | return axios 22 | .get(`${this.props.horizonURL}/accounts/${accountId}`) 23 | .then((response) => { 24 | let xlmBalance = find( 25 | response.data.balances, 26 | (b) => b.asset_type == "native", 27 | ); 28 | let balance = xlmBalance.balance; 29 | balances[accountId] = new BigNumber(balance); 30 | }); 31 | }), 32 | ).then(() => { 33 | this.setState({ balances }); 34 | }); 35 | } 36 | 37 | componentDidMount() { 38 | // Update balances 39 | this.timerID = setInterval(() => this.loadBalances(), 60 * 60 * 1000); 40 | this.loadBalances(); 41 | } 42 | 43 | componentWillUnmount() { 44 | clearInterval(this.timerID); 45 | } 46 | 47 | render() { 48 | let sum = _.reduce( 49 | this.state.balances, 50 | (sum, balance) => sum.add(balance), 51 | new BigNumber(0), 52 | ); 53 | 54 | return ( 55 | 56 |
List of accounts: {this.props.label}
57 | {sum.gt(0) ? ( 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | {Object.keys(this.state.balances).map((key) => { 67 | return ( 68 | 69 | 75 | 83 | 84 | ); 85 | })} 86 | 87 | 88 | 89 | 90 | 93 | 94 | 95 |
AccountBalance
70 | 74 | 76 | {typeof this.state.balances[key] === "undefined" 77 | ? "Loading..." 78 | : `${this.state.balances[key].toFormat( 79 | 0, 80 | BigNumber.ROUND_FLOOR, 81 | )} XLM`} 82 |
Sum 91 | {sum.toFormat(0, BigNumber.ROUND_FLOOR)} XLM 92 |
96 | ) : ( 97 | "Loading..." 98 | )} 99 |
100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /frontend/components/LumensCirculating.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AmountWidget from "./AmountWidget"; 3 | import BigNumber from "bignumber.js"; 4 | import Panel from "muicss/lib/react/panel"; 5 | import { circulatingSupply } from "../../common/lumens.js"; 6 | 7 | export default class LumensCirculating extends AmountWidget { 8 | constructor(props) { 9 | super(props); 10 | } 11 | 12 | componentDidMount() { 13 | this.timerID = setInterval(() => this.updateAmount(), 60 * 60 * 1000); 14 | this.updateAmount(); 15 | } 16 | 17 | componentWillUnmount() { 18 | clearInterval(this.timerID); 19 | } 20 | 21 | updateAmount() { 22 | circulatingSupply().then((amount) => { 23 | this.setState({ 24 | amount: amount, 25 | code: "XLM", 26 | loading: false, 27 | }); 28 | }); 29 | } 30 | 31 | renderName() { 32 | return ( 33 |
34 | Circulating Supply 35 | 36 | API 37 | 38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/components/LumensDistributed.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AmountWidget from "./AmountWidget"; 3 | import Panel from "muicss/lib/react/panel"; 4 | import BigNumber from "bignumber.js"; 5 | import axios from "axios"; 6 | import find from "lodash/find"; 7 | import { distributionAll } from "../../common/lumens.js"; 8 | 9 | export default class LumensDistributed extends AmountWidget { 10 | constructor(props) { 11 | super(props); 12 | } 13 | 14 | componentDidMount() { 15 | this.timerID = setInterval(() => this.updateAmount(), 60 * 60 * 1000); 16 | this.updateAmount(); 17 | } 18 | 19 | componentWillUnmount() { 20 | clearInterval(this.timerID); 21 | } 22 | 23 | updateAmount() { 24 | distributionAll().then((amount) => { 25 | this.setState({ amount, code: "XLM", loading: false }); 26 | }); 27 | } 28 | 29 | renderName() { 30 | return ( 31 |
32 | Lumens Distributed 33 | 34 | API 35 | 36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/components/LumensNonCirculating.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AmountWidget from "./AmountWidget"; 3 | import Panel from "muicss/lib/react/panel"; 4 | import { noncirculatingSupply } from "../../common/lumens.js"; 5 | 6 | export default class LumensNonCirculating extends AmountWidget { 7 | constructor(props) { 8 | super(props); 9 | } 10 | 11 | componentDidMount() { 12 | this.timerID = setInterval(() => this.updateAmount(), 60 * 60 * 1000); 13 | this.updateAmount(); 14 | } 15 | 16 | componentWillUnmount() { 17 | clearInterval(this.timerID); 18 | } 19 | 20 | updateAmount() { 21 | noncirculatingSupply().then((amount) => { 22 | this.setState({ amount, code: "XLM", loading: false }); 23 | }); 24 | } 25 | 26 | renderName() { 27 | return ( 28 |
29 | Non-Circulating Supply 30 | 31 | API 32 | 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/components/NetworkStatus.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import round from "lodash/round"; 5 | import { ago, agoSeconds } from "../common/time"; 6 | 7 | // ledgersInAverageCalculation defines how many last ledgers should be 8 | // considered when calculating average ledger length. 9 | const ledgersInAverageCalculation = 200; 10 | 11 | export default class NetworkStatus extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = { loading: true }; 15 | } 16 | 17 | // This method will be called when a new ledger is created. 18 | onNewLedger(ledger) { 19 | let lastLedgerSequence = ledger.sequence; 20 | let protocolVersion = ledger.protocol_version; 21 | let closedAt = new Date(ledger.closed_at); 22 | let lastLedgerLength = closedAt - this.state.closedAt; 23 | // Update last ${ledgersInAverageCalculation} ledgers length sum by subtracting 24 | // the oldest measurement we have and adding the newest. 25 | this.records.unshift(ledger); 26 | let ledgerLengthSum = 27 | this.state.ledgerLengthSum - 28 | (new Date(this.records[this.records.length - 2].closed_at) - 29 | new Date(this.records[this.records.length - 1].closed_at)) / 30 | 1000 + 31 | (new Date(this.records[0].closed_at) - 32 | new Date(this.records[1].closed_at)) / 33 | 1000; 34 | this.records.pop(); 35 | this.setState({ 36 | closedAt, 37 | lastLedgerSequence, 38 | lastLedgerLength, 39 | ledgerLengthSum, 40 | protocolVersion, 41 | }); 42 | } 43 | 44 | getLastLedgers() { 45 | axios 46 | .get( 47 | `${this.props.horizonURL}/ledgers?order=desc&limit=${ledgersInAverageCalculation}`, 48 | ) 49 | .then((response) => { 50 | let ledger = response.data._embedded.records[0]; 51 | let lastLedgerSequence = ledger.sequence; 52 | let protocolVersion = ledger.protocol_version; 53 | let prevLedger = response.data._embedded.records[1]; 54 | let closedAt = new Date(ledger.closed_at); 55 | let lastLedgerLength = 56 | new Date(ledger.closed_at) - new Date(prevLedger.closed_at); 57 | 58 | this.records = response.data._embedded.records; 59 | let ledgerLengthSum = 0; 60 | for (let i = 0; i < this.records.length - 1; i++) { 61 | ledgerLengthSum += 62 | (new Date(this.records[i].closed_at) - 63 | new Date(this.records[i + 1].closed_at)) / 64 | 1000; 65 | } 66 | 67 | this.setState({ 68 | closedAt, 69 | lastLedgerLength, 70 | lastLedgerSequence, 71 | ledgerLengthSum, 72 | protocolVersion, 73 | loading: false, 74 | }); 75 | // Start listening to events 76 | this.props.emitter.addListener( 77 | this.props.newLedgerEventName, 78 | this.onNewLedger.bind(this), 79 | ); 80 | }); 81 | } 82 | 83 | componentDidMount() { 84 | // Update closedAgo 85 | this.timerID = setInterval(() => { 86 | let closedAgo = null; 87 | 88 | if (this.state.closedAt) { 89 | closedAgo = agoSeconds(this.state.closedAt); 90 | } 91 | 92 | this.setState({ closedAgo }); 93 | }, 1000); 94 | this.getLastLedgers(); 95 | } 96 | 97 | componentWillUnmount() { 98 | clearInterval(this.timerID); 99 | } 100 | 101 | render() { 102 | let statusClass; 103 | let statusText; 104 | 105 | let averageLedgerLength = 106 | this.state.ledgerLengthSum / ledgersInAverageCalculation; 107 | if (this.state.loading) { 108 | statusText = Loading...; 109 | } else if (this.state.closedAgo >= 90) { 110 | // If last ledger closed more than 90 seconds ago it means network is down. 111 | statusClass = "down"; 112 | statusText = ( 113 | 114 | Network (or monitoring node) down! 115 | 116 | ); 117 | } else { 118 | // Now we check the average close time but we also need to check the latest ledger 119 | // close time because if there are no new ledgers it means that network is slow or down. 120 | if (averageLedgerLength <= 10 && this.state.closedAgo < 20) { 121 | statusText = ( 122 | 123 | Up and running! 124 | 125 | ); 126 | } else if (averageLedgerLength <= 15 && this.state.closedAgo < 40) { 127 | statusClass = "slow"; 128 | statusText = ( 129 | 130 | Network slow! 131 | 132 | ); 133 | } else { 134 | statusClass = "very-slow"; 135 | statusText = ( 136 | 137 | Network very slow! 138 | 139 | ); 140 | } 141 | } 142 | 143 | return ( 144 | 145 |
Network Status: {this.props.network}
146 |
147 | {/* Fancy pulse effect */} 148 |
149 |
150 |
151 |
152 |
153 |
154 | {statusText} 155 |
156 | {!this.state.loading ? ( 157 |
158 | Protocol version: {this.state.protocolVersion} 159 |
160 | Last ledger: #{this.state.lastLedgerSequence} closed ~ 161 | {ago(this.state.closedAt)} ago in{" "} 162 | {this.state.lastLedgerLength / 1000}s. 163 |
164 | Average ledger close time in the last{" "} 165 | {ledgersInAverageCalculation} ledgers:{" "} 166 | {round(averageLedgerLength, 2)}s. 167 |
168 | ) : ( 169 | "" 170 | )} 171 |
172 |
173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /frontend/components/PublicNetworkLedgersHistoryChart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import { scale, format } from "d3"; 5 | import BarChart from "react-d3-components/lib/BarChart"; 6 | import clone from "lodash/clone"; 7 | import each from "lodash/each"; 8 | 9 | export default class PublicNetworkLedgersHistoryChart extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.panel = null; 13 | this.colorScale = scale.category10(); 14 | this.state = { 15 | loading: true, 16 | chartWidth: 400, 17 | chartHeight: this.props.chartHeight || 120, 18 | }; 19 | } 20 | 21 | componentDidMount() { 22 | this.getLedgers(); 23 | setInterval(() => this.getLedgers(), 1000 * 60 * 5); 24 | // Update chart width 25 | this.updateSize(); 26 | setInterval(() => this.updateSize(), 5000); 27 | } 28 | 29 | updateSize() { 30 | let value = this.panel.offsetWidth - 20; 31 | if (this.state.chartWidth != value) { 32 | this.setState({ chartWidth: value }); 33 | } 34 | } 35 | 36 | getLedgers() { 37 | axios.get("/api/ledgers/public").then((response) => { 38 | let data = [ 39 | { 40 | label: "Transactions", 41 | values: [], 42 | }, 43 | { 44 | label: "Operations", 45 | values: [], 46 | }, 47 | ]; 48 | each(response.data, (day) => { 49 | data[0].values.unshift({ x: day.date, y: day.transaction_count }); 50 | data[1].values.unshift({ x: day.date, y: day.operation_count }); 51 | }); 52 | this.setState({ loading: false, data }); 53 | }); 54 | } 55 | 56 | render() { 57 | return ( 58 |
{ 60 | this.panel = el; 61 | }} 62 | > 63 | 64 |
65 | Txs &{" "} 66 | Ops in 67 | the last 30 days: Live Network 68 |
69 | {this.state.loading ? ( 70 | "Loading..." 71 | ) : ( 72 | 80 | )} 81 |
82 |
83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/components/RecentOperations.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import moment from "moment"; 5 | import each from "lodash/each"; 6 | import defaults from "lodash/defaults"; 7 | import AccountBadge from "./AccountBadge"; 8 | import LiquidityPoolBadge from "./LiquidityPoolBadge"; 9 | import AssetLink from "./AssetLink"; 10 | import BigNumber from "bignumber.js"; 11 | import { ago } from "../common/time"; 12 | 13 | export default class RecentOperations extends React.Component { 14 | constructor(props) { 15 | super(props); 16 | this.props = defaults(props, { limit: 10 }); 17 | this.state = { loading: true, operations: [] }; 18 | 19 | this.url = `${this.props.horizonURL}/operations`; 20 | if (this.props.account) { 21 | this.url = `${this.props.horizonURL}/accounts/${this.props.account}/operations`; 22 | } 23 | this.url = `${this.url}?order=desc&limit=${this.props.limit}`; 24 | } 25 | 26 | getRecentOperations() { 27 | if (this.operationsLoading) { 28 | return; 29 | } 30 | this.operationsLoading = true; 31 | 32 | axios.get(this.url).then((response) => { 33 | let records = response.data._embedded.records; 34 | let operations = []; 35 | each(records, (operation) => { 36 | operation.createdAtMoment = moment(operation.created_at); 37 | operation.ago = ago(operation.createdAtMoment); 38 | operations.push(operation); 39 | }); 40 | this.setState({ operations }); 41 | this.operationsLoading = false; 42 | }); 43 | } 44 | 45 | componentDidMount() { 46 | this.getRecentOperations(); 47 | this.timerID = setInterval(() => this.getRecentOperations(), 10 * 1000); 48 | } 49 | 50 | componentWillUnmount() { 51 | clearInterval(this.timerID); 52 | } 53 | 54 | amount(am, asset_type, asset_code, asset_issuer) { 55 | // Strip zeros and `.` 56 | let amount = new BigNumber(am).toFormat(7).replace(/\.*0+$/, ""); 57 | let code; 58 | if (asset_type == "native") { 59 | code = XLM; 60 | } else { 61 | code = asset_code; 62 | } 63 | 64 | return ( 65 | 66 | {amount}{" "} 67 | 72 | 73 | ); 74 | } 75 | 76 | operationTypeColRender(op) { 77 | switch (op.type) { 78 | case "create_account": 79 | return ( 80 | 81 | {this.amount(op.starting_balance, "native")} »{" "} 82 | 87 | 88 | ); 89 | case "payment": 90 | return ( 91 | 92 | {this.amount( 93 | op.amount, 94 | op.asset_type, 95 | op.asset_code, 96 | op.asset_issuer, 97 | )}{" "} 98 | »{" "} 99 | 104 | 105 | ); 106 | case "path_payment_strict_receive": 107 | return ( 108 | 109 | max{" "} 110 | {this.amount( 111 | op.source_max, 112 | op.source_asset_type, 113 | op.source_asset_code, 114 | op.source_asset_issuer, 115 | )}{" "} 116 | »{" "} 117 | {this.amount( 118 | op.amount, 119 | op.asset_type, 120 | op.asset_code, 121 | op.asset_issuer, 122 | )}{" "} 123 | »{" "} 124 | 129 | 130 | ); 131 | case "change_trust": 132 | if (op.asset_type === "liquidity_pool_shares") { 133 | return ( 134 | 135 | Liquidity pool{" "} 136 | 140 | 141 | ); 142 | } 143 | 144 | return ( 145 | 146 | {" "} 151 | issued by{" "} 152 | 157 | 158 | ); 159 | case "allow_trust": 160 | return ( 161 | 162 | {op.authorize ? "Allowed" : "Disallowed"}{" "} 163 | {" "} 168 | to hold{" "} 169 | 174 | 175 | ); 176 | case "manage_sell_offer": 177 | case "create_passive_sell_offer": 178 | let action; 179 | 180 | if (op.amount == 0) { 181 | action = "Remove offer:"; 182 | } else if (op.offer_id != 0) { 183 | action = "Update offer: sell"; 184 | } else { 185 | action = "Sell"; 186 | } 187 | 188 | return ( 189 | 190 | {action}{" "} 191 | {this.amount( 192 | op.amount, 193 | op.selling_asset_type, 194 | op.selling_asset_code, 195 | op.selling_asset_issuer, 196 | )}{" "} 197 | for{" "} 198 | {op.buying_asset_type == "native" ? ( 199 | XLM 200 | ) : ( 201 | 206 | )} 207 | 208 | ); 209 | case "account_merge": 210 | return ( 211 | 212 | »{" "} 213 | 214 | 215 | ); 216 | case "manage_data": 217 | return ( 218 | 219 | Key:{" "} 220 | 221 | {op.name.length <= 20 ? op.name : op.name.substr(0, 20) + "..."} 222 | 223 | 224 | ); 225 | case "liquidity_pool_deposit": 226 | return Shares received: {op.shares_received}; 227 | case "liquidity_pool_withdraw": 228 | return Shares sold: {op.shares}; 229 | } 230 | } 231 | 232 | render() { 233 | return ( 234 | 235 |
236 | Recent operations: {this.props.label}{" "} 237 | {this.props.account ? this.props.account.substr(0, 4) : ""} 238 | 239 | API 240 | 241 |
242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | {this.state.operations.map((op) => { 253 | return ( 254 | 255 | 262 | 269 | 270 | 277 | 278 | ); 279 | })} 280 | 281 |
SourceOperationDetailsTime ago
256 | 261 | 263 | 264 | {op.type == "create_passive_offer" 265 | ? "passive_offer" 266 | : op.type} 267 | 268 | {this.operationTypeColRender(op)} 271 | {op.ago ? ( 272 | {op.ago} 273 | ) : ( 274 | "..." 275 | )} 276 |
282 |
283 | ); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /frontend/components/ScheduledMaintenance.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import moment from "moment"; 4 | import sanitizeHtml from "../utilities/sanitizeHtml.js"; 5 | 6 | export const ScheduledMaintenance = ({ scheduledMaintenances }) => { 7 | const sortedMaintenances = scheduledMaintenances 8 | .slice() 9 | .sort((a, b) => (moment(a).isSameOrBefore(b) ? 1 : -1)); 10 | const { 11 | id, 12 | name, 13 | incident_updates: updates, 14 | scheduled_for, 15 | } = sortedMaintenances[0]; 16 | const scheduledFor = moment(scheduled_for); 17 | 18 | return ( 19 | 20 |
21 | Scheduled Maintenance:{" "} 22 | 23 | {name} 24 | {" "} 25 | on{" "} 26 | {moment(scheduled_for).utc().format("dddd, MMMM Do YYYY, [at] h:mma")}{" "} 27 | UTC ( 28 | {moment(scheduled_for).format( 29 | moment(scheduled_for).utc().format("dddd") === 30 | moment(scheduled_for).format("dddd") 31 | ? "h:mma" 32 | : "MMMM Do YYYY, h:mma", 33 | )}{" "} 34 | local time) 35 |
36 | {updates.length > 0 ? ( 37 | {sanitizeHtml(updates[0].body)} 38 | ) : null} 39 |
40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/components/TotalCoins.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AmountWidget from "./AmountWidget"; 3 | import { totalSupply } from "../../common/lumens.js"; 4 | 5 | export default class TotalCoins extends AmountWidget { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | componentDidMount() { 11 | this.timerID = setInterval(() => this.updateAmount(), 60 * 60 * 1000); 12 | this.updateAmount(); 13 | } 14 | 15 | componentWillUnmount() { 16 | clearInterval(this.timerID); 17 | } 18 | 19 | updateAmount() { 20 | totalSupply().then((amount) => { 21 | let code = "XLM"; 22 | this.setState({ amount, code, loading: false }); 23 | }); 24 | } 25 | 26 | renderName() { 27 | return ( 28 |
29 | Total Supply 30 | 31 | API 32 | 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/components/TransactionsChart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Panel from "muicss/lib/react/panel"; 3 | import axios from "axios"; 4 | import { scale, format } from "d3"; 5 | import BarChart from "react-d3-components/lib/BarChart"; 6 | import clone from "lodash/clone"; 7 | import each from "lodash/each"; 8 | 9 | export default class TransactionsChart extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.panel = null; 13 | this.colorScale = scale.category10(); 14 | this.state = { 15 | loading: true, 16 | chartWidth: 400, 17 | chartHeigth: this.props.chartHeigth || 120, 18 | }; 19 | this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; 20 | } 21 | 22 | componentDidMount() { 23 | this.getLedgers(); 24 | // Update chart width 25 | this.updateSize(); 26 | setInterval(() => this.updateSize(), 5000); 27 | } 28 | 29 | updateSize() { 30 | let value = this.panel.offsetWidth - 20; 31 | if (this.state.chartWidth != value) { 32 | this.setState({ chartWidth: value }); 33 | } 34 | } 35 | 36 | onNewLedger(ledger) { 37 | let data = clone(this.state.data); 38 | data[0].values.push({ 39 | x: ledger.sequence.toString(), 40 | y: ledger.successful_transaction_count, 41 | }); 42 | data[1].values.push({ 43 | x: ledger.sequence.toString(), 44 | y: ledger.operation_count - ledger.successful_transaction_count, 45 | }); 46 | data[0].values.shift(); 47 | data[1].values.shift(); 48 | this.setState({ loading: false, data }); 49 | } 50 | 51 | getLedgers() { 52 | axios.get(this.url).then((response) => { 53 | let data = [ 54 | { 55 | label: "Transactions", 56 | values: [], 57 | }, 58 | { 59 | label: "Operations", 60 | values: [], 61 | }, 62 | ]; 63 | each(response.data._embedded.records, (ledger) => { 64 | data[0].values.unshift({ 65 | x: ledger.sequence.toString(), 66 | y: ledger.successful_transaction_count, 67 | }); 68 | data[1].values.unshift({ 69 | x: ledger.sequence.toString(), 70 | y: ledger.operation_count - ledger.successful_transaction_count, 71 | }); 72 | }); 73 | this.setState({ loading: false, data }); 74 | // Start listening to events 75 | this.props.emitter.addListener( 76 | this.props.newLedgerEventName, 77 | this.onNewLedger.bind(this), 78 | ); 79 | }); 80 | } 81 | 82 | render() { 83 | return ( 84 |
{ 86 | this.panel = el; 87 | }} 88 | > 89 | 90 |
91 | Successful{" "} 92 | Txs &{" "} 93 | Ops in 94 | the last {this.props.limit} ledgers: {this.props.network} 95 | 96 | API 97 | 98 |
99 | {this.state.loading ? ( 100 | "Loading..." 101 | ) : ( 102 | 110 | )} 111 |
112 |
113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /frontend/events.js: -------------------------------------------------------------------------------- 1 | export const LIVE_NEW_LEDGER = "LIVE_NEW_LEDGER"; 2 | export const TEST_NEW_LEDGER = "TEST_NEW_LEDGER"; 3 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Stellar Network Dashboard 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 30 | 31 | 36 | 41 | 46 | 52 | 56 | 57 | 58 | 62 | 67 | 68 | 74 | 75 | 76 | 77 |
78 | 79 | 80 | 81 | 82 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /frontend/scss/_force.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Press+Start+2P'); 2 | 3 | #main.force { 4 | background: black; 5 | 6 | h1.may4 { 7 | color: white; 8 | font-family: 'Press Start 2P', cursive; 9 | font-size: 1em; 10 | text-align: center; 11 | } 12 | 13 | section > h1 { 14 | color: white; 15 | text-shadow: none; 16 | } 17 | 18 | section > h2 { 19 | color: white; 20 | text-shadow: none; 21 | } 22 | 23 | a { 24 | color: #b20014; 25 | } 26 | 27 | .mui-appbar { 28 | background-color: #7E0010; 29 | 30 | .icons i { 31 | color: white; 32 | } 33 | 34 | .icons a { 35 | color: white; 36 | } 37 | } 38 | 39 | .mui-panel { 40 | background-color: #222; 41 | color: white; 42 | } 43 | 44 | .account-tag { 45 | background-color: black; 46 | } 47 | 48 | .tag { 49 | color: #b20014; 50 | } 51 | 52 | .api-link { 53 | background-color: #7E0010; 54 | color: white; 55 | } 56 | 57 | .recharts-wrapper { 58 | background-color: white; 59 | } 60 | 61 | footer { 62 | background-color: black; 63 | color: white; 64 | } 65 | 66 | @mixin glow($color) { 67 | @keyframes glow_#{$color} { 68 | from { color: #{$color}; box-shadow: 0 0 9px #{$color}; } 69 | 50% { color: #{$color}; box-shadow: 0 0 50px #{$color}; } 70 | to { color: #{$color}; box-shadow: 0 0 9px #{$color}; } 71 | } 72 | } 73 | 74 | @include glow("lime"); 75 | @include glow("orange"); 76 | @include glow("red"); 77 | @include glow("gray"); 78 | 79 | .pulse-container { 80 | width: 180px; 81 | height: 30px; 82 | position: relative; 83 | margin-left: auto; 84 | margin-right: auto; 85 | margin-top: 30px; 86 | 87 | .pulse { 88 | opacity: 1; 89 | width: 180px; 90 | height: 9px; 91 | background-color: white; 92 | 93 | &.pulse1 { 94 | animation: glow_lime 1.5s linear infinite; 95 | box-shadow: 0px 0px 10px, 0px 0px 2px inset; 96 | border-top-left-radius: 0; 97 | border-top-right-radius: 20px 10px; 98 | border-bottom-right-radius: 20px 10px; 99 | border-bottom-left-radius: 0; 100 | } 101 | 102 | &.pulse2 { 103 | animation: none; 104 | height: 5px; 105 | margin-top: 2px; 106 | width: 30px; 107 | margin-left: -30px; 108 | border-right: 1px solid black; 109 | 110 | border-top-left-radius: 2px; 111 | border-top-right-radius: 0; 112 | border-bottom-right-radius: 0; 113 | border-bottom-left-radius: 2px; 114 | 115 | @include prefixer(background, linear-gradient(to right, #fff 0%, #999 10%, #fff 20%, #999 30%, #fff 40%, #999 50%, #fff 70%, #999 70%, #fff 80%, #999 90%, #fff 100%), webkit moz spec); 116 | } 117 | 118 | &.pulse1.slow { 119 | animation: glow_orange 1.5s linear infinite; 120 | background-color: orange; 121 | } 122 | 123 | &.pulse1.very-slow { 124 | animation: glow_red 1.5s linear infinite; 125 | background-color: red; 126 | } 127 | 128 | &.pulse1.down { 129 | animation: glow_gray 1.5s linear infinite; 130 | background-color: black; 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /frontend/scss/_main.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #eeeeee; 3 | font-family: "Roboto", "Helvetica Neue", Helvetica, Arial; 4 | } 5 | 6 | footer { 7 | background-color: white; 8 | font-size: 0.9em; 9 | padding: 15px; 10 | @include prefixer(border-shadow, 0px 0px 20px 2px #dddddd, webkit moz spec); 11 | } 12 | 13 | section { 14 | clear: both; 15 | 16 | > h1 { 17 | text-align: center; 18 | text-transform: uppercase; 19 | letter-spacing: 2px; 20 | font-size: 1em; 21 | font-weight: 600; 22 | text-shadow: 1px 1px white; 23 | margin-bottom: 0; 24 | } 25 | 26 | > h2 { 27 | color: #999; 28 | text-align: center; 29 | font-size: 0.9em; 30 | font-weight: 600; 31 | text-shadow: 1px 1px white; 32 | margin: 0 auto 25px auto; 33 | width: 60%; 34 | line-height: normal; 35 | 36 | @media only screen and (max-width: 768px) { 37 | width: 90%; 38 | } 39 | } 40 | } 41 | 42 | .mui-appbar { 43 | display: flex; 44 | justify-content: space-between; 45 | 46 | .mui--text-headline { 47 | margin-left: 20px; 48 | float: left; 49 | } 50 | 51 | a { 52 | color: white; 53 | text-decoration: none; 54 | } 55 | 56 | .left { 57 | float: left; 58 | } 59 | 60 | .back { 61 | margin-left: 20px; 62 | font-size: 0.8em; 63 | margin-top: 5px; 64 | } 65 | 66 | .back a { 67 | color: #b3dbfb; 68 | } 69 | 70 | .icons { 71 | display: flex; 72 | align-items: center; 73 | 74 | .icon { 75 | margin-right: 20px; 76 | text-align: center; 77 | } 78 | } 79 | } 80 | 81 | .mui--bg-accent-light { 82 | &.mui-panel { 83 | margin-bottom: 0; 84 | } 85 | 86 | a { 87 | color: #fff; 88 | text-decoration: underline; 89 | } 90 | } 91 | 92 | .mui--bg-accent { 93 | &.mui-panel { 94 | margin-bottom: 0; 95 | } 96 | 97 | a { 98 | color: #fff; 99 | text-decoration: underline; 100 | } 101 | } 102 | 103 | .widget-name { 104 | text-transform: uppercase; 105 | 106 | .tag { 107 | color: rgb(33, 150, 243); 108 | font-size: 0.7em; 109 | font-weight: 500; 110 | padding-left: 2px; 111 | 112 | i.material-icons { 113 | font-size: 1.1em; 114 | } 115 | } 116 | } 117 | 118 | @media only screen and (min-width: 768px) and (max-width: 1180px) { 119 | .widget-name .tag .hide-narrow-panel { 120 | display: none; 121 | } 122 | } 123 | 124 | .mui-table.small { 125 | font-size: 0.8em; 126 | margin-bottom: 0px; 127 | } 128 | 129 | .mui-table.small tbody > tr:hover { 130 | background-color: #f0f0f0; 131 | } 132 | 133 | .chart-labels { 134 | font-size: 0.8em; 135 | } 136 | 137 | .mui-table.small td, 138 | .mui-table.small th { 139 | padding: 0px; 140 | } 141 | 142 | .row { 143 | clear: both; 144 | } 145 | 146 | .margin-top10 { 147 | margin-top: 10px; 148 | } 149 | 150 | .small { 151 | font-size: 0.8em; 152 | } 153 | 154 | .gray { 155 | color: #bbbbbb; 156 | } 157 | 158 | .clear { 159 | clear: both; 160 | } 161 | 162 | .api-link { 163 | float: right; 164 | text-transform: uppercase; 165 | font-size: 0.8em; 166 | background-color: rgb(255, 111, 0); 167 | color: white; 168 | padding: 2px; 169 | font-weight: bold; 170 | } 171 | 172 | .api-link:hover { 173 | color: white; 174 | text-decoration: none; 175 | } 176 | 177 | .api-link:active { 178 | color: white; 179 | text-decoration: none; 180 | } 181 | 182 | .amount-column { 183 | text-align: left; 184 | } 185 | 186 | .account { 187 | margin-left: 2px; 188 | } 189 | 190 | .account-tag { 191 | text-transform: uppercase; 192 | font-size: 0.8em; 193 | background-color: #eeeeee; 194 | padding: 2px; 195 | } 196 | 197 | .slick-slider { 198 | user-select: initial; 199 | -webkit-user-select: initial; 200 | -moz-user-select: initial; 201 | -ms-user-select: initial; 202 | } 203 | 204 | @keyframes anim_pulse { 205 | 0%, 206 | 100% { 207 | transform: scale(0); 208 | } 209 | 50% { 210 | transform: scale(1); 211 | } 212 | } 213 | 214 | .pulse-container { 215 | width: 60px; 216 | height: 60px; 217 | position: relative; 218 | margin-left: auto; 219 | margin-right: auto; 220 | 221 | .pulse { 222 | background-color: rgb(33, 150, 243); 223 | width: 60px; 224 | height: 60px; 225 | border-radius: 100%; 226 | opacity: 0.6; 227 | position: absolute; 228 | top: 0px; 229 | left: 0px; 230 | 231 | &.pulse1 { 232 | animation: anim_pulse 2s ease-in-out 1s infinite both; 233 | } 234 | 235 | &.pulse2 { 236 | animation: anim_pulse 2s ease-in-out 0s infinite both; 237 | } 238 | 239 | &.pulse1.slow { 240 | animation: anim_pulse 4s ease-in-out 2s infinite both; 241 | background-color: orange; 242 | } 243 | 244 | &.pulse2.slow { 245 | animation: anim_pulse 4s ease-in-out 0s infinite both; 246 | background-color: orange; 247 | } 248 | 249 | &.pulse1.very-slow { 250 | animation: anim_pulse 10s ease-in-out 5s infinite both; 251 | background-color: red; 252 | } 253 | 254 | &.pulse2.very-slow { 255 | animation: anim_pulse 10s ease-in-out 0s infinite both; 256 | background-color: red; 257 | } 258 | 259 | &.pulse1.down { 260 | animation: anim_pulse 20s ease-in-out 10s infinite both; 261 | background-color: black; 262 | } 263 | 264 | &.pulse2.down { 265 | animation: anim_pulse 20s ease-in-out 0s infinite both; 266 | background-color: black; 267 | } 268 | } 269 | } 270 | 271 | .axis text { 272 | font-size: 10px; 273 | } 274 | 275 | .arc text { 276 | font-size: 10px; 277 | } 278 | 279 | .node-panel { 280 | padding: 0px !important; 281 | margin-bottom: 5px; 282 | 283 | i.dicon { 284 | position: absolute; 285 | color: #777; 286 | font-size: 18px; 287 | } 288 | 289 | .afterI { 290 | margin-left: 22px; 291 | } 292 | 293 | .uptime-great { 294 | font-weight: bold; 295 | color: #2196f3; 296 | } 297 | 298 | .uptime-good { 299 | color: #2196f3; 300 | } 301 | 302 | .uptime-normal { 303 | color: #ff6f00; 304 | } 305 | 306 | .uptime-bad { 307 | color: #cc0000; 308 | } 309 | } 310 | 311 | .node-panel-large { 312 | display: flex; 313 | align-items: center; 314 | min-height: 30px; 315 | } 316 | 317 | .node-circle-container { 318 | display: flex; 319 | align-items: center; 320 | } 321 | 322 | .node-circle { 323 | width: 10px; 324 | height: 10px; 325 | border-radius: 100%; 326 | padding: 0 !important; 327 | margin-right: 10px; 328 | } 329 | 330 | .node-circle.blue { 331 | background-color: #2196f3; 332 | } 333 | 334 | .node-circle.orange { 335 | background-color: orange; 336 | } 337 | 338 | .node-circle.red { 339 | background-color: red; 340 | } 341 | 342 | .node-barchart-container { 343 | display: flex; 344 | justify-content: flex-end; 345 | align-items: center; 346 | position: relative; 347 | } 348 | 349 | .node-barchart-container .domain { 350 | display: none; 351 | visibility: hidden; 352 | } 353 | 354 | .node-barchart { 355 | padding-top: 5px !important; 356 | } 357 | 358 | .node-hovered-container { 359 | visibility: hidden; 360 | z-index: 10; 361 | width: 100%; 362 | height: 100%; 363 | position: absolute; 364 | left: 0; 365 | transition: all ease-out 100ms; 366 | } 367 | 368 | .node-pubkey { 369 | display: flex; 370 | align-items: center; 371 | justify-content: center; 372 | background-color: white; 373 | width: 85%; 374 | height: 100%; 375 | font-family: monospace; 376 | font-size: 8pt; 377 | box-shadow: 1px 0px 7px 0px white; 378 | } 379 | 380 | .node-panel:hover .node-hovered-container { 381 | visibility: visible; 382 | transition: all ease-in 100ms; 383 | } 384 | 385 | .node-dropdown-button { 386 | cursor: pointer; 387 | } 388 | 389 | .node-dropdown { 390 | padding: 0px 10px; 391 | } 392 | 393 | .node-update-interval-container { 394 | padding-bottom: 5px; 395 | } 396 | 397 | ul.incidents { 398 | margin: 0; 399 | padding: 0; 400 | list-style-type: none; 401 | font-size: 12px; 402 | } 403 | 404 | .mui--text-subhead { 405 | @media screen and (max-width: 500px) { 406 | font-size: 14px; 407 | line-height: 20px; 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /frontend/scss/index.scss: -------------------------------------------------------------------------------- 1 | @import "../../node_modules/bourbon/app/assets/stylesheets/_bourbon"; 2 | @import "_main"; 3 | @import "_force"; 4 | -------------------------------------------------------------------------------- /frontend/utilities/sanitizeHtml.js: -------------------------------------------------------------------------------- 1 | import parse from "html-react-parser"; 2 | import DOMPurify from "dompurify"; 3 | 4 | function sanitizeHtml(html) { 5 | return parse(DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })); 6 | } 7 | 8 | export default sanitizeHtml; 9 | -------------------------------------------------------------------------------- /gcloud/service-account-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "", 4 | "private_key_id": "", 5 | "private_key": "", 6 | "client_email": "", 7 | "client_id": "", 8 | "auth_uri": "", 9 | "token_uri": "", 10 | "auth_provider_x509_cert_url": "", 11 | "client_x509_cert_url": "" 12 | } 13 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | var bs = require("browser-sync").create(); 5 | var gulp = require("gulp"); 6 | var path = require("path"); 7 | var webpack = require("webpack"); 8 | var ExtractTextPlugin = require("extract-text-webpack-plugin"); 9 | 10 | var webpackOptions = { 11 | entry: { 12 | app: "./frontend/app.js", 13 | vendor: [ 14 | "react", 15 | "react-dom", 16 | "muicss", 17 | "stellar-sdk", 18 | "axios", 19 | "d3", 20 | "fbemitter", 21 | ], 22 | }, 23 | devtool: "source-map", 24 | resolve: { 25 | root: [path.resolve("frontend"), path.resolve("common")], 26 | modulesDirectories: ["node_modules"], 27 | }, 28 | module: { 29 | loaders: [ 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules/, 33 | loader: "babel-loader", 34 | query: { presets: ["es2015", "react"] }, 35 | }, 36 | { test: /\.json$/, loader: "json-loader" }, 37 | { test: /\.html$/, loader: "file?name=[name].html" }, 38 | { 39 | test: /\.scss$/, 40 | loader: ExtractTextPlugin.extract( 41 | "style-loader", 42 | "css-loader!sass-loader", 43 | ), 44 | }, 45 | ], 46 | }, 47 | plugins: [ 48 | new webpack.IgnorePlugin(/ed25519/), 49 | new ExtractTextPlugin("style.css"), 50 | ], 51 | }; 52 | 53 | const develop = function (done) { 54 | var options = merge(webpackOptions, { 55 | output: { 56 | filename: "[name].js", 57 | path: "./.tmp", 58 | }, 59 | plugins: [new webpack.optimize.CommonsChunkPlugin("vendor", "vendor.js")], 60 | }); 61 | 62 | var watchOptions = { 63 | aggregateTimeout: 300, 64 | }; 65 | 66 | var bsInitialized = false; 67 | 68 | var compiler = webpack(options); 69 | compiler.purgeInputFileSystem(); 70 | compiler.watch(watchOptions, function (error, stats) { 71 | if (!bsInitialized) { 72 | gulp.watch(".tmp/**/*").on("change", bs.reload); 73 | bs.init({ 74 | port: 3000, 75 | online: false, 76 | notify: false, 77 | server: "./.tmp", 78 | socket: { 79 | domain: "localhost:3000", 80 | }, 81 | }); 82 | bsInitialized = true; 83 | } 84 | console.log( 85 | stats.toString({ 86 | hash: false, 87 | version: false, 88 | timings: true, 89 | chunks: false, 90 | colors: true, 91 | }), 92 | ); 93 | }); 94 | }; 95 | 96 | const build = function (done) { 97 | var options = merge(webpackOptions, { 98 | bail: true, 99 | output: { 100 | // TODO chunkhash 101 | filename: "[name].js", //"[name]-[chunkhash].js", 102 | path: "./dist", 103 | }, 104 | plugins: [ 105 | new webpack.optimize.CommonsChunkPlugin("vendor", "vendor.js"), 106 | new webpack.optimize.DedupePlugin(), 107 | new webpack.optimize.OccurenceOrderPlugin(), 108 | new webpack.DefinePlugin({ 109 | "process.env": { 110 | NODE_ENV: JSON.stringify("production"), 111 | }, 112 | }), 113 | new webpack.optimize.UglifyJsPlugin(), 114 | ], 115 | }); 116 | 117 | var compiler = webpack(options); 118 | compiler.purgeInputFileSystem(); 119 | compiler.run(done); 120 | }; 121 | 122 | function merge(object1, object2) { 123 | return _.mergeWith(object1, object2, function (a, b) { 124 | if (_.isArray(a)) { 125 | return a.concat(b); 126 | } 127 | }); 128 | } 129 | 130 | gulp.task("develop", develop); 131 | gulp.task("build", build); 132 | gulp.task("default", develop); 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stellar-dashboard", 3 | "prettier": "@stellar/prettier-config", 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "app.js", 7 | "engines": { 8 | "node": "16.x" 9 | }, 10 | "scripts": { 11 | "start": "gulp", 12 | "build": "gulp build", 13 | "heroku-postbuild": "gulp build", 14 | "lint:prettier": "prettier --config --write '**/*.js'", 15 | "test": "DEV=true mocha -r ts-node/register './test/**/*.ts' --exit", 16 | "start:backend": "UPDATE_DATA=true DEV=true ts-node backend/app.ts", 17 | "prepare": "husky install", 18 | "pre-commit": "lint-staged" 19 | }, 20 | "lint-staged": { 21 | "**/*.js": [ 22 | "prettier --write" 23 | ], 24 | "./backend/*.ts": "eslint --cache" 25 | }, 26 | "resolutions": { 27 | "**/ua-parser-js": "0.7.28" 28 | }, 29 | "author": "Stellar.org", 30 | "dependencies": { 31 | "@google-cloud/bigquery": "^5.10.0", 32 | "@stellar/prettier-config": "^1.0.1", 33 | "axios": "^0.19.0", 34 | "babel-core": "^6.23.0", 35 | "babel-loader": "^6.3.2", 36 | "babel-preset-es2015": "^6.22.0", 37 | "babel-preset-react": "^6.23.0", 38 | "babel-register": "^6.24.0", 39 | "bignumber.js": "^9.0.1", 40 | "bourbon": "^4.3.4", 41 | "bourbon-neat": "^2.0.0", 42 | "browser-sync": "^2.18.2", 43 | "css-loader": "2.1.1", 44 | "dompurify": "^2.2.6", 45 | "dotenv": "^16.0.0", 46 | "express": "^4.14.0", 47 | "express-http-proxy": "^1.0.6", 48 | "extract-text-webpack-plugin": "^0.7.0", 49 | "fbemitter": "^2.1.1", 50 | "file-loader": "^0.9.0", 51 | "gulp": "^4.0.2", 52 | "html-react-parser": "^1.2.4", 53 | "json-loader": "^0.5.1", 54 | "lodash": "^4.17.15", 55 | "moment": "^2.17.1", 56 | "morgan": "^1.8.2", 57 | "muicss": "^0.9.5", 58 | "node-sass": "^6.0.0", 59 | "react": "^16.3.2", 60 | "react-d3-components": "^0.9.1", 61 | "react-dom": "^16.3.2", 62 | "redis": "^4.0.1", 63 | "sass-loader": "^6.0.6", 64 | "stellar-sdk": "^2.0.0-beta.7", 65 | "style-loader": "^0.19.0", 66 | "typescript": "^4.5.4", 67 | "webpack": "^1.13.3" 68 | }, 69 | "devDependencies": { 70 | "@stellar/eslint-config": "^2.1.2", 71 | "@stellar/tsconfig": "^1.0.2", 72 | "@types/chai": "^4.3.0", 73 | "@types/express": "^4.17.13", 74 | "@types/express-http-proxy": "^1.6.3", 75 | "@types/lodash": "^4.14.178", 76 | "@types/mocha": "^9.0.0", 77 | "@types/morgan": "^1.9.3", 78 | "@typescript-eslint/eslint-plugin": "^5.9.1", 79 | "@typescript-eslint/parser": "^5.9.1", 80 | "chai": "^4.3.4", 81 | "eslint": "^8.6.0", 82 | "eslint-config-prettier": "^8.3.0", 83 | "eslint-config-react-app": "^7.0.0", 84 | "eslint-plugin-jsdoc": "^37.6.1", 85 | "eslint-plugin-prefer-arrow": "^1.2.3", 86 | "husky": ">=6", 87 | "lint-staged": ">=10", 88 | "mocha": "^8.4.0", 89 | "prettier": "^2.5.1", 90 | "supertest": "^6.1.6", 91 | "ts-node": "^10.4.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test-setup.ts 2 | -------------------------------------------------------------------------------- /test/test-setup.ts: -------------------------------------------------------------------------------- 1 | require("babel-register")({ 2 | presets: ["es2015"], 3 | }); 4 | -------------------------------------------------------------------------------- /test/tests/integration/backend.ts: -------------------------------------------------------------------------------- 1 | import chai from "chai"; 2 | const request = require("supertest"); 3 | 4 | const { app, updateLumensCache } = require("../../../backend/routes"); 5 | describe("integration", function () { 6 | this.timeout(10000); 7 | // update caches 8 | before(async function () { 9 | await updateLumensCache(); 10 | }); 11 | 12 | describe("backend api endpoints", function () { 13 | it("/api/lumens should return successfuly with data", async function () { 14 | let { body } = await request(app).get("/api/lumens").expect(200); 15 | 16 | chai.expect(body).to.be.an("object"); 17 | chai.expect(Object.keys(body).length).to.not.equal(0); 18 | }); 19 | 20 | it("/api/v2/lumens should return successfuly with data", async function () { 21 | let { body } = await request(app).get("/api/v2/lumens").expect(200); 22 | 23 | chai.expect(body).to.be.an("object"); 24 | chai.expect(Object.keys(body).length).to.not.equal(0); 25 | }); 26 | 27 | it("/api/v2/lumens/total-supply should return successfuly with data", async function () { 28 | let { body } = await request(app) 29 | .get("/api/v2/lumens/total-supply") 30 | .expect(200); 31 | 32 | chai.expect(body).to.be.an("number"); 33 | chai.expect(body).to.not.equal(0); 34 | }); 35 | 36 | it("/api/v2/lumens/circulating-supply should return successfuly with data", async function () { 37 | let { body } = await request(app) 38 | .get("/api/v2/lumens/circulating-supply") 39 | .expect(200); 40 | 41 | chai.expect(body).to.be.an("number"); 42 | chai.expect(body).to.not.equal(0); 43 | }); 44 | 45 | it("/api/v3/lumens should return successfuly with data", async function () { 46 | let { body } = await request(app).get("/api/v3/lumens").expect(200); 47 | 48 | chai.expect(body).to.be.an("object"); 49 | chai.expect(Object.keys(body).length).to.not.equal(0); 50 | }); 51 | 52 | it("/api/v3/lumens/all should return successfuly with data", async function () { 53 | let { body } = await request(app).get("/api/v3/lumens/all").expect(200); 54 | 55 | chai.expect(body).to.be.an("object"); 56 | chai.expect(Object.keys(body).length).to.not.equal(0); 57 | }); 58 | 59 | it("/api/v3/lumens/total-supply should return successfuly with data", async function () { 60 | let { body } = await request(app) 61 | .get("/api/v3/lumens/total-supply") 62 | .expect(200); 63 | 64 | chai.expect(body).to.be.an("string"); 65 | chai.expect(body).to.not.equal(""); 66 | }); 67 | 68 | it("/api/v3/lumens/circulating-supply should return successfuly with data", async function () { 69 | let { body } = await request(app) 70 | .get("/api/v3/lumens/circulating-supply") 71 | .expect(200); 72 | 73 | chai.expect(body).to.be.an("string"); 74 | chai.expect(body).to.not.equal(""); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/tests/unit/backend.ts: -------------------------------------------------------------------------------- 1 | import chai from "chai"; 2 | import { redisClient } from "../../../backend/redis"; 3 | import { updateCache, catchup, LedgerRecord } from "../../../backend/ledgers"; 4 | const v1 = require("../../../backend/lumens"); 5 | const v2v3 = require("../../../backend/v2v3/lumens"); 6 | 7 | const REDIS_LEDGER_KEY_TEST = "ledgers_test"; 8 | const REDIS_PAGING_TOKEN_KEY_TEST = "paging_token_test"; 9 | 10 | // 10s timeout added for the multiple calls to Horizon per test, which occasionally 11 | // surpasses the default 2s timeout causing an error. 12 | 13 | describe("lumens v1", function () { 14 | this.timeout(10000); 15 | describe("updateApiLumens", function () { 16 | it("should run without error and caches should update", async function () { 17 | let err = await v1.updateApiLumens(); 18 | chai.assert.isUndefined(err, "there was no error"); 19 | 20 | let cached = await redisClient.get("lumensV1"); 21 | chai.expect(cached).to.not.be.null; 22 | let obj = JSON.parse(cached as string); 23 | 24 | chai 25 | .expect(obj) 26 | .to.be.an("object") 27 | .that.has.all.keys([ 28 | "updatedAt", 29 | "totalCoins", 30 | "availableCoins", 31 | "programs", 32 | ]); 33 | chai 34 | .expect(obj.programs) 35 | .to.be.an("object") 36 | .that.has.all.keys([ 37 | "directDevelopment", 38 | "ecosystemSupport", 39 | "useCaseInvestment", 40 | "userAcquisition", 41 | ]); 42 | }); 43 | }); 44 | }); 45 | 46 | describe("lumens v2", function () { 47 | this.timeout(10000); 48 | describe("updateApiLumens", function () { 49 | it("should run without error and caches should update", async function () { 50 | let err = await v2v3.updateApiLumens(); 51 | chai.assert.isUndefined(err, "there was no error"); 52 | 53 | let cached = await redisClient.get("lumensV2"); 54 | chai.expect(cached).to.not.be.null; 55 | let obj = JSON.parse(cached as string); 56 | 57 | chai 58 | .expect(obj) 59 | .to.be.an("object") 60 | .that.has.all.keys([ 61 | "updatedAt", 62 | "originalSupply", 63 | "inflationLumens", 64 | "burnedLumens", 65 | "totalSupply", 66 | "upgradeReserve", 67 | "feePool", 68 | "sdfMandate", 69 | "circulatingSupply", 70 | "_details", 71 | ]); 72 | for (var k in obj) { 73 | chai.expect(obj[k].toString()).to.not.be.empty; 74 | } 75 | }); 76 | }); 77 | }); 78 | 79 | describe("lumens v3", function () { 80 | this.timeout(10000); 81 | describe("updateApiLumens", function () { 82 | it("should run without error and caches should update", async function () { 83 | let err = await v2v3.updateApiLumens(); 84 | chai.assert.isUndefined(err, "there was no error"); 85 | 86 | let cached = await redisClient.get("lumensV3"); 87 | chai.expect(cached).to.not.be.null; 88 | let obj = JSON.parse(cached as string); 89 | 90 | chai 91 | .expect(obj) 92 | .to.be.an("object") 93 | .that.has.all.keys([ 94 | "updatedAt", 95 | "originalSupply", 96 | "inflationLumens", 97 | "burnedLumens", 98 | "totalSupply", 99 | "upgradeReserve", 100 | "feePool", 101 | "sdfMandate", 102 | "circulatingSupply", 103 | "_details", 104 | ]); 105 | for (var k in obj) { 106 | chai.expect(obj[k].toString()).to.not.be.empty; 107 | } 108 | 109 | cached = await redisClient.get("totalSupplyCheckResponse"); 110 | chai.expect(cached).to.not.be.null; 111 | obj = JSON.parse(cached as string); 112 | 113 | chai 114 | .expect(obj) 115 | .to.be.an("object") 116 | .that.has.all.keys([ 117 | "updatedAt", 118 | "totalSupply", 119 | "inflationLumens", 120 | "burnedLumens", 121 | "totalSupplySum", 122 | "upgradeReserve", 123 | "feePool", 124 | "sdfMandate", 125 | "circulatingSupply", 126 | ]); 127 | for (var k in obj) { 128 | chai.expect(obj[k].toString()).to.not.be.empty; 129 | } 130 | }); 131 | }); 132 | }); 133 | 134 | describe("ledgers", function () { 135 | describe("updateCache", function () { 136 | it("should store cache with correct data", async function () { 137 | // cleanup 138 | await redisClient.del(REDIS_LEDGER_KEY_TEST); 139 | await redisClient.del(REDIS_PAGING_TOKEN_KEY_TEST); 140 | 141 | const ledgers: LedgerRecord[] = [ 142 | { 143 | paging_token: "101", 144 | sequence: 10001, 145 | successful_transaction_count: 10, 146 | failed_transaction_count: 5, 147 | operation_count: 50, 148 | closed_at: "2022-01-11T01:06:00Z", 149 | }, 150 | { 151 | paging_token: "102", 152 | sequence: 10002, 153 | successful_transaction_count: 20, 154 | failed_transaction_count: 10, 155 | operation_count: 100, 156 | closed_at: "2022-01-12T01:06:00Z", 157 | }, 158 | { 159 | paging_token: "103", 160 | sequence: 10003, 161 | successful_transaction_count: 30, 162 | failed_transaction_count: 20, 163 | operation_count: 200, 164 | closed_at: "2022-01-12T01:07:00Z", 165 | }, 166 | ]; 167 | 168 | await updateCache( 169 | ledgers, 170 | REDIS_LEDGER_KEY_TEST, 171 | REDIS_PAGING_TOKEN_KEY_TEST, 172 | ); 173 | 174 | const cachedLedgers = await redisClient.get(REDIS_LEDGER_KEY_TEST); 175 | const cachedPagingToken = await redisClient.get( 176 | REDIS_PAGING_TOKEN_KEY_TEST, 177 | ); 178 | chai.expect(JSON.parse(cachedLedgers as string)).to.eql([ 179 | { 180 | date: "01-12", 181 | transaction_count: 80, 182 | operation_count: 300, 183 | }, 184 | { 185 | date: "01-11", 186 | transaction_count: 15, 187 | operation_count: 50, 188 | }, 189 | ]); 190 | chai.assert.equal(cachedPagingToken as string, "103"); 191 | }); 192 | }); 193 | describe("catchup", function () { 194 | this.timeout(20000); 195 | it("should handle large amounts of ledgers", async function () { 196 | // cleanup 197 | await redisClient.del(REDIS_LEDGER_KEY_TEST); 198 | await redisClient.del(REDIS_PAGING_TOKEN_KEY_TEST); 199 | 200 | await catchup( 201 | REDIS_LEDGER_KEY_TEST, 202 | "168143176454897664", 203 | REDIS_PAGING_TOKEN_KEY_TEST, 204 | 1000, 205 | ); 206 | 207 | const cachedLedgers = await redisClient.get(REDIS_LEDGER_KEY_TEST); 208 | const cachedPagingToken = await redisClient.get( 209 | REDIS_PAGING_TOKEN_KEY_TEST, 210 | ); 211 | 212 | chai 213 | .expect(JSON.parse(cachedLedgers as string)) 214 | .to.eql([ 215 | { date: "01-12", transaction_count: 403018, operation_count: 781390 }, 216 | ]); 217 | chai.assert.equal(cachedPagingToken as string, "168147471422193664"); 218 | }); 219 | it("should not update if caught up", async function () { 220 | await redisClient.set(REDIS_LEDGER_KEY_TEST, "[]"); 221 | await redisClient.set(REDIS_PAGING_TOKEN_KEY_TEST, "10"); 222 | 223 | await catchup( 224 | REDIS_LEDGER_KEY_TEST, 225 | "now", 226 | REDIS_PAGING_TOKEN_KEY_TEST, 227 | 0, 228 | ); 229 | 230 | const cachedLedgers = await redisClient.get(REDIS_LEDGER_KEY_TEST); 231 | const cachedPagingToken = await redisClient.get( 232 | REDIS_PAGING_TOKEN_KEY_TEST, 233 | ); 234 | chai.assert.equal(cachedLedgers as string, "[]"); 235 | chai.assert.equal(cachedPagingToken as string, "10"); 236 | }); 237 | it("should only store last 30 days", async function () { 238 | // cleanup 239 | await redisClient.del(REDIS_LEDGER_KEY_TEST); 240 | await redisClient.del(REDIS_PAGING_TOKEN_KEY_TEST); 241 | 242 | const ledgers: LedgerRecord[] = []; 243 | for (let i = 1; i <= 31; i++) { 244 | ledgers.push( 245 | { 246 | paging_token: String(100 + 2 * i - 1), 247 | sequence: 1000 + 2 * i - 1, 248 | successful_transaction_count: 10 + i, 249 | failed_transaction_count: 5 + i, 250 | operation_count: 50 + i, 251 | closed_at: `2022-01-${("0" + i).slice(-2)}T01:06:00Z`, 252 | }, 253 | { 254 | paging_token: String(101 + 2 * i), 255 | sequence: 1001 + 2 * i, 256 | successful_transaction_count: 10 + i, 257 | failed_transaction_count: 5 + i, 258 | operation_count: 50 + i, 259 | closed_at: `2022-01-${("0" + i).slice(-2)}T01:06:01Z`, 260 | }, 261 | ); 262 | } 263 | await updateCache( 264 | ledgers, 265 | REDIS_LEDGER_KEY_TEST, 266 | REDIS_PAGING_TOKEN_KEY_TEST, 267 | ); 268 | 269 | const cachedLedgers = await redisClient.get(REDIS_LEDGER_KEY_TEST); 270 | 271 | const cachedPagingToken = await redisClient.get( 272 | REDIS_PAGING_TOKEN_KEY_TEST, 273 | ); 274 | chai.assert.equal(JSON.parse(cachedLedgers as string).length, 30); 275 | chai.assert.equal(JSON.parse(cachedLedgers as string)[0].date, "01-31"); 276 | chai.assert.equal(cachedPagingToken as string, "163"); 277 | }); 278 | }); 279 | }); 280 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@stellar/tsconfig", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "target": "es6", 6 | "module": "commonjs", 7 | "baseUrl": "./", 8 | }, 9 | "ts-node": { 10 | "files": true 11 | }, 12 | "include": ["./backend"] 13 | } 14 | --------------------------------------------------------------------------------