Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
56 changes: 56 additions & 0 deletions apps/indexer/src/eventHandlers/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,59 @@ export const delegatedVotesChanged = async (
timestamp,
});
};

/**
* Tornado Cash voting power is the account's `lockedBalance` in the Governance
* contract — there is NO DelegateVotesChanged event. We derive each account's
* voting power from lock/unlock token Transfers (TORN moving in/out of the
* governor pre-v2 or the TornadoVault post-v2). `delta` is +amount on lock and
* -amount on unlock. Deterministic, no external calls (indexer-skill safe).
*
* NOTE (future refinement): the exact source of truth is `lockedBalance(account)`
* read on the `RewardUpdateSuccessful`/`RewardUpdateFailed` events. The
* Transfer-derived value here matches it unless a lock/unlock occurs without a
* net token movement (not possible for lock/unlock today). See RESEARCH.md §5.
*/
export const lockedVotingPowerChanged = async (
context: Context,
daoId: DaoIdEnum,
args: {
account: Address;
delta: bigint;
txHash: Hex;
timestamp: bigint;
logIndex: number;
},
) => {
const { account, delta, txHash, timestamp, logIndex } = args;
const normalizedAccount = getAddress(account);

await ensureAccountExists(context, account);

const { votingPower: newBalance } = await context.db
.insert(accountPower)
.values({
accountId: normalizedAccount,
daoId,
votingPower: delta > 0n ? delta : 0n,
})
.onConflictDoUpdate((current) => ({
votingPower: current.votingPower + delta,
}));

const deltaMod = delta > 0n ? delta : -delta;

await context.db
.insert(votingPowerHistory)
.values({
daoId,
transactionHash: txHash,
accountId: normalizedAccount,
votingPower: newBalance,
delta,
deltaMod,
timestamp,
logIndex,
})
.onConflictDoNothing();
};
45 changes: 40 additions & 5 deletions apps/indexer/src/indexer/torn/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,32 @@ Governor voting token: Voting power comes from `lockedBalance` in the Governance

## What's Pending

### No per-account votingPowerHistory

The standard pattern relies on `DelegateVotesChanged` events which TORN doesn't emit. Voting power = `lockedBalance` in the governor. We track aggregate `delegatedSupply` (total locked TORN) but individual `votingPowerHistory` records are not populated.

**To close this gap:** Detect Transfer events to/from the governance contract and create synthetic `votingPowerHistory` entries. Requires careful handling when delegation shifts occur (voting power moves between accounts without a Transfer).
### Per-account voting power — ADDRESSED (requires reindex)

Voting power = `lockedBalance` in the governor; TORN emits no `DelegateVotesChanged`.
It is now derived in `erc20.ts` from lock/unlock token Transfers: TORN moving **into**
governance custody (governor pre-v2, **TornadoVault** post-v2) is a lock crediting the
sender's voting power; moving **out** is an unlock debiting the receiver. This populates
`accountPower.votingPower` + `votingPowerHistory` via `lockedVotingPowerChanged`
(`eventHandlers/delegation.ts`) and also fixes `delegatedSupply` to include the Vault
(previously governor-only — it missed ~2.6M TORN held in the Vault).

- **Requires a full reindex** to take effect.
- **Verify after reindex** with the reconciliation script (`delegatedSupply` ≈ Σ `lockedBalance`;
sampled `accountPower.votingPower` == on-chain `lockedBalance(account)`).
- **Future refinement (deferred):** the exact source of truth is `lockedBalance(account)` read on
`RewardUpdateSuccessful`/`RewardUpdateFailed`. The Transfer-derived value matches it for normal
lock/unlock; switch to event+`readContract` only if a discrepancy is found.

### Re-vote vote-tally drift — DEFERRED (needs indexer test)

`_castVote` lets a voter overwrite a prior vote, but the `Voted` handler uses
`onConflictDoNothing` on `(voter, proposalId)` (to avoid Ponder's batch-flush
`DelayedInsertError`), so a genuine re-vote is dropped and tallies can drift from chain.
A correct fix must reverse the old receipt and apply the new one **while staying
idempotent on replay** (e.g. dedupe on `(txHash, logIndex)`), which needs an indexer
backfill run to confirm it doesn't re-introduce the crash. Re-votes appear infrequent;
deferred until it can be tested. (Also flagged in review on #2002.)

### No abstain votes

Expand All @@ -47,6 +68,20 @@ Tornado Cash does NOT emit events for state transitions between Pending, Active,

The `TornadoStakingRewards` contract (0x5B3f656C80E8ddb9ec01Dd9018815576E9238c29) distributes relayer fees to locked TORN holders. This economic dimension is not captured. It doesn't affect governance voting directly but represents additional yield for participants.

### Treasury accounting — VERIFY (deferred)

`TreasuryAddresses[TORN]` is currently empty, so the indexed `treasury` supply metric is 0.
Tornado's treasury is non-trivial: the governance contract holds ~4.73M TORN that **commingles**
DAO-owned treasury with pre-v2 user locks, so its balance is *not* a clean treasury figure
(adding the governor to `TreasuryAddresses` would overcount by the locked amount). The real
treasury ≈ governance-owned TORN that does not back any account's `lockedBalance`, plus ETH.

- **Before relying on attack-profitability:** confirm whether that view uses the on-chain
*liquid treasury call* (`attackProfitability.supportsLiquidTreasuryCall: true`) or the indexed
`treasury` metric. If the latter, treasury will read 0 until this is resolved.
- A correct figure likely needs a dedicated computation (governance TORN balance − Σ `lockedBalance`
custodied in the governor, + ETH/other assets). Deferred as a TORN-specific specificity.

### Proposal target decoding

Proposals are deployed contracts executed via `delegatecall` from the Governance contract. We index the `target` address but cannot decode what the proposal does without reading the target contract's bytecode. `alreadySupportCalldataReview` is set to `false`.
Expand Down
41 changes: 34 additions & 7 deletions apps/indexer/src/indexer/torn/erc20.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ponder } from "ponder:registry";
import { token } from "ponder:schema";
import { Address, getAddress } from "viem";

import { tokenTransfer } from "@/eventHandlers";
import { tokenTransfer, lockedVotingPowerChanged } from "@/eventHandlers";
import {
updateDelegatedSupply,
updateCirculatingSupply,
Expand All @@ -26,6 +26,11 @@ export function TORNTokenIndexer(address: Address, decimals: number) {
const governorAddress = getAddress(
CONTRACT_ADDRESSES[DaoIdEnum.TORN].governor.address,
);
// Post-v2, locked TORN is custodied in the TornadoVault, not the governor
// (GovernanceVaultUpgrade._transferTokens). Both are lock "sinks": a Transfer
// into either is a lock; out of either is an unlock. Pre-v2 used the governor.
// https://etherscan.io/address/0x2F50508a8a3D323B91336FA3eA6Ae50e55f32185
const vaultAddress = getAddress("0x2F50508a8a3D323B91336FA3eA6Ae50e55f32185");

ponder.on("TORNToken:setup", async ({ context }) => {
await context.db.insert(token).values({
Expand Down Expand Up @@ -147,18 +152,40 @@ export function TORNTokenIndexer(address: Address, decimals: number) {

await updateCirculatingSupply(context, daoId, address, timestamp);

// Track locks/unlocks: transfers to/from the governance contract
// Track locks/unlocks: TORN moving in/out of governance custody (the
// governor pre-v2, the TornadoVault post-v2). The non-custody side is the
// user whose lockedBalance (voting power) changes. We update both the
// aggregate delegatedSupply and the per-account voting power.
const normalizedTo = getAddress(to);
const normalizedFrom = getAddress(from);

if (normalizedTo === governorAddress) {
// Locking TORN into governance
const toIsSink =
normalizedTo === governorAddress || normalizedTo === vaultAddress;
const fromIsSink =
normalizedFrom === governorAddress || normalizedFrom === vaultAddress;

// Lock: tokens move INTO custody from a user (ignore internal
// governor<->vault moves such as the v2 migration).
if (toIsSink && !fromIsSink) {
await updateDelegatedSupply(context, daoId, address, value, timestamp);
await lockedVotingPowerChanged(context, daoId, {
account: from,
delta: value,
txHash: event.transaction.hash,
timestamp,
logIndex: event.log.logIndex,
});
}

if (normalizedFrom === governorAddress) {
// Unlocking TORN from governance
// Unlock: tokens move OUT of custody to a user.
if (fromIsSink && !toIsSink) {
await updateDelegatedSupply(context, daoId, address, -value, timestamp);
await lockedVotingPowerChanged(context, daoId, {
account: to,
delta: -value,
Comment on lines +180 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't classify every custody outflow as an unlock

When a proposal transfers TORN out of the governor/vault, this is not necessarily a user unlock: Tornado proposals execute by delegatecall and can move treasury/attack-recovery funds, and the 2023 exploit also changed locked balances in storage before draining custody. In those cases this branch debits the recipient with delta: -value even though the recipient was never credited by a lock Transfer, so a full TORN reindex can write negative accountPower.votingPower and corrupt active-supply/history views. Key this off an actual lockedBalance change or governance event/read rather than raw Transfer direction.

Useful? React with 👍 / 👎.

txHash: event.transaction.hash,
timestamp,
logIndex: event.log.logIndex,
});
}
});
}