diff --git a/README.md b/README.md
index 2b63578..d83ae9a 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,10 @@ Base URL: http://stats.filspark.com/
http://stats.filspark.com/participants/change-rates
+- `GET /participants/top-earning`
+
+ http://stats.filspark.com/participants/top-earning
+
- `GET /participant/:address/scheduled-rewards?address=
&from=&to=`
http://stats.filspark.com/participant/0x000000000000000000000000000000000000dEaD/scheduled-rewards
diff --git a/stats/lib/platform-routes.js b/stats/lib/platform-routes.js
index f96ff50..e2b66d3 100644
--- a/stats/lib/platform-routes.js
+++ b/stats/lib/platform-routes.js
@@ -3,6 +3,7 @@ import {
fetchDailyStationCount,
fetchMonthlyStationCount,
fetchDailyRewardTransfers,
+ fetchTopEarningParticipants,
fetchParticipantsWithTopMeasurements,
fetchDailyStationAcceptedMeasurementCount
} from './platform-stats-fetchers.js'
@@ -33,6 +34,8 @@ export const handlePlatformRoutes = async (req, res, pgPools) => {
await respond(pgPools.evaluate, fetchDailyStationAcceptedMeasurementCount)
} else if (req.method === 'GET' && url === '/participants/top-measurements') {
await respond(pgPools.evaluate, fetchParticipantsWithTopMeasurements)
+ } else if (req.method === 'GET' && url === '/participants/top-earning') {
+ await respond(pgPools.stats, fetchTopEarningParticipants)
} else if (req.method === 'GET' && url === '/transfers/daily') {
await respond(pgPools.stats, fetchDailyRewardTransfers)
} else {
diff --git a/stats/lib/platform-stats-fetchers.js b/stats/lib/platform-stats-fetchers.js
index fe0de9b..7ba2d37 100644
--- a/stats/lib/platform-stats-fetchers.js
+++ b/stats/lib/platform-stats-fetchers.js
@@ -1,5 +1,5 @@
import assert from 'http-assert'
-import { getDailyDistinctCount, getMonthlyDistinctCount } from './request-helpers.js'
+import { getDailyDistinctCount, getMonthlyDistinctCount, today } from './request-helpers.js'
/** @typedef {import('@filecoin-station/spark-stats-db').Queryable} Queryable */
@@ -71,3 +71,31 @@ export const fetchDailyRewardTransfers = async (pgPool, filter) => {
`, [filter.from, filter.to])
return rows
}
+
+/**
+ * @param {Queryable} pgPool
+ * @param {import('./typings.js').DateRangeFilter} filter
+ */
+export const fetchTopEarningParticipants = async (pgPool, filter) => {
+ // The query combines "transfers until filter.to" with "latest scheduled rewards as of today".
+ // As a result, it produces incorrect result if `to` is different from `now()`.
+ // See https://github.com/filecoin-station/spark-stats/pull/170#discussion_r1664080395
+ assert(filter.to === today(), 400, 'filter.to must be today, other values are not supported')
+ const { rows } = await pgPool.query(`
+ WITH latest_scheduled_rewards AS (
+ SELECT DISTINCT ON (participant_address) participant_address, scheduled_rewards
+ FROM daily_scheduled_rewards
+ ORDER BY participant_address, day DESC
+ )
+ SELECT
+ COALESCE(drt.to_address, lsr.participant_address) as participant_address,
+ COALESCE(SUM(drt.amount), 0) + COALESCE(lsr.scheduled_rewards, 0) as total_rewards
+ FROM daily_reward_transfers drt
+ FULL OUTER JOIN latest_scheduled_rewards lsr
+ ON drt.to_address = lsr.participant_address
+ WHERE (drt.day >= $1 AND drt.day <= $2) OR drt.day IS NULL
+ GROUP BY COALESCE(drt.to_address, lsr.participant_address), lsr.scheduled_rewards
+ ORDER BY total_rewards DESC
+ `, [filter.from, filter.to])
+ return rows
+}
diff --git a/stats/lib/request-helpers.js b/stats/lib/request-helpers.js
index 621fca6..dd51536 100644
--- a/stats/lib/request-helpers.js
+++ b/stats/lib/request-helpers.js
@@ -5,7 +5,7 @@ import pg from 'pg'
/** @typedef {import('@filecoin-station/spark-stats-db').Queryable} Queryable */
-const getDayAsISOString = (d) => d.toISOString().split('T')[0]
+export const getDayAsISOString = (d) => d.toISOString().split('T')[0]
export const today = () => getDayAsISOString(new Date())
diff --git a/stats/test/platform-routes.test.js b/stats/test/platform-routes.test.js
index 1c9b4f6..6ae29c2 100644
--- a/stats/test/platform-routes.test.js
+++ b/stats/test/platform-routes.test.js
@@ -6,6 +6,7 @@ import { getPgPools } from '@filecoin-station/spark-stats-db'
import { assertResponseStatus, getPort } from './test-helpers.js'
import { createHandler } from '../lib/handler.js'
+import { getDayAsISOString, today } from '../lib/request-helpers.js'
const STATION_STATS = { stationId: 'station1', participantAddress: 'f1abcdef', inetGroup: 'group1' }
@@ -47,6 +48,7 @@ describe('Platform Routes HTTP request handler', () => {
await pgPools.evaluate.query('REFRESH MATERIALIZED VIEW top_measurement_participants_yesterday_mv')
await pgPools.stats.query('DELETE FROM daily_reward_transfers')
+ await pgPools.stats.query('DELETE FROM daily_scheduled_rewards')
})
describe('GET /stations/daily', () => {
@@ -247,6 +249,105 @@ describe('Platform Routes HTTP request handler', () => {
])
})
})
+
+ describe('GET /participants/top-earning', () => {
+ const yesterdayDate = new Date()
+ yesterdayDate.setDate(yesterdayDate.getDate() - 1)
+ const yesterday = getDayAsISOString(yesterdayDate)
+ console.log('yesterday', yesterday)
+
+ const oneWeekAgoDate = new Date()
+ oneWeekAgoDate.setDate(oneWeekAgoDate.getDate() - 7)
+ const oneWeekAgo = getDayAsISOString(oneWeekAgoDate)
+ console.log('oneWeekAgo', oneWeekAgo)
+
+ const setupScheduledRewardsData = async () => {
+ await pgPools.stats.query(`
+ INSERT INTO daily_scheduled_rewards (day, participant_address, scheduled_rewards)
+ VALUES
+ ('${yesterday}', 'address1', 10),
+ ('${yesterday}', 'address2', 20),
+ ('${yesterday}', 'address3', 30),
+ ('${today()}', 'address1', 15),
+ ('${today()}', 'address2', 25),
+ ('${today()}', 'address3', 35)
+ `)
+ }
+ it('returns top earning participants for the given date range', async () => {
+ // First two dates should be ignored
+ await givenDailyRewardTransferMetrics(pgPools.stats, '2024-01-09', [
+ { toAddress: 'address1', amount: 100, lastCheckedBlock: 1 },
+ { toAddress: 'address2', amount: 100, lastCheckedBlock: 1 },
+ { toAddress: 'address3', amount: 100, lastCheckedBlock: 1 }
+ ])
+ await givenDailyRewardTransferMetrics(pgPools.stats, '2024-01-10', [
+ { toAddress: 'address1', amount: 100, lastCheckedBlock: 1 }
+ ])
+
+ // These should be included in the results
+ await givenDailyRewardTransferMetrics(pgPools.stats, oneWeekAgo, [
+ { toAddress: 'address2', amount: 150, lastCheckedBlock: 1 },
+ { toAddress: 'address1', amount: 50, lastCheckedBlock: 1 }
+ ])
+ await givenDailyRewardTransferMetrics(pgPools.stats, today(), [
+ { toAddress: 'address3', amount: 200, lastCheckedBlock: 1 },
+ { toAddress: 'address2', amount: 100, lastCheckedBlock: 1 }
+ ])
+
+ // Set up scheduled rewards data
+ await setupScheduledRewardsData()
+
+ const res = await fetch(
+ new URL(
+ `/participants/top-earning?from=${oneWeekAgo}&to=${today()}`,
+ baseUrl
+ ), {
+ redirect: 'manual'
+ }
+ )
+ await assertResponseStatus(res, 200)
+ const topEarners = await res.json()
+ assert.deepStrictEqual(topEarners, [
+ { participant_address: 'address2', total_rewards: '275' },
+ { participant_address: 'address3', total_rewards: '235' },
+ { participant_address: 'address1', total_rewards: '65' }
+ ])
+ })
+ it('returns top earning participants for the given date range with no existing reward transfers', async () => {
+ await setupScheduledRewardsData()
+
+ await givenDailyRewardTransferMetrics(pgPools.stats, today(), [
+ { toAddress: 'address1', amount: 100, lastCheckedBlock: 1 }
+ ])
+
+ const res = await fetch(
+ new URL(
+ `/participants/top-earning?from=${oneWeekAgo}&to=${today()}`,
+ baseUrl
+ ), {
+ redirect: 'manual'
+ }
+ )
+ await assertResponseStatus(res, 200)
+ const topEarners = await res.json()
+ assert.deepStrictEqual(topEarners, [
+ { participant_address: 'address1', total_rewards: '115' },
+ { participant_address: 'address3', total_rewards: '35' },
+ { participant_address: 'address2', total_rewards: '25' }
+ ])
+ })
+ it('returns 400 if the date range end is not today', async () => {
+ const res = await fetch(
+ new URL(
+ `/participants/top-earning?from=${oneWeekAgo}&to=${yesterday}`,
+ baseUrl
+ ), {
+ redirect: 'manual'
+ }
+ )
+ await assertResponseStatus(res, 400)
+ })
+ })
})
const givenDailyStationMetrics = async (pgPoolEvaluate, day, stationStats) => {