Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
451db65
feat: integrate tornado cash
pikonha Jun 28, 2026
c9f71b5
Merge branch 'dev' into feat/tornado-cash-integration
pikonha Jun 29, 2026
f542b75
chore: enalbe offchain api returning 400 for the not-supporte ones
pikonha Jun 29, 2026
753d616
docs(torn): add RESEARCH.md — DAO anatomy, tech funnel & remaining-ga…
Zeugh-eth Jun 29, 2026
53cd4e8
docs(torn): apply Codex review — fix funnel order & white-label config
Zeugh-eth Jun 29, 2026
3666793
docs(torn): apply Codex round 2 — vault lock custody & date-scope bac…
Zeugh-eth Jun 29, 2026
5ececaa
docs(torn): apply Codex round 3 — prefix-spoof framing & Vault TODO
Zeugh-eth Jun 29, 2026
7605886
docs(torn): apply Codex round 4 — watch both RewardUpdate outcomes
Zeugh-eth Jun 29, 2026
2a69466
fix(torn): correct quorum calc + governance-config facts (review foll…
Zeugh-eth Jun 29, 2026
4e60b18
fix(torn): reconcile dashboard with quorum + re-vote changes (Codex)
Zeugh-eth Jun 29, 2026
f317b16
feat(torn): per-account voting power + Vault lock custody (gap #6)
Zeugh-eth Jun 30, 2026
b49cacf
docs(torn): note treasury accounting specificity (deferred)
Zeugh-eth Jun 30, 2026
06de6fd
Merge branch 'feat/torn-voting-power' into integration/tornado-cash
Zeugh-eth Jun 30, 2026
5026eee
Merge branch 'docs/torn-research' into integration/tornado-cash
Zeugh-eth Jun 30, 2026
8848fae
style(torn): single-line vaultAddress (prettier/lint)
Zeugh-eth Jun 30, 2026
83382f3
Merge branch 'feat/torn-voting-power' into integration/tornado-cash
Zeugh-eth Jun 30, 2026
e0f75d6
test(torn): add reconciliation smoke-test script (production gate)
Zeugh-eth Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tornado-cash-integration.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 22 additions & 26 deletions apps/api/cmd/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ if (!daoClient) {
}

const pgClient = drizzle(env.DATABASE_URL, {
schema,
schema: { ...schema, ...offchainSchema },
casing: "snake_case",
});

Expand Down Expand Up @@ -396,32 +396,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(
{
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./uni";
export * from "./shu";
export * from "./aave";
export * from "./fluid";
export * from "./torn";

export interface DAOClient {
getDaoId: () => string;
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/clients/torn/abi.ts
Original file line number Diff line number Diff line change
@@ -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;
203 changes: 203 additions & 0 deletions apps/api/src/clients/torn/index.ts
Original file line number Diff line number Diff line change
@@ -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<TTransport, TChain, TAccount>, address: Address) {
super(client);
this.address = address;
this.abi = TORNGovernorAbi;
}

getDaoId(): string {
return "TORN";
}

async getQuorum(_proposalId: string | null): Promise<bigint> {
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<bigint> {
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<bigint> {
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<bigint> {
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<bigint> {
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<bigint> {
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<string> {
// 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;
}
}
16 changes: 16 additions & 0 deletions apps/api/src/controllers/proposals/offchainProposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { OffchainProposalsService } from "@/services";
export function offchainProposals(
app: Hono,
service: OffchainProposalsService,
supportOffchain: boolean,
) {
app.openapi(
createRoute({
Expand All @@ -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");

Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/controllers/votes/offchainNonVoters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { OffchainNonVotersService } from "@/services";
export function offchainNonVoters(
app: Hono,
service: OffchainNonVotersService,
supportOffchain: boolean,
) {
app.openapi(
createRoute({
Expand All @@ -35,6 +36,14 @@ export function offchainNonVoters(
},
},
},
400: {
description: "Offchain data not supported",
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
},
404: {
description: "Proposal not found",
content: {
Expand All @@ -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");
Expand Down
Loading
Loading