Skip to content

fix(api): repair persisted ERC-20 transfer recipients from before #2252 - #2259

Open
tony8713 wants to merge 1 commit into
masterfrom
fix/api-repair-erc20-transfer-recipients
Open

fix(api): repair persisted ERC-20 transfer recipients from before #2252#2259
tony8713 wants to merge 1 commit into
masterfrom
fix/api-repair-erc20-transfer-recipients

Conversation

@tony8713

@tony8713 tony8713 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #2252, which fixed the decoder but deliberately left the already-written rows alone. ENS (@gregskril) is still seeing the wrong recipient on their proposal, because the wrong value is persisted.

Why the symptom outlived the fix

The indexer decodes the calldata once, at index time, and writes the result into proposalmetadataitems.execution:

  • apps/api/src/evm/protocols/openzeppelin/writers.ts:431-442
  • apps/api/src/evm/protocols/governor-bravo/writers.ts:372-393

So #2252 corrects proposals indexed after it deployed (api deployed from cb368c0e on 2026-08-04 15:01 UTC), and nothing else. No governor proposal has been created since, so the fix has had no occasion to show itself, and every historical row still carries the pre-fix value.

api.snapshot.box today, for ENS proposal [Executable] Next Era of ENS DAO: Empowering the ENS Foundation:

"_form": { "recipient": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", ... }
"data": "0xa9059cbb0000000000000000000000009c7db6b1085ec4d07f75c0bd91ad3fcd368fa19e00000000000000000000000000000000000000000000d3c21bcecceda1000000"

0xC18360... is the ENS token itself. The calldata says 0x9C7dB6B1085ec4D07f75c0BD91AD3FcD368fA19E, the ENS DAO Safe (v1.4.1, 3-of-5).

Scope

Measured against the live API: 623 proposals across the 6 governor spaces, 184 sendToken transactions, of which 140 decode from an ERC-20 transfer and all 140 are wrong, spread over 96 proposals.

space proposals transactions
Uniswap 0x408ED635… 37 38
Compound 0xc0Da0293… 34 42
ENS 0x323A7639… 24 59
Arbitrum Core 0x789fC990… 1 1
total 96 140

The 43 native-ETH sendToken entries (data === '0x') were always correct, because there the call target genuinely is the recipient.

What this does

repairErc20TransferRecipients.ts reads the affected rows from the public API (no credentials) and emits guarded SQL on stdout:

bun run src/scripts/repairErc20TransferRecipients.ts > repair.sql
psql "$DATABASE_URL" -f repair.sql

Each statement rewrites exactly one JSON path:

UPDATE proposalmetadataitems
SET execution = jsonb_set(execution::jsonb, '{0,_form,recipient}', '"0x9C7dB6B1085ec4D07f75c0BD91AD3FcD368fA19E"'::jsonb)::text
WHERE id = '0x323A76393544d5ecca80cd6ef2A560C6a395b7E3/80619211450810140112687536515944199882433060764177806587986222097717655810120_metadata'
  AND upper_inf(block_range)
  AND execution::jsonb #>> '{0,_form,recipient}' = '0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72';
  • to, data, value, salt and the token metadata are untouched. Only the displayed payee moves.
  • Live version only. upper_inf(block_range) leaves checkpoint's closed history alone; we are correcting a decode, not recording a new observation, so no new version is inserted.
  • Idempotent and safe to re-run. The guard is the recipient we expect to find, so a row already repaired, or since re-indexed correctly, matches nothing and is skipped.
  • Malformed calldata is skipped, not guessed at. A transfer payload has to be exactly a selector plus two 32-byte words.

Currently 140 statements, 53 KB.

Verified against real data

Not reviewed by reading: the 96 real production rows were loaded into a local Postgres 16 and the generated SQL applied.

run 1: 140 statements, each "UPDATE 1"
run 2: 140 statements, each "UPDATE 0"        -- idempotent
=== ENS live row, tx 0
recipient                                  | to_field                                   | value | amount                    | token
0x9C7dB6B1085ec4D07f75c0BD91AD3FcD368fA19E | 0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72 | 0     | 1000000000000000000000000 | 0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72

=== ENS row, closed block_range [50,100) -- history preserved
0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72

=== live sendToken rows still naming their own token contract
0     (was 140)

=== live ERC-20 transfers whose recipient equals the calldata argument
correct 140 | incorrect 0

The one row deliberately left alone

Uniswap Governor Bravo proposal 81 stores a doubled selector:

0xa9059cbba9059cbb0000000000000000000000005069a64b…

That is not our bug. getActions(81) on chain returns signature transfer(address,uint256) and a calldata that already carries the selector, and Governor Bravo's execute prepends the sighash from the signature, so the payload the Governor itself executes is double-prefixed. governor-bravo/writers.ts faithfully reproduces it. The proposal is malformed on chain; the stored data is right. Its recipient and amount are both meaningless, and the strict length check leaves them as they are rather than inventing an address.

Worth a separate change: decodeExecution should probably reject a transfer whose calldata is not 68 bytes and fall back to raw, instead of rendering a fabricated recipient and a nonsense amount. Not in this PR.

Execution was never at risk

Worth stating explicitly, since a wrong payee is alarming: _form is display data. queue / execute read only top-level to / value / data, all carried through verbatim from the on-chain ProposalCreated event (packages/sx.js/src/clients/openzeppelin/ethereum-tx/index.ts:96-98, 122-124). The ENS proposal's stored to/data/value are byte-identical to the on-chain propose() args.

The one place _form.recipient becomes a real transaction is the Safe batch export, apps/ui/src/helpers/safe/ build.ts:38-42, which re-encodes transfer(recipient, amount) and drops data. It is unreachable here: ProposalExecutionsList.vue:83 only renders that button for strategyType === 'ReadOnlyExecution', which is produced solely by the offchain plugin path, whose transactions come from the proposal author verbatim and never through this decoder. Governor proposals are OpenZeppelinTimelockController / GovernorBravoTimelock.

Tests

cd apps/api && bun run test
✓ src/scripts/repairErc20TransferRecipients.test.ts (13 tests)
  Test Files  4 passed (4)
       Tests  36 passed (36)

Fixtures are the verbatim production rows: the ENS one, the malformed Uniswap 81 one, a native-ETH send, and an already-correct row. Repo-wide bun run build, typecheck, lint, lint:unused and test are green.

🤖 Generated with Claude Code

#2252 fixed the decoder, but the decoded execution is persisted into
proposalmetadataitems.execution at index time, so it only corrected
proposals indexed after the deploy. The 140 ERC-20 transfers already
written across 96 proposals in the 6 governor spaces still name the token
contract as the payee, which is what ENS is still seeing on their
proposal.

Add a script that re-derives each recipient from the calldata already
stored on the row and emits guarded UPDATE statements on stdout. Only
_form.recipient is rewritten; to, data, value and the token metadata are
untouched. Each statement is scoped to the live version of the row
(upper_inf(block_range)) and guarded on the recipient it expects to find,
so re-running is a no-op and a row that has since been re-indexed
correctly is skipped.

A transfer whose calldata is not exactly a selector plus two words is
left alone rather than given an invented recipient. Uniswap Governor
Bravo proposal 81 is the one such row: it supplies both a
transfer(address,uint256) signature and a calldata that already carries
the selector, so what the Governor executes is double-prefixed and is not
a well-formed transfer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant