diff --git a/.changeset/gateful-upstream-commit-gate.md b/.changeset/gateful-upstream-commit-gate.md deleted file mode 100644 index 734ad0978f..0000000000 --- a/.changeset/gateful-upstream-commit-gate.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@anticapture/api": patch -"@anticapture/gateful": patch -"@anticapture/relayer": patch -"@anticapture/address-enrichment": patch ---- - -Stop the production dashboard build from generating its SDK against the -previous release's OpenAPI spec. - -Gateful merges the DAO APIs' specs into `/docs/json` on every request, so the -deploy gate waiting for gateful's own commit proved nothing about the schemas -it would serve: on #2093 gateful reported the new commit at 14:33:54, codegen -read the spec at 14:34:23, and `ens-api` only came up at 14:34:34 — the -dashboard build failed on a field the API hadn't started advertising yet. - -Every service whose OpenAPI gateful merges into `/docs/json` — the DAO APIs, -the relayer and address enrichment — now reports its running commit on -`/health`, and gateful passes it through as `upstreams..commit` alongside -an `upstreams..kind`. `scripts/wait-for-gateful.mjs` then holds the -deploy until each of them reports a commit listed for its kind in -`EXPECTED_UPSTREAM_SHAS`: the last commit that touched the paths Railway -watches to rebuild that service, plus everything after it. - -Expressing it as "that commit or newer" per service, rather than "does this -push change the API", keeps the gate correct when a push that leaves a service -alone supersedes an in-flight push that changed it, and when a push carries -more than one commit. An upstream reporting no commit counts as stale — the -previous release is still answering — which cannot deadlock, because teaching a -service to report its commit necessarily touches its own watched paths. -Authful contributes no merged schemas and only has to be reachable. - -`@anticapture/client#codegen` is also no longer cached by turbo: its real input -is a live URL no hash can see, so the poisoned output above was replayed on -every retry of that commit and no re-run could ever fix it. `#build` had to go -with it: tsup runs with `dts: true`, so that same unhashable spec is compiled -into `dist`, and a retry that re-ran codegen would restore the stale `dist` -straight over the freshly generated output. Declaring `generated/**` as an -input does not close this — turbo hashes inputs before the run, and the -directory is gitignored, so on a fresh checkout it is still empty at the moment -the hash is taken. diff --git a/.gitignore b/.gitignore index 28e4662c6c..f16c96b3c3 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,7 @@ apps/gateful/openapi/gateful.json # Local planning/spec artifacts (superpowers) — not part of the repo docs/superpowers/ +# Browser-automation debugging output (page snapshots, screenshots) +.playwright-mcp/ + .work/ diff --git a/apps/address-enrichment/CHANGELOG.md b/apps/address-enrichment/CHANGELOG.md index d79f5611f1..28d36a36d4 100644 --- a/apps/address-enrichment/CHANGELOG.md +++ b/apps/address-enrichment/CHANGELOG.md @@ -1,5 +1,44 @@ # @anticapture/address-enrichment +## 1.1.2 + +### Patch Changes + +- [#2098](https://github.com/blockful/anticapture/pull/2098) [`002b33c`](https://github.com/blockful/anticapture/commit/002b33ca39cd7e2aaa5373732fe02aa09a25dd93) Thanks [@pikonha](https://github.com/pikonha)! - Stop the production dashboard build from generating its SDK against the + previous release's OpenAPI spec. + + Gateful merges the DAO APIs' specs into `/docs/json` on every request, so the + deploy gate waiting for gateful's own commit proved nothing about the schemas + it would serve: on [#2093](https://github.com/blockful/anticapture/issues/2093) gateful reported the new commit at 14:33:54, codegen + read the spec at 14:34:23, and `ens-api` only came up at 14:34:34 — the + dashboard build failed on a field the API hadn't started advertising yet. + + Every service whose OpenAPI gateful merges into `/docs/json` — the DAO APIs, + the relayer and address enrichment — now reports its running commit on + `/health`, and gateful passes it through as `upstreams..commit` alongside + an `upstreams..kind`. `scripts/wait-for-gateful.mjs` then holds the + deploy until each of them reports a commit listed for its kind in + `EXPECTED_UPSTREAM_SHAS`: the last commit that touched the paths Railway + watches to rebuild that service, plus everything after it. + + Expressing it as "that commit or newer" per service, rather than "does this + push change the API", keeps the gate correct when a push that leaves a service + alone supersedes an in-flight push that changed it, and when a push carries + more than one commit. An upstream reporting no commit counts as stale — the + previous release is still answering — which cannot deadlock, because teaching a + service to report its commit necessarily touches its own watched paths. + Authful contributes no merged schemas and only has to be reachable. + + `@anticapture/client#codegen` is also no longer cached by turbo: its real input + is a live URL no hash can see, so the poisoned output above was replayed on + every retry of that commit and no re-run could ever fix it. `#build` had to go + with it: tsup runs with `dts: true`, so that same unhashable spec is compiled + into `dist`, and a retry that re-ran codegen would restore the stale `dist` + straight over the freshly generated output. Declaring `generated/**` as an + input does not close this — turbo hashes inputs before the run, and the + directory is gitignored, so on a fresh checkout it is still empty at the moment + the hash is taken. + ## 1.1.1 ### Patch Changes diff --git a/apps/address-enrichment/package.json b/apps/address-enrichment/package.json index 1e2dc13dc6..ff5dd0c904 100644 --- a/apps/address-enrichment/package.json +++ b/apps/address-enrichment/package.json @@ -1,6 +1,6 @@ { "name": "@anticapture/address-enrichment", - "version": "1.1.1", + "version": "1.1.2", "private": true, "scripts": { "dev": "tsx watch src/index.ts", diff --git a/apps/api/CHANGELOG.md b/apps/api/CHANGELOG.md index 02f4257151..f690144347 100644 --- a/apps/api/CHANGELOG.md +++ b/apps/api/CHANGELOG.md @@ -1,5 +1,108 @@ # @anticapture/api +## 1.8.0 + +### Minor Changes + +- [#2084](https://github.com/blockful/anticapture/pull/2084) [`3af2f54`](https://github.com/blockful/anticapture/commit/3af2f542ad10c1e944f76510d8c65d46ab910654) Thanks [@brunod-e](https://github.com/brunod-e)! - `GET /:dao/feed/events` accepts `relevance=ALL`, which drops the value threshold and returns every event instead of only those at or above a tier. The relevance tiers are cumulative value floors (LOW already includes MEDIUM and HIGH), so there was previously no way to ask for events below the LOW floor. Omitting the param still defaults to MEDIUM, so existing consumers are unaffected. + +- [#2084](https://github.com/blockful/anticapture/pull/2084) [`4e59732`](https://github.com/blockful/anticapture/commit/4e59732daf40b800986ab9ec42a10127b29465f4) Thanks [@brunod-e](https://github.com/brunod-e)! - Holders & Delegates v3 (DEV-562, DEV-476) + + API: new endpoints backing the module. `GET /:dao/voting-powers/inactive-summary` + (delegated VP parked with inactive delegates), `GET /:dao/accounts/:address/delegators/historical` + (former delegators with VP impact, start/end and redelegation target), and + `GET /:dao/addresses/labels` (per-DAO treasury/vesting labels, where an unlock + contract whose label does not mention vesting is classified by address so the + dashboard can still relabel its transfers as a vesting unlock; contracts whose + outgoing transfers are not unlocks, such as airdrop distributors and staking + vaults, stay out). Adds an optional + `address` filter to `GET /:dao/feed/events`, and an optional `toDate` upper bound + to `GET /:dao/proposals-activity` so a bounded period counts only the proposals + inside it. That upper bound is keyed on when a proposal's voting opens (creation + plus the DAO voting delay), not on when it was created, so on DAOs with a + non-zero voting delay a proposal created inside the period whose voting only + opens after it no longer counts: no vote could land in the window, and counting + it marked delegates inactive on proposals they could not yet vote on. On + `GET /:dao/voting-powers/inactive-summary` that also keeps `totalProposals` at + zero when the window holds nothing votable, instead of reporting every delegate + as inactive. Both `GET /:dao/proposals-activity` and + `GET /:dao/voting-powers/inactive-summary` also bound the vote by `toDate`: a + proposal that opens near the end of the period stays votable after it, so a vote + cast later no longer counts as activity inside a period that closed before the + vote existed. The proposal is still listed, with no vote attached. The same bound + applies at the other end: a proposal whose voting period overlaps `fromDate` is + in scope, but a vote cast on it before that date happened outside the period and + no longer counts as activity inside it either. On + `GET /:dao/accounts/:address/delegators/historical`, `amount` reports the voting + power the queried address actually lost at the move away rather than the value + stored on the last delegation event: balances that move while a delegation stands + write no delegation row, so that value is a stale snapshot, and the share it + represented is instead applied to the balance the move-away event carries. Full + delegation therefore reports the whole balance moved, and partial delegation + (SCR) keeps its fraction rather than claiming the sibling delegates' part. On AAVE, `fromValue`/`toValue` on `GET /:dao/voting-powers` now filter the + delegated voting power alone instead of the combined total (delegated power plus + the account's own balance), matching both the `votingPower` ordering on the same + endpoint and every other DAO's behavior, so the range a client asks for matches + the delegation figure it renders. + Feed DELEGATION metadata gains an optional `delegatees` array of + `{ delegate, amount }`, present only when the source event split voting power + across more than one delegatee (partial delegation, as SCR does), ordered by + delegate address ascending; `delegate`/`amount` stay as they were and describe + the primary delegatee, so existing consumers are unaffected. Gateful re-exposes + the expanded surface through its aggregated OpenAPI spec (no gateway code + change). + + Dashboard: value min/max filters on the Delegates and Token Holders tables; + Delegates as the default tab and the sidebar renamed to "Stakeholders"; larger + rows with bottom borders and a continuous activity ring; voting power shown as a + percentage of quorum; inactive-delegate flagging and 0/0 activity states + ("Inactive" / "No proposals" / "Never voted"); the inactive-VP alert banner on + Token Holders; clickable addresses that re-point the drawer everywhere; a + per-address Activity tab in the drawer, on the DAOs that serve the activity + feed; Buy/Sell relabeled to In / Out / Vested; + a dust badge and "Hide dust" switch on Top Interactions; a "Filter low importance" + toggle and "All time" range on Voting Power History; a MAX option and a custom + calendar range on the time selector, single days included; and a Former + Delegators view in the + delegate profile. + +### Patch Changes + +- [#2098](https://github.com/blockful/anticapture/pull/2098) [`002b33c`](https://github.com/blockful/anticapture/commit/002b33ca39cd7e2aaa5373732fe02aa09a25dd93) Thanks [@pikonha](https://github.com/pikonha)! - Stop the production dashboard build from generating its SDK against the + previous release's OpenAPI spec. + + Gateful merges the DAO APIs' specs into `/docs/json` on every request, so the + deploy gate waiting for gateful's own commit proved nothing about the schemas + it would serve: on [#2093](https://github.com/blockful/anticapture/issues/2093) gateful reported the new commit at 14:33:54, codegen + read the spec at 14:34:23, and `ens-api` only came up at 14:34:34 — the + dashboard build failed on a field the API hadn't started advertising yet. + + Every service whose OpenAPI gateful merges into `/docs/json` — the DAO APIs, + the relayer and address enrichment — now reports its running commit on + `/health`, and gateful passes it through as `upstreams..commit` alongside + an `upstreams..kind`. `scripts/wait-for-gateful.mjs` then holds the + deploy until each of them reports a commit listed for its kind in + `EXPECTED_UPSTREAM_SHAS`: the last commit that touched the paths Railway + watches to rebuild that service, plus everything after it. + + Expressing it as "that commit or newer" per service, rather than "does this + push change the API", keeps the gate correct when a push that leaves a service + alone supersedes an in-flight push that changed it, and when a push carries + more than one commit. An upstream reporting no commit counts as stale — the + previous release is still answering — which cannot deadlock, because teaching a + service to report its commit necessarily touches its own watched paths. + Authful contributes no merged schemas and only has to be reachable. + + `@anticapture/client#codegen` is also no longer cached by turbo: its real input + is a live URL no hash can see, so the poisoned output above was replayed on + every retry of that commit and no re-run could ever fix it. `#build` had to go + with it: tsup runs with `dts: true`, so that same unhashable spec is compiled + into `dist`, and a retry that re-ran codegen would restore the stale `dist` + straight over the freshly generated output. Declaring `generated/**` as an + input does not close this — turbo hashes inputs before the run, and the + directory is gitignored, so on a fresh checkout it is still empty at the moment + the hash is taken. + ## 1.7.0 ### Minor Changes diff --git a/apps/api/cmd/aave.ts b/apps/api/cmd/aave.ts index cfd14f11cf..f0f878fd7e 100644 --- a/apps/api/cmd/aave.ts +++ b/apps/api/cmd/aave.ts @@ -13,6 +13,7 @@ import { fromZodError } from "zod-validation-error"; import { DaoCache } from "@/cache/dao-cache"; import { accountBalances, + addressLabels, dao, historicalBalances, historicalVotingPower, @@ -46,6 +47,7 @@ import { import { AAVEVotingPowerRepository } from "@/repositories/voting-power/aave"; import { AccountBalanceService, + AddressLabelsService, DaoService, HealthService, HistoricalBalancesService, @@ -218,6 +220,7 @@ transfers( ), ); dao(app, daoService); +addressLabels(app, new AddressLabelsService(env.DAO_ID)); docs(app); serve( diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index 40510bc954..1b1b71c9ed 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -14,6 +14,7 @@ import { accountBalanceVariations, accountBalances, accountInteractions, + addressLabels, dao, delegationPercentage, governanceActivity, @@ -32,7 +33,9 @@ import { votingPowers, delegations, delegators, + formerDelegators, historicalDelegations, + inactiveVotingPowerSummary, votes, offchainProposals, offchainVotes, @@ -71,7 +74,9 @@ import { VotingPowerRepository, DelegationsRepository, DelegatorsRepository, + FormerDelegatorsRepository, HistoricalDelegationsRepository, + InactiveVotingPowerSummaryRepository, VotesRepository, FeedRepository, AccountBalanceQueryFragments, @@ -81,6 +86,7 @@ import { } from "@/repositories"; import { AccountBalanceService, + AddressLabelsService, BalanceVariationsService, CoingeckoService, DaoService, @@ -98,6 +104,8 @@ import { HistoricalDelegationsService, DelegationsService, DelegatorsService, + FormerDelegatorsService, + InactiveVotingPowerSummaryService, VotesService, FeedService, OffchainProposalsService, @@ -289,6 +297,14 @@ delegators( new DelegatorsService(wrapWithTracing(new DelegatorsRepository(pgClient))), ), ); +formerDelegators( + app, + wrapWithTracing( + new FormerDelegatorsService( + wrapWithTracing(new FormerDelegatorsRepository(pgClient)), + ), + ), +); const treasuryService = wrapWithTracing( createTreasuryService( @@ -340,7 +356,20 @@ lastUpdate(app, pgClient); delegationPercentage(app, delegationPercentageService); historicalVotingPower(app, votingPowerService); votingPowerVariations(app, votingPowerService); +// Registered before votingPowers so the static /voting-powers/inactive-summary +// path is matched ahead of the /voting-powers/{address} param route. +inactiveVotingPowerSummary( + app, + wrapWithTracing( + new InactiveVotingPowerSummaryService( + wrapWithTracing(new InactiveVotingPowerSummaryRepository(pgClient)), + daoClient, + blockTime, + ), + ), +); votingPowers(app, votingPowerService); +addressLabels(app, new AddressLabelsService(env.DAO_ID)); accountBalanceVariations(app, balanceVariationsService); accountBalances(app, env.DAO_ID, accountBalanceService); accountInteractions( diff --git a/apps/api/package.json b/apps/api/package.json index 50c86a621c..395ef39b34 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@anticapture/api", - "version": "1.7.0", + "version": "1.8.0", "private": true, "main": "dist/index.js", "type": "module", diff --git a/apps/api/src/controllers/addresses/index.ts b/apps/api/src/controllers/addresses/index.ts new file mode 100644 index 0000000000..014d779db5 --- /dev/null +++ b/apps/api/src/controllers/addresses/index.ts @@ -0,0 +1,34 @@ +import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; + +import { AddressLabelsResponseSchema } from "@/mappers"; +import { setCacheControl } from "@/middlewares"; +import { AddressLabelsService } from "@/services"; + +export function addressLabels(app: Hono, service: AddressLabelsService) { + app.openapi( + createRoute({ + method: "get", + operationId: "addressLabels", + path: "/addresses/labels", + summary: "Get labeled addresses", + description: + "Returns the DAO's known labeled addresses (treasury and vesting contracts)", + tags: ["addresses"], + middleware: [setCacheControl(60)], + responses: { + 200: { + description: "Successfully retrieved labeled addresses", + content: { + "application/json": { + schema: AddressLabelsResponseSchema, + }, + }, + }, + }, + }), + (context) => { + const result = service.getAddressLabels(); + return context.json(AddressLabelsResponseSchema.parse(result), 200); + }, + ); +} diff --git a/apps/api/src/controllers/delegations/former-delegators.ts b/apps/api/src/controllers/delegations/former-delegators.ts new file mode 100644 index 0000000000..65eb66c91f --- /dev/null +++ b/apps/api/src/controllers/delegations/former-delegators.ts @@ -0,0 +1,52 @@ +import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; + +import { + FormerDelegatorsRequestParamsSchema, + FormerDelegatorsRequestQuerySchema, + FormerDelegatorsResponseSchema, +} from "@/mappers/delegations/former-delegators"; +import { setCacheControl } from "@/middlewares"; +import { FormerDelegatorsService } from "@/services/delegations/former-delegators"; + +export function formerDelegators(app: Hono, service: FormerDelegatorsService) { + app.openapi( + createRoute({ + method: "get", + operationId: "formerDelegators", + path: "/accounts/{address}/delegators/historical", + summary: "Get former delegators", + description: + "Get delegators that delegated to an account in the past but whose latest delegation is no longer to it", + tags: ["delegations", "skip-pagination"], + middleware: [setCacheControl(60)], + request: { + params: FormerDelegatorsRequestParamsSchema, + query: FormerDelegatorsRequestQuerySchema, + }, + responses: { + 200: { + description: "Returns former delegators for an account", + content: { + "application/json": { + schema: FormerDelegatorsResponseSchema, + }, + }, + }, + }, + }), + + async (context) => { + const { address } = context.req.valid("param"); + const { skip, limit, orderDirection } = context.req.valid("query"); + + const result = await service.getFormerDelegators( + address, + skip, + limit, + orderDirection, + ); + + return context.json(FormerDelegatorsResponseSchema.parse(result), 200); + }, + ); +} diff --git a/apps/api/src/controllers/delegations/index.ts b/apps/api/src/controllers/delegations/index.ts index 570db240fe..e2ebed420d 100644 --- a/apps/api/src/controllers/delegations/index.ts +++ b/apps/api/src/controllers/delegations/index.ts @@ -1,3 +1,4 @@ export * from "./historical"; export * from "./delegations"; export * from "./delegators"; +export * from "./former-delegators"; diff --git a/apps/api/src/controllers/index.ts b/apps/api/src/controllers/index.ts index 31ddf826fc..9ad60aa811 100644 --- a/apps/api/src/controllers/index.ts +++ b/apps/api/src/controllers/index.ts @@ -18,3 +18,4 @@ export * from "./votes/offchainNonVoters"; export * from "./event-relevance"; export * from "./feed"; export * from "./revenue"; +export * from "./addresses"; diff --git a/apps/api/src/controllers/proposals/proposals-activity.ts b/apps/api/src/controllers/proposals/proposals-activity.ts index 50a94fc883..75c4360dff 100644 --- a/apps/api/src/controllers/proposals/proposals-activity.ts +++ b/apps/api/src/controllers/proposals/proposals-activity.ts @@ -47,6 +47,7 @@ export function proposalsActivity( const { address, fromDate, + toDate, skip, limit, orderBy, @@ -59,6 +60,7 @@ export function proposalsActivity( const result = await service.getProposalsActivity({ address, fromDate, + toDate, daoId, skip, limit, diff --git a/apps/api/src/controllers/voting-power/inactive-summary.ts b/apps/api/src/controllers/voting-power/inactive-summary.ts new file mode 100644 index 0000000000..30965a0a68 --- /dev/null +++ b/apps/api/src/controllers/voting-power/inactive-summary.ts @@ -0,0 +1,51 @@ +import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; + +import { + InactiveVotingPowerSummaryRequestSchema, + InactiveVotingPowerSummaryResponseSchema, +} from "@/mappers"; +import { setCacheControl } from "@/middlewares"; +import { InactiveVotingPowerSummaryService } from "@/services"; + +export function inactiveVotingPowerSummary( + app: Hono, + service: InactiveVotingPowerSummaryService, +) { + app.openapi( + createRoute({ + method: "get", + operationId: "inactiveVotingPowerSummary", + path: "/voting-powers/inactive-summary", + summary: "Get inactive delegated voting power summary", + description: + "Returns the share of delegated voting power assigned to delegates that cast zero votes on proposals whose voting period falls within the given time window", + tags: ["voting-power"], + middleware: [setCacheControl(60)], + request: { + query: InactiveVotingPowerSummaryRequestSchema, + }, + responses: { + 200: { + description: + "Successfully retrieved inactive delegated voting power summary", + content: { + "application/json": { + schema: InactiveVotingPowerSummaryResponseSchema, + }, + }, + }, + }, + }), + async (context) => { + const { fromDate, toDate } = context.req.valid("query"); + const result = await service.getInactiveVotingPowerSummary( + fromDate, + toDate, + ); + return context.json( + InactiveVotingPowerSummaryResponseSchema.parse(result), + 200, + ); + }, + ); +} diff --git a/apps/api/src/controllers/voting-power/index.ts b/apps/api/src/controllers/voting-power/index.ts index 2844f789f2..5a6e1ed111 100644 --- a/apps/api/src/controllers/voting-power/index.ts +++ b/apps/api/src/controllers/voting-power/index.ts @@ -1,3 +1,4 @@ export * from "./historical"; export * from "./variations"; export * from "./listing"; +export * from "./inactive-summary"; diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index 3aa56416e6..daf006e920 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -484,6 +484,16 @@ export enum FeedRelevance { LOW = "LOW", } +// Query-only counterpart of FeedRelevance: the tiers are cumulative value +// floors (LOW also returns MEDIUM and HIGH) and ALL drops the floor entirely. +// ALL is not a tier an event can have, so it lives apart from FeedRelevance. +export enum FeedRelevanceFilter { + ALL = "ALL", + HIGH = "HIGH", + MEDIUM = "MEDIUM", + LOW = "LOW", +} + export enum FeedEventType { VOTE = "VOTE", PROPOSAL = "PROPOSAL", diff --git a/apps/api/src/mappers/addresses/index.ts b/apps/api/src/mappers/addresses/index.ts new file mode 100644 index 0000000000..2107fbdde6 --- /dev/null +++ b/apps/api/src/mappers/addresses/index.ts @@ -0,0 +1,33 @@ +import { z } from "@hono/zod-openapi"; + +import { addressOutputField } from "../shared"; + +export const AddressLabelCategorySchema = z + .enum(["treasury", "vesting"]) + .openapi("AddressLabelCategory", { + description: "High-level category derived from the address label.", + }); + +export const AddressLabelItemSchema = z + .object({ + address: addressOutputField("Labeled address."), + label: z.string().openapi({ + description: "Human-readable label for the address.", + example: "Foundation Vesting Wallet", + }), + category: AddressLabelCategorySchema, + }) + .openapi("AddressLabelItem", { + description: "Known DAO-labeled address with its category.", + }); + +export const AddressLabelsResponseSchema = z + .object({ + items: z.array(AddressLabelItemSchema), + }) + .openapi("AddressLabelsResponse", { + description: "Labeled treasury and vesting addresses for the DAO.", + }); + +export type AddressLabelItem = z.infer; +export type AddressLabelsResponse = z.infer; diff --git a/apps/api/src/mappers/delegations/former-delegators.ts b/apps/api/src/mappers/delegations/former-delegators.ts new file mode 100644 index 0000000000..50e8e5dff7 --- /dev/null +++ b/apps/api/src/mappers/delegations/former-delegators.ts @@ -0,0 +1,76 @@ +import { z } from "@hono/zod-openapi"; +import { Address } from "viem"; + +import { + AddressSchema, + addressPathParams, + bigintAsStringField, + defaultDescOrderDirection, + paginatedListResponse, + paginationQueryParams, +} from "../shared"; + +export type DBFormerDelegator = { + delegatorAddress: Address; + amount: bigint; + redelegatedAmount: bigint; + startTimestamp: bigint; + endTimestamp: bigint; + redelegatedTo: Address | null; +}; + +export const FormerDelegatorsRequestParamsSchema = addressPathParams( + "FormerDelegatorsRequestParams", + "Path params for fetching former delegators of a delegate address.", +); + +export const FormerDelegatorsRequestQuerySchema = z + .object({ + ...paginationQueryParams(), + orderDirection: defaultDescOrderDirection(), + }) + .openapi("FormerDelegatorsRequestQuery", { + description: + "Query params used to page former delegators for a delegate address. Results are ordered by the timestamp the delegator moved away.", + }); + +export type FormerDelegatorsRequestQuery = z.infer< + typeof FormerDelegatorsRequestQuerySchema +>; + +export const FormerDelegatorItemSchema = z + .object({ + delegatorAddress: AddressSchema.openapi({ format: "ethereum-address" }), + amount: bigintAsStringField( + "Voting power the queried address lost when the delegator moved away: the share of the delegator's balance held by this address, applied to the balance at the move-away event. Encoded as a decimal string.", + ), + redelegatedAmount: bigintAsStringField( + "Delegated amount at the move-away event (the delegator's value once they left the queried address), encoded as a decimal string.", + ), + startTimestamp: bigintAsStringField( + "Timestamp of the first delegation event of the last delegation stint, in Unix seconds.", + ), + endTimestamp: bigintAsStringField( + "Timestamp of the event where the delegator moved away, in Unix seconds.", + ), + redelegatedTo: AddressSchema.nullable().openapi({ + description: + "Delegate the delegator moved to, when known. Null when the move-away event does not reference the queried address as the previous delegate.", + format: "ethereum-address", + }), + }) + .openapi("FormerDelegatorItem", { + description: + "Delegator that delegated to the queried address in the past but whose latest delegation is no longer to it.", + }); + +export const FormerDelegatorsResponseSchema = paginatedListResponse( + FormerDelegatorItemSchema, +).openapi("FormerDelegatorsResponse", { + description: "Paginated former delegators for a delegate address.", +}); + +export type FormerDelegatorItem = z.infer; +export type FormerDelegatorsResponse = z.infer< + typeof FormerDelegatorsResponseSchema +>; diff --git a/apps/api/src/mappers/delegations/index.ts b/apps/api/src/mappers/delegations/index.ts index 570db240fe..e2ebed420d 100644 --- a/apps/api/src/mappers/delegations/index.ts +++ b/apps/api/src/mappers/delegations/index.ts @@ -1,3 +1,4 @@ export * from "./historical"; export * from "./delegations"; export * from "./delegators"; +export * from "./former-delegators"; diff --git a/apps/api/src/mappers/feed/index.ts b/apps/api/src/mappers/feed/index.ts index 63878cfc24..0a11c5dfcc 100644 --- a/apps/api/src/mappers/feed/index.ts +++ b/apps/api/src/mappers/feed/index.ts @@ -1,9 +1,10 @@ import { z } from "@hono/zod-openapi"; import { feedEvent } from "@/database"; -import { FeedEventType, FeedRelevance } from "@/lib/constants"; +import { FeedEventType, FeedRelevanceFilter } from "@/lib/constants"; import { + AddressSchema, FeedEventTypeSchema, FeedRelevanceSchema, normalizeQueryArray, @@ -50,8 +51,9 @@ export const FeedRequestSchema = z example: "timestamp", }), orderDirection: defaultDescOrderDirection(), - relevance: z.enum(FeedRelevance).optional().openapi({ - description: "Filter events by relevance tier.", + relevance: z.enum(FeedRelevanceFilter).optional().openapi({ + description: + "Filter events by relevance tier. Tiers are cumulative value floors, so LOW also returns MEDIUM and HIGH events. Use ALL to drop the floor and return every event. Defaults to MEDIUM when omitted.", }), type: FeedEventTypeListSchema.optional().openapi("FeedEventTypeList", { type: "array", @@ -63,6 +65,11 @@ export const FeedRequestSchema = z "Filter events by governance activity type. Pass repeated query params or a comma-delimited list.", example: ["VOTE"], }), + address: AddressSchema.optional().openapi({ + description: + "Filter events involving this address in any role (voter, proposer, delegator, delegate, previous delegate, sender or recipient). Case-insensitive.", + example: "0x1111111111111111111111111111111111111111", + }), ...earliestLatestDateRangeQueryParams("event"), }) .openapi("FeedRequest", { @@ -151,21 +158,43 @@ export const FeedTransferMetadataSchema = z description: "Metadata payload for a TRANSFER feed event.", }); +export const FeedDelegationSplitSchema = z + .object({ + delegate: z.string().openapi({ description: "Delegate address." }), + amount: z.string().openapi({ + description: + "Voting power delegated to this delegate, as a decimal string.", + format: "bigint", + }), + }) + .openapi("FeedDelegationSplit", { + description: + "One delegatee of a delegation event that split voting power across several delegates.", + }); + export const FeedDelegationMetadataSchema = z .object({ kind: z.literal(FeedEventType.DELEGATION).openapi({ description: "Discriminator identifying the metadata variant.", }), delegator: z.string().openapi({ description: "Delegator address." }), - delegate: z.string().openapi({ description: "New delegate address." }), - previousDelegate: z - .string() - .nullable() - .openapi({ description: "Previous delegate address, when known." }), + delegate: z.string().openapi({ + description: + "New delegate address. On a split delegation this is the primary delegatee: the one matching the `address` filter when the feed is filtered, otherwise the first delegatee of the event. Read `delegatees` for the full list.", + }), + previousDelegate: z.string().nullable().openapi({ + description: + "Previous delegate address, when known. Taken from the primary delegatee's row.", + }), amount: z.string().openapi({ - description: "Delegated voting power, as a decimal string.", + description: + "Voting power delegated to `delegate`, as a decimal string. On a split delegation this is the primary delegatee's share, not the event total.", format: "bigint", }), + delegatees: z.array(FeedDelegationSplitSchema).optional().openapi({ + description: + "Every delegatee of the event, ordered by delegate address ascending. Present only when the event split voting power across more than one delegatee, as partial delegation does; absence means a single delegatee, already fully described by `delegate` and `amount`.", + }), }) .openapi("FeedDelegationMetadata", { description: "Metadata payload for a DELEGATION feed event.", diff --git a/apps/api/src/mappers/index.ts b/apps/api/src/mappers/index.ts index 236f4879ed..d43c1228c7 100644 --- a/apps/api/src/mappers/index.ts +++ b/apps/api/src/mappers/index.ts @@ -15,3 +15,4 @@ export * from "./treasury"; export * from "./votes"; export * from "./voting-power"; export * from "./feed"; +export * from "./addresses"; diff --git a/apps/api/src/mappers/proposals/activity.ts b/apps/api/src/mappers/proposals/activity.ts index 425cd45729..f383859116 100644 --- a/apps/api/src/mappers/proposals/activity.ts +++ b/apps/api/src/mappers/proposals/activity.ts @@ -21,6 +21,11 @@ export const ProposalActivityRequestSchema = z fromDate: unixTimestampQueryParam( "Lower bound for proposal timestamps, in Unix seconds.", ), + toDate: unixTimestampQueryParam( + "Upper bound for proposal timestamps, in Unix seconds. Proposals that " + + "only open after this instant are excluded, so a bounded period " + + "reports the votes cast inside it instead of everything up to today.", + ), skip: paginationSkipQueryParam( "Number of proposal activity records to skip.", ), diff --git a/apps/api/src/mappers/voting-power/inactive-summary.ts b/apps/api/src/mappers/voting-power/inactive-summary.ts new file mode 100644 index 0000000000..7bebfc8f58 --- /dev/null +++ b/apps/api/src/mappers/voting-power/inactive-summary.ts @@ -0,0 +1,52 @@ +import { z } from "@hono/zod-openapi"; + +import { inclusiveDateRangeQueryParams } from "../shared"; + +export const InactiveVotingPowerSummaryRequestSchema = z + .object({ + ...inclusiveDateRangeQueryParams("the delegate activity window"), + }) + .openapi("InactiveVotingPowerSummaryRequest", { + description: + "Optional time-window query params used to compute delegate inactivity.", + }); + +export type InactiveVotingPowerSummaryRequest = z.infer< + typeof InactiveVotingPowerSummaryRequestSchema +>; + +export const InactiveVotingPowerSummaryResponseSchema = z + .object({ + totalDelegatedVotingPower: z.string().openapi({ + description: + "Sum of all positive delegated voting power, encoded as a decimal string.", + format: "bigint", + }), + inactiveDelegatedVotingPower: z.string().openapi({ + description: + "Delegated voting power assigned to delegates that cast zero votes on proposals within the window, encoded as a decimal string.", + format: "bigint", + }), + inactivePercentage: z.number().openapi({ + description: + "Share of delegated voting power assigned to inactive delegates, as a percentage (0-100). Zero when no proposal existed in the window.", + }), + totalProposals: z.number().int().openapi({ + description: + "Number of proposals whose voting period falls within the window.", + }), + }) + .openapi("InactiveVotingPowerSummaryResponse", { + description: + "Summary of delegated voting power assigned to inactive delegates.", + }); + +export type InactiveVotingPowerSummaryResponse = z.infer< + typeof InactiveVotingPowerSummaryResponseSchema +>; + +export type DBInactiveVotingPowerSummary = { + totalDelegatedVotingPower: bigint; + inactiveDelegatedVotingPower: bigint; + totalProposals: number; +}; diff --git a/apps/api/src/mappers/voting-power/index.ts b/apps/api/src/mappers/voting-power/index.ts index cb880d8c5e..2cb48ea0a9 100644 --- a/apps/api/src/mappers/voting-power/index.ts +++ b/apps/api/src/mappers/voting-power/index.ts @@ -1,2 +1,3 @@ export * from "./historical"; export * from "./variations"; +export * from "./inactive-summary"; diff --git a/apps/api/src/repositories/delegations/former-delegators.ts b/apps/api/src/repositories/delegations/former-delegators.ts new file mode 100644 index 0000000000..cdf049e67e --- /dev/null +++ b/apps/api/src/repositories/delegations/former-delegators.ts @@ -0,0 +1,188 @@ +import { sql } from "drizzle-orm"; +import { Address } from "viem"; + +import { Drizzle } from "@/database"; +import { DBFormerDelegator } from "@/mappers"; + +type FormerDelegatorRow = { + delegator_address: Address; + amount: string; + redelegated_amount: string; + start_timestamp: string; + end_timestamp: string; + redelegated_to: Address | null; +}; + +export class FormerDelegatorsRepository { + constructor(private readonly db: Drizzle) {} + + async getFormerDelegators( + address: Address, + skip: number, + limit: number, + orderDirection: "asc" | "desc", + ): Promise<{ items: DBFormerDelegator[]; totalCount: number }> { + const cte = this.buildFormerDelegatorsCte(address); + const direction = sql.raw(orderDirection === "asc" ? "ASC" : "DESC"); + + const pageQuery = sql` + ${cte} + SELECT * + FROM former_delegators + ORDER BY end_timestamp::numeric ${direction}, delegator_address ASC + LIMIT ${limit} OFFSET ${skip} + `; + + const countQuery = sql` + ${cte} + SELECT COUNT(*) AS total_count + FROM former_delegators + `; + + const [pageResult, countResult] = await Promise.all([ + this.db.execute(pageQuery), + this.db.execute<{ total_count: string }>(countQuery), + ]); + + return { + items: pageResult.rows.map((row) => ({ + delegatorAddress: row.delegator_address, + amount: BigInt(row.amount), + redelegatedAmount: BigInt(row.redelegated_amount), + startTimestamp: BigInt(row.start_timestamp), + endTimestamp: BigInt(row.end_timestamp), + redelegatedTo: row.redelegated_to, + })), + totalCount: Number(countResult.rows[0]?.total_count ?? 0), + }; + } + + /** + * A gaps-and-islands pass groups each delegator's events into stints of + * consecutive delegations towards the queried address; the event right after + * the last stint is the move-away event, so a delegator with one is former. + * + * Rows are collapsed per source event before being sequenced: partial + * delegation DAOs (SCR) write one row per delegatee out of a single + * `DelegateChanged`, all sharing the transaction hash, log index and + * timestamp, and sequencing them individually would read each one as a move + * away from its own sibling. + * + * Events are sequenced by (timestamp, log_index), which is a chronological + * order only while no two blocks share a timestamp: `log_index` restarts in + * every block, so same-second blocks could be interleaved and the wrong event + * read as the move-away one. Ethereum, Optimism and Scroll all have block + * times of two seconds or more, so the tie cannot happen there; Arbitrum + * (0.25s, `lib/constants.ts`) can produce same-second blocks, and its former + * delegators can be misread until `delegations` carries a block number that + * is sequenced ahead of `log_index` — an indexer schema change and a reindex. + */ + private buildFormerDelegatorsCte(address: Address) { + return sql` + WITH delegation_rows AS ( + SELECT + delegator_account_id AS delegator, + delegate_account_id AS delegate, + previous_delegate, + delegated_value, + timestamp, + transaction_hash, + log_index + FROM delegations + WHERE delegator_account_id IN ( + SELECT DISTINCT delegator_account_id + FROM delegations + WHERE delegate_account_id = ${address} + ) + ), + events AS ( + SELECT + delegator, + MIN(timestamp) AS timestamp, + BOOL_OR(delegate = ${address}) AS to_target, + -- what the delegator had on the queried address at this event + MAX(delegated_value) FILTER (WHERE delegate = ${address}) + AS target_value, + -- everything the event delegated, across all delegatees + SUM(delegated_value) AS event_value, + COUNT(*) FILTER (WHERE previous_delegate = ${address}) + AS from_target_count, + MIN(delegate) FILTER (WHERE previous_delegate = ${address}) + AS from_target_delegate, + ROW_NUMBER() OVER ( + PARTITION BY delegator + ORDER BY MIN(timestamp) ASC, log_index ASC, transaction_hash ASC + ) AS rn + FROM delegation_rows + GROUP BY delegator, transaction_hash, log_index + ), + islands AS ( + SELECT + *, + rn - ROW_NUMBER() OVER ( + PARTITION BY delegator, to_target + ORDER BY rn + ) AS island + FROM events + ), + stints AS ( + SELECT + delegator, + island, + MIN(timestamp) AS start_timestamp, + MAX(rn) AS last_rn + FROM islands + WHERE to_target + GROUP BY delegator, island + ), + last_stints AS ( + SELECT DISTINCT ON (delegator) + delegator, + start_timestamp, + last_rn + FROM stints + ORDER BY delegator, last_rn DESC + ), + former_delegators AS ( + SELECT + ls.delegator AS delegator_address, + -- Voting power the queried address actually lost at the move away. + -- target_value alone is a snapshot of the delegator's balance back + -- when they last delegated here: balances that move while the + -- delegation stands write no delegations row, so the snapshot goes + -- stale and would under- or over-state the loss. What survives a + -- balance change is the share of the balance this address held, so + -- the stale value is rescaled onto the balance the move-away event + -- carries. Full-delegation DAOs hold the whole balance, making the + -- share 1 and the loss the move-away value; partial delegation (SCR) + -- keeps its fraction instead of claiming the sibling delegates' part. + CASE + WHEN last_event.event_value = 0 THEN 0 + ELSE FLOOR( + last_event.target_value::numeric + * move_event.event_value::numeric + / last_event.event_value::numeric + ) + END::text AS amount, + move_event.event_value::text AS redelegated_amount, + ls.start_timestamp::text AS start_timestamp, + move_event.timestamp::text AS end_timestamp, + -- Only name a destination when the move-away event points a single + -- delegation away from the queried address. A split across several + -- new delegatees has no single destination, so it stays null. + CASE + WHEN move_event.from_target_count = 1 + THEN move_event.from_target_delegate + ELSE NULL + END AS redelegated_to + FROM last_stints ls + JOIN events last_event + ON last_event.delegator = ls.delegator + AND last_event.rn = ls.last_rn + JOIN events move_event + ON move_event.delegator = ls.delegator + AND move_event.rn = ls.last_rn + 1 + ) + `; + } +} diff --git a/apps/api/src/repositories/delegations/former-delegators.unit.test.ts b/apps/api/src/repositories/delegations/former-delegators.unit.test.ts new file mode 100644 index 0000000000..863e854705 --- /dev/null +++ b/apps/api/src/repositories/delegations/former-delegators.unit.test.ts @@ -0,0 +1,624 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { Address } from "viem"; + +import type { Drizzle } from "@/database"; +import * as schema from "@/database/schema"; +import { delegation } from "@/database/schema"; + +import { FormerDelegatorsRepository } from "./former-delegators"; + +const DELEGATE: Address = "0x1111111111111111111111111111111111111111"; +const OTHER_DELEGATE: Address = "0x9999999999999999999999999999999999999999"; +const THIRD_DELEGATE: Address = "0x8888888888888888888888888888888888888888"; +const DELEGATOR_A: Address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DELEGATOR_B: Address = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const DELEGATOR_C: Address = "0xcccccccccccccccccccccccccccccccccccccccc"; + +type DelegationInsert = typeof delegation.$inferInsert; + +let txCounter = 0; + +const createDelegation = ( + overrides: Partial = {}, +): DelegationInsert => ({ + transactionHash: `0x${(txCounter++).toString(16).padStart(64, "0")}`, + daoId: "UNI", + delegateAccountId: DELEGATE, + delegatorAccountId: DELEGATOR_A, + delegatedValue: 0n, + previousDelegate: null, + timestamp: 1700000000n, + logIndex: 0, + ...overrides, +}); + +describe("FormerDelegatorsRepository", () => { + let client: PGlite; + let db: Drizzle; + let repository: FormerDelegatorsRepository; + + beforeAll(async () => { + client = new PGlite(); + db = drizzle(client, { schema }); + repository = new FormerDelegatorsRepository(db); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const { apply } = await pushSchema(schema, db as any); + await apply(); + }); + + afterAll(async () => { + await client.close(); + }); + + beforeEach(async () => { + await db.delete(delegation); + txCounter = 0; + }); + + it("returns empty when no delegations exist", async () => { + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result).toEqual({ items: [], totalCount: 0 }); + }); + + it("excludes delegators still delegating to the address", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 500n, + timestamp: 1000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result).toEqual({ items: [], totalCount: 0 }); + }); + + it("excludes delegators that never delegated to the address", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + timestamp: 1000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result).toEqual({ items: [], totalCount: 0 }); + }); + + it("returns a delegator that moved away with amount, stint and destination", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: DELEGATE, + delegatedValue: 500n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 500n, + timestamp: 2000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result).toEqual({ + items: [ + { + delegatorAddress: DELEGATOR_A, + amount: 500n, + redelegatedAmount: 500n, + startTimestamp: 1000n, + endTimestamp: 2000n, + redelegatedTo: OTHER_DELEGATE, + }, + ], + totalCount: 1, + }); + }); + + it("takes the share from the last event of the stint, not the first", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 100n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 300n, + timestamp: 2000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 300n, + timestamp: 3000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items).toEqual([ + { + delegatorAddress: DELEGATOR_A, + amount: 300n, + redelegatedAmount: 300n, + startTimestamp: 1000n, + endTimestamp: 3000n, + redelegatedTo: OTHER_DELEGATE, + }, + ]); + }); + + // Balances that move while the delegation stands write no `delegations` row, + // so the value stored on the last event is stale by the time of the move away + // and only the share it represents can be carried forward. + it("reports the balance at the move away when it grew while delegated", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 100n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 1000n, + timestamp: 2000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items[0]).toMatchObject({ + amount: 1000n, + redelegatedAmount: 1000n, + }); + }); + + it("reports the balance at the move away when it shrank while delegated", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 1000n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 100n, + timestamp: 2000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items[0]).toMatchObject({ + amount: 100n, + redelegatedAmount: 100n, + }); + }); + + it("reports no loss when the delegator emptied the balance before moving away", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 500n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 0n, + timestamp: 2000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items[0]).toMatchObject({ + amount: 0n, + redelegatedAmount: 0n, + }); + }); + + it("reports no loss when the delegator held nothing during the stint", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 0n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 700n, + timestamp: 2000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items[0]).toMatchObject({ + amount: 0n, + redelegatedAmount: 700n, + }); + }); + + it("sets redelegatedTo to null when the move-away event does not reference the address", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 500n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: null, + timestamp: 2000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items[0]?.redelegatedTo).toBeNull(); + }); + + it("uses the most recent stint when the delegator came back and left again", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 100n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + timestamp: 2000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: DELEGATE, + previousDelegate: OTHER_DELEGATE, + delegatedValue: 700n, + timestamp: 3000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: THIRD_DELEGATE, + previousDelegate: DELEGATE, + delegatedValue: 700n, + timestamp: 4000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items).toEqual([ + { + delegatorAddress: DELEGATOR_A, + amount: 700n, + redelegatedAmount: 700n, + startTimestamp: 3000n, + endTimestamp: 4000n, + redelegatedTo: THIRD_DELEGATE, + }, + ]); + }); + + it("excludes delegators whose latest delegation returned to the address", async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + timestamp: 2000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: DELEGATE, + previousDelegate: OTHER_DELEGATE, + timestamp: 3000n, + }), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result).toEqual({ items: [], totalCount: 0 }); + }); + + // Partial delegation DAOs (SCR) write one row per delegatee out of a single + // DelegateChanged, all sharing the transaction hash, log index and timestamp. + describe("partial delegation", () => { + const splitDelegation = ( + transactionHash: string, + delegates: { delegate: Address; value: bigint }[], + timestamp: bigint, + ): DelegationInsert[] => + delegates.map(({ delegate, value }) => ({ + transactionHash, + daoId: "SCR", + delegateAccountId: delegate, + delegatorAccountId: DELEGATOR_A, + delegatedValue: value, + previousDelegate: null, + timestamp, + logIndex: 0, + })); + + it("keeps a delegate that shares the latest event with another delegate", async () => { + await db.insert(delegation).values( + splitDelegation( + `0x${"1".padStart(64, "0")}`, + [ + { delegate: DELEGATE, value: 400n }, + { delegate: OTHER_DELEGATE, value: 600n }, + ], + 1000n, + ), + ); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result).toEqual({ items: [], totalCount: 0 }); + }); + + it("reports the delegate dropped from a later split, not its siblings", async () => { + await db.insert(delegation).values([ + ...splitDelegation( + `0x${"1".padStart(64, "0")}`, + [ + { delegate: DELEGATE, value: 400n }, + { delegate: OTHER_DELEGATE, value: 600n }, + ], + 1000n, + ), + ...splitDelegation( + `0x${"2".padStart(64, "0")}`, + [ + { delegate: OTHER_DELEGATE, value: 500n }, + { delegate: THIRD_DELEGATE, value: 500n }, + ], + 2000n, + ), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items).toEqual([ + { + delegatorAddress: DELEGATOR_A, + amount: 400n, + // no single destination for a split, so the whole event is reported + redelegatedAmount: 1000n, + startTimestamp: 1000n, + endTimestamp: 2000n, + redelegatedTo: null, + }, + ]); + + // the sibling that stayed is still an active delegate + const other = await repository.getFormerDelegators( + OTHER_DELEGATE, + 0, + 10, + "desc", + ); + expect(other).toEqual({ items: [], totalCount: 0 }); + }); + + // Rescaling a stale value must carry the fraction this delegate held, not + // the whole move-away event: the siblings' part was never its voting power. + it("keeps the delegate's fraction when the balance changed while delegated", async () => { + await db.insert(delegation).values([ + // 40% of a 1000 balance + ...splitDelegation( + `0x${"1".padStart(64, "0")}`, + [ + { delegate: DELEGATE, value: 400n }, + { delegate: OTHER_DELEGATE, value: 600n }, + ], + 1000n, + ), + // balance doubled to 2000 before the split dropped this delegate + ...splitDelegation( + `0x${"2".padStart(64, "0")}`, + [ + { delegate: OTHER_DELEGATE, value: 1000n }, + { delegate: THIRD_DELEGATE, value: 1000n }, + ], + 2000n, + ), + ]); + + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items[0]).toMatchObject({ + // 40% of 2000, not the 2000 the whole event moved + amount: 800n, + redelegatedAmount: 2000n, + }); + }); + }); + + describe("ordering and pagination", () => { + beforeEach(async () => { + await db.insert(delegation).values([ + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegatedValue: 100n, + timestamp: 1000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_A, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + timestamp: 4000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_B, + delegatedValue: 200n, + timestamp: 2000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_B, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + timestamp: 5000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_C, + delegatedValue: 300n, + timestamp: 3000n, + }), + createDelegation({ + delegatorAccountId: DELEGATOR_C, + delegateAccountId: OTHER_DELEGATE, + previousDelegate: DELEGATE, + timestamp: 6000n, + }), + ]); + }); + + it("orders by endTimestamp descending", async () => { + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "desc", + ); + + expect(result.items.map((item) => item.delegatorAddress)).toEqual([ + DELEGATOR_C, + DELEGATOR_B, + DELEGATOR_A, + ]); + expect(result.totalCount).toBe(3); + }); + + it("orders by endTimestamp ascending", async () => { + const result = await repository.getFormerDelegators( + DELEGATE, + 0, + 10, + "asc", + ); + + expect(result.items.map((item) => item.delegatorAddress)).toEqual([ + DELEGATOR_A, + DELEGATOR_B, + DELEGATOR_C, + ]); + }); + + it("applies skip and limit while keeping totalCount", async () => { + const result = await repository.getFormerDelegators( + DELEGATE, + 1, + 1, + "desc", + ); + + expect(result.items.map((item) => item.delegatorAddress)).toEqual([ + DELEGATOR_B, + ]); + expect(result.totalCount).toBe(3); + }); + }); +}); diff --git a/apps/api/src/repositories/delegations/index.ts b/apps/api/src/repositories/delegations/index.ts index acd5edde9f..1c06377dfc 100644 --- a/apps/api/src/repositories/delegations/index.ts +++ b/apps/api/src/repositories/delegations/index.ts @@ -1,3 +1,4 @@ export * from "./general"; export * from "./historical"; export * from "./delegators"; +export * from "./former-delegators"; diff --git a/apps/api/src/repositories/feed/feed.repository.unit.test.ts b/apps/api/src/repositories/feed/feed.repository.unit.test.ts index 1a38e06f0f..3f2227c1e0 100644 --- a/apps/api/src/repositories/feed/feed.repository.unit.test.ts +++ b/apps/api/src/repositories/feed/feed.repository.unit.test.ts @@ -13,7 +13,7 @@ import { votesOnchain, votingPowerHistory, } from "@/database/schema"; -import { FeedEventType, FeedRelevance } from "@/lib/constants"; +import { FeedEventType, FeedRelevanceFilter } from "@/lib/constants"; import { FeedRequest } from "@/mappers"; import { FeedRepository } from "."; @@ -27,7 +27,7 @@ const defaultFeedParams = ( limit: 10, orderBy: "timestamp", orderDirection: "desc", - relevance: FeedRelevance.MEDIUM, + relevance: FeedRelevanceFilter.MEDIUM, ...overrides, }); @@ -592,5 +592,305 @@ describe("FeedRepository", () => { const types = result.items.map((i) => i.type).sort(); expect(types).toEqual(["DELEGATION", "VOTE"]); }); + + describe("address filter", () => { + const DELEGATOR = "0xAaAaAAAaaAaAAaAaAaAAAAAaAAAAaaAAAAaAaAA1"; + const DELEGATE = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBb2"; + const PREVIOUS_DELEGATE = "0xCcccCcCCcCCCCCcCcCcccCcCCCcCccccccCCCCc3"; + const SENDER = "0xdDDDddDdDdddDDdDDDDdDDDDDDDddDDdDdDdDDd4"; + const RECIPIENT = "0xEEEeEEEeeEeEeeEEEEeeeeEEeEeeeEEeEEeeEeE5"; + const VOTER = "0xFFffFfFffFFFfffffFfFFFfFfffFfFFFFffFFF6"; + const PROPOSER = "0x1234123412341234123412341234123412341234"; + + beforeEach(async () => { + await db.insert(feedEvent).values([ + createFeedEvent({ txHash: "0xd1", logIndex: 0, type: "DELEGATION" }), + createFeedEvent({ txHash: "0xt1", logIndex: 0, type: "TRANSFER" }), + createFeedEvent({ txHash: "0xv1", logIndex: 0, type: "VOTE" }), + createFeedEvent({ txHash: "0xp1", logIndex: 0, type: "PROPOSAL" }), + createFeedEvent({ + txHash: "0xe1", + logIndex: 0, + type: "PROPOSAL_EXTENDED", + proposalId: "prop-1", + }), + ]); + await db.insert(delegation).values({ + transactionHash: "0xd1", + daoId: "UNI", + delegateAccountId: DELEGATE, + delegatorAccountId: DELEGATOR, + previousDelegate: PREVIOUS_DELEGATE, + delegatedValue: 100n, + timestamp: 1700000000n, + logIndex: 0, + }); + await db.insert(transfer).values({ + transactionHash: "0xt1", + daoId: "UNI", + tokenId: "token", + amount: 100n, + fromAccountId: SENDER, + toAccountId: RECIPIENT, + timestamp: 1700000000n, + logIndex: 0, + }); + await db.insert(votesOnchain).values({ + txHash: "0xv1", + daoId: "UNI", + voterAccountId: VOTER, + proposalId: "prop-1", + support: "1", + votingPower: 100n, + timestamp: 1700000000n, + logIndex: 0, + }); + await db.insert(proposalsOnchain).values({ + id: "prop-1", + txHash: "0xp1", + daoId: "UNI", + proposerAccountId: PROPOSER, + targets: [], + values: [], + signatures: [], + calldatas: [], + startBlock: 1, + endBlock: 2, + title: "Proposal", + description: "Proposal", + timestamp: 1700000000n, + endTimestamp: 1700100000n, + status: "ACTIVE", + }); + }); + + const getFilteredTxHashes = async (address: string) => { + const result = await repository.getFeedEvents( + defaultFeedParams({ address: address as `0x${string}` }), + defaultThresholds(), + ); + return { + txHashes: result.items.map((i) => i.txHash).sort(), + totalCount: result.totalCount, + }; + }; + + it("matches delegation events by delegator, delegate, and previous delegate", async () => { + expect(await getFilteredTxHashes(DELEGATOR)).toEqual({ + txHashes: ["0xd1"], + totalCount: 1, + }); + expect(await getFilteredTxHashes(DELEGATE)).toEqual({ + txHashes: ["0xd1"], + totalCount: 1, + }); + expect(await getFilteredTxHashes(PREVIOUS_DELEGATE)).toEqual({ + txHashes: ["0xd1"], + totalCount: 1, + }); + }); + + it("matches transfer events by sender and recipient", async () => { + expect(await getFilteredTxHashes(SENDER)).toEqual({ + txHashes: ["0xt1"], + totalCount: 1, + }); + expect(await getFilteredTxHashes(RECIPIENT)).toEqual({ + txHashes: ["0xt1"], + totalCount: 1, + }); + }); + + it("matches vote events by voter", async () => { + expect(await getFilteredTxHashes(VOTER)).toEqual({ + txHashes: ["0xv1"], + totalCount: 1, + }); + }); + + it("matches proposal and proposal-extended events by proposer", async () => { + expect(await getFilteredTxHashes(PROPOSER)).toEqual({ + txHashes: ["0xe1", "0xp1"], + totalCount: 2, + }); + }); + + it("matches case-insensitively regardless of stored casing", async () => { + expect( + await getFilteredTxHashes( + DELEGATOR.toUpperCase().replace("0X", "0x"), + ), + ).toEqual({ + txHashes: ["0xd1"], + totalCount: 1, + }); + expect(await getFilteredTxHashes(VOTER.toLowerCase())).toEqual({ + txHashes: ["0xv1"], + totalCount: 1, + }); + }); + + it("returns nothing for an address not present in any event", async () => { + expect( + await getFilteredTxHashes( + "0x9999999999999999999999999999999999999999", + ), + ).toEqual({ txHashes: [], totalCount: 0 }); + }); + + it("combines with the type filter", async () => { + const result = await repository.getFeedEvents( + defaultFeedParams({ + address: DELEGATOR as `0x${string}`, + type: [FeedEventType.TRANSFER], + }), + defaultThresholds(), + ); + + expect(result.items).toHaveLength(0); + expect(result.totalCount).toBe(0); + }); + + // A partial delegation (SCR) writes one row per delegatee out of a single + // DelegateChanged, all sharing the transaction hash and log index. + it("keeps the delegation row matching the filtered address when one event has several", async () => { + const SPLIT_A = "0x1111111111111111111111111111111111111111"; + const SPLIT_B = "0x2222222222222222222222222222222222222222"; + + await db.insert(feedEvent).values( + createFeedEvent({ + txHash: "0xsplit", + logIndex: 0, + type: "DELEGATION", + }), + ); + await db.insert(delegation).values([ + { + transactionHash: "0xsplit", + daoId: "UNI", + delegateAccountId: SPLIT_A, + delegatorAccountId: DELEGATOR, + previousDelegate: null, + delegatedValue: 40n, + timestamp: 1700000000n, + logIndex: 0, + }, + { + transactionHash: "0xsplit", + daoId: "UNI", + delegateAccountId: SPLIT_B, + delegatorAccountId: DELEGATOR, + previousDelegate: null, + delegatedValue: 60n, + timestamp: 1700000000n, + logIndex: 0, + }, + ]); + + const metadataFor = async (address: string) => { + const result = await repository.getFeedEvents( + defaultFeedParams({ address: address as `0x${string}` }), + defaultThresholds(), + ); + return result.items.find((i) => i.txHash === "0xsplit")?.metadata; + }; + + expect(await metadataFor(SPLIT_A)).toMatchObject({ + delegate: SPLIT_A, + amount: "40", + }); + expect(await metadataFor(SPLIT_B)).toMatchObject({ + delegate: SPLIT_B, + amount: "60", + }); + }); + + // Filtering by the delegator matches every sibling row, so the primary + // row is arbitrary and `delegatees` has to carry the full split. + it("lists every delegatee of a split event, filtered or not", async () => { + const SPLIT_A = "0x2222222222222222222222222222222222222222"; + const SPLIT_B = "0x1111111111111111111111111111111111111111"; + + await db.insert(feedEvent).values( + createFeedEvent({ + txHash: "0xsplit", + logIndex: 0, + type: "DELEGATION", + }), + ); + // Inserted with the higher address first so a passing assertion proves + // the ascending sort, not the row order. + await db.insert(delegation).values([ + { + transactionHash: "0xsplit", + daoId: "UNI", + delegateAccountId: SPLIT_A, + delegatorAccountId: DELEGATOR, + previousDelegate: null, + delegatedValue: 60n, + timestamp: 1700000000n, + logIndex: 0, + }, + { + transactionHash: "0xsplit", + daoId: "UNI", + delegateAccountId: SPLIT_B, + delegatorAccountId: DELEGATOR, + previousDelegate: null, + delegatedValue: 40n, + timestamp: 1700000000n, + logIndex: 0, + }, + ]); + + const splitMetadataFor = async (address?: string) => { + const result = await repository.getFeedEvents( + defaultFeedParams( + address ? { address: address as `0x${string}` } : {}, + ), + defaultThresholds(), + ); + return result.items.find((i) => i.txHash === "0xsplit")?.metadata; + }; + + const expectedDelegatees = [ + { delegate: SPLIT_B, amount: "40" }, + { delegate: SPLIT_A, amount: "60" }, + ]; + + expect(await splitMetadataFor(DELEGATOR)).toMatchObject({ + delegator: DELEGATOR, + delegatees: expectedDelegatees, + }); + expect(await splitMetadataFor()).toMatchObject({ + delegatees: expectedDelegatees, + }); + // The per-delegatee filter still selects its own row as the primary one. + expect(await splitMetadataFor(SPLIT_B)).toMatchObject({ + delegate: SPLIT_B, + amount: "40", + delegatees: expectedDelegatees, + }); + }); + + it("omits delegatees for a single-delegatee event", async () => { + const result = await repository.getFeedEvents( + defaultFeedParams({ address: DELEGATOR as `0x${string}` }), + defaultThresholds(), + ); + const metadata = result.items.find( + (i) => i.txHash === "0xd1", + )?.metadata; + + expect(metadata).toEqual({ + kind: FeedEventType.DELEGATION, + delegator: DELEGATOR, + delegate: DELEGATE, + previousDelegate: PREVIOUS_DELEGATE, + amount: "100", + }); + expect(metadata).not.toHaveProperty("delegatees"); + }); + }); }); }); diff --git a/apps/api/src/repositories/feed/index.ts b/apps/api/src/repositories/feed/index.ts index 660222441b..0a6b6c93af 100644 --- a/apps/api/src/repositories/feed/index.ts +++ b/apps/api/src/repositories/feed/index.ts @@ -59,14 +59,23 @@ export class FeedRepository { items: EnrichedFeedEvent[]; totalCount: number; }> { - const { skip, limit, orderBy, orderDirection, type, fromDate, toDate } = - req; + const { + skip, + limit, + orderBy, + orderDirection, + type, + fromDate, + toDate, + address, + } = req; const relevanceFilter = this.buildRelevanceFilter(type, valueThresholds); const where = and( fromDate ? gte(feedEvent.timestamp, fromDate) : undefined, toDate ? lte(feedEvent.timestamp, toDate) : undefined, + address ? this.buildAddressFilter(address) : undefined, relevanceFilter, ); @@ -85,13 +94,14 @@ export class FeedRepository { this.db.$count(feedEvent, where), ]); - const items = await this.enrichWithMetadata(rows); + const items = await this.enrichWithMetadata(rows, address); return { items, totalCount }; } private async enrichWithMetadata( rows: DBFeedEvent[], + address?: string, ): Promise { if (rows.length === 0) return []; @@ -134,9 +144,8 @@ export class FeedRepository { this.fetchProposals(extendedProposalIds), ]); - const delegationByKey = new Map( - delegations.map((d) => [`${d.transactionHash}:${d.logIndex}`, d]), - ); + const delegationByKey = this.indexDelegationsByKey(delegations, address); + const delegationsByKey = this.groupDelegationsByKey(delegations); const transferByKey = new Map( transfers.map((t) => [`${t.transactionHash}:${t.logIndex}`, t]), ); @@ -152,6 +161,7 @@ export class FeedRepository { ...row, metadata: this.buildMetadata(row, { delegationByKey, + delegationsByKey, transferByKey, voteByKey, proposalByTxHash, @@ -160,10 +170,78 @@ export class FeedRepository { })); } + /** + * Picks the primary row per event key. Partial delegation DAOs (SCR) write + * one row per delegatee out of a single `DelegateChanged`, all sharing the + * transaction hash and log index, so when an address filter is active the row + * mentioning it wins instead of an unrelated sibling. Siblings are not + * dropped: `groupDelegationsByKey` carries them in `delegatees`. + */ + private indexDelegationsByKey( + delegations: DelegationRow[], + address?: string, + ): Map { + const addr = address?.toLowerCase(); + const byKey = new Map(); + + for (const d of delegations) { + const key = `${d.transactionHash}:${d.logIndex}`; + const current = byKey.get(key); + if (!current) { + byKey.set(key, d); + continue; + } + if (addr && !this.delegationMentions(current, addr)) { + if (this.delegationMentions(d, addr)) byKey.set(key, d); + } + } + + return byKey; + } + + /** + * Every delegation row of an event, keyed the same way: the primary row alone + * cannot describe a split, so the whole set travels with it. Sorted by + * delegate address because the database guarantees no row order and the + * rendered list has to be stable. + */ + private groupDelegationsByKey( + delegations: DelegationRow[], + ): Map { + const byKey = new Map(); + + for (const d of delegations) { + const key = `${d.transactionHash}:${d.logIndex}`; + const group = byKey.get(key); + if (group) group.push(d); + else byKey.set(key, [d]); + } + + for (const group of byKey.values()) { + group.sort((a, b) => { + const left = a.delegateAccountId.toLowerCase(); + const right = b.delegateAccountId.toLowerCase(); + if (left === right) return 0; + return left < right ? -1 : 1; + }); + } + + return byKey; + } + + private delegationMentions(d: DelegationRow, lowercasedAddress: string) { + return ( + d.delegatorAccountId.toLowerCase() === lowercasedAddress || + d.delegateAccountId.toLowerCase() === lowercasedAddress || + d.previousDelegate?.toLowerCase() === lowercasedAddress + ); + } + private buildMetadata( row: DBFeedEvent, lookups: { delegationByKey: Map; + delegationsByKey: Map; transferByKey: Map; voteByKey: Map; proposalByTxHash: Map; @@ -175,12 +253,24 @@ export class FeedRepository { case FeedEventType.DELEGATION: { const d = lookups.delegationByKey.get(key); if (!d) return null; + const siblings = lookups.delegationsByKey.get(key) ?? [d]; const meta: DelegationMeta = { kind: FeedEventType.DELEGATION, delegator: d.delegatorAccountId, delegate: d.delegateAccountId, previousDelegate: d.previousDelegate, amount: d.delegatedValue.toString(), + // Only a split carries the array: a lone delegatee is already + // described by `delegate` and `amount`, and consumers read a missing + // `delegatees` as "not a split". + ...(siblings.length > 1 + ? { + delegatees: siblings.map((s) => ({ + delegate: s.delegateAccountId, + amount: s.delegatedValue.toString(), + })), + } + : {}), }; return meta; } @@ -340,6 +430,54 @@ export class FeedRepository { .where(where); } + // feed_event has no account column, so addresses are matched in the source + // table each event type is derived from, keyed by (tx_hash, log_index). + // Lowercased on both sides because source tables may store checksummed + // addresses. feed_event columns use Drizzle refs so they follow whatever + // alias the query builder assigns (see the proposerVotingPower note above). + private buildAddressFilter(address: string): SQL { + const addr = address.toLowerCase(); + return sql`( + (${feedEvent.type} = 'DELEGATION' AND EXISTS ( + SELECT 1 FROM delegations d + WHERE d.transaction_hash = ${feedEvent.txHash} + AND d.log_index = ${feedEvent.logIndex} + AND ( + LOWER(d.delegator_account_id) = ${addr} + OR LOWER(d.delegate_account_id) = ${addr} + OR LOWER(d.previous_delegate) = ${addr} + ) + )) + OR (${feedEvent.type} = 'TRANSFER' AND EXISTS ( + SELECT 1 FROM transfers t + WHERE t.transaction_hash = ${feedEvent.txHash} + AND t.log_index = ${feedEvent.logIndex} + AND ( + LOWER(t.from_account_id) = ${addr} + OR LOWER(t.to_account_id) = ${addr} + ) + )) + OR (${feedEvent.type} = 'VOTE' AND EXISTS ( + SELECT 1 FROM votes_onchain v + WHERE v.tx_hash = ${feedEvent.txHash} + AND v.log_index = ${feedEvent.logIndex} + AND LOWER(v.voter_account_id) = ${addr} + )) + OR (${feedEvent.type} = 'PROPOSAL' AND EXISTS ( + SELECT 1 FROM proposals_onchain p + WHERE p.tx_hash = ${feedEvent.txHash} + AND LOWER(p.proposer_account_id) = ${addr} + )) + OR (${feedEvent.type} = 'PROPOSAL_EXTENDED' + AND ${feedEvent.proposalId} IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proposals_onchain p + WHERE p.id = ${feedEvent.proposalId} + AND LOWER(p.proposer_account_id) = ${addr} + )) + )`; + } + private buildRelevanceFilter( types: FeedEventType[] | undefined, valueThresholds: Partial>, diff --git a/apps/api/src/repositories/proposals-activity/index.ts b/apps/api/src/repositories/proposals-activity/index.ts index 8925f583aa..99fa87dd6e 100644 --- a/apps/api/src/repositories/proposals-activity/index.ts +++ b/apps/api/src/repositories/proposals-activity/index.ts @@ -47,6 +47,53 @@ export type OrderDirection = "asc" | "desc"; export class DrizzleProposalsActivityRepository { constructor(private readonly db: Drizzle) {} + /** + * Upper bound of the activity window, keyed on when the proposal's voting + * opens rather than on its end, so a bounded period reports the proposals the + * delegate could vote on inside it instead of everything created up to today. + * + * Voting opens at the creation timestamp plus the DAO voting delay, which is + * why the delay is passed separately from the combined window length: keying + * on creation alone would take in a proposal created inside the period whose + * voting only opens after it, and since no vote can land in the window the + * delegate would read as inactive on a proposal they could not yet vote on. + */ + private proposalEndCondition( + activityEnd: number | undefined, + votingDelaySeconds: number, + timestampColumn: string, + ) { + if (activityEnd === undefined) return sql.raw(""); + return sql` AND (${sql.raw(timestampColumn)} + ${votingDelaySeconds}) <= ${activityEnd}`; + } + + /** + * Same bound applied to the vote itself, on the vote's own timestamp: a + * proposal opened just before the period ends stays votable after it, so + * without this a vote cast later would count as activity inside a period that + * closed before the vote existed. On the LEFT JOIN it belongs in the ON + * clause, never in WHERE, so the proposal is still listed with no vote + * attached. + */ + private voteEndCondition( + activityEnd: number | undefined, + timestampColumn: string, + ) { + if (activityEnd === undefined) return sql.raw(""); + return sql` AND ${sql.raw(timestampColumn)} <= ${activityEnd}`; + } + + /** + * Lower bound of the same window, on the vote. A proposal whose voting period + * overlaps the period start is in scope, but a vote cast on it before that + * start happened outside the period and must not read as activity inside it. + * With no `fromDate` the caller passes the address's first vote, which no vote + * can precede, so the bound is a no-op there. + */ + private voteStartCondition(activityStart: number, timestampColumn: string) { + return sql` AND ${sql.raw(timestampColumn)} >= ${activityStart}`; + } + async getFirstVoteTimestamp(address: Address): Promise { // Only consider votes on non-canceled proposals so activityStart matches // the activity scope (canceled proposals are excluded from the metrics). @@ -72,13 +119,16 @@ export class DrizzleProposalsActivityRepository { daoId: DaoIdEnum, activityStart: number, votingPeriodSeconds: number, + votingDelaySeconds: number, + activityEnd?: number, ): Promise { const query = sql` - SELECT id, dao_id, proposer_account_id, description, start_block, end_block, + SELECT id, dao_id, proposer_account_id, description, start_block, end_block, timestamp, status, for_votes, against_votes, abstain_votes, (timestamp + ${votingPeriodSeconds}) as proposal_end_timestamp FROM proposals_onchain WHERE (timestamp + ${votingPeriodSeconds}) >= ${activityStart} + ${this.proposalEndCondition(activityEnd, votingDelaySeconds, "timestamp")} AND UPPER(status) <> 'CANCELED' ORDER BY timestamp DESC `; @@ -91,6 +141,8 @@ export class DrizzleProposalsActivityRepository { address: Address, daoId: DaoIdEnum, proposalIds: string[], + activityStart: number, + activityEnd?: number, ): Promise { if (proposalIds.length === 0) return []; @@ -103,6 +155,8 @@ export class DrizzleProposalsActivityRepository { proposalIds.map((id) => sql`${id}`), sql.raw(", "), )}) + ${this.voteStartCondition(activityStart, "timestamp")} + ${this.voteEndCondition(activityEnd, "timestamp")} `; const result = await this.db.execute(query); @@ -113,11 +167,13 @@ export class DrizzleProposalsActivityRepository { address: Address, activityStart: number, votingPeriodSeconds: number, + votingDelaySeconds: number, skip: number, limit: number, orderBy: OrderByField, orderDirection: OrderDirection, userVoteFilter?: VoteFilter, + activityEnd?: number, ): Promise<{ proposals: DbProposalWithVote[]; totalCount: number; @@ -162,8 +218,9 @@ export class DrizzleProposalsActivityRepository { p.*, v.tx_hash as vote_id, v.voter_account_id, v.proposal_id, v.support, v.voting_power, v.reason, v.timestamp as vote_timestamp FROM proposals_onchain p - LEFT JOIN votes_onchain v ON p.id = v.proposal_id AND v.voter_account_id = ${address} + LEFT JOIN votes_onchain v ON p.id = v.proposal_id AND v.voter_account_id = ${address}${this.voteStartCondition(activityStart, "v.timestamp")}${this.voteEndCondition(activityEnd, "v.timestamp")} WHERE (p.timestamp + ${votingPeriodSeconds}) >= ${activityStart} + ${this.proposalEndCondition(activityEnd, votingDelaySeconds, "p.timestamp")} AND UPPER(p.status) <> 'CANCELED' ${sql.raw(voteFilterCondition)} ${sql.raw(orderByClause)} @@ -174,8 +231,9 @@ export class DrizzleProposalsActivityRepository { const countQuery = sql` SELECT COUNT(*) as total_count FROM proposals_onchain p - LEFT JOIN votes_onchain v ON p.id = v.proposal_id AND v.voter_account_id = ${address} + LEFT JOIN votes_onchain v ON p.id = v.proposal_id AND v.voter_account_id = ${address}${this.voteStartCondition(activityStart, "v.timestamp")}${this.voteEndCondition(activityEnd, "v.timestamp")} WHERE (p.timestamp + ${votingPeriodSeconds}) >= ${activityStart} + ${this.proposalEndCondition(activityEnd, votingDelaySeconds, "p.timestamp")} AND UPPER(p.status) <> 'CANCELED' ${sql.raw(voteFilterCondition)} `; diff --git a/apps/api/src/repositories/proposals-activity/index.unit.test.ts b/apps/api/src/repositories/proposals-activity/index.unit.test.ts index 5641affd7f..6ec9dd447c 100644 --- a/apps/api/src/repositories/proposals-activity/index.unit.test.ts +++ b/apps/api/src/repositories/proposals-activity/index.unit.test.ts @@ -136,7 +136,7 @@ describe("DrizzleProposalsActivityRepository", () => { describe("getUserVotes", () => { it("returns empty array when proposalIds is empty", async () => { - const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, []); + const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, [], 0); expect(result).toHaveLength(0); }); @@ -149,9 +149,12 @@ describe("DrizzleProposalsActivityRepository", () => { createVote({ txHash: "0xvoteB", voterAccountId: OTHER_VOTER }), ]); - const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, [ - "proposal-1", - ]); + const result = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 0, + ); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("0xvoteA"); @@ -177,10 +180,12 @@ describe("DrizzleProposalsActivityRepository", () => { }), ]); - const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, [ - "proposal-uni", - "proposal-arb", - ]); + const result = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-uni", "proposal-arb"], + 0, + ); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("0xvoteUni"); @@ -200,9 +205,12 @@ describe("DrizzleProposalsActivityRepository", () => { createVote({ txHash: "0xvote2", proposalId: "proposal-2" }), ]); - const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, [ - "proposal-1", - ]); + const result = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 0, + ); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("0xvote1"); @@ -223,9 +231,12 @@ describe("DrizzleProposalsActivityRepository", () => { createVote({ txHash: "0xvote2", proposalId: "proposal-2" }), ]); - const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, [ - hostileId, - ]); + const result = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + [hostileId], + 0, + ); expect(result).toHaveLength(1); expect(result[0]?.proposal_id).toBe(hostileId); @@ -251,16 +262,124 @@ describe("DrizzleProposalsActivityRepository", () => { createVote({ txHash: "0xvote3", proposalId: "proposal-3" }), ]); - const result = await repository.getUserVotes(VOTER, DaoIdEnum.UNI, [ - "proposal-1", - "proposal-2", - "proposal-3", - ]); + const result = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1", "proposal-2", "proposal-3"], + 0, + ); expect(result).toHaveLength(3); const ids = result.map((v) => v.id).sort(); expect(ids).toEqual(["0xvote1", "0xvote2", "0xvote3"]); }); + + it("excludes votes cast after activityEnd", async () => { + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1699700000n })); + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-1", + timestamp: 1699900000n, + }), + ); + + const votes = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 0, + 1699800000, + ); + + expect(votes).toEqual([]); + }); + + it("keeps votes cast at or before activityEnd", async () => { + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1699700000n })); + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-1", + timestamp: 1699800000n, + }), + ); + + const votes = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 0, + 1699800000, + ); + + expect(votes).toHaveLength(1); + }); + + it("keeps every vote when no activityEnd is given", async () => { + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1699700000n })); + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-1", + timestamp: 1699900000n, + }), + ); + + const votes = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 0, + ); + + expect(votes).toHaveLength(1); + }); + + it("excludes votes cast before activityStart", async () => { + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1699700000n })); + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-1", + timestamp: 1699750000n, + }), + ); + + const votes = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 1699800000, + ); + + expect(votes).toEqual([]); + }); + + it("keeps votes cast at or after activityStart", async () => { + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1699700000n })); + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-1", + timestamp: 1699800000n, + }), + ); + + const votes = await repository.getUserVotes( + VOTER, + DaoIdEnum.UNI, + ["proposal-1"], + 1699800000, + ); + + expect(votes).toHaveLength(1); + }); }); describe("getProposals", () => { @@ -282,6 +401,7 @@ describe("DrizzleProposalsActivityRepository", () => { DaoIdEnum.UNI, 1699950000, 100000, + 0, ); expect(result).toHaveLength(1); @@ -298,7 +418,7 @@ describe("DrizzleProposalsActivityRepository", () => { }), ]); - const result = await repository.getProposals(DaoIdEnum.UNI, 0, 100000); + const result = await repository.getProposals(DaoIdEnum.UNI, 0, 100000, 0); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("active"); @@ -313,9 +433,262 @@ describe("DrizzleProposalsActivityRepository", () => { DaoIdEnum.UNI, 9999999999, 100, + 0, ); expect(result).toHaveLength(0); }); + + it("excludes proposals that only open after activityEnd", async () => { + await db.insert(proposalsOnchain).values([ + createProposal({ + id: "inside", + txHash: "0xtx1", + timestamp: 1699900000n, + }), + createProposal({ + id: "after", + txHash: "0xtx2", + timestamp: 1700200000n, + }), + ]); + + const result = await repository.getProposals( + DaoIdEnum.UNI, + 0, + 100000, + 0, + 1700000000, + ); + + expect(result.map((p) => p.id)).toEqual(["inside"]); + }); + + it("excludes a proposal created inside the window whose voting opens after it", async () => { + await db.insert(proposalsOnchain).values([ + createProposal({ + id: "votable", + txHash: "0xtx1", + timestamp: 1699800000n, + }), + createProposal({ + id: "not-open-yet", + txHash: "0xtx2", + timestamp: 1699990000n, + }), + ]); + + // 20000s of voting delay: `not-open-yet` is created before activityEnd but + // only becomes votable at 1700010000, past the end of the window. + const result = await repository.getProposals( + DaoIdEnum.UNI, + 0, + 100000, + 20000, + 1700000000, + ); + + expect(result.map((p) => p.id)).toEqual(["votable"]); + }); + + it("keeps every proposal after activityStart when no activityEnd is given", async () => { + await db.insert(proposalsOnchain).values([ + createProposal({ + id: "inside", + txHash: "0xtx1", + timestamp: 1699900000n, + }), + createProposal({ + id: "after", + txHash: "0xtx2", + timestamp: 1700200000n, + }), + ]); + + const result = await repository.getProposals(DaoIdEnum.UNI, 0, 100000, 0); + + expect(result).toHaveLength(2); + }); + }); + + describe("getProposalsWithVotesAndPagination", () => { + beforeEach(async () => { + await db.insert(proposalsOnchain).values([ + createProposal({ + id: "proposal-1", + txHash: "0xtx1", + status: "EXECUTED", + timestamp: 1699900000n, + }), + createProposal({ + id: "proposal-2", + txHash: "0xtx2", + status: "DEFEATED", + timestamp: 1699800000n, + }), + createProposal({ + id: "proposal-3", + txHash: "0xtx3", + status: "ACTIVE", + timestamp: 1699700000n, + }), + ]); + }); + + const getPage = () => + repository.getProposalsWithVotesAndPagination( + VOTER, + 0, + 100000, + 0, + 0, + 10, + "timestamp", + "desc", + ); + + it("returns the proposal page with a matching total count", async () => { + const result = await getPage(); + + expect(result.proposals.map((p) => p.proposal.id)).toEqual([ + "proposal-1", + "proposal-2", + "proposal-3", + ]); + expect(result.totalCount).toBe(3); + }); + + it("drops proposals opened after activityEnd from the page and the count", async () => { + const result = await repository.getProposalsWithVotesAndPagination( + VOTER, + 0, + 100000, + 0, + 0, + 10, + "timestamp", + "desc", + undefined, + 1699800000, + ); + + expect(result.proposals.map((p) => p.proposal.id)).toEqual([ + "proposal-2", + "proposal-3", + ]); + expect(result.totalCount).toBe(2); + }); + + it("keeps the proposal but drops a vote cast after activityEnd", async () => { + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-3", + timestamp: 1699900000n, + }), + ); + + const result = await repository.getProposalsWithVotesAndPagination( + VOTER, + 0, + 100000, + 0, + 0, + 10, + "timestamp", + "desc", + undefined, + 1699800000, + ); + + const proposal3 = result.proposals.find( + (p) => p.proposal.id === "proposal-3", + ); + // The proposal opened inside the window, so it stays listed; the vote + // landed after the window closed, so it must not be attached. + expect(proposal3).toBeDefined(); + expect(proposal3!.userVote).toBeNull(); + expect(result.totalCount).toBe(2); + }); + + it("keeps the proposal but drops a vote cast before activityStart", async () => { + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-3", + timestamp: 1699720000n, + }), + ); + + // proposal-3 opens at 1699700000 and stays votable until 1699800000, so + // it overlaps a window starting at 1699750000 even though the vote does + // not. + const result = await repository.getProposalsWithVotesAndPagination( + VOTER, + 1699750000, + 100000, + 0, + 0, + 10, + "timestamp", + "desc", + ); + + const proposal3 = result.proposals.find( + (p) => p.proposal.id === "proposal-3", + ); + expect(proposal3).toBeDefined(); + expect(proposal3!.userVote).toBeNull(); + }); + + // Without the voting delay on the upper bound this proposal is listed with + // no vote attached, which reads as a delegate who skipped a proposal they + // could not yet vote on. + it("drops a proposal whose voting only opens after activityEnd", async () => { + const result = await repository.getProposalsWithVotesAndPagination( + VOTER, + 0, + 100000, + 150000, + 0, + 10, + "timestamp", + "desc", + undefined, + 1699900000, + ); + + // proposal-1 opens at 1699900000 + 150000, past the window; proposal-2 and + // proposal-3 open at 1699950000 and 1699850000 respectively. + expect(result.proposals.map((p) => p.proposal.id)).toEqual([ + "proposal-3", + ]); + expect(result.totalCount).toBe(1); + }); + + it("keeps a vote cast inside the window attached to its proposal", async () => { + await db.insert(votesOnchain).values( + createVote({ + proposalId: "proposal-3", + timestamp: 1699750000n, + }), + ); + + const result = await repository.getProposalsWithVotesAndPagination( + VOTER, + 0, + 100000, + 0, + 0, + 10, + "timestamp", + "desc", + undefined, + 1699800000, + ); + + const proposal3 = result.proposals.find( + (p) => p.proposal.id === "proposal-3", + ); + expect(proposal3!.userVote).not.toBeNull(); + }); }); }); diff --git a/apps/api/src/repositories/voting-power/aave.ts b/apps/api/src/repositories/voting-power/aave.ts index 999ccdc923..df39a5be41 100644 --- a/apps/api/src/repositories/voting-power/aave.ts +++ b/apps/api/src/repositories/voting-power/aave.ts @@ -139,6 +139,12 @@ export class AAVEVotingPowerRepository { .as("variation"); const combinedPowerSql = sql`(COALESCE(${accountPower.votingPower}, 0) + COALESCE(${balanceSubquery.totalBalance}, 0))`; + // Delegated voting power on its own, i.e. the combined total minus the + // account's own balance. The amount filter targets this, matching both the + // `votingPower` ordering below and what consumers render as delegation + // received: filtering the combined total would let a large self balance + // alone satisfy a minimum, or push a delegated account past a maximum. + const delegatedPowerSql = sql`COALESCE(${accountPower.votingPower}, 0)`; const absoluteChangeSql = sql`COALESCE(${variationSubquery.absoluteChange}, 0)`; const percentageChangeSql = sql` CASE @@ -160,7 +166,7 @@ export class AAVEVotingPowerRepository { : orderBy === "total" ? combinedPowerSql : orderBy === "votingPower" - ? sql`COALESCE(${accountPower.votingPower}, 0)` + ? delegatedPowerSql : orderBy === "balance" ? sql`COALESCE(${balanceSubquery.totalBalance}, 0)` : sql`COALESCE(${accountPower.delegationsCount}, 0)`, @@ -196,7 +202,7 @@ export class AAVEVotingPowerRepository { this.filterToSql( addresses, amountFilter, - combinedPowerSql, + delegatedPowerSql, sql`${allAccountIds.accountId}`, ), ) @@ -221,7 +227,7 @@ export class AAVEVotingPowerRepository { this.filterToSql( addresses, amountFilter, - combinedPowerSql, + delegatedPowerSql, sql`${allAccountIds.accountId}`, ), ); @@ -334,7 +340,7 @@ export class AAVEVotingPowerRepository { private filterToSql( addresses: Address[], amountFilter: AmountFilter, - totalVotingPowerSql?: SQL, + votingPowerSql?: SQL, accountIdSql?: SQL, ): SQL | undefined { const conditions = []; @@ -344,15 +350,15 @@ export class AAVEVotingPowerRepository { inArray(accountIdSql ?? sql`${accountPower.accountId}`, addresses), ); } - if (totalVotingPowerSql) { + if (votingPowerSql) { if (amountFilter.minAmount) { conditions.push( - sql`${totalVotingPowerSql} > ${BigInt(amountFilter.minAmount)}`, + sql`${votingPowerSql} > ${BigInt(amountFilter.minAmount)}`, ); } if (amountFilter.maxAmount) { conditions.push( - sql`${totalVotingPowerSql} < ${BigInt(amountFilter.maxAmount)}`, + sql`${votingPowerSql} < ${BigInt(amountFilter.maxAmount)}`, ); } } else { diff --git a/apps/api/src/repositories/voting-power/aave.unit.test.ts b/apps/api/src/repositories/voting-power/aave.unit.test.ts index 3fcc215783..cefdd38578 100644 --- a/apps/api/src/repositories/voting-power/aave.unit.test.ts +++ b/apps/api/src/repositories/voting-power/aave.unit.test.ts @@ -384,6 +384,52 @@ describe("AAVEVotingPowerRepository", () => { expect(result.items[0]!.absoluteChange).toBeDefined(); }); + it("should apply the amount filter to delegated power, not the combined total", async () => { + // Self-held balance only: no delegation received, so a minimum of 500 + // must exclude it even though the combined total is 2000. + await db.insert(accountPower).values( + createAccountPowerRow({ + accountId: TEST_ACCOUNT_1, + votingPower: 0n, + }), + ); + await db.insert(accountBalance).values( + createAccountBalance({ + accountId: TEST_ACCOUNT_1, + tokenId: "aToken", + balance: 2000n, + }), + ); + // Delegated power of 1000 with a balance that pushes the combined total + // over any max the user could set on the delegation received column. + await db.insert(accountPower).values( + createAccountPowerRow({ + accountId: TEST_ACCOUNT_2, + votingPower: 1000n, + }), + ); + await db.insert(accountBalance).values( + createAccountBalance({ + accountId: TEST_ACCOUNT_2, + tokenId: "aToken", + balance: 9000n, + }), + ); + + const result = await repository.getVotingPowers( + 0, + 10, + "desc", + "votingPower", + { minAmount: 500n, maxAmount: 1500n }, + [], + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0]!.accountId).toBe(TEST_ACCOUNT_2); + expect(result.totalCount).toBe(1); + }); + it("should return NO BASELINE when previous power was 0 and there is a change", async () => { await db.insert(accountPower).values( createAccountPowerRow({ diff --git a/apps/api/src/repositories/voting-power/inactive-summary.ts b/apps/api/src/repositories/voting-power/inactive-summary.ts new file mode 100644 index 0000000000..8e5c885ff0 --- /dev/null +++ b/apps/api/src/repositories/voting-power/inactive-summary.ts @@ -0,0 +1,80 @@ +import { sql } from "drizzle-orm"; + +import { Drizzle } from "@/database"; +import { DBInactiveVotingPowerSummary } from "@/mappers"; + +export class InactiveVotingPowerSummaryRepository { + constructor(private readonly db: Drizzle) {} + + /** + * Total delegated voting power and the share held by delegates that cast zero + * votes in the window, in a single query. Window semantics mirror the + * proposals-activity service: a proposal is in the window when its voting + * period (creation timestamp plus the DAO voting period) overlaps it. + */ + async getInactiveDelegatedVotingPowerSummary( + votingPeriodSeconds: number, + votingDelaySeconds: number, + fromDate?: number, + toDate?: number, + ): Promise { + const fromFilter = fromDate + ? sql` AND (timestamp + ${votingPeriodSeconds}) >= ${fromDate}` + : sql``; + // Keyed on when voting opens, not on creation: a proposal created inside the + // window whose voting only opens after it takes no vote in the window, so + // counting it would report every delegate as inactive on a proposal none of + // them could vote on yet -- and when it is the only proposal in the window, + // the `totalProposals === 0` guard downstream no longer catches it. + const toFilter = toDate + ? sql` AND (timestamp + ${votingDelaySeconds}) <= ${toDate}` + : sql``; + // Proposals that open near the end of the window stay votable past it, so a + // vote cast after `toDate` must not count as activity inside the window. + const voteToFilter = toDate ? sql` AND v.timestamp <= ${toDate}` : sql``; + // Mirror image: a proposal whose voting period overlaps `fromDate` can also + // have been voted on before it, and that vote is outside the window too. + const voteFromFilter = fromDate + ? sql` AND v.timestamp >= ${fromDate}` + : sql``; + + const query = sql` + WITH window_proposals AS ( + SELECT id + FROM proposals_onchain + WHERE UPPER(status) <> 'CANCELED'${fromFilter}${toFilter} + ) + SELECT + (SELECT COUNT(*) FROM window_proposals) AS total_proposals, + COALESCE(SUM(ap.voting_power), 0)::text AS total_delegated_voting_power, + COALESCE(SUM(ap.voting_power) FILTER ( + WHERE NOT EXISTS ( + SELECT 1 + FROM votes_onchain v + WHERE v.voter_account_id = ap.account_id + AND v.proposal_id IN (SELECT id FROM window_proposals)${voteFromFilter}${voteToFilter} + ) + ), 0)::text AS inactive_delegated_voting_power + FROM account_power ap + WHERE ap.voting_power > 0 + `; + + const result = await this.db.execute<{ + total_proposals: string | number; + total_delegated_voting_power: string; + inactive_delegated_voting_power: string; + }>(query); + + const row = result.rows[0]; + + return { + totalProposals: Number(row?.total_proposals ?? 0), + totalDelegatedVotingPower: BigInt( + row?.total_delegated_voting_power ?? "0", + ), + inactiveDelegatedVotingPower: BigInt( + row?.inactive_delegated_voting_power ?? "0", + ), + }; + } +} diff --git a/apps/api/src/repositories/voting-power/inactive-summary.unit.test.ts b/apps/api/src/repositories/voting-power/inactive-summary.unit.test.ts new file mode 100644 index 0000000000..7033147cf9 --- /dev/null +++ b/apps/api/src/repositories/voting-power/inactive-summary.unit.test.ts @@ -0,0 +1,343 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { Address } from "viem"; + +import type { Drizzle } from "@/database"; +import * as schema from "@/database/schema"; +import { + accountPower, + proposalsOnchain, + votesOnchain, +} from "@/database/schema"; + +import { InactiveVotingPowerSummaryRepository } from "./inactive-summary"; + +const DELEGATE_A: Address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DELEGATE_B: Address = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const DELEGATE_C: Address = "0xcccccccccccccccccccccccccccccccccccccccc"; + +type AccountPowerInsert = typeof accountPower.$inferInsert; +type ProposalInsert = typeof proposalsOnchain.$inferInsert; +type VoteInsert = typeof votesOnchain.$inferInsert; + +const VOTING_PERIOD_SECONDS = 100000; + +const createAccountPower = ( + overrides: Partial = {}, +): AccountPowerInsert => ({ + accountId: DELEGATE_A, + daoId: "UNI", + votingPower: 1000n, + ...overrides, +}); + +const createProposal = ( + overrides: Partial = {}, +): ProposalInsert => ({ + id: "proposal-1", + txHash: "0xtx1", + daoId: "UNI", + proposerAccountId: DELEGATE_A, + targets: [], + values: [], + signatures: [], + calldatas: [], + startBlock: 100, + endBlock: 200, + title: "Test proposal", + description: "Test proposal", + timestamp: 1000n, + endTimestamp: 2000n, + status: "EXECUTED", + ...overrides, +}); + +const createVote = (overrides: Partial = {}): VoteInsert => ({ + txHash: "0xvote1", + daoId: "UNI", + voterAccountId: DELEGATE_A, + proposalId: "proposal-1", + support: "1", + votingPower: 1000n, + timestamp: 1500n, + logIndex: 0, + ...overrides, +}); + +describe("InactiveVotingPowerSummaryRepository", () => { + let client: PGlite; + let db: Drizzle; + let repository: InactiveVotingPowerSummaryRepository; + + beforeAll(async () => { + client = new PGlite(); + db = drizzle(client, { schema }); + repository = new InactiveVotingPowerSummaryRepository(db); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const { apply } = await pushSchema(schema, db as any); + await apply(); + }); + + afterAll(async () => { + await client.close(); + }); + + beforeEach(async () => { + await db.delete(votesOnchain); + await db.delete(proposalsOnchain); + await db.delete(accountPower); + }); + + it("returns zeros when no data exists", async () => { + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + ); + + expect(result).toEqual({ + totalProposals: 0, + totalDelegatedVotingPower: 0n, + inactiveDelegatedVotingPower: 0n, + }); + }); + + it("sums only positive voting power into the total", async () => { + await db + .insert(accountPower) + .values([ + createAccountPower({ accountId: DELEGATE_A, votingPower: 1000n }), + createAccountPower({ accountId: DELEGATE_B, votingPower: 0n }), + ]); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + ); + + expect(result.totalDelegatedVotingPower).toBe(1000n); + }); + + it("counts delegates with no votes on window proposals as inactive", async () => { + await db + .insert(accountPower) + .values([ + createAccountPower({ accountId: DELEGATE_A, votingPower: 700n }), + createAccountPower({ accountId: DELEGATE_B, votingPower: 300n }), + ]); + await db.insert(proposalsOnchain).values(createProposal()); + await db + .insert(votesOnchain) + .values(createVote({ voterAccountId: DELEGATE_A })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + ); + + expect(result).toEqual({ + totalProposals: 1, + totalDelegatedVotingPower: 1000n, + inactiveDelegatedVotingPower: 300n, + }); + }); + + it("only counts proposals whose voting period overlaps the window", async () => { + await db + .insert(accountPower) + .values([ + createAccountPower({ accountId: DELEGATE_A, votingPower: 700n }), + createAccountPower({ accountId: DELEGATE_B, votingPower: 300n }), + createAccountPower({ accountId: DELEGATE_C, votingPower: 100n }), + ]); + await db.insert(proposalsOnchain).values([ + // Voting period ends at 101000, before the window starts. + createProposal({ id: "proposal-old", txHash: "0xtx1", timestamp: 1000n }), + // Voting period [500000, 600000], inside the window. + createProposal({ + id: "proposal-window", + txHash: "0xtx2", + timestamp: 500000n, + }), + ]); + await db.insert(votesOnchain).values([ + // A voted only on the out-of-window proposal, so it stays inactive. + createVote({ + txHash: "0xvoteA", + voterAccountId: DELEGATE_A, + proposalId: "proposal-old", + timestamp: 50000n, + }), + createVote({ + txHash: "0xvoteB", + voterAccountId: DELEGATE_B, + proposalId: "proposal-window", + timestamp: 550000n, + }), + ]); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + 200000, + ); + + expect(result).toEqual({ + totalProposals: 1, + totalDelegatedVotingPower: 1100n, + inactiveDelegatedVotingPower: 800n, + }); + }); + + it("excludes proposals created after toDate", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + await db.insert(proposalsOnchain).values([ + createProposal({ id: "proposal-1", txHash: "0xtx1", timestamp: 1000n }), + createProposal({ + id: "proposal-late", + txHash: "0xtx2", + timestamp: 900000n, + }), + ]); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + undefined, + 500000, + ); + + expect(result.totalProposals).toBe(1); + }); + + it("excludes a proposal whose voting only opens after toDate", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + // Created before toDate, but a 20000s voting delay only opens it at 60000. + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 40000n })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 20000, + undefined, + 50000, + ); + + // No proposal was votable inside the window, so no delegate can be called + // inactive for it -- counting it would report 100% inactive voting power. + expect(result.totalProposals).toBe(0); + expect(result.inactiveDelegatedVotingPower).toBe(500n); + }); + + it("ignores votes cast after toDate on a proposal opened inside the window", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + // Opens inside the window, still votable after it ends. + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1000n })); + await db + .insert(votesOnchain) + .values(createVote({ voterAccountId: DELEGATE_A, timestamp: 90000n })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + undefined, + 50000, + ); + + expect(result.totalProposals).toBe(1); + // The only vote landed after the window closed, so the delegate was + // inactive for the period the user selected. + expect(result.inactiveDelegatedVotingPower).toBe(500n); + }); + + it("counts votes cast inside the window as active", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1000n })); + await db + .insert(votesOnchain) + .values(createVote({ voterAccountId: DELEGATE_A, timestamp: 40000n })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + undefined, + 50000, + ); + + expect(result.inactiveDelegatedVotingPower).toBe(0n); + }); + + it("ignores votes cast before fromDate on a proposal that overlaps the window", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + // Votable over [1000, 101000], so it overlaps a window starting at 50000. + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1000n })); + await db + .insert(votesOnchain) + .values(createVote({ voterAccountId: DELEGATE_A, timestamp: 20000n })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + 50000, + ); + + expect(result.totalProposals).toBe(1); + // The only vote predates the window, so the delegate cast nothing inside it. + expect(result.inactiveDelegatedVotingPower).toBe(500n); + }); + + it("counts votes cast at or after fromDate as active", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + await db + .insert(proposalsOnchain) + .values(createProposal({ id: "proposal-1", timestamp: 1000n })); + await db + .insert(votesOnchain) + .values(createVote({ voterAccountId: DELEGATE_A, timestamp: 50000n })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + 50000, + ); + + expect(result.inactiveDelegatedVotingPower).toBe(0n); + }); + + it("reports zero proposals when none fall inside the window", async () => { + await db + .insert(accountPower) + .values(createAccountPower({ accountId: DELEGATE_A, votingPower: 500n })); + await db + .insert(proposalsOnchain) + .values(createProposal({ timestamp: 1000n })); + + const result = await repository.getInactiveDelegatedVotingPowerSummary( + VOTING_PERIOD_SECONDS, + 0, + 999999999, + ); + + expect(result.totalProposals).toBe(0); + }); +}); diff --git a/apps/api/src/repositories/voting-power/index.ts b/apps/api/src/repositories/voting-power/index.ts index 93fc957916..9e1626f84e 100644 --- a/apps/api/src/repositories/voting-power/index.ts +++ b/apps/api/src/repositories/voting-power/index.ts @@ -1,3 +1,4 @@ export * from "./nouns"; export * from "./torn"; export * from "./general"; +export * from "./inactive-summary"; diff --git a/apps/api/src/services/addresses/index.ts b/apps/api/src/services/addresses/index.ts new file mode 100644 index 0000000000..6fa74cb3d4 --- /dev/null +++ b/apps/api/src/services/addresses/index.ts @@ -0,0 +1,68 @@ +import { Address } from "viem"; + +import { NonCirculatingAddresses, TreasuryAddresses } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; +import { AddressLabelItem, AddressLabelsResponse } from "@/mappers"; + +// Unlock contracts whose label does not mention vesting, so the label alone +// cannot classify them. Referenced off the source records rather than retyped, +// so an address can never drift from the list it came from. +// +// The rest of NonCirculatingAddresses stays out on purpose, because an outgoing +// transfer from those is not an unlock: ZK's Merkle distributors pay airdrop +// claims, AAVE's LEND migrator is permanently locked with the migration +// discontinued, and TORN's vault hands a staker back their own deposit. +const VESTING_ADDRESSES: ReadonlySet = new Set( + [ + // Linear vesting for contributors, unlock end Dec 2025 + NonCirculatingAddresses[DaoIdEnum.ENS]["Token Timelock"], + // ZK Nation allocations, released to their holders over time + NonCirculatingAddresses[DaoIdEnum.ZK]["Matter Labs Allocation"], + NonCirculatingAddresses[DaoIdEnum.ZK]["Foundation Allocation"], + NonCirculatingAddresses[DaoIdEnum.ZK]["Guardians Allocation"], + NonCirculatingAddresses[DaoIdEnum.ZK]["Security Council Allocation"], + NonCirculatingAddresses[DaoIdEnum.ZK]["ZKsync Association Allocation"], + ] + // A renamed key drops out here rather than landing as undefined; the unit + // tests read the same keys, so the rename fails there instead of silently + // reclassifying the address as treasury. + .filter((address): address is Address => address !== undefined) + .map((address) => address.toLowerCase()), +); + +// A label mentioning vesting ("Vesting Address", "treasuryVester") classifies +// itself; the rest is treasury unless the address is a known unlock contract. +const categorize = ( + label: string, + address: Address, +): AddressLabelItem["category"] => + label.toLowerCase().includes("vest") || + VESTING_ADDRESSES.has(address.toLowerCase()) + ? "vesting" + : "treasury"; + +export class AddressLabelsService { + constructor(private readonly daoId: DaoIdEnum) {} + + getAddressLabels(): AddressLabelsResponse { + const labeled = new Map(); + + const collect = (entries: Record) => { + for (const [label, address] of Object.entries(entries)) { + const key = address.toLowerCase(); + if (!labeled.has(key)) { + labeled.set(key, { + address, + label, + category: categorize(label, address), + }); + } + } + }; + + collect(TreasuryAddresses[this.daoId] ?? {}); + collect(NonCirculatingAddresses[this.daoId] ?? {}); + + return { items: Array.from(labeled.values()) }; + } +} diff --git a/apps/api/src/services/addresses/index.unit.test.ts b/apps/api/src/services/addresses/index.unit.test.ts new file mode 100644 index 0000000000..e8525c730c --- /dev/null +++ b/apps/api/src/services/addresses/index.unit.test.ts @@ -0,0 +1,97 @@ +import { NonCirculatingAddresses, TreasuryAddresses } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +import { AddressLabelsService } from "."; + +describe("AddressLabelsService", () => { + it("returns treasury and vesting labels for the DAO", () => { + const service = new AddressLabelsService(DaoIdEnum.UNI); + + const { items } = service.getAddressLabels(); + + expect(items).toContainEqual({ + address: TreasuryAddresses[DaoIdEnum.UNI].timelock, + label: "timelock", + category: "treasury", + }); + expect(items).toContainEqual({ + address: TreasuryAddresses[DaoIdEnum.UNI].treasuryVester1, + label: "treasuryVester1", + category: "vesting", + }); + expect(items).toHaveLength( + Object.keys(TreasuryAddresses[DaoIdEnum.UNI]).length + + Object.keys(NonCirculatingAddresses[DaoIdEnum.UNI]).length, + ); + }); + + it("categorizes labels containing 'vest' as vesting regardless of casing", () => { + const service = new AddressLabelsService(DaoIdEnum.ARB); + + const { items } = service.getAddressLabels(); + + expect(items).toContainEqual({ + address: TreasuryAddresses[DaoIdEnum.ARB]["Foundation Vesting Wallet"], + label: "Foundation Vesting Wallet", + category: "vesting", + }); + expect(items).toContainEqual({ + address: TreasuryAddresses[DaoIdEnum.ARB]["DAO Treasury"], + label: "DAO Treasury", + category: "treasury", + }); + }); + + // The dashboard only relabels an incoming transfer as a vesting unlock when + // the source address comes back as `vesting`, so an unlock contract whose + // label does not say "vest" has to be classified by address. + it("classifies an unlock contract whose label omits vesting as vesting", () => { + const service = new AddressLabelsService(DaoIdEnum.ENS); + + const { items } = service.getAddressLabels(); + + expect(items).toContainEqual({ + address: NonCirculatingAddresses[DaoIdEnum.ENS]["Token Timelock"], + label: "Token Timelock", + category: "vesting", + }); + }); + + it("classifies ZK allocations as vesting and its distributors as treasury", () => { + const service = new AddressLabelsService(DaoIdEnum.ZK); + + const { items } = service.getAddressLabels(); + + expect(items).toContainEqual({ + address: NonCirculatingAddresses[DaoIdEnum.ZK]["Matter Labs Allocation"], + label: "Matter Labs Allocation", + category: "vesting", + }); + // A transfer out of a distributor is an airdrop claim, not an unlock. + expect(items).toContainEqual({ + address: + NonCirculatingAddresses[DaoIdEnum.ZK]["Initial Merkle Distributor"], + label: "Initial Merkle Distributor", + category: "treasury", + }); + }); + + it("keeps a staking vault out of vesting", () => { + const service = new AddressLabelsService(DaoIdEnum.TORN); + + const { items } = service.getAddressLabels(); + + // Transfers out return a staker their own deposit. + expect(items).toContainEqual({ + address: NonCirculatingAddresses[DaoIdEnum.TORN].vault, + label: "vault", + category: "treasury", + }); + }); + + it("is deterministic across calls", () => { + const service = new AddressLabelsService(DaoIdEnum.GTC); + + expect(service.getAddressLabels()).toEqual(service.getAddressLabels()); + }); +}); diff --git a/apps/api/src/services/delegations/former-delegators.ts b/apps/api/src/services/delegations/former-delegators.ts new file mode 100644 index 0000000000..53720f7a7e --- /dev/null +++ b/apps/api/src/services/delegations/former-delegators.ts @@ -0,0 +1,30 @@ +import { Address } from "viem"; + +import { DBFormerDelegator } from "@/mappers"; + +interface Repository { + getFormerDelegators( + address: Address, + skip: number, + limit: number, + orderDirection: "asc" | "desc", + ): Promise<{ items: DBFormerDelegator[]; totalCount: number }>; +} + +export class FormerDelegatorsService { + constructor(private readonly formerDelegatorsRepository: Repository) {} + + async getFormerDelegators( + address: Address, + skip: number, + limit: number, + orderDirection: "asc" | "desc", + ): Promise<{ items: DBFormerDelegator[]; totalCount: number }> { + return this.formerDelegatorsRepository.getFormerDelegators( + address, + skip, + limit, + orderDirection, + ); + } +} diff --git a/apps/api/src/services/delegations/index.ts b/apps/api/src/services/delegations/index.ts index 676cf14eec..f518b6cada 100644 --- a/apps/api/src/services/delegations/index.ts +++ b/apps/api/src/services/delegations/index.ts @@ -1,3 +1,4 @@ export * from "./current"; export * from "./historical"; export * from "./delegators"; +export * from "./former-delegators"; diff --git a/apps/api/src/services/feed/feed.unit.test.ts b/apps/api/src/services/feed/feed.unit.test.ts index d9f4188347..f9ab9162e9 100644 --- a/apps/api/src/services/feed/feed.unit.test.ts +++ b/apps/api/src/services/feed/feed.unit.test.ts @@ -2,7 +2,11 @@ import { parseEther } from "viem"; import { describe, it, expect, beforeEach } from "vitest"; import { z } from "zod"; -import { FeedEventType, FeedRelevance } from "@/lib/constants"; +import { + FeedEventType, + FeedRelevance, + FeedRelevanceFilter, +} from "@/lib/constants"; import { DaoIdEnum } from "@/lib/enums"; import { getDaoRelevanceThreshold } from "@/lib/eventRelevance"; import { @@ -36,7 +40,7 @@ const createRequest = (overrides: Partial = {}): FeedRequest => ({ limit: 10, orderBy: "timestamp", orderDirection: "desc", - relevance: FeedRelevance.MEDIUM, + relevance: FeedRelevanceFilter.MEDIUM, ...overrides, }); @@ -158,7 +162,7 @@ describe("FeedService", () => { ]; const result = await service.getFeedEvents( - createRequest({ relevance: FeedRelevance.LOW }), + createRequest({ relevance: FeedRelevanceFilter.LOW }), ); expect(result.items[0]?.relevance).toBe(FeedRelevance.LOW); @@ -185,7 +189,7 @@ describe("FeedService", () => { ]; const result = await service.getFeedEvents( - createRequest({ relevance: FeedRelevance.LOW }), + createRequest({ relevance: FeedRelevanceFilter.LOW }), ); expect(result.items[0]?.relevance).toBe(FeedRelevance.HIGH); @@ -208,13 +212,66 @@ describe("FeedService", () => { ]; const result = await service.getFeedEvents( - createRequest({ relevance: FeedRelevance.MEDIUM }), + createRequest({ relevance: FeedRelevanceFilter.MEDIUM }), ); expect(result.items).toHaveLength(1); expect(result.items[0]?.relevance).toBe(FeedRelevance.MEDIUM); }); + it("should keep events below every tier when relevance is ALL", async () => { + simpleRepo.items = [ + createFeedEvent({ + type: "VOTE", + // well under the LOW threshold, so MEDIUM (the default) drops it + value: parseEther("1"), + logIndex: 0, + }), + createFeedEvent({ + type: "VOTE", + value: ensThresholds[FeedEventType.VOTE][FeedRelevance.HIGH], + logIndex: 1, + }), + ]; + + const result = await service.getFeedEvents( + createRequest({ relevance: FeedRelevanceFilter.ALL }), + ); + + expect(result.items).toHaveLength(2); + expect(result.items[0]?.relevance).toBe(FeedRelevance.LOW); + expect(result.items[1]?.relevance).toBe(FeedRelevance.HIGH); + }); + + it("should still scope ALL to the requested event types", async () => { + simpleRepo.items = [ + createFeedEvent({ type: "VOTE", value: parseEther("1"), logIndex: 0 }), + ]; + + // ALL zeroes the value floor but keeps one entry per type, since the + // repository derives the type filter from those keys. + const thresholds: Partial> = {}; + const spyRepo = { + items: simpleRepo.items, + getFeedEvents: async ( + _req: FeedRequest, + valueThresholds: Partial>, + ) => { + Object.assign(thresholds, valueThresholds); + return { items: simpleRepo.items, totalCount: 1 }; + }, + }; + + await new FeedService(DaoIdEnum.ENS, spyRepo).getFeedEvents( + createRequest({ relevance: FeedRelevanceFilter.ALL }), + ); + + expect(Object.keys(thresholds).sort()).toEqual( + Object.keys(ensThresholds).sort(), + ); + expect(Object.values(thresholds).every((v) => v === 0n)).toBe(true); + }); + it("should use NOUNS thresholds for NOUNS dao", async () => { const nounsService = new FeedService(DaoIdEnum.NOUNS, simpleRepo); simpleRepo.items = [ @@ -223,7 +280,7 @@ describe("FeedService", () => { ]; const result = await nounsService.getFeedEvents( - createRequest({ relevance: FeedRelevance.MEDIUM }), + createRequest({ relevance: FeedRelevanceFilter.MEDIUM }), ); expect(result.items).toHaveLength(2); diff --git a/apps/api/src/services/feed/index.ts b/apps/api/src/services/feed/index.ts index 2f96492899..c545cad3b0 100644 --- a/apps/api/src/services/feed/index.ts +++ b/apps/api/src/services/feed/index.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import { FeedEventType, FeedRelevance } from "@/lib/constants"; +import { + FeedEventType, + FeedRelevance, + FeedRelevanceFilter, +} from "@/lib/constants"; import { DaoIdEnum } from "@/lib/enums"; import { getDaoRelevanceThreshold } from "@/lib/eventRelevance"; import { @@ -32,7 +36,7 @@ export class FeedService { async getFeedEvents(req: FeedRequest): Promise { const valueThresholds = this.getValueThresholds( - req.relevance ?? FeedRelevance.MEDIUM, + req.relevance ?? FeedRelevanceFilter.MEDIUM, ); const response = await this.repo.getFeedEvents(req, valueThresholds); return { @@ -69,13 +73,16 @@ export class FeedService { } private getValueThresholds( - relevance: FeedRelevance, + relevance: FeedRelevanceFilter, ): Partial> { const daoThresholds = getDaoRelevanceThreshold(this.daoId); const result: Partial> = {}; for (const [type, levels] of Object.entries(daoThresholds)) { - result[type as FeedEventType] = levels[relevance]; + // ALL keeps an entry per type with a zero floor instead of an empty map: + // the repository derives the `type` filter from these keys. + result[type as FeedEventType] = + relevance === FeedRelevanceFilter.ALL ? 0n : levels[relevance]; } return result; diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index d9461a0036..250a007c7b 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -21,3 +21,5 @@ export * from "./votes/offchainNonVoters"; export * from "./event-relevance"; export * from "./health"; export * from "./revenue"; +export * from "./addresses"; +export * from "./voting-power/inactive-summary"; diff --git a/apps/api/src/services/proposals-activity/index.ts b/apps/api/src/services/proposals-activity/index.ts index 85a348059f..e29f7189a0 100644 --- a/apps/api/src/services/proposals-activity/index.ts +++ b/apps/api/src/services/proposals-activity/index.ts @@ -17,6 +17,7 @@ const FINAL_PROPOSAL_STATUSES = ["EXECUTED", "DEFEATED", "CANCELED", "EXPIRED"]; export interface ProposalActivityRequest { address: Address; fromDate?: number; + toDate?: number; daoId: DaoIdEnum; skip?: number; limit?: number; @@ -71,23 +72,29 @@ export interface ProposalsActivityRepository { daoId: DaoIdEnum, activityStart: number, votingPeriodSeconds: number, + votingDelaySeconds: number, + activityEnd?: number, ): Promise; getUserVotes( address: Address, daoId: DaoIdEnum, proposalIds: string[], + activityStart: number, + activityEnd?: number, ): Promise; getProposalsWithVotesAndPagination( address: Address, activityStart: number, votingPeriodSeconds: number, + votingDelaySeconds: number, skip: number, limit: number, orderBy: OrderByField, orderDirection: OrderDirection, userVoteFilter?: VoteFilter, + activityEnd?: number, ): Promise<{ proposals: DbProposalWithVote[]; totalCount: number; @@ -103,6 +110,7 @@ export class ProposalsActivityService { async getProposalsActivity({ address, fromDate, + toDate, daoId, skip = 0, limit = 10, @@ -123,8 +131,13 @@ export class ProposalsActivityService { const votingPeriodBlocks = await this.daoClient.getVotingPeriod(); const votingDelay = await this.daoClient.getVotingDelay(); + // The window length spans the whole life of a proposal, delay included, so + // its lower bound catches every proposal still votable at `fromDate`. The + // delay travels on its own as well, because the upper bound has to key on + // when voting opens rather than on when the proposal was created. const votingPeriodSeconds = Number(votingPeriodBlocks + votingDelay) * blockTime; + const votingDelaySeconds = Number(votingDelay) * blockTime; const activityStart = fromDate && fromDate > firstVoteTimestamp ? fromDate : firstVoteTimestamp; @@ -135,11 +148,13 @@ export class ProposalsActivityService { address, activityStart, votingPeriodSeconds, + votingDelaySeconds, skip, limit, orderBy, orderDirection, userVoteFilter, + toDate, ); if (proposalsWithVotes.length === 0) { @@ -192,11 +207,15 @@ export class ProposalsActivityService { daoId, activityStart, votingPeriodSeconds, + votingDelaySeconds, + toDate, ); const allUserVotes = await this.repository.getUserVotes( address, daoId, allProposals.map((p: DbProposal) => p.id), + activityStart, + toDate, ); const analytics = this.calculateAnalytics(allProposals, allUserVotes); diff --git a/apps/api/src/services/proposals-activity/index.unit.test.ts b/apps/api/src/services/proposals-activity/index.unit.test.ts index 165cf9e4fc..b9e77c4990 100644 --- a/apps/api/src/services/proposals-activity/index.unit.test.ts +++ b/apps/api/src/services/proposals-activity/index.unit.test.ts @@ -20,6 +20,8 @@ const VOTER_ADDRESS = "0x1111111111111111111111111111111111111111" as Address; function createStubRepo(): ProposalsActivityRepository & { lastActivityStart: number | null; + lastVotingPeriodSeconds: number | null; + lastVotingDelaySeconds: number | null; firstVoteTs: number | null; proposals: DbProposal[]; votes: DbVote[]; @@ -27,6 +29,8 @@ function createStubRepo(): ProposalsActivityRepository & { } { const stub = { lastActivityStart: null as number | null, + lastVotingPeriodSeconds: null as number | null, + lastVotingDelaySeconds: null as number | null, firstVoteTs: null as number | null, proposals: [] as DbProposal[], votes: [] as DbVote[], @@ -37,6 +41,7 @@ function createStubRepo(): ProposalsActivityRepository & { _daoId: DaoIdEnum, _activityStart: number, _votingPeriodSeconds: number, + _votingDelaySeconds: number, ) => stub.proposals, getUserVotes: async ( _address: Address, @@ -46,7 +51,8 @@ function createStubRepo(): ProposalsActivityRepository & { getProposalsWithVotesAndPagination: async ( _addr: Address, activityStart: number, - _votingPeriodSeconds: number, + votingPeriodSeconds: number, + votingDelaySeconds: number, _skip: number, _limit: number, _orderBy: OrderByField, @@ -54,6 +60,8 @@ function createStubRepo(): ProposalsActivityRepository & { _userVoteFilter?: VoteFilter, ) => { stub.lastActivityStart = activityStart; + stub.lastVotingPeriodSeconds = votingPeriodSeconds; + stub.lastVotingDelaySeconds = votingDelaySeconds; return stub.paginationResult; }, }; @@ -147,6 +155,17 @@ describe("ProposalsActivityService", () => { }); }); + it("forwards the voting delay apart from the whole window length", async () => { + repo.firstVoteTs = 1699000000; + + await service.getProposalsActivity(defaultRequest); + + // votingPeriod 40320 + votingDelay 2, at a 12s block time + expect(repo.lastVotingPeriodSeconds).toBe(40322 * 12); + // the delay alone, so the window's upper bound can key on voting start + expect(repo.lastVotingDelaySeconds).toBe(2 * 12); + }); + it("should return proposals with user votes and analytics", async () => { repo.firstVoteTs = 1699000000; repo.paginationResult = { diff --git a/apps/api/src/services/voting-power/inactive-summary.ts b/apps/api/src/services/voting-power/inactive-summary.ts new file mode 100644 index 0000000000..ae13ee28d9 --- /dev/null +++ b/apps/api/src/services/voting-power/inactive-summary.ts @@ -0,0 +1,65 @@ +import { + DBInactiveVotingPowerSummary, + InactiveVotingPowerSummaryResponse, +} from "@/mappers"; + +interface InactiveVotingPowerSummaryRepository { + getInactiveDelegatedVotingPowerSummary( + votingPeriodSeconds: number, + votingDelaySeconds: number, + fromDate?: number, + toDate?: number, + ): Promise; +} + +interface VotingPeriodClient { + getVotingPeriod: () => Promise; + getVotingDelay: () => Promise; +} + +export class InactiveVotingPowerSummaryService { + constructor( + private readonly repository: InactiveVotingPowerSummaryRepository, + private readonly daoClient: VotingPeriodClient, + private readonly blockTime: number, + ) {} + + async getInactiveVotingPowerSummary( + fromDate?: number, + toDate?: number, + ): Promise { + // Same voting-window derivation as the proposals-activity service. + const votingPeriodBlocks = await this.daoClient.getVotingPeriod(); + const votingDelay = await this.daoClient.getVotingDelay(); + const votingPeriodSeconds = + Number(votingPeriodBlocks + votingDelay) * this.blockTime; + const votingDelaySeconds = Number(votingDelay) * this.blockTime; + + const summary = + await this.repository.getInactiveDelegatedVotingPowerSummary( + votingPeriodSeconds, + votingDelaySeconds, + fromDate, + toDate, + ); + + // A delegate can only be inactive once a proposal existed in the window. + const inactiveDelegatedVotingPower = + summary.totalProposals === 0 ? 0n : summary.inactiveDelegatedVotingPower; + + const inactivePercentage = + summary.totalProposals === 0 || summary.totalDelegatedVotingPower === 0n + ? 0 + : Number( + (inactiveDelegatedVotingPower * 10000n) / + summary.totalDelegatedVotingPower, + ) / 100; + + return { + totalDelegatedVotingPower: summary.totalDelegatedVotingPower.toString(), + inactiveDelegatedVotingPower: inactiveDelegatedVotingPower.toString(), + inactivePercentage, + totalProposals: summary.totalProposals, + }; + } +} diff --git a/apps/api/src/services/voting-power/inactive-summary.unit.test.ts b/apps/api/src/services/voting-power/inactive-summary.unit.test.ts new file mode 100644 index 0000000000..fe071fd46e --- /dev/null +++ b/apps/api/src/services/voting-power/inactive-summary.unit.test.ts @@ -0,0 +1,102 @@ +import { vi } from "vitest"; + +import { DBInactiveVotingPowerSummary } from "@/mappers"; + +import { InactiveVotingPowerSummaryService } from "./inactive-summary"; + +const BLOCK_TIME = 12; + +const createService = (summary: DBInactiveVotingPowerSummary) => { + const getInactiveDelegatedVotingPowerSummary = vi + .fn<() => Promise>() + .mockResolvedValue(summary); + const daoClient = { + getVotingPeriod: vi.fn().mockResolvedValue(100n), + getVotingDelay: vi.fn().mockResolvedValue(20n), + }; + const service = new InactiveVotingPowerSummaryService( + { getInactiveDelegatedVotingPowerSummary }, + daoClient, + BLOCK_TIME, + ); + return { service, getInactiveDelegatedVotingPowerSummary }; +}; + +describe("InactiveVotingPowerSummaryService", () => { + it("derives the voting window from the DAO client and forwards the dates", async () => { + const { service, getInactiveDelegatedVotingPowerSummary } = createService({ + totalDelegatedVotingPower: 1000n, + inactiveDelegatedVotingPower: 250n, + totalProposals: 3, + }); + + await service.getInactiveVotingPowerSummary(100, 200); + + // window length (votingPeriod + votingDelay) * blockTime = (100 + 20) * 12, + // and the delay on its own = 20 * 12, for the voting-start upper bound + expect(getInactiveDelegatedVotingPowerSummary).toHaveBeenCalledWith( + 1440, + 240, + 100, + 200, + ); + }); + + it("computes the inactive percentage", async () => { + const { service } = createService({ + totalDelegatedVotingPower: 1000n, + inactiveDelegatedVotingPower: 250n, + totalProposals: 3, + }); + + const result = await service.getInactiveVotingPowerSummary(); + + expect(result).toEqual({ + totalDelegatedVotingPower: "1000", + inactiveDelegatedVotingPower: "250", + inactivePercentage: 25, + totalProposals: 3, + }); + }); + + it("rounds the percentage to two decimal places", async () => { + const { service } = createService({ + totalDelegatedVotingPower: 3000n, + inactiveDelegatedVotingPower: 1000n, + totalProposals: 1, + }); + + const result = await service.getInactiveVotingPowerSummary(); + + expect(result.inactivePercentage).toBe(33.33); + }); + + it("reports zero inactivity when no proposal existed in the window", async () => { + const { service } = createService({ + totalDelegatedVotingPower: 1000n, + inactiveDelegatedVotingPower: 1000n, + totalProposals: 0, + }); + + const result = await service.getInactiveVotingPowerSummary(); + + expect(result).toEqual({ + totalDelegatedVotingPower: "1000", + inactiveDelegatedVotingPower: "0", + inactivePercentage: 0, + totalProposals: 0, + }); + }); + + it("reports zero percentage when there is no delegated voting power", async () => { + const { service } = createService({ + totalDelegatedVotingPower: 0n, + inactiveDelegatedVotingPower: 0n, + totalProposals: 2, + }); + + const result = await service.getInactiveVotingPowerSummary(); + + expect(result.inactivePercentage).toBe(0); + }); +}); diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 8f55efee36..d3ba6bbbb6 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -22,6 +22,10 @@ RESEND_API_KEY= RESEND_FROM_EMAIL=onboarding@resend.dev CONTACT_EMAIL= +# ClickUp public data-report integration (server-only) +CLICKUP_API_TOKEN= +CLICKUP_REPORT_LIST_ID= + # GitHub token for higher API rate limits (5000 req/hour vs 60 unauthenticated) GITHUB_TOKEN= diff --git a/apps/dashboard/CHANGELOG.md b/apps/dashboard/CHANGELOG.md index 900a2246c1..b096df5d7b 100644 --- a/apps/dashboard/CHANGELOG.md +++ b/apps/dashboard/CHANGELOG.md @@ -1,5 +1,95 @@ # @anticapture/dashboard +## 2.13.2 + +### Patch Changes + +- [#2109](https://github.com/blockful/anticapture/pull/2109) [`12e803e`](https://github.com/blockful/anticapture/commit/12e803ef4d83ac877be1c0cd15a7443d17725ab6) Thanks [@pikonha](https://github.com/pikonha)! - Sync the Token Holders and AAVE delegation amount filters with the URL so shared links show the active range. + +## 2.13.1 + +### Patch Changes + +- [#2107](https://github.com/blockful/anticapture/pull/2107) [`c864c11`](https://github.com/blockful/anticapture/commit/c864c11e5e93f9c5698a31af9c24077899151ce9) Thanks [@pikonha](https://github.com/pikonha)! - Fix PR review findings: tag proposal-creation telemetry on the menu items instead of the trigger, keep the delegates amount filter in sync with the URL filter state, and derive the custom range end boundary from local midnight so DST days aren't off by an hour. + +## 2.13.0 + +### Minor Changes + +- [#2102](https://github.com/blockful/anticapture/pull/2102) [`7236413`](https://github.com/blockful/anticapture/commit/723641373326a4607dbb500eca844948c62603f2) Thanks [@brunod-e](https://github.com/brunod-e)! - Import a proposal from JSON when creating one. + +## 2.12.0 + +### Minor Changes + +- [#2084](https://github.com/blockful/anticapture/pull/2084) [`4e59732`](https://github.com/blockful/anticapture/commit/4e59732daf40b800986ab9ec42a10127b29465f4) Thanks [@brunod-e](https://github.com/brunod-e)! - Holders & Delegates v3 (DEV-562, DEV-476) + + API: new endpoints backing the module. `GET /:dao/voting-powers/inactive-summary` + (delegated VP parked with inactive delegates), `GET /:dao/accounts/:address/delegators/historical` + (former delegators with VP impact, start/end and redelegation target), and + `GET /:dao/addresses/labels` (per-DAO treasury/vesting labels, where an unlock + contract whose label does not mention vesting is classified by address so the + dashboard can still relabel its transfers as a vesting unlock; contracts whose + outgoing transfers are not unlocks, such as airdrop distributors and staking + vaults, stay out). Adds an optional + `address` filter to `GET /:dao/feed/events`, and an optional `toDate` upper bound + to `GET /:dao/proposals-activity` so a bounded period counts only the proposals + inside it. That upper bound is keyed on when a proposal's voting opens (creation + plus the DAO voting delay), not on when it was created, so on DAOs with a + non-zero voting delay a proposal created inside the period whose voting only + opens after it no longer counts: no vote could land in the window, and counting + it marked delegates inactive on proposals they could not yet vote on. On + `GET /:dao/voting-powers/inactive-summary` that also keeps `totalProposals` at + zero when the window holds nothing votable, instead of reporting every delegate + as inactive. Both `GET /:dao/proposals-activity` and + `GET /:dao/voting-powers/inactive-summary` also bound the vote by `toDate`: a + proposal that opens near the end of the period stays votable after it, so a vote + cast later no longer counts as activity inside a period that closed before the + vote existed. The proposal is still listed, with no vote attached. The same bound + applies at the other end: a proposal whose voting period overlaps `fromDate` is + in scope, but a vote cast on it before that date happened outside the period and + no longer counts as activity inside it either. On + `GET /:dao/accounts/:address/delegators/historical`, `amount` reports the voting + power the queried address actually lost at the move away rather than the value + stored on the last delegation event: balances that move while a delegation stands + write no delegation row, so that value is a stale snapshot, and the share it + represented is instead applied to the balance the move-away event carries. Full + delegation therefore reports the whole balance moved, and partial delegation + (SCR) keeps its fraction rather than claiming the sibling delegates' part. On AAVE, `fromValue`/`toValue` on `GET /:dao/voting-powers` now filter the + delegated voting power alone instead of the combined total (delegated power plus + the account's own balance), matching both the `votingPower` ordering on the same + endpoint and every other DAO's behavior, so the range a client asks for matches + the delegation figure it renders. + Feed DELEGATION metadata gains an optional `delegatees` array of + `{ delegate, amount }`, present only when the source event split voting power + across more than one delegatee (partial delegation, as SCR does), ordered by + delegate address ascending; `delegate`/`amount` stay as they were and describe + the primary delegatee, so existing consumers are unaffected. Gateful re-exposes + the expanded surface through its aggregated OpenAPI spec (no gateway code + change). + + Dashboard: value min/max filters on the Delegates and Token Holders tables; + Delegates as the default tab and the sidebar renamed to "Stakeholders"; larger + rows with bottom borders and a continuous activity ring; voting power shown as a + percentage of quorum; inactive-delegate flagging and 0/0 activity states + ("Inactive" / "No proposals" / "Never voted"); the inactive-VP alert banner on + Token Holders; clickable addresses that re-point the drawer everywhere; a + per-address Activity tab in the drawer, on the DAOs that serve the activity + feed; Buy/Sell relabeled to In / Out / Vested; + a dust badge and "Hide dust" switch on Top Interactions; a "Filter low importance" + toggle and "All time" range on Voting Power History; a MAX option and a custom + calendar range on the time selector, single days included; and a Former + Delegators view in the + delegate profile. + +- [#2097](https://github.com/blockful/anticapture/pull/2097) [`dd95b49`](https://github.com/blockful/anticapture/commit/dd95b49f054856560f4fe0a6e4175b7e4383ae53) Thanks [@alextnetto](https://github.com/alextnetto)! - Update the ENS Security Council card to the council seated in July 2026 (5/8 multisig, expires July 16, 2028) and add Compound's Proposal Guardian with its expiration + +### Patch Changes + +- [#2083](https://github.com/blockful/anticapture/pull/2083) [`7d4c104`](https://github.com/blockful/anticapture/commit/7d4c104bfc2250997bae446d99e88502c31d6ec7) Thanks [@pikonha](https://github.com/pikonha)! - Move data inconsistency report trigger from Help dropdown to inline Flag icon in each panel. The panel name is now structurally correct (it's literally where you clicked), removing the need for the dropdown, `report-panels.ts` constants, the `section` field, and the server-side allowlist. + +- [#2101](https://github.com/blockful/anticapture/pull/2101) [`db75781`](https://github.com/blockful/anticapture/commit/db75781b4cb59395bd6097c58b18502e7658b5ed) Thanks [@alextnetto](https://github.com/alextnetto)! - Raise the proposal description limit in the create-proposal form from 10,000 to 100,000 characters, matching the ceiling the drafts endpoint already enforces. Long governance proposals are no longer blocked from being published, and the editor footer counter warns as the new limit approaches instead of failing with a generic error. + ## 2.11.1 ### Patch Changes diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index ae14997c98..8298a41323 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -65,7 +65,7 @@ pnpm dashboard test:e2e:headed pnpm dashboard test:e2e:ui ``` -Tests live in `e2e/`. Coverage: Panel (`/`), DAO Overview (`/ens`), Holders & Delegates (`/ens/holders-and-delegates`), Proposals (`/ens/proposals`), Activity Feed (`/ens/activity-feed`), and mobile smoke tests at 390×844. +Tests live in `e2e/`. Coverage: Panel (`/`), DAO Overview (`/ens`), Stakeholders (`/ens/stakeholders`), Proposals (`/ens/proposals`), Activity Feed (`/ens/activity-feed`), and mobile smoke tests at 390×844. Live upstream outages or missing ENS data fail clearly rather than silently pass with mocked fallbacks. diff --git a/apps/dashboard/app/[daoId]/(main)/attack-profitability/page.tsx b/apps/dashboard/app/[daoId]/(main)/attack-profitability/page.tsx index e619244483..c7201a0544 100644 --- a/apps/dashboard/app/[daoId]/(main)/attack-profitability/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/attack-profitability/page.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next"; import { AttackProfitabilitySection } from "@/features/attack-profitability"; import { TheSectionLayout } from "@/shared/components"; import { SubSectionsContainer } from "@/shared/components/design-system/section"; +import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton"; import { PAGES_CONSTANTS } from "@/shared/constants/pages-constants"; import daoConfigByDaoId from "@/shared/dao-config"; import type { DaoIdEnum } from "@/shared/types/daos"; @@ -54,6 +55,7 @@ export default async function AttackProfitabilityPage({ title={PAGES_CONSTANTS.attackProfitability.title} icon={} description={PAGES_CONSTANTS.attackProfitability.description} + headerAction={} > ", + sectionTitle: "", }); } diff --git a/apps/dashboard/app/[daoId]/(main)/holders-and-delegates/page.tsx b/apps/dashboard/app/[daoId]/(main)/holders-and-delegates/page.tsx index 10aa0d5ab1..6b2e9abbee 100644 --- a/apps/dashboard/app/[daoId]/(main)/holders-and-delegates/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/holders-and-delegates/page.tsx @@ -1,48 +1,10 @@ -import type { Metadata } from "next"; - -import { HoldersAndDelegatesSection } from "@/features/holders-and-delegates"; -import daoConfigByDaoId from "@/shared/dao-config"; -import type { DaoIdEnum } from "@/shared/types/daos"; +import { permanentRedirect } from "next/navigation"; type Props = { params: Promise<{ daoId: string }>; }; -export async function generateMetadata(props: Props): Promise { - const params = await props.params; - const daoId = params.daoId.toUpperCase() as DaoIdEnum; - - const canonicalPath = `/${params.daoId}/holders-and-delegates`; - - return { - title: `${daoId} DAO Token Holders & Delegate Security Analysis — Anticapture`, - description: `Analyze token holder concentration and delegate distribution for ${daoId} DAO to identify governance capture risks, whale dominance, and delegate centralization threats.`, - alternates: { canonical: canonicalPath }, - openGraph: { - url: canonicalPath, - title: `${daoId} DAO Token Holders & Delegate Security Analysis — Anticapture`, - description: `Analyze token holder concentration and delegate distribution for ${daoId} DAO to identify governance capture risks, whale dominance, and delegate centralization threats.`, - }, - twitter: { - card: "summary_large_image", - title: `${daoId} DAO Token Holders & Delegate Security Analysis — Anticapture`, - description: `Analyze token holder concentration and delegate distribution for ${daoId} DAO to identify governance capture risks, whale dominance, and delegate centralization threats.`, - }, - }; -} - -export default async function HoldersAndDelegatesPage({ - params, -}: { - params: Promise<{ daoId: string }>; -}) { +export default async function LegacyHoldersAndDelegatesPage({ params }: Props) { const { daoId } = await params; - const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; - const daoConstants = daoConfigByDaoId[daoIdEnum]; - - if (!daoConstants.dataTables) { - return null; - } - - return ; + permanentRedirect(`/${daoId}/stakeholders`); } diff --git a/apps/dashboard/app/[daoId]/(main)/stakeholders/opengraph-image.tsx b/apps/dashboard/app/[daoId]/(main)/stakeholders/opengraph-image.tsx new file mode 100644 index 0000000000..64344475c5 --- /dev/null +++ b/apps/dashboard/app/[daoId]/(main)/stakeholders/opengraph-image.tsx @@ -0,0 +1,20 @@ +import { createDaoSectionOgImage } from "@/shared/og"; +import type { DaoIdEnum } from "@/shared/types/daos"; + +export const alt = "Anticapture Stakeholders"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function OpengraphImage({ + params, +}: { + params: Promise<{ daoId: string }>; +}) { + const { daoId } = await params; + const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; + + return await createDaoSectionOgImage({ + daoId: daoIdEnum, + sectionTitle: "", + }); +} diff --git a/apps/dashboard/app/[daoId]/(main)/stakeholders/page.tsx b/apps/dashboard/app/[daoId]/(main)/stakeholders/page.tsx new file mode 100644 index 0000000000..a0be99c3c5 --- /dev/null +++ b/apps/dashboard/app/[daoId]/(main)/stakeholders/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; + +import { HoldersAndDelegatesSection } from "@/features/holders-and-delegates"; +import daoConfigByDaoId from "@/shared/dao-config"; +import type { DaoIdEnum } from "@/shared/types/daos"; + +type Props = { + params: Promise<{ daoId: string }>; +}; + +export async function generateMetadata(props: Props): Promise { + const params = await props.params; + const daoId = params.daoId.toUpperCase() as DaoIdEnum; + + const canonicalPath = `/${params.daoId}/stakeholders`; + + return { + title: `${daoId} DAO Stakeholder Security Analysis - Anticapture`, + description: `Analyze token holder concentration and delegate distribution for ${daoId} DAO to identify governance capture risks, whale dominance, and delegate centralization threats.`, + alternates: { canonical: canonicalPath }, + openGraph: { + url: canonicalPath, + title: `${daoId} DAO Stakeholder Security Analysis - Anticapture`, + description: `Analyze token holder concentration and delegate distribution for ${daoId} DAO to identify governance capture risks, whale dominance, and delegate centralization threats.`, + }, + twitter: { + card: "summary_large_image", + title: `${daoId} DAO Stakeholder Security Analysis - Anticapture`, + description: `Analyze token holder concentration and delegate distribution for ${daoId} DAO to identify governance capture risks, whale dominance, and delegate centralization threats.`, + }, + }; +} + +export default async function StakeholdersPage({ + params, +}: { + params: Promise<{ daoId: string }>; +}) { + const { daoId } = await params; + const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; + const daoConstants = daoConfigByDaoId[daoIdEnum]; + + if (!daoConstants.dataTables) { + return null; + } + + return ; +} diff --git a/apps/dashboard/app/[daoId]/(main)/token-distribution/page.tsx b/apps/dashboard/app/[daoId]/(main)/token-distribution/page.tsx index 93cb9ea182..43a5b6b9c2 100644 --- a/apps/dashboard/app/[daoId]/(main)/token-distribution/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/token-distribution/page.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next"; import { TokenDistributionSection } from "@/features/token-distribution"; import { TheSectionLayout } from "@/shared/components"; import { SubSectionsContainer } from "@/shared/components/design-system/section"; +import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton"; import { PAGES_CONSTANTS } from "@/shared/constants/pages-constants"; import daoConfigByDaoId from "@/shared/dao-config"; import type { DaoIdEnum } from "@/shared/types/daos"; @@ -54,6 +55,7 @@ export default async function TokenDistributionPage({ title={PAGES_CONSTANTS.tokenDistribution.title} icon={} description={PAGES_CONSTANTS.tokenDistribution.description} + headerAction={} > diff --git a/apps/dashboard/app/aave/holders-and-delegates/DelegationTable.tsx b/apps/dashboard/app/aave/stakeholders/DelegationTable.tsx similarity index 86% rename from apps/dashboard/app/aave/holders-and-delegates/DelegationTable.tsx rename to apps/dashboard/app/aave/stakeholders/DelegationTable.tsx index b0934e9cda..3cb910b45a 100644 --- a/apps/dashboard/app/aave/holders-and-delegates/DelegationTable.tsx +++ b/apps/dashboard/app/aave/stakeholders/DelegationTable.tsx @@ -6,7 +6,7 @@ import { Plus } from "lucide-react"; import { parseAsStringEnum, useQueryState } from "nuqs"; import { useMemo } from "react"; import type { Address } from "viem"; -import { formatUnits } from "viem"; +import { formatUnits, parseUnits } from "viem"; import { useDelegates, @@ -18,15 +18,23 @@ import { Button } from "@/shared/components/design-system/buttons/button/Button" import { CopyAndPasteButton } from "@/shared/components/buttons/CopyAndPasteButton"; import { EnsAvatar } from "@/shared/components/design-system/avatars/ens-avatar/EnsAvatar"; import { AddressFilter } from "@/shared/components/design-system/table/filters/AddressFilter"; +import { AmountFilter } from "@/shared/components/design-system/table/filters/amount-filter/AmountFilter"; +import type { AmountFilterState } from "@/shared/components/design-system/table/filters/amount-filter/store/amount-filter-store"; import { Percentage } from "@/shared/components/design-system/table/Percentage"; import { Table } from "@/shared/components/design-system/table/Table"; import { ArrowUpDown, ArrowState } from "@/shared/components/icons"; import { PERCENTAGE_NO_BASELINE } from "@/shared/constants/api"; +import { DAYS_IN_SECONDS } from "@/shared/constants/time-related"; import { useScreenSize } from "@/shared/hooks/useScreenSize"; import { DaoIdEnum } from "@/shared/types/daos"; import type { TimeInterval } from "@/shared/types/enums"; import { formatNumberUserReadable } from "@/shared/utils/formatNumberUserReadable"; +const AMOUNT_SORT_OPTIONS = [ + { value: "largest-first", label: "Largest first" }, + { value: "smallest-first", label: "Smallest first" }, +]; + interface DelegateTableData { address: string; votingPower: string; @@ -39,9 +47,19 @@ interface DelegateTableData { delegators: number; } +/** + * Deliberately a reduced version of the shared `Delegates` component: the AAVE + * API registers no proposal endpoints, so the quorum percentage, the activity + * ring, the inactive states and the average vote timing have no data here. + */ export function DelegationTable({ days }: { days: TimeInterval }) { const pageLimit: number = 20; + const fromDate = useMemo( + () => Math.floor(Date.now() / 1000) - DAYS_IN_SECONDS[days], + [days], + ); + const [drawerAddress, setDrawerAddress] = useQueryState("drawerAddress"); const [currentAddressFilter, setCurrentAddressFilter] = useQueryState("address"); @@ -60,9 +78,21 @@ export function DelegationTable({ days }: { days: TimeInterval }) { "balance", ]).withDefault("votingPower"), ); + const [minValue, setMinValue] = useQueryState("minValue"); + const [maxValue, setMaxValue] = useQueryState("maxValue"); const daoId = DaoIdEnum.AAVE; const decimals = 18; + // API expects raw token units; URL values are human-readable and user-editable + const toRawUnits = (value: string | null): string | undefined => { + if (!value) return undefined; + try { + return parseUnits(value, decimals).toString(); + } catch { + return undefined; + } + }; + const handleAddressFilterApply = (address: string | undefined) => { setCurrentAddressFilter(address || ""); }; @@ -92,10 +122,12 @@ export function DelegationTable({ days }: { days: TimeInterval }) { orderBy: orderByMap[sortBy as DelegateSortKey], orderDirection: sortOrder, daoId, - days, + fromDate, address: currentAddressFilter || undefined, limit: pageLimit, skipActivity: true, + fromValue: toRawUnits(minValue), + toValue: toRawUnits(maxValue), }); const { isMobile } = useScreenSize(); @@ -299,26 +331,34 @@ export function DelegationTable({ days }: { days: TimeInterval }) { ); }, header: () => ( - + ), meta: { columnClassName: "w-40", @@ -477,7 +517,7 @@ export function DelegationTable({ days }: { days: TimeInterval }) { columns={delegateColumns} data={loading ? Array(DEFAULT_ITEMS_PER_PAGE).fill({}) : tableData} onRowClick={(row) => setDrawerAddress(row.address as Address)} - size="sm" + withRowBorders hasMore={hasNextPage} isLoadingMore={fetchingMore} onLoadMore={fetchNextPage} diff --git a/apps/dashboard/app/aave/holders-and-delegates/page.tsx b/apps/dashboard/app/aave/stakeholders/page.tsx similarity index 76% rename from apps/dashboard/app/aave/holders-and-delegates/page.tsx rename to apps/dashboard/app/aave/stakeholders/page.tsx index bc32ccac67..9b8fae9da2 100644 --- a/apps/dashboard/app/aave/holders-and-delegates/page.tsx +++ b/apps/dashboard/app/aave/stakeholders/page.tsx @@ -2,18 +2,19 @@ import { Suspense } from "react"; -import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"; +import { parseAsStringEnum, useQueryState } from "nuqs"; import { TabButton } from "@/features/holders-and-delegates/components/TabButton"; import { TokenHolders } from "@/features/holders-and-delegates/token-holder"; import { Footer } from "@/shared/components/design-system/footer"; import { SwitcherDate } from "@/shared/components"; +import { ReportPanelButton } from "@/shared/components/report/ReportPanelButton"; import { DaoIdEnum } from "@/shared/types/daos"; import { TimeInterval } from "@/shared/types/enums"; import { HeaderDAOSidebar, HeaderSidebar, StickyPageHeader } from "@/widgets"; import { HeaderMobile } from "@/widgets/HeaderMobile"; -import { DelegationTable } from "@/app/aave/holders-and-delegates/DelegationTable"; +import { DelegationTable } from "@/app/aave/stakeholders/DelegationTable"; import { TheSectionLayout } from "@/shared/components/containers/TheSectionLayout"; import { SubSectionsContainer } from "@/shared/components/design-system/section"; import { PAGES_CONSTANTS } from "@/shared/constants/pages-constants"; @@ -32,21 +33,29 @@ function AavePageContent() { "days", parseAsStringEnum(Object.values(TimeInterval)).withDefault(defaultDays), ); + // Enum parsed like the shared section: a stale `?tab=foo` would otherwise + // render Delegates with neither tab button highlighted. const [activeTab, setActiveTab] = useQueryState( "tab", - parseAsString.withDefault("tokenHolders"), + parseAsStringEnum(["tokenHolders", "delegates"]).withDefault( + "delegates", + ), ); const setDrawerAddress = useQueryState("drawerAddress")[1]; const setCurrentAddressFilter = useQueryState("address")[1]; const setSortOrder = useQueryState("sort")[1]; const setSortBy = useQueryState("sortBy")[1]; + const setMinValue = useQueryState("minValue")[1]; + const setMaxValue = useQueryState("maxValue")[1]; const cleanupFilters = () => { setDrawerAddress(null); setCurrentAddressFilter(null); setSortOrder(null); setSortBy(null); + setMinValue(null); + setMaxValue(null); }; const handleTabChange = (tab: TabId) => { @@ -73,7 +82,6 @@ function AavePageContent() {
} description={PAGES_CONSTANTS.holdersAndDelegates.description} > @@ -85,15 +93,24 @@ function AavePageContent() { key={tab.id} id={tab.id} label={tab.label} - activeTab={activeTab as TabId} + activeTab={activeTab} setActiveTab={handleTabChange} /> ))}
- +
+ + +
{activeTab === "delegates" ? ( diff --git a/apps/dashboard/app/api/report/route.test.ts b/apps/dashboard/app/api/report/route.test.ts new file mode 100644 index 0000000000..9008fed4a9 --- /dev/null +++ b/apps/dashboard/app/api/report/route.test.ts @@ -0,0 +1,155 @@ +import { NextRequest } from "next/server"; + +import { POST } from "./route"; + +const originalFetch = global.fetch; +const originalEnvironment = { ...process.env }; + +const createRequest = ( + ip: string, + overrides = {}, + headers: Record = {}, +) => + new NextRequest("http://localhost:3000/api/report", { + method: "POST", + headers: { + "content-type": "application/json", + "x-real-ip": ip, + ...headers, + }, + body: JSON.stringify({ + daoId: "ens", + panel: "Token distribution", + description: "The displayed supply is stale.", + email: "reporter@example.com", + url: "http://localhost:3000/ens/token-distribution", + ...overrides, + }), + }); + +type ClientIPTestCase = { + name: string; + ip: string; + headers: Record; +}; + +describe("POST /api/report", () => { + beforeEach(() => { + process.env = { + ...originalEnvironment, + CLICKUP_API_TOKEN: "clickup-token", + CLICKUP_REPORT_LIST_ID: "901327958573", + }; + global.fetch = jest.fn(); + }); + + afterAll(() => { + process.env = originalEnvironment; + global.fetch = originalFetch; + }); + + it("creates a ClickUp task containing report context", async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + new Response("{}", { status: 200 }), + ); + + const response = await POST(createRequest("203.0.113.1")); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.clickup.com/api/v2/list/901327958573/task", + expect.objectContaining({ + headers: { + Authorization: "clickup-token", + "content-type": "application/json", + }, + body: expect.stringContaining("[Report] ENS — Token distribution"), + }), + ); + expect( + String( + (global.fetch as jest.MockedFunction).mock.calls[0][1] + ?.body, + ), + ).toContain("reporter@example.com"); + }); + + it("rejects the fourth report from one trusted IP", async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + new Response("{}", { status: 200 }), + ); + const ip = "203.0.113.2"; + + await POST(createRequest(ip)); + await POST(createRequest(ip)); + await POST(createRequest(ip)); + const response = await POST(createRequest(ip)); + + expect(response.status).toBe(429); + }); + + it("returns a graceful error when ClickUp fails", async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + new Response("error", { status: 500 }), + ); + + const response = await POST(createRequest("203.0.113.3")); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "We couldn't submit your report. Please try again shortly.", + }); + }); + + it("includes subject in ClickUp title when provided", async () => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + new Response("{}", { status: 200 }), + ); + + const response = await POST( + createRequest("203.0.113.4", { subject: "0xabc123" }), + ); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.clickup.com/api/v2/list/901327958573/task", + expect.objectContaining({ + body: expect.stringContaining( + "[Report] ENS — Token distribution (0xabc123)", + ), + }), + ); + }); + + it.each([ + { + name: "Railway real IP", + ip: "203.0.113.5", + headers: { "x-real-ip": "203.0.113.5" }, + }, + { + name: "Vercel forwarded IP", + ip: "", + headers: { "x-forwarded-for": "203.0.113.6, 10.0.0.2" }, + }, + { + name: "valid forwarded IP after malformed real IP", + ip: "not-an-ip", + headers: { + "x-real-ip": "not-an-ip", + "x-forwarded-for": "203.0.113.7", + }, + }, + ])("rate-limits by $name", async ({ ip, headers }) => { + (global.fetch as jest.MockedFunction).mockResolvedValue( + new Response("{}", { status: 200 }), + ); + + await POST(createRequest(ip, {}, headers)); + await POST(createRequest(ip, {}, headers)); + await POST(createRequest(ip, {}, headers)); + const response = await POST(createRequest(ip, {}, headers)); + + expect(response.status).toBe(429); + }); +}); diff --git a/apps/dashboard/app/api/report/route.ts b/apps/dashboard/app/api/report/route.ts new file mode 100644 index 0000000000..a0c8efe435 --- /dev/null +++ b/apps/dashboard/app/api/report/route.ts @@ -0,0 +1,168 @@ +import { isIP } from "node:net"; + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const REPORT_LIMIT = 3; +const REPORT_WINDOW_MS = 60 * 60 * 1000; +type ReportAttempt = { + id: string; + timestamp: number; +}; + +const reportAttempts = new Map(); + +// ClickUp "Project" relationship field on the shared Backlog list; every +// report task must be linked to Anticapture (86ahtje7p) to show up in triage. +const ANTICAPTURE_PROJECT_FIELD_ID = "c28c5f87-5225-4fbe-a957-2e7bd538ae0d"; +const ANTICAPTURE_PROJECT_TASK_ID = "86ahtje7p"; + +const reportSchema = z.object({ + daoId: z.string().trim().min(1).max(100), + panel: z.string().trim().min(1).max(200), + subject: z.string().trim().max(200).optional(), + description: z.string().trim().min(3).max(5000), + email: z.union([z.string().trim().email(), z.literal("")]).optional(), + url: z.string().url().max(2000), +}); + +const getClientIP = (request: NextRequest) => { + const realIP = request.headers.get("x-real-ip")?.trim(); + if (realIP && isIP(realIP)) return realIP; + + const forwardedIP = request.headers + .get("x-forwarded-for") + ?.split(",") + .at(0) + ?.trim(); + return forwardedIP && isIP(forwardedIP) ? forwardedIP : null; +}; + +// Reserves a slot synchronously (no await in between check and write, so +// concurrent requests on the same instance can't all observe an empty +// bucket) and returns whether the caller is over the limit. Roll back with +// releaseReportAttempt if the request fails after reserving. +const reserveReportAttempt = (ip: string, now = Date.now()) => { + const attempts = (reportAttempts.get(ip) ?? []).filter( + (attempt) => now - attempt.timestamp < REPORT_WINDOW_MS, + ); + + if (attempts.length >= REPORT_LIMIT) { + reportAttempts.set(ip, attempts); + return { limited: true as const }; + } + + const reserved = { + id: crypto.randomUUID(), + timestamp: now, + }; + reportAttempts.set(ip, [...attempts, reserved]); + return { limited: false as const, reserved }; +}; + +const releaseReportAttempt = (ip: string, reserved: ReportAttempt) => { + reportAttempts.set( + ip, + (reportAttempts.get(ip) ?? []).filter( + (attempt) => attempt.id !== reserved.id, + ), + ); +}; + +const formatDescription = ({ + description, + url, + email, +}: { + description: string; + url: string; + email?: string; +}) => + [ + "## User report", + "", + description, + "", + `Page URL: ${url}`, + ...(email ? [`Reporter email: ${email}`] : []), + ].join("\n"); + +export const POST = async (request: NextRequest) => { + // Railway supplies x-real-ip; Vercel supplies x-forwarded-for. + const clientIP = getClientIP(request) ?? "unknown"; + let reservedAttempt: ReportAttempt | undefined; + + try { + const payload = reportSchema.parse(await request.json()); + + const reservation = reserveReportAttempt(clientIP); + if (reservation.limited) { + return NextResponse.json( + { + error: + "Too many reports from this address. Please try again in an hour.", + }, + { status: 429 }, + ); + } + reservedAttempt = reservation.reserved; + + const token = process.env.CLICKUP_API_TOKEN; + const listId = process.env.CLICKUP_REPORT_LIST_ID; + if (!token || !listId) { + console.error("ClickUp report integration is not configured"); + releaseReportAttempt(clientIP, reservedAttempt); + return NextResponse.json( + { + error: "Reports are temporarily unavailable. Please try again later.", + }, + { status: 503 }, + ); + } + + const clickUpResponse = await fetch( + `https://api.clickup.com/api/v2/list/${listId}/task`, + { + method: "POST", + headers: { Authorization: token, "content-type": "application/json" }, + body: JSON.stringify({ + name: `[Report] ${payload.daoId.toUpperCase()} — ${payload.panel}${payload.subject ? ` (${payload.subject})` : ""}`, + markdown_description: formatDescription(payload), + custom_fields: [ + { + id: ANTICAPTURE_PROJECT_FIELD_ID, + value: { add: [ANTICAPTURE_PROJECT_TASK_ID], rem: [] }, + }, + ], + }), + }, + ); + + if (!clickUpResponse.ok) { + console.error("ClickUp report creation failed", clickUpResponse.status); + releaseReportAttempt(clientIP, reservedAttempt); + return NextResponse.json( + { error: "We couldn't submit your report. Please try again shortly." }, + { status: 502 }, + ); + } + + return NextResponse.json({ message: "Report submitted successfully" }); + } catch (error) { + if (reservedAttempt) releaseReportAttempt(clientIP, reservedAttempt); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Validation failed", details: error.errors }, + { status: 400 }, + ); + } + + console.error("Report submission failed", error); + return NextResponse.json( + { error: "We couldn't submit your report. Please try again shortly." }, + { status: 502 }, + ); + } +}; diff --git a/apps/dashboard/app/sitemap.ts b/apps/dashboard/app/sitemap.ts index 3e05810421..e2f4a6d888 100644 --- a/apps/dashboard/app/sitemap.ts +++ b/apps/dashboard/app/sitemap.ts @@ -30,7 +30,7 @@ const DAO_SUB_ROUTES = [ "/risk-analysis", "/proposals", "/token-distribution", - "/holders-and-delegates", + "/stakeholders", "/activity-feed", "/resilience-stages", "/attack-profitability", diff --git a/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/opengraph-image.tsx b/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/opengraph-image.tsx index 1ec06e49d2..2a0d035bc1 100644 --- a/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/opengraph-image.tsx +++ b/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/opengraph-image.tsx @@ -1,7 +1,7 @@ import { createWhitelabelOgImage } from "@/shared/og/whitelabel-og-image"; import { toDaoIdEnum } from "@/shared/types/daos"; -export const alt = "Holders & Delegates"; +export const alt = "Stakeholders"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; @@ -15,5 +15,5 @@ export default async function OpengraphImage({ if (!daoIdEnum) return new Response(null, { status: 404 }); - return createWhitelabelOgImage(daoIdEnum, "Holders & Delegates"); + return createWhitelabelOgImage(daoIdEnum, "Stakeholders"); } diff --git a/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/page.tsx b/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/page.tsx index a147d5de1e..57f391bbf8 100644 --- a/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/page.tsx +++ b/apps/dashboard/app/whitelabel/[daoId]/holders-and-delegates/page.tsx @@ -1,29 +1,12 @@ -import type { Metadata } from "next"; - -import { HoldersAndDelegatesSection } from "@/features/holders-and-delegates"; -import daoConfigByDaoId from "@/shared/dao-config"; -import type { DaoIdEnum } from "@/shared/types/daos"; +import { permanentRedirect } from "next/navigation"; type Props = { params: Promise<{ daoId: string }>; }; -export async function generateMetadata({ params }: Props): Promise { - const { daoId } = await params; - const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; - const daoConfig = daoConfigByDaoId[daoIdEnum]; - - return { - title: "Holders & Delegates", - description: `Explore holder concentration and delegate activity for ${daoConfig.name}.`, - }; -} - export default async function WhitelabelHoldersAndDelegatesPage({ params, }: Props) { const { daoId } = await params; - const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; - - return ; + permanentRedirect(`/whitelabel/${daoId}/stakeholders`); } diff --git a/apps/dashboard/app/whitelabel/[daoId]/stakeholders/opengraph-image.tsx b/apps/dashboard/app/whitelabel/[daoId]/stakeholders/opengraph-image.tsx new file mode 100644 index 0000000000..2a0d035bc1 --- /dev/null +++ b/apps/dashboard/app/whitelabel/[daoId]/stakeholders/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { createWhitelabelOgImage } from "@/shared/og/whitelabel-og-image"; +import { toDaoIdEnum } from "@/shared/types/daos"; + +export const alt = "Stakeholders"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function OpengraphImage({ + params, +}: { + params: Promise<{ daoId: string }>; +}) { + const { daoId } = await params; + const daoIdEnum = toDaoIdEnum(daoId); + + if (!daoIdEnum) return new Response(null, { status: 404 }); + + return createWhitelabelOgImage(daoIdEnum, "Stakeholders"); +} diff --git a/apps/dashboard/app/whitelabel/[daoId]/stakeholders/page.tsx b/apps/dashboard/app/whitelabel/[daoId]/stakeholders/page.tsx new file mode 100644 index 0000000000..5d0cb94bbd --- /dev/null +++ b/apps/dashboard/app/whitelabel/[daoId]/stakeholders/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; + +import { HoldersAndDelegatesSection } from "@/features/holders-and-delegates"; +import daoConfigByDaoId from "@/shared/dao-config"; +import type { DaoIdEnum } from "@/shared/types/daos"; + +type Props = { + params: Promise<{ daoId: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { daoId } = await params; + const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; + const daoConfig = daoConfigByDaoId[daoIdEnum]; + + return { + title: "Stakeholders", + description: `Explore holder concentration and delegate activity for ${daoConfig.name}.`, + }; +} + +export default async function WhitelabelStakeholdersPage({ params }: Props) { + const { daoId } = await params; + const daoIdEnum = daoId.toUpperCase() as DaoIdEnum; + + return ; +} diff --git a/apps/dashboard/e2e/holders-and-delegates.spec.ts b/apps/dashboard/e2e/holders-and-delegates.spec.ts index 8ad11edbeb..55505effcd 100644 --- a/apps/dashboard/e2e/holders-and-delegates.spec.ts +++ b/apps/dashboard/e2e/holders-and-delegates.spec.ts @@ -83,25 +83,36 @@ const applyAddressFilter = async ( await popoverApply.evaluate((el: HTMLElement) => el.click()); }; -test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { - test("renders Holders & Delegates heading", async ({ goto, page }) => { +const selectTab = async (page: Page, label: "Delegates" | "Token Holders") => { + const tab = page.locator('[role="tab"]').filter({ hasText: label }); + await expect(tab).toBeVisible({ timeout: 15_000 }); + if ((await tab.getAttribute("aria-selected")) !== "true") { + await tab.click(); + } + await expect(tab).toHaveAttribute("aria-selected", "true"); +}; + +test.describe("Stakeholders page (/ens/stakeholders)", () => { + test("redirects legacy Holders & Delegates URL", async ({ goto, page }) => { await goto("/ens/holders-and-delegates"); + await expect(page).toHaveURL(/\/ens\/stakeholders/); + }); + + test("renders Stakeholders heading", async ({ goto, page }) => { + await goto("/ens/stakeholders"); await expect( - page.locator("h4").filter({ hasText: "Holders & Delegates" }), + page.locator("h4").filter({ hasText: "Stakeholders" }), ).toBeVisible(); }); - test("shows Token Holders tab as default", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); - const tokenHoldersTab = page - .locator('[role="tab"]') - .filter({ hasText: "Token Holders" }); - await expect(tokenHoldersTab).toBeVisible({ timeout: 15_000 }); - await expect(tokenHoldersTab).toHaveAttribute("aria-selected", "true"); + test("shows Delegates tab as default", async ({ goto, page }) => { + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); }); test("Token Holders table shows key columns", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -110,13 +121,9 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { }); test("switching to Delegates tab updates content", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); - await expect(delegatesTab).toHaveAttribute("aria-selected", "true"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); + await selectTab(page, "Delegates"); // Delegates tab shows Voting Power column await expect(page.getByText(/Voting Power/).first()).toBeVisible({ timeout: 15_000, @@ -124,17 +131,13 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { }); test("Delegates tab URL reflects tab state", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); - await expect(page).toHaveURL(/tab=delegates/); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); + await expect(page).toHaveURL(/tab=tokenHolders/); }); test("address filter affordance is present", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); // Address column has a filter popover trigger await expect(page.getByText("Address").first()).toBeVisible({ timeout: 15_000, @@ -142,7 +145,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { }); test("opening a holder drawer shows drawer tabs", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -175,16 +179,17 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { ).toBeHidden(); return; } - const cells = rows.first().locator("td"); - const cellCount = await cells.count(); - if (cellCount < 3) { + const detailsButton = rows.first().getByRole("button", { + name: "Details", + }); + if ((await detailsButton.count()) === 0) { await expect( page.getByText(/we ran into a hiccup/i).first(), "holders table rendered an error state", ).toBeHidden(); return; } - await cells.nth(2).click({ force: true }); + await detailsButton.click({ force: true }); await expect(page).toHaveURL(/drawerAddress=/, { timeout: 10_000 }); // Check drawer tab labels await expect( @@ -202,12 +207,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -235,16 +236,17 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { ).toBeHidden(); return; } - const cells = rows.first().locator("td"); - const cellCount = await cells.count(); - if (cellCount < 3) { + const detailsButton = rows.first().getByRole("button", { + name: "Details", + }); + if ((await detailsButton.count()) === 0) { await expect( page.getByText(/we ran into a hiccup/i).first(), "delegates table rendered an error state", ).toBeHidden(); return; } - await cells.nth(2).click({ force: true }); + await detailsButton.click({ force: true }); await expect(page).toHaveURL(/drawerAddress=/, { timeout: 10_000 }); await expect( page.locator('[role="tab"]').filter({ hasText: "Vote Composition" }), @@ -258,7 +260,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -275,12 +278,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -297,7 +296,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -322,14 +322,18 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); const balanceHeader = page .locator("table thead") .first() - .getByRole("button", { name: /^Balance/ }); + .getByText(/^Balance/) + .locator("..") + .getByRole("button") + .first(); await expect(balanceHeader).toBeVisible(); // First click on Token Holders headers can be dropped before React is // ready; retry click + URL assertion until one toggle lands. @@ -343,7 +347,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); + await selectTab(page, "Token Holders"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -364,19 +369,18 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); const vpHeader = page .locator("table thead") .first() - .getByRole("button", { name: /^Voting Power/ }); + .getByText(/^Voting Power/) + .locator("..") + .getByRole("button") + .first(); await expect(vpHeader).toBeVisible(); await vpHeader.click(); await expect(page).toHaveURL(/sortBy=votingPower|sort=/, { @@ -385,12 +389,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { }); test("Delegates sort by Change cycles sort state", async ({ goto, page }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -409,12 +409,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); @@ -433,12 +429,8 @@ test.describe("Holders & Delegates page (/ens/holders-and-delegates)", () => { goto, page, }) => { - await goto("/ens/holders-and-delegates"); - const delegatesTab = page - .locator('[role="tab"]') - .filter({ hasText: "Delegates" }); - await expect(delegatesTab).toBeVisible({ timeout: 15_000 }); - await delegatesTab.click(); + await goto("/ens/stakeholders"); + await selectTab(page, "Delegates"); await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000, }); diff --git a/apps/dashboard/e2e/mobile-smoke.spec.ts b/apps/dashboard/e2e/mobile-smoke.spec.ts index 5c58128292..aee6f28e20 100644 --- a/apps/dashboard/e2e/mobile-smoke.spec.ts +++ b/apps/dashboard/e2e/mobile-smoke.spec.ts @@ -21,13 +21,13 @@ test.describe("Mobile smoke tests", () => { }); }); - test("Holders & Delegates (/ens/holders-and-delegates) renders heading on mobile", async ({ + test("Stakeholders (/ens/stakeholders) renders heading on mobile", async ({ goto, page, }) => { - await goto("/ens/holders-and-delegates"); + await goto("/ens/stakeholders"); await expect( - page.locator("h4").filter({ hasText: "Holders & Delegates" }), + page.locator("h4").filter({ hasText: "Stakeholders" }), ).toBeVisible({ timeout: 15_000 }); }); diff --git a/apps/dashboard/e2e/report-data.spec.ts b/apps/dashboard/e2e/report-data.spec.ts new file mode 100644 index 0000000000..5554c0c3ae --- /dev/null +++ b/apps/dashboard/e2e/report-data.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "./fixtures"; + +test.describe("Data report", () => { + test("submits a report from a panel flag icon", async ({ goto, page }) => { + await goto("/ens/token-distribution"); + await page.route("**/api/report", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ message: "Report submitted successfully" }), + }); + }); + + await page.getByTestId("report-panel-button").click(); + await page + .getByLabel("What looks incorrect?") + .fill("The displayed supply is stale."); + await page.getByRole("button", { name: "Submit report" }).click(); + + await expect(page.getByText("Report received")).toBeVisible(); + }); + + test("shows a friendly rate-limit error", async ({ goto, page }) => { + await goto("/ens/token-distribution"); + await page.route("**/api/report", async (route) => { + await route.fulfill({ + status: 429, + contentType: "application/json", + body: JSON.stringify({ + error: + "Too many reports from this address. Please try again in an hour.", + }), + }); + }); + + await page.getByTestId("report-panel-button").click(); + await page + .getByLabel("What looks incorrect?") + .fill("The displayed supply is stale."); + await page.getByRole("button", { name: "Submit report" }).click(); + + await expect( + page.getByText( + "Too many reports from this address. Please try again in an hour.", + ), + ).toBeVisible(); + }); + + test("submits correct panel name when switching tabs", async ({ + goto, + page, + }) => { + await goto("/ens/holders-and-delegates"); + let reportPayload: Record | null = null; + await page.route("**/api/report", async (route) => { + reportPayload = route.request().postDataJSON() as Record; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ message: "Report submitted successfully" }), + }); + }); + + await page.getByRole("button", { name: "Delegates" }).click(); + await page.getByTestId("report-panel-button").click(); + await page + .getByLabel("What looks incorrect?") + .fill("Delegate data looks wrong."); + await page.getByRole("button", { name: "Submit report" }).click(); + + await expect(page.getByText("Report received")).toBeVisible(); + expect(reportPayload).toMatchObject({ + daoId: "ens", + panel: "Delegates", + description: "Delegate data looks wrong.", + email: "", + }); + expect(reportPayload).not.toHaveProperty("section"); + expect(reportPayload).toHaveProperty("url"); + }); +}); diff --git a/apps/dashboard/features/create-proposal/components/BodyField.tsx b/apps/dashboard/features/create-proposal/components/BodyField.tsx index 08d36a0cd4..13d32cce2d 100644 --- a/apps/dashboard/features/create-proposal/components/BodyField.tsx +++ b/apps/dashboard/features/create-proposal/components/BodyField.tsx @@ -10,6 +10,8 @@ import { MDXEditor, Separator, StrikeThroughSupSubToggles, + codeBlockPlugin, + codeMirrorPlugin, diffSourcePlugin, headingsPlugin, linkDialogPlugin, @@ -76,7 +78,6 @@ export const BodyField = ({ version = 0 }: BodyFieldProps) => { "[&_.mdxeditor-toolbar]:rounded-base! [&_.mdxeditor-toolbar]:!mb-2 [&_.mdxeditor-toolbar]:!bg-transparent [&_.mdxeditor-toolbar]:!px-0 [&_.mdxeditor-toolbar]:!py-0", "[&_.mdxeditor-root-contenteditable]:max-h-100 md:[&_.mdxeditor-root-contenteditable]:max-h-150 [&_.mdxeditor-root-contenteditable]:overflow-y-auto", "[&_.cm-editor]:min-h-75 [&_.cm-editor]:max-h-100 [&_.cm-scroller]:min-h-75 [&_.cm-scroller]:max-h-100 md:[&_.cm-editor]:min-h-130 md:[&_.cm-editor]:max-h-150 md:[&_.cm-scroller]:min-h-130 md:[&_.cm-scroller]:max-h-150", - // Markdown source view (CodeMirror) — match the dashboard theme "[&_.cm-editor]:bg-surface-background [&_.cm-editor]:text-primary", "[&_.cm-scroller]:bg-surface-background", "[&_.cm-content]:bg-surface-background [&_.cm-content]:text-primary [&_.cm-content]:caret-primary", @@ -91,7 +92,6 @@ export const BodyField = ({ version = 0 }: BodyFieldProps) => { "[&_.tok-comment]:text-secondary [&_.tok-comment]:italic", "[&_.tok-number]:text-warning [&_.tok-bool]:text-warning [&_.tok-atom]:text-warning", "[&_.tok-punctuation]:text-secondary [&_.tok-meta]:text-secondary", - // Heading styles for Visual Editor content (mdxeditor content area) "[&_.mdxeditor-root-contenteditable_h1]:mb-3 [&_.mdxeditor-root-contenteditable_h1]:mt-4 [&_.mdxeditor-root-contenteditable_h1]:text-2xl [&_.mdxeditor-root-contenteditable_h1]:font-semibold", "[&_.mdxeditor-root-contenteditable_h2]:mb-2 [&_.mdxeditor-root-contenteditable_h2]:mt-4 [&_.mdxeditor-root-contenteditable_h2]:text-xl [&_.mdxeditor-root-contenteditable_h2]:font-semibold", "[&_.mdxeditor-root-contenteditable_h3]:mb-2 [&_.mdxeditor-root-contenteditable_h3]:mt-3 [&_.mdxeditor-root-contenteditable_h3]:text-lg [&_.mdxeditor-root-contenteditable_h3]:font-semibold", @@ -122,6 +122,17 @@ export const BodyField = ({ version = 0 }: BodyFieldProps) => { linkPlugin(), linkDialogPlugin(), tablePlugin(), + codeBlockPlugin({ defaultCodeBlockLanguage: "" }), + codeMirrorPlugin({ + codeBlockLanguages: { + "": "Plain text", + json: "JSON", + js: "JavaScript", + ts: "TypeScript", + solidity: "Solidity", + bash: "Shell", + }, + }), markdownShortcutPlugin(), diffSourcePlugin({ viewMode: mode === "markdown" ? "source" : "rich-text", @@ -166,7 +177,7 @@ export const BodyField = ({ version = 0 }: BodyFieldProps) => { />

- {body.length} / {BODY_CHAR_LIMIT.toLocaleString()} + {body.length.toLocaleString()} / {BODY_CHAR_LIMIT.toLocaleString()}

); diff --git a/apps/dashboard/features/create-proposal/components/JsonTextarea.tsx b/apps/dashboard/features/create-proposal/components/JsonTextarea.tsx new file mode 100644 index 0000000000..471765165c --- /dev/null +++ b/apps/dashboard/features/create-proposal/components/JsonTextarea.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useRef, type Ref } from "react"; + +import { cn } from "@/shared/utils/cn"; + +type JsonTextareaProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; + errorLine?: number; + showLineNumbers?: boolean; + hasError?: boolean; + ariaLabel?: string; + className?: string; + ref?: Ref; +}; + +export const JsonTextarea = ({ + value, + onChange, + placeholder, + errorLine, + showLineNumbers = false, + hasError = false, + ariaLabel, + className, + ref, +}: JsonTextareaProps) => { + const gutterRef = useRef(null); + + const lineCount = value === "" ? 0 : value.split("\n").length; + + const gutterDigits = Math.max(2, String(lineCount).length); + + return ( +
+ {showLineNumbers && ( +
+ {Array.from({ length: lineCount }, (_, index) => { + const line = index + 1; + return ( +
+ {line} +
+ ); + })} +
+ )} + +