diff --git a/.changeset/late-eagles-pick.md b/.changeset/late-eagles-pick.md new file mode 100644 index 000000000..d0ff41185 --- /dev/null +++ b/.changeset/late-eagles-pick.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +support Tornado Cash proposal creation diff --git a/.changeset/offchain-support-guards.md b/.changeset/offchain-support-guards.md new file mode 100644 index 000000000..9124ff68b --- /dev/null +++ b/.changeset/offchain-support-guards.md @@ -0,0 +1,5 @@ +--- +"@anticapture/api": patch +--- + +Return unsupported-offchain errors consistently across offchain proposal and vote routes. diff --git a/.changeset/torn-delegation-voting-power.md b/.changeset/torn-delegation-voting-power.md new file mode 100644 index 000000000..5d2528830 --- /dev/null +++ b/.changeset/torn-delegation-voting-power.md @@ -0,0 +1,5 @@ +--- +"@anticapture/indexer": minor +--- + +TORN: route locked voting power to the locker's delegate and move it between delegates on Delegated/Undelegated. TORN emits no DelegateVotesChanged, so per-account voting power is now fully synthesized from lock/unlock Transfers plus delegation shifts. diff --git a/.changeset/torn-lock-transfer-direction.md b/.changeset/torn-lock-transfer-direction.md new file mode 100644 index 000000000..328df6356 --- /dev/null +++ b/.changeset/torn-lock-transfer-direction.md @@ -0,0 +1,5 @@ +--- +"@anticapture/api": patch +--- + +Normalize TORN lock/unlock transfer direction in voting-power history so the locker (not the custody contract) is shown as the delegator. diff --git a/.changeset/torn-proposal-display.md b/.changeset/torn-proposal-display.md new file mode 100644 index 000000000..1dab9e17c --- /dev/null +++ b/.changeset/torn-proposal-display.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +Render Tornado Cash proposal descriptions as Markdown (unwrapping the stringified-JSON body) and show a proposal Info card on the Actions tab for proposals without executable actions. diff --git a/.changeset/torn-treasury-transfers-phantom-voting-power.md b/.changeset/torn-treasury-transfers-phantom-voting-power.md new file mode 100644 index 000000000..6301485da --- /dev/null +++ b/.changeset/torn-treasury-transfers-phantom-voting-power.md @@ -0,0 +1,5 @@ +--- +"@anticapture/indexer": patch +--- + +Fix TORN voting power going negative from treasury transfers. TORN derives per-account voting power from lock/unlock Transfers in/out of the governor, but TORN also flows through the governor for treasury purposes (proposal executions, batch grant payouts, the governor↔vault migration). Those were misread as user unlocks and subtracted voting power from recipients that never locked (e.g. proposal #6 funding a staking pool booked a -120k unlock against a contract with zero locked balance), producing negative voting power and phantom locked balances. A custody transfer is now only counted as a lock/unlock when its counterparty is the transaction sender, which is true for genuine `lock()`/`unlock()` calls and false for treasury flows. diff --git a/.changeset/torn-vote-change-mutability.md b/.changeset/torn-vote-change-mutability.md new file mode 100644 index 000000000..ed3b8dd96 --- /dev/null +++ b/.changeset/torn-vote-change-mutability.md @@ -0,0 +1,5 @@ +--- +"@anticapture/indexer": patch +--- + +Fix TORN votes not reflecting vote changes. TORN's governor lets a voter re-cast to change their vote, but the `Voted` handler used `onConflictDoNothing` on the unique `(voter, proposal)` row and dropped every subsequent cast — so a voter who switched from For to Against still showed as For and the proposal tally kept the stale vote. The handler now overwrites the existing row and moves the voting power between the for/against buckets on a change, while still treating an exact re-processed log (backfill/reorg) as a no-op. Requires a reindex to repopulate. diff --git a/.changeset/torn-vote-options.md b/.changeset/torn-vote-options.md new file mode 100644 index 000000000..be4c390eb --- /dev/null +++ b/.changeset/torn-vote-options.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +Make TORN vote recasting reachable (show "Change your vote" on already-voted onchain proposals when the DAO allows changing votes) and hide the Abstain option for Tornado Cash, whose binary governor rejects abstain votes. diff --git a/.changeset/torn-voting-power-history-transfer-link.md b/.changeset/torn-voting-power-history-transfer-link.md new file mode 100644 index 000000000..8ba679ef5 --- /dev/null +++ b/.changeset/torn-voting-power-history-transfer-link.md @@ -0,0 +1,6 @@ +--- +"@anticapture/api": patch +"@anticapture/dashboard": patch +--- + +Fix TORN historical voting power rows being rendered as bogus zero-address delegations. TORN derives voting power directly from Transfers, so each history row shares the Transfer's log index, which the generic repository's strict `<` join never matched. Added a dedicated TORN voting-power repository that links the causing event at `logIndex <= row logIndex`. Dashboard also formats the auto-delegation fallback amount instead of dumping the raw delta. diff --git a/.changeset/tornado-cash-integration.md b/.changeset/tornado-cash-integration.md new file mode 100644 index 000000000..d38972c71 --- /dev/null +++ b/.changeset/tornado-cash-integration.md @@ -0,0 +1,7 @@ +--- +"@anticapture/indexer": minor +"@anticapture/api": minor +"@anticapture/dashboard": minor +--- + +Integrate Tornado Cash DAO (TORN): custom stake-to-vote indexer (lock-based delegated supply, timestamp governance), timestamp-based proposal-status API client, and dashboard config/icon. diff --git a/.claude/skills/local-dev-stack/SKILL.md b/.claude/skills/local-dev-stack/SKILL.md new file mode 100644 index 000000000..ec721a559 --- /dev/null +++ b/.claude/skills/local-dev-stack/SKILL.md @@ -0,0 +1,61 @@ +--- +name: local-dev +description: Use when you need to hit the running local stack (API, gateful, dashboard, indexer) to test or verify a change. Covers scripts/dev.sh, hot reload, and the ports each service listens on. +--- + +# Local Dev Stack + +The local stack is started with `scripts/dev.sh` (usually already running — check the +ports before starting your own). **All services hot-reload on file save** — after +editing source you do NOT need to restart; just re-hit the endpoint. + +## Start it + +```bash +./scripts/dev.sh # e.g. tornado, uniswap, gitcoin, scroll, shutter, compound +./scripts/dev.sh --indexer # also run the indexer for that DAO +./scripts/dev.sh --debug-api # wait for API from IDE debugger instead of spawning it +./scripts/dev.sh # no DAO → API skipped, uses DAO_API_* from .env +``` + +### Railway env (per service) + +By **default all services run locally** (env from `.env`). Opt into `railway run -e dev` +env injection per service: + +| Flag | API | Gateful | +| -------------- | ------- | ------- | +| _(none)_ | local | local | +| `--rw-api` | railway | local | +| `--rw-gateful` | local | railway | +| `--rw` | railway | railway | + +`--rw-api` falls back to `.env` if the Railway service isn't found. Relayer and +Address Enrichment always use `railway run` when available. + +On startup it **kills anything already on the ports below**, so a second run replaces +the first. + +## Ports + +| Service | Port | URL | Notes | +| ------------------ | ----- | ---------------------- | ----------------------------------- | +| API | 42069 | http://localhost:42069 | only when a `` is passed | +| Indexer | 42070 | http://localhost:42070 | only with `--indexer` | +| Gateful (gateway) | 4001 | http://localhost:4001 | always | +| Dashboard | 3000 | http://localhost:3000 | always | +| Address Enrichment | 3001 | http://localhost:3001 | optional, skipped if no Railway svc | +| Relayer (ENS) | 3002 | http://localhost:3002 | ENS only | + +Client SDK codegen runs in watch mode (no port) and regenerates on API/gateful changes. + +## Testing tips + +- **Test the API directly** at `http://localhost:42069` — OpenAPI/REST. +- **Test the aggregated surface** through gateful at `http://localhost:4001`. +- The DAO is identified by short id in env: `DAO_API_` (e.g. `DAO_API_TORN`, + `DAO_API_UNI`). dev.sh exports these automatically. +- When the API restarts, a watchdog touches `apps/gateful/src/_dev-reload.ts` so gateful + reloads and re-points at the recovered API. + +Stop everything with Ctrl+C (dev.sh traps it and kills the whole process group). diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 059555cc8..4e11750e4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -138,6 +138,9 @@ jobs: needs: [configure-vercel-preview, wait-for-gateful] if: needs.configure-vercel-preview.outputs.configured == 'true' runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read steps: - name: Checkout repository @@ -157,6 +160,7 @@ jobs: run: echo "name=$(git log -1 --format='%an')" >> "$GITHUB_OUTPUT" - name: Trigger Vercel preview redeploy + id: deploy env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }} @@ -181,8 +185,11 @@ jobs: # AND a runtime env (--env): the /api/gateful/* proxy route reads # process.env.ANTICAPTURE_API_URL at request time, so --build-env alone # would build against the right URL but serve a stale/missing one. + # `vercel deploy` prints the deployment URL to stdout; capture it so the + # step below can surface it on the PR (the CLI deploy path doesn't get + # Vercel's native Git-integration check/comment). run: | - npx --yes vercel@latest deploy --yes --token="${VERCEL_TOKEN}" \ + url=$(npx --yes vercel@latest deploy --yes --token="${VERCEL_TOKEN}" \ --build-env ANTICAPTURE_API_URL="${GATEFUL_URL}" \ --env ANTICAPTURE_API_URL="${GATEFUL_URL}" \ --meta githubCommitSha="${GIT_SHA}" \ @@ -196,7 +203,15 @@ jobs: --meta githubOrg="${GIT_ORG}" \ --meta githubRepo="${GIT_REPO}" \ --meta githubRepoId="${GIT_REPO_ID}" \ - --meta githubDeployment=1 + --meta githubDeployment=1) + echo "url=${url}" >> "$GITHUB_OUTPUT" + + - name: Comment preview URL on PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: vercel-preview + message: | + 🔍 **Vercel preview:** ${{ steps.deploy.outputs.url }} changeset-path-filter: name: Detect changeset requirement diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index 390fd73b8..932cffee6 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -68,6 +68,7 @@ import { HistoricalBalanceRepository, NFTPriceRepository, NounsVotingPowerRepository, + TORNVotingPowerRepository, TokenRepository, TransfersRepository, TreasuryRepository, @@ -181,7 +182,7 @@ if (!daoClient) { } const pgClient = drizzle(env.DATABASE_URL, { - schema, + schema: { ...schema, ...offchainSchema }, casing: "snake_case", }); @@ -236,7 +237,9 @@ const votingPowerService = wrapWithTracing( new VotingPowerService( env.DAO_ID === DaoIdEnum.NOUNS || env.DAO_ID === DaoIdEnum.LIL_NOUNS ? wrapWithTracing(new NounsVotingPowerRepository(pgClient)) - : votingPowerRepo, + : env.DAO_ID === DaoIdEnum.TORN + ? wrapWithTracing(new TORNVotingPowerRepository(pgClient)) + : votingPowerRepo, votingPowerRepo, ), ); @@ -396,32 +399,28 @@ if (env.DAO_ID === DaoIdEnum.ENS) { revenue(app, revenueDuneClient); } -if (daoClient.supportOffchainData()) { - const pgUnifiedClient = drizzle(env.DATABASE_URL, { - schema: { ...schema, ...offchainSchema }, - casing: "snake_case", - }); - - const offchainProposalsRepo = wrapWithTracing( - new OffchainProposalRepository(pgUnifiedClient), - ); - const offchainVotesRepo = wrapWithTracing( - new OffchainVoteRepository(pgUnifiedClient), - ); - offchainProposals( - app, - wrapWithTracing(new OffchainProposalsService(offchainProposalsRepo)), - ); - offchainVotes( - app, - wrapWithTracing(new OffchainVotesService(offchainVotesRepo)), - ); +const supportOffchain = daoClient.supportOffchainData(); +const offchainProposalsRepo = wrapWithTracing( + new OffchainProposalRepository(pgClient), +); +const offchainVotesRepo = wrapWithTracing(new OffchainVoteRepository(pgClient)); +offchainProposals( + app, + wrapWithTracing(new OffchainProposalsService(offchainProposalsRepo)), + supportOffchain, +); +offchainVotes( + app, + wrapWithTracing(new OffchainVotesService(offchainVotesRepo)), + supportOffchain, +); - const offchainNonVotersRepo = new OffchainNonVotersRepositoryImpl( - pgUnifiedClient, - ); - offchainNonVoters(app, new OffchainNonVotersService(offchainNonVotersRepo)); -} +const offchainNonVotersRepo = new OffchainNonVotersRepositoryImpl(pgClient); +offchainNonVoters( + app, + wrapWithTracing(new OffchainNonVotersService(offchainNonVotersRepo)), + supportOffchain, +); serve( { diff --git a/apps/api/src/clients/index.ts b/apps/api/src/clients/index.ts index f9ab39998..66f387d9d 100644 --- a/apps/api/src/clients/index.ts +++ b/apps/api/src/clients/index.ts @@ -10,6 +10,7 @@ export * from "./uni"; export * from "./shu"; export * from "./aave"; export * from "./fluid"; +export * from "./torn"; export interface DAOClient { getDaoId: () => string; diff --git a/apps/api/src/clients/torn/abi.ts b/apps/api/src/clients/torn/abi.ts new file mode 100644 index 000000000..8d5bcc761 --- /dev/null +++ b/apps/api/src/clients/torn/abi.ts @@ -0,0 +1,44 @@ +export const TORNGovernorAbi = [ + { + type: "function", + name: "QUORUM_VOTES", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "VOTING_DELAY", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "VOTING_PERIOD", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "PROPOSAL_THRESHOLD", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "EXECUTION_DELAY", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "EXECUTION_EXPIRATION", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/apps/api/src/clients/torn/index.ts b/apps/api/src/clients/torn/index.ts new file mode 100644 index 000000000..8658409dc --- /dev/null +++ b/apps/api/src/clients/torn/index.ts @@ -0,0 +1,203 @@ +import { Account, Address, Chain, Client, Transport } from "viem"; + +import { DAOClient } from "@/clients"; +import { ProposalStatus } from "@/lib/constants"; + +import { GovernorBase } from "../governor.base"; + +import { TORNGovernorAbi } from "./abi"; + +const BLOCK_TIME = 12; + +export class TORNClient< + TTransport extends Transport = Transport, + TChain extends Chain = Chain, + TAccount extends Account | undefined = Account | undefined, +> + extends GovernorBase + implements DAOClient +{ + protected address: Address; + protected abi: typeof TORNGovernorAbi; + + constructor(client: Client, address: Address) { + super(client); + this.address = address; + this.abi = TORNGovernorAbi; + } + + getDaoId(): string { + return "TORN"; + } + + async getQuorum(_proposalId: string | null): Promise { + return this.getCachedQuorum(async () => { + return this.readContract({ + abi: this.abi, + address: this.address, + functionName: "QUORUM_VOTES", + }); + }); + } + + // TORN governance times everything in seconds, but the platform contract + // for getVotingDelay/getVotingPeriod expects BLOCK units (consumers multiply + // by blockTime to recover seconds). Convert seconds → blocks on read. + async getVotingDelay(): Promise { + if (!this.cache.votingDelay) { + const seconds = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_DELAY", + })) as bigint; + this.cache.votingDelay = seconds / BigInt(BLOCK_TIME); + } + return this.cache.votingDelay!; + } + + async getVotingPeriod(): Promise { + if (!this.cache.votingPeriod) { + const seconds = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_PERIOD", + })) as bigint; + this.cache.votingPeriod = seconds / BigInt(BLOCK_TIME); + } + return this.cache.votingPeriod!; + } + + async getProposalThreshold(): Promise { + if (!this.cache.proposalThreshold) { + this.cache.proposalThreshold = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "PROPOSAL_THRESHOLD", + })) as bigint; + } + return this.cache.proposalThreshold!; + } + + async getTimelockDelay(): Promise { + if (!this.cache.timelockDelay) { + this.cache.timelockDelay = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_DELAY", + })) as bigint; + } + return this.cache.timelockDelay!; + } + + private async getExecutionExpiration(): Promise { + if (!this.cache.executionPeriod) { + this.cache.executionPeriod = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_EXPIRATION", + })) as bigint; + } + return this.cache.executionPeriod!; + } + + calculateQuorum(votes: { + forVotes: bigint; + againstVotes: bigint; + abstainVotes: bigint; + }): bigint { + // Tornado's Governance.state() reaches quorum on forVotes + againstVotes + // (both sides count toward QUORUM_VOTES), not forVotes alone. + return votes.forVotes + votes.againstVotes; + } + + alreadySupportCalldataReview(): boolean { + return false; + } + + /** + * Tornado Cash proposal status — timestamp-based, not block-based. + * + * The governor uses seconds for all timing parameters (VOTING_DELAY, + * VOTING_PERIOD, EXECUTION_DELAY, EXECUTION_EXPIRATION). We derive + * startTime synthetically from endTimestamp and the block range. + * + * State machine: + * EXECUTED → finalized (persisted by indexer) + * now < startTime → PENDING + * now < endTimestamp → ACTIVE + * forVotes + againstVotes < quorum → NO_QUORUM + * forVotes <= againstVotes → DEFEATED + * now < endTimestamp + EXECUTION_DELAY → QUEUED + * now < endTimestamp + EXECUTION_DELAY + EXECUTION_EXPIRATION → PENDING_EXECUTION + * else → EXPIRED + */ + async getProposalStatus( + proposal: { + id: string; + status: string; + startBlock: number; + endBlock: number; + forVotes: bigint; + againstVotes: bigint; + abstainVotes: bigint; + endTimestamp: bigint; + }, + _currentBlock: number, + currentTimestamp: number, + ): Promise { + // Already finalized via event + if (proposal.status === ProposalStatus.EXECUTED) { + return ProposalStatus.EXECUTED; + } + + if (proposal.status === ProposalStatus.CANCELED) { + return ProposalStatus.CANCELED; + } + + const now = BigInt(currentTimestamp); + const endTimestamp = proposal.endTimestamp; + + // Estimate startTime from endTimestamp and block range + const votingDurationSeconds = + BigInt(proposal.endBlock - proposal.startBlock) * BigInt(BLOCK_TIME); + const startTime = endTimestamp - votingDurationSeconds; + + if (now < startTime) { + return ProposalStatus.PENDING; + } + + if (now < endTimestamp) { + return ProposalStatus.ACTIVE; + } + + // After voting period ends — check quorum and majority + const quorum = await this.getQuorum(proposal.id); + const proposalQuorum = this.calculateQuorum({ + forVotes: proposal.forVotes, + againstVotes: proposal.againstVotes, + abstainVotes: proposal.abstainVotes, + }); + + if (proposalQuorum < quorum) { + return ProposalStatus.NO_QUORUM; + } + + if (proposal.forVotes <= proposal.againstVotes) { + return ProposalStatus.DEFEATED; + } + + // Passed — check timelock windows + const executionDelay = await this.getTimelockDelay(); + const executionExpiration = await this.getExecutionExpiration(); + + if (now < endTimestamp + executionDelay) { + return ProposalStatus.QUEUED; + } + + if (now < endTimestamp + executionDelay + executionExpiration) { + return ProposalStatus.PENDING_EXECUTION; + } + + return ProposalStatus.EXPIRED; + } +} diff --git a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts index d1e740584..75b78cb0e 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts @@ -62,6 +62,14 @@ const BASE_PROPOSAL_ITEM = { strategies: [], }; +const createApp = (supportOffchain = true) => { + const repo = new OffchainProposalRepository(db); + const service = new OffchainProposalsService(repo); + const testApp = new Hono(); + offchainProposalsController(testApp, service, supportOffchain); + return testApp; +}; + beforeAll(async () => { client = new PGlite(); const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; @@ -78,13 +86,45 @@ afterAll(async () => { beforeEach(async () => { await db.delete(offchainProposals); - const repo = new OffchainProposalRepository(db); - const service = new OffchainProposalsService(repo); - app = new Hono(); - offchainProposalsController(app, service); + app = createApp(); }); describe("Offchain Proposals Controller", () => { + describe("supportOffchain=false", () => { + it("should return 400 for proposal list", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + + it("should return 400 for proposal search", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals/search?query=test"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + + it("should return 400 for proposal detail", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals/proposal-1"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + }); + describe("GET /offchain/proposals", () => { it("should return 200 with correct response shape", async () => { const proposal = createProposal(); diff --git a/apps/api/src/controllers/proposals/offchainProposals.ts b/apps/api/src/controllers/proposals/offchainProposals.ts index 90d1925d1..8d4e360c6 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.ts @@ -16,6 +16,7 @@ import { OffchainProposalsService } from "@/services"; export function offchainProposals( app: Hono, service: OffchainProposalsService, + supportOffchain: boolean, ) { app.openapi( createRoute({ @@ -39,9 +40,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { skip, limit, orderDirection, status, fromDate, endDate, lean } = context.req.valid("query"); @@ -86,9 +102,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { query, skip, limit, lean } = context.req.valid("query"); const { items, totalCount } = await service.searchProposals({ @@ -138,9 +169,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { lean } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts b/apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts new file mode 100644 index 000000000..f5968edd2 --- /dev/null +++ b/apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts @@ -0,0 +1,37 @@ +import { OpenAPIHono as Hono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; +import type { Address } from "viem"; +import { OffchainNonVotersService } from "@/services/votes/offchainNonVoters"; +import type { OffchainNonVotersRepository } from "@/repositories/votes/offchainNonVoters"; +import { offchainNonVoters as offchainNonVotersController } from "./offchainNonVoters"; + +class StubOffchainNonVotersRepository implements OffchainNonVotersRepository { + async proposalExists() { + return true; + } + + async getOffchainNonVoters() { + return []; + } + + async getOffchainNonVotersCount(_proposalId: string, _addresses?: Address[]) { + return 0; + } +} + +describe("Offchain Non-Voters Controller", () => { + it("should return 400 when supportOffchain=false", async () => { + const service = new OffchainNonVotersService( + new StubOffchainNonVotersRepository(), + ); + const app = new Hono(); + offchainNonVotersController(app, service, false); + + const res = await app.request("/offchain/proposals/proposal-1/non-voters"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); +}); diff --git a/apps/api/src/controllers/votes/offchainNonVoters.ts b/apps/api/src/controllers/votes/offchainNonVoters.ts index f7a101215..d2d59ab6f 100644 --- a/apps/api/src/controllers/votes/offchainNonVoters.ts +++ b/apps/api/src/controllers/votes/offchainNonVoters.ts @@ -10,6 +10,7 @@ import { OffchainNonVotersService } from "@/services"; export function offchainNonVoters( app: Hono, service: OffchainNonVotersService, + supportOffchain: boolean, ) { app.openapi( createRoute({ @@ -35,6 +36,14 @@ export function offchainNonVoters( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, 404: { description: "Proposal not found", content: { @@ -46,6 +55,13 @@ export function offchainNonVoters( }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { skip, limit, orderDirection, addresses } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts index caef2a131..40526a2a2 100644 --- a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts +++ b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts @@ -64,6 +64,14 @@ const createOffchainProposal = ( ...overrides, }); +const createApp = (supportOffchain = true) => { + const repo = new OffchainVoteRepository(db); + const service = new OffchainVotesService(repo); + const testApp = new Hono(); + offchainVotesController(testApp, service, supportOffchain); + return testApp; +}; + beforeAll(async () => { client = new PGlite(); const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; @@ -81,13 +89,34 @@ beforeEach(async () => { await db.delete(offchainVotes); await db.delete(offchainProposals); - const repo = new OffchainVoteRepository(db); - const service = new OffchainVotesService(repo); - app = new Hono(); - offchainVotesController(app, service); + app = createApp(); }); describe("Offchain Votes Controller", () => { + describe("supportOffchain=false", () => { + it("should return 400 for vote list", async () => { + app = createApp(false); + + const res = await app.request("/offchain/votes"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + + it("should return 400 for proposal vote list", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals/proposal-1/votes"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + }); + describe("GET /offchain/votes", () => { it("should return 200 with correct response shape", async () => { await db.insert(offchainProposals).values(createOffchainProposal()); diff --git a/apps/api/src/controllers/votes/offchainVotes.ts b/apps/api/src/controllers/votes/offchainVotes.ts index a8e990deb..7467b506f 100644 --- a/apps/api/src/controllers/votes/offchainVotes.ts +++ b/apps/api/src/controllers/votes/offchainVotes.ts @@ -1,6 +1,7 @@ import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; import { + ErrorResponseSchema, OffchainProposalRequestSchema, OffchainVotesRequestSchema, OffchainVotesResponseSchema, @@ -8,7 +9,11 @@ import { import { setCacheControl } from "@/middlewares"; import { OffchainVotesService } from "@/services"; -export function offchainVotes(app: Hono, service: OffchainVotesService) { +export function offchainVotes( + app: Hono, + service: OffchainVotesService, + supportOffchain: boolean, +) { app.openapi( createRoute({ method: "get", @@ -30,9 +35,24 @@ export function offchainVotes(app: Hono, service: OffchainVotesService) { }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { skip, limit, @@ -80,9 +100,24 @@ export function offchainVotes(app: Hono, service: OffchainVotesService) { }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { skip, diff --git a/apps/api/src/lib/client.ts b/apps/api/src/lib/client.ts index 32792b00a..cc8c11b7b 100644 --- a/apps/api/src/lib/client.ts +++ b/apps/api/src/lib/client.ts @@ -14,6 +14,7 @@ import { SHUClient, AAVEClient, FLUIDClient, + TORNClient, } from "@/clients"; import { CONTRACT_ADDRESSES } from "./constants"; @@ -84,6 +85,10 @@ export function getClient< case DaoIdEnum.AAVE: { return new AAVEClient(client); } + case DaoIdEnum.TORN: { + const { governor } = CONTRACT_ADDRESSES[daoId]; + return new TORNClient(client, governor.address); + } default: return null; } diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index 4341f9e63..3aa56416e 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -220,6 +220,19 @@ export const CONTRACT_ADDRESSES = { startBlock: 12422079, }, }, + [DaoIdEnum.TORN]: { + blockTime: 12, + tokenType: "ERC20", + token: { + address: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + decimals: 18, + startBlock: 11474599, + }, + governor: { + address: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + startBlock: 11474695, + }, + }, } as const; export const TreasuryAddresses: Record> = { @@ -390,6 +403,50 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, + [DaoIdEnum.TORN]: { + governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + }, +}; + +// Mirrors the indexer's NonCirculatingAddresses. Locked/vesting supply held by +// DAO-controlled contracts; excluded from holder listings alongside treasury +// when excludeDaoAddresses is set. +export const NonCirculatingAddresses: Record< + DaoIdEnum, + Record +> = { + [DaoIdEnum.UNI]: {}, + [DaoIdEnum.ENS]: { + "Token Timelock": "0xd7a029db2585553978190db5e85ec724aa4df23f", + }, + [DaoIdEnum.ARB]: {}, + [DaoIdEnum.AAVE]: { + "LEND to AAVE Migrator": "0x317625234562B1526Ea2FaC4030Ea499C5291de4", + }, + [DaoIdEnum.OP]: {}, + [DaoIdEnum.SHU]: {}, + [DaoIdEnum.LIL_NOUNS]: {}, + [DaoIdEnum.NOUNS]: {}, + [DaoIdEnum.GTC]: {}, + [DaoIdEnum.SCR]: {}, + [DaoIdEnum.COMP]: {}, + [DaoIdEnum.OBOL]: {}, + [DaoIdEnum.ZK]: { + "Initial Merkle Distributor": "0x66fd4fc8fa52c9bec2aba368047a0b27e24ecfe4", + "Second ZK Distributor": "0xb294F411cB52c7C6B6c0B0b61DBDf398a8b0725d", + "Third ZK Distributor": "0xf29d698e74ef1904bcfdb20ed38f9f3ef0a89e5b", + "Matter Labs Allocation": "0xa97fbc75ccbc7d4353c4d2676ed18cd0c5aaf7e6", + "Foundation Allocation": "0xd78dc27d4db8f428c67f542216a2b23663838405", + "Guardians Allocation": "0x21b27952f8621f54f3cb652630e122ec81dd2dc1", + "Security Council Allocation": "0x0ad50686c159040e57ddce137db0b63c67473450", + "ZKsync Association Allocation": + "0x0681e3808a0aa12004fb815ebb4515dc823cfbb4", + }, + [DaoIdEnum.FLUID]: {}, + [DaoIdEnum.TORN]: { + governance: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + vault: "0x2F50508a8a3D323B91336FA3eA6ae50E55f32185", + }, }; export enum ProposalStatus { diff --git a/apps/api/src/lib/enums.ts b/apps/api/src/lib/enums.ts index b45c20d6c..025878d72 100644 --- a/apps/api/src/lib/enums.ts +++ b/apps/api/src/lib/enums.ts @@ -13,6 +13,7 @@ export enum DaoIdEnum { OBOL = "OBOL", ZK = "ZK", FLUID = "FLUID", + TORN = "TORN", } export const SECONDS_IN_DAY = 24 * 60 * 60; diff --git a/apps/api/src/lib/eventRelevance.ts b/apps/api/src/lib/eventRelevance.ts index f0599ea20..dd83c6bc7 100644 --- a/apps/api/src/lib/eventRelevance.ts +++ b/apps/api/src/lib/eventRelevance.ts @@ -232,6 +232,25 @@ const DAO_RELEVANCE_THRESHOLDS: Record = { [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, }, + [DaoIdEnum.TORN]: { + [FeedEventType.TRANSFER]: thresholds( + parseEther("1000"), + parseEther("10000"), + parseEther("100000"), + ), + [FeedEventType.DELEGATION]: thresholds( + parseEther("1000"), + parseEther("10000"), + parseEther("100000"), + ), + [FeedEventType.VOTE]: thresholds( + parseEther("1000"), + parseEther("10000"), + parseEther("100000"), + ), + [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, + [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, + }, }; export function getDaoRelevanceThreshold(daoId: DaoIdEnum): EventRelevanceMap { diff --git a/apps/api/src/repositories/feed/index.ts b/apps/api/src/repositories/feed/index.ts index ac4e2e351..660222441 100644 --- a/apps/api/src/repositories/feed/index.ts +++ b/apps/api/src/repositories/feed/index.ts @@ -104,25 +104,35 @@ export class FeedRepository { const voteKeys = rows .filter((r) => r.type === FeedEventType.VOTE) .map((r) => ({ txHash: r.txHash, logIndex: r.logIndex })); - const proposalIds = Array.from( + // A PROPOSAL creation event and its proposal row are written from the same + // log, so match them by txHash (one ProposalCreated per tx). This avoids + // relying on feed_event.proposal_id, which some governors (e.g. TORN) leave + // null on the creation event. PROPOSAL_EXTENDED lives in a different tx, so + // it still keys by proposal_id. + const proposalTxHashes = Array.from( new Set( rows - .filter( - (r) => - r.type === FeedEventType.PROPOSAL || - r.type === FeedEventType.PROPOSAL_EXTENDED, - ) + .filter((r) => r.type === FeedEventType.PROPOSAL) + .map((r) => r.txHash), + ), + ); + const extendedProposalIds = Array.from( + new Set( + rows + .filter((r) => r.type === FeedEventType.PROPOSAL_EXTENDED) .map((r) => r.proposalId) .filter((id): id is string => id != null), ), ); - const [delegations, transfers, votes, proposals] = await Promise.all([ - this.fetchDelegations(delegationKeys), - this.fetchTransfers(transferKeys), - this.fetchVotes(voteKeys), - this.fetchProposals(proposalIds), - ]); + const [delegations, transfers, votes, createdProposals, extendedProposals] = + await Promise.all([ + this.fetchDelegations(delegationKeys), + this.fetchTransfers(transferKeys), + this.fetchVotes(voteKeys), + this.fetchProposalsByTxHash(proposalTxHashes), + this.fetchProposals(extendedProposalIds), + ]); const delegationByKey = new Map( delegations.map((d) => [`${d.transactionHash}:${d.logIndex}`, d]), @@ -133,7 +143,10 @@ export class FeedRepository { const voteByKey = new Map( votes.map((v) => [`${v.txHash}:${v.logIndex}`, v]), ); - const proposalById = new Map(proposals.map((p) => [p.id, p])); + const proposalByTxHash = new Map( + createdProposals.map((p) => [p.txHash, p]), + ); + const proposalById = new Map(extendedProposals.map((p) => [p.id, p])); return rows.map((row) => ({ ...row, @@ -141,6 +154,7 @@ export class FeedRepository { delegationByKey, transferByKey, voteByKey, + proposalByTxHash, proposalById, }), })); @@ -152,6 +166,7 @@ export class FeedRepository { delegationByKey: Map; transferByKey: Map; voteByKey: Map; + proposalByTxHash: Map; proposalById: Map; }, ): FeedMetadata | null { @@ -195,8 +210,7 @@ export class FeedRepository { return meta; } case FeedEventType.PROPOSAL: { - if (!row.proposalId) return null; - const p = lookups.proposalById.get(row.proposalId); + const p = lookups.proposalByTxHash.get(row.txHash); if (!p) return null; const meta: ProposalMeta = { kind: FeedEventType.PROPOSAL, @@ -286,9 +300,21 @@ export class FeedRepository { private async fetchProposals(proposalIds: string[]): Promise { if (proposalIds.length === 0) return []; + return this.selectProposals(inArray(proposalsOnchain.id, proposalIds)); + } + + private async fetchProposalsByTxHash( + txHashes: string[], + ): Promise { + if (txHashes.length === 0) return []; + return this.selectProposals(inArray(proposalsOnchain.txHash, txHashes)); + } + + private selectProposals(where: SQL): Promise { return this.db .select({ id: proposalsOnchain.id, + txHash: proposalsOnchain.txHash, proposerAccountId: proposalsOnchain.proposerAccountId, title: proposalsOnchain.title, endBlock: proposalsOnchain.endBlock, @@ -311,7 +337,7 @@ export class FeedRepository { )`, }) .from(proposalsOnchain) - .where(inArray(proposalsOnchain.id, proposalIds)); + .where(where); } private buildRelevanceFilter( @@ -366,6 +392,7 @@ type VoteRow = { type ProposalRow = { id: string; + txHash: string; proposerAccountId: string; title: string; endBlock: number; diff --git a/apps/api/src/repositories/voting-power/index.ts b/apps/api/src/repositories/voting-power/index.ts index e1308af25..93fc95791 100644 --- a/apps/api/src/repositories/voting-power/index.ts +++ b/apps/api/src/repositories/voting-power/index.ts @@ -1,2 +1,3 @@ export * from "./nouns"; +export * from "./torn"; export * from "./general"; diff --git a/apps/api/src/repositories/voting-power/torn.ts b/apps/api/src/repositories/voting-power/torn.ts new file mode 100644 index 000000000..82262fb29 --- /dev/null +++ b/apps/api/src/repositories/voting-power/torn.ts @@ -0,0 +1,143 @@ +import { gte, and, lte, desc, eq, asc, sql } from "drizzle-orm"; +import { Address } from "viem"; + +import { Drizzle, votingPowerHistory, delegation, transfer } from "@/database"; +import { DBHistoricalVotingPowerWithRelations } from "@/mappers"; + +/** + * TORN derives per-account voting power directly from lock/unlock Transfers + * (it emits no DelegateVotesChanged), so each `votingPowerHistory` row is + * written with the SAME `logIndex` as the Transfer that produced it. The + * generic repository links events with `logIndex < votingPowerHistory.logIndex`, + * which never matches a same-index event and leaves the row with no transfer — + * the dashboard then renders it as a bogus delegation from the zero address. + * + * This repository matches the causing event at `logIndex <= votingPowerHistory.logIndex` + * (closest preceding-or-equal), keeping everything else identical to the generic one. + */ +export class TORNVotingPowerRepository { + constructor(private readonly db: Drizzle) {} + + async getHistoricalVotingPowerCount( + accountId?: Address, + minDelta?: string, + maxDelta?: string, + fromDate?: number, + toDate?: number, + ): Promise { + return await this.db.$count( + votingPowerHistory, + and( + accountId ? eq(votingPowerHistory.accountId, accountId) : undefined, + minDelta + ? gte(votingPowerHistory.deltaMod, BigInt(minDelta)) + : undefined, + maxDelta + ? lte(votingPowerHistory.deltaMod, BigInt(maxDelta)) + : undefined, + fromDate + ? gte(votingPowerHistory.timestamp, BigInt(fromDate)) + : undefined, + toDate ? lte(votingPowerHistory.timestamp, BigInt(toDate)) : undefined, + ), + ); + } + + async getHistoricalVotingPowers( + skip: number, + limit: number, + orderDirection: "asc" | "desc", + orderBy: "timestamp" | "delta", + accountId?: Address, + minDelta?: string, + maxDelta?: string, + fromDate?: number, + toDate?: number, + ): Promise { + const result = await this.db + .select() + .from(votingPowerHistory) + .leftJoin( + delegation, + sql`${votingPowerHistory.transactionHash} = ${delegation.transactionHash} + AND ${delegation.logIndex} = ( + SELECT MAX(${delegation.logIndex}) + FROM ${delegation} + WHERE ${delegation.transactionHash} = ${votingPowerHistory.transactionHash} + AND ${delegation.logIndex} <= ${votingPowerHistory.logIndex} + )`, + ) + .leftJoin( + transfer, + sql`${votingPowerHistory.transactionHash} = ${transfer.transactionHash} + AND ${transfer.logIndex} = ( + SELECT MAX(${transfer.logIndex}) + FROM ${transfer} + WHERE ${transfer.transactionHash} = ${votingPowerHistory.transactionHash} + AND ${transfer.logIndex} <= ${votingPowerHistory.logIndex} + )`, + ) + .where( + and( + accountId ? eq(votingPowerHistory.accountId, accountId) : undefined, + minDelta + ? gte(votingPowerHistory.deltaMod, BigInt(minDelta)) + : undefined, + maxDelta + ? lte(votingPowerHistory.deltaMod, BigInt(maxDelta)) + : undefined, + fromDate + ? gte(votingPowerHistory.timestamp, BigInt(fromDate)) + : undefined, + toDate + ? lte(votingPowerHistory.timestamp, BigInt(toDate)) + : undefined, + ), + ) + .orderBy( + orderDirection === "asc" + ? asc( + orderBy === "timestamp" + ? votingPowerHistory.timestamp + : votingPowerHistory.deltaMod, + ) + : desc( + orderBy === "timestamp" + ? votingPowerHistory.timestamp + : votingPowerHistory.deltaMod, + ), + ) + .limit(limit) + .offset(skip); + + return result.map((row) => { + const transfers = + row.delegations && + row.delegations?.logIndex > (row.transfers?.logIndex || 0) + ? null + : row.transfers; + + return { + ...row.voting_power_history, + delegations: + row.transfers && + row.transfers?.logIndex > (row.delegations?.logIndex || 0) + ? null + : row.delegations, + // TORN's lock/unlock Transfer moves tokens OPPOSITE to the voting-power + // change: a lock (VP gain) sends TORN wallet -> custody, an unlock (VP + // loss) sends custody -> wallet. The dashboard derives the delegator as + // `isGain ? transfer.to : transfer.from`, which would surface the + // custody contract instead of the locker. Swap from/to so the locker + // who caused the change is shown. + transfers: transfers + ? { + ...transfers, + fromAccountId: transfers.toAccountId, + toAccountId: transfers.fromAccountId, + } + : null, + }; + }); + } +} diff --git a/apps/api/src/repositories/voting-power/torn.unit.test.ts b/apps/api/src/repositories/voting-power/torn.unit.test.ts new file mode 100644 index 000000000..d444ca7dd --- /dev/null +++ b/apps/api/src/repositories/voting-power/torn.unit.test.ts @@ -0,0 +1,169 @@ +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 { votingPowerHistory, delegation, transfer } from "@/database/schema"; +import * as schema from "@/database/schema"; + +import { TORNVotingPowerRepository } from "./torn"; + +type VotingPowerHistoryInsert = typeof votingPowerHistory.$inferInsert; +type DelegationInsert = typeof delegation.$inferInsert; +type TransferInsert = typeof transfer.$inferInsert; + +const TEST_ACCOUNT: Address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TEST_ACCOUNT_2: Address = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const TEST_DAO = "TORN"; +const TX_HASH = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + +const createHistoryRow = ( + overrides: Partial = {}, +): VotingPowerHistoryInsert => ({ + transactionHash: TX_HASH, + daoId: TEST_DAO, + accountId: TEST_ACCOUNT, + votingPower: 1000n, + delta: 200n, + deltaMod: 200n, + timestamp: 1700000000n, + logIndex: 150, + ...overrides, +}); + +const createTransfer = ( + overrides: Partial = {}, +): TransferInsert => ({ + transactionHash: TX_HASH, + daoId: TEST_DAO, + tokenId: "token-1", + amount: 100n, + fromAccountId: TEST_ACCOUNT, + toAccountId: TEST_ACCOUNT_2, + timestamp: 1700000000n, + logIndex: 150, + ...overrides, +}); + +const createDelegation = ( + overrides: Partial = {}, +): DelegationInsert => ({ + transactionHash: TX_HASH, + daoId: TEST_DAO, + delegateAccountId: TEST_ACCOUNT, + delegatorAccountId: TEST_ACCOUNT_2, + delegatedValue: 0n, + timestamp: 1700000000n, + logIndex: 150, + ...overrides, +}); + +describe("TORNVotingPowerRepository", () => { + let client: PGlite; + let db: Drizzle; + let repository: TORNVotingPowerRepository; + + beforeAll(async () => { + client = new PGlite(); + db = drizzle(client, { schema }); + repository = new TORNVotingPowerRepository(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(votingPowerHistory); + await db.delete(delegation); + await db.delete(transfer); + }); + + // The defining TORN behavior: voting power is derived directly from the + // Transfer, so the history row shares the transfer's logIndex. The generic + // repository's strict `<` join would drop it; this one matches on `<=`. + it("links a transfer whose logIndex equals the row logIndex", async () => { + await db + .insert(votingPowerHistory) + .values(createHistoryRow({ logIndex: 150 })); + await db.insert(transfer).values(createTransfer({ logIndex: 150 })); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + ); + + expect(result).toHaveLength(1); + expect(result[0]!.transfers).not.toBeNull(); + expect(result[0]!.transfers!.logIndex).toBe(150); + expect(result[0]!.delegations).toBeNull(); + }); + + it("links a delegation whose logIndex equals the row logIndex", async () => { + await db + .insert(votingPowerHistory) + .values(createHistoryRow({ logIndex: 150 })); + await db.insert(delegation).values(createDelegation({ logIndex: 150 })); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + ); + + expect(result).toHaveLength(1); + expect(result[0]!.delegations).not.toBeNull(); + expect(result[0]!.delegations!.logIndex).toBe(150); + }); + + it("returns null relations when none share or precede the row logIndex", async () => { + await db + .insert(votingPowerHistory) + .values(createHistoryRow({ logIndex: 150 })); + // transfer in the same tx but AFTER the row is not the cause + await db.insert(transfer).values(createTransfer({ logIndex: 151 })); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + ); + + expect(result).toHaveLength(1); + expect(result[0]!.transfers).toBeNull(); + expect(result[0]!.delegations).toBeNull(); + }); + + it("filters by accountId", async () => { + await db.insert(votingPowerHistory).values([ + createHistoryRow({ accountId: TEST_ACCOUNT, logIndex: 150 }), + createHistoryRow({ + accountId: TEST_ACCOUNT_2, + transactionHash: + "0x0000000000000000000000000000000000000000000000000000000000000002", + logIndex: 151, + }), + ]); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + TEST_ACCOUNT, + ); + + expect(result).toHaveLength(1); + expect(result[0]!.accountId).toBe(TEST_ACCOUNT); + }); +}); diff --git a/apps/api/src/services/account-balance/listing.ts b/apps/api/src/services/account-balance/listing.ts index 8544c94f9..84e2ac4f0 100644 --- a/apps/api/src/services/account-balance/listing.ts +++ b/apps/api/src/services/account-balance/listing.ts @@ -2,7 +2,7 @@ import { Address } from "viem"; import { DaoIdEnum } from "@/lib/enums"; import { AmountFilter, DBAccountBalanceWithVariation } from "@/mappers"; -import { TreasuryAddresses } from "@/lib/constants"; +import { TreasuryAddresses, NonCirculatingAddresses } from "@/lib/constants"; export interface AccountBalanceRepositoryInterface { getAccountBalancesWithVariation( @@ -48,7 +48,10 @@ export class AccountBalanceService { totalCount: number; }> { const daoAddresses = excludeDaoAddresses - ? Object.values(TreasuryAddresses[daoId]) + ? [ + ...Object.values(TreasuryAddresses[daoId]), + ...Object.values(NonCirculatingAddresses[daoId]), + ] : []; return await this.repo.getAccountBalancesWithVariation( variationFromTimestamp, diff --git a/apps/api/src/services/coingecko/types.ts b/apps/api/src/services/coingecko/types.ts index 8bb6f7667..d8fb2fd62 100644 --- a/apps/api/src/services/coingecko/types.ts +++ b/apps/api/src/services/coingecko/types.ts @@ -26,6 +26,7 @@ export const CoingeckoTokenIdEnum: Record = { ZK: "zksync", SHU: "shutter", FLUID: "fluid", + TORN: "tornado-cash", } as const; export const CoingeckoIdToAssetPlatformId = { @@ -41,6 +42,7 @@ export const CoingeckoIdToAssetPlatformId = { [CoingeckoTokenIdEnum.ZK]: AssetPlatformEnum.ZKSYNC, [CoingeckoTokenIdEnum.SHU]: AssetPlatformEnum.ETHEREUM, [CoingeckoTokenIdEnum.FLUID]: AssetPlatformEnum.ETHEREUM, + [CoingeckoTokenIdEnum.TORN]: AssetPlatformEnum.ETHEREUM, } as const; export interface CoingeckoHistoricalMarketData { diff --git a/apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts new file mode 100644 index 000000000..3e48afd2e --- /dev/null +++ b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts @@ -0,0 +1,25 @@ +import ActivityFeedPage from "./page"; +import { redirect } from "next/navigation"; + +jest.mock("next/navigation", () => ({ + redirect: jest.fn(), +})); + +jest.mock("@/features/feed", () => ({ + ActivityFeedSection: () => null, +})); + +describe("ActivityFeedPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders Tornado activity feed when enabled in DAO config", async () => { + const result = await ActivityFeedPage({ + params: Promise.resolve({ daoId: "torn" }), + }); + + expect(redirect).not.toHaveBeenCalled(); + expect(result.props.feedDaoId).toBe("torn"); + }); +}); diff --git a/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx index 4dde4a986..0d209cbf6 100644 --- a/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx @@ -1,22 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { - feedEventsPathParamsDaoEnum, - type FeedEventsPathParams, -} from "@anticapture/client"; import { ActivityFeedSection } from "@/features/feed"; - -type Props = { - params: Promise<{ daoId: string }>; -}; - -const supportedDaos: FeedEventsPathParams["dao"][] = Object.values( - feedEventsPathParamsDaoEnum, -); - -const isSupportedDao = (value: string): value is FeedEventsPathParams["dao"] => - supportedDaos.includes(value as FeedEventsPathParams["dao"]); +import daoConfigByDaoId from "@/shared/dao-config"; +import { toDaoIdEnum } from "@/shared/types/daos"; export async function generateMetadata(props: Props): Promise { const params = await props.params; @@ -49,11 +36,17 @@ export default async function ActivityFeedPage({ params: Promise<{ daoId: string }>; }) { const { daoId } = await params; + const daoIdEnum = toDaoIdEnum(daoId); const feedDaoId = daoId.toLowerCase(); + const daoConfig = daoIdEnum ? daoConfigByDaoId[daoIdEnum] : undefined; - if (!isSupportedDao(feedDaoId)) { + if (!daoConfig?.activityFeed) { redirect(`/${daoId}`); } return ; } + +type Props = { + params: Promise<{ daoId: string }>; +}; diff --git a/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx b/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx index 039dc0457..765f1c753 100644 --- a/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { permanentRedirect } from "next/navigation"; import { ProposalSection } from "@/features/governance/components/proposal-overview/ProposalSection"; +import { normalizeProposalDescription } from "@/features/governance/utils/normalizeProposalDescription"; import { buildProposalSeoText } from "@/shared/seo/proposalMetadata"; import type { DaoIdEnum } from "@/shared/types/daos"; import { @@ -45,7 +46,7 @@ export async function generateMetadata(props: Props): Promise { ? isOffchainProposal(proposal) ? proposal.body : proposal.variant === "full" - ? proposal.description + ? normalizeProposalDescription(proposal.description) : undefined : undefined; diff --git a/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx b/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx index 67e7b435e..b01f646cd 100644 --- a/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx +++ b/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx @@ -1,11 +1,6 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { - feedEventsPathParamsDaoEnum, - type FeedEventsPathParams, -} from "@anticapture/client"; - import { ActivityFeedSection } from "@/features/feed"; import daoConfigByDaoId from "@/shared/dao-config"; import { toDaoIdEnum } from "@/shared/types/daos"; @@ -14,12 +9,6 @@ type Props = { params: Promise<{ daoId: string }>; }; -const supportedDaos: string[] = Object.values(feedEventsPathParamsDaoEnum); - -function isSupportedDao(value: string): value is FeedEventsPathParams["dao"] { - return supportedDaos.includes(value); -} - export async function generateMetadata({ params }: Props): Promise { const { daoId } = await params; const daoIdEnum = toDaoIdEnum(daoId); @@ -38,9 +27,11 @@ export async function generateMetadata({ params }: Props): Promise { export default async function WhitelabelActivityFeedPage({ params }: Props) { const { daoId } = await params; + const daoIdEnum = toDaoIdEnum(daoId); const feedDaoId = daoId.toLowerCase(); + const daoConfig = daoIdEnum ? daoConfigByDaoId[daoIdEnum] : undefined; - if (!isSupportedDao(feedDaoId)) { + if (!daoConfig?.activityFeed) { redirect(`/whitelabel/${daoId}`); } diff --git a/apps/dashboard/e2e/dao-overview.spec.ts b/apps/dashboard/e2e/dao-overview.spec.ts index e61854479..bea628bc4 100644 --- a/apps/dashboard/e2e/dao-overview.spec.ts +++ b/apps/dashboard/e2e/dao-overview.spec.ts @@ -47,6 +47,15 @@ test.describe("DAO Overview page (/ens)", () => { }); }); + test("hides resilience and attack exposure section when both are unavailable", async ({ + goto, + page, + }) => { + await goto("/torn"); + await expect(page.locator("text=RESILIENCE STAGES")).toHaveCount(0); + await expect(page.locator("text=ATTACK EXPOSURE")).toHaveCount(0); + }); + test("shows attack profitability chart or empty state", async ({ goto, page, diff --git a/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx b/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx index c35f128d9..88c62c54d 100644 --- a/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx +++ b/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx @@ -27,9 +27,10 @@ export const AttackProfitabilitySection = ({ attackProfitability: AttackProfitabilityConfig; }) => { const defaultDays = TimeInterval.ONE_YEAR; + const defaultCostMetric = daoId === "TORN" ? "Locked" : "Delegated"; const [days, setDays] = useState(defaultDays); const [treasuryMetric, setTreasuryMetric] = useState(`Non-${daoId}`); - const [costMetric, setCostMetric] = useState("Delegated"); + const [costMetric, setCostMetric] = useState(defaultCostMetric); const [dropdownValue, setDropdownValue] = useState