What's wrong
oneinch.project_swaps (and the per-chain oneinch_<chain>.project_swaps) contains two rows for the same swap when that swap is settled against a 1inch limit/Fusion order: one row with intent = false / mode = 'intra-chain: classic: direct' and no order attached, and one row with intent = true / mode = 'intra-chain: intents: auction' carrying a real order_hash.
Both rows carry the same tx_hash, the same call_trace_address and the same amount_usd, so any consumer that sums amount_usd — market-share reporting being the obvious one — double-counts that volume.
They differ in call_trade_id: the classic row uses tx_hash || call_trace_address, the intent row uses order_hash. Since call_trade_id is part of the model's merge unique_key
unique_key = ['blockchain', 'block_month', 'block_number', 'tx_hash', 'second_side', 'call_trace_address', 'call_trade_id']
the later write cannot replace the earlier one, and both survive.
Scale (ethereum, measured 2026-08-21)
Counting affected pairs per day over 2026-06-01 .. 2026-08-20:
- 52 affected days, 1,191 pairs, ~$3.15M of double-counted
amount_usd — ethereum, project = '1inch', second_side = false only.
- Every pair has
min(amount_usd) = max(amount_usd) within the pair, i.e. it is a plain double count, not two legs of one trade.
- Nothing before 2026-06-24; every day from 2026-06-24 onward is affected except a handful.
- Other chains on the same day (2026-08-19): base 12 pairs, bnb 1, polygon 1.
The striking part is when the pairs occur. On each day they all fall inside a ~10-minute window, and that window moves as a block over time:
| period |
window (UTC) |
| 2026-06-24 .. 06-29 |
06:08 – 06:20 |
| 2026-06-30 .. 07-01 |
05:11 – 05:19 |
| 2026-07-05 .. 07-07 |
04:10 – 04:16 |
| 2026-07-08 .. 2026-08-20 |
03:01 – 03:18 |
(two days also show a single second cluster: 2026-07-07 at 14:16 and 2026-08-06 at 15:15)
Hypothesis — explicitly not verified
The clustering behaves like a scheduled job rather than like a property of the data, including the step changes in time-of-day. That fits a daily incremental run boundary: a swap whose lo order has not landed in oneinch_<chain>.lo at run time gets written as classic, and is written again as intent on a later run under a different call_trade_id, leaving the classic row behind.
I cannot verify this — it needs visibility into the scheduler that runs daily_spellbook. Everything above the line is measured; this paragraph is a guess that fits the measurements.
Verified against the source: for the affected rows, the intent-side order_hash values do exist in oneinch_ethereum.lo at the same block_number, so the order-attached row is the correct representation and the classic row is the stale one.
Reproduce
select
date(block_time) as block_date,
count(*) as pairs,
round(sum(usd)) as usd_counted_twice,
min(block_time) as first_pair,
max(block_time) as last_pair
from (
select
block_number, tx_hash, call_trace_address,
min(amount_usd) as usd,
min(block_time) as block_time
from oneinch_ethereum.project_swaps
where project = '1inch'
and second_side = false
and block_time >= timestamp '2026-06-01'
and block_time < timestamp '2026-08-21'
group by 1, 2, 3
having count(*) > 1
and count(distinct intent) > 1
)
group by 1
order by 1
To see a single pair side by side (2026-08-19):
select intent, mode, call_trade_id, order_hash, amount_usd, "user"
from oneinch_ethereum.project_swaps
where tx_hash = 0x041dccda14489aecd52fdd0ba51a6ec6aaaaedc3d863602c83fe0544452e0248
How this was found
While porting the project_orders / project_swaps tree into our own dbt project, we reconciled our ethereum build against the live Spellbook tables for 2026-08-19. Everything matched to the cent on 24 of 26 projects; the only unexplained gap was 1inch being 17 rows / $24,735 lower on our side, with the same distinct-tx count. Those 17 rows turned out to be these duplicates. A fresh full-window build produces only the order-attached row, which is what pointed at the incremental path rather than the decode logic.
Possible fixes
I did not open a PR because the right fix depends on intent (pun accepted) that maintainers know better than I do:
- dedupe on
(tx_hash, call_trace_address) after the merge, preferring the order-attached row;
- drop
call_trade_id from unique_key, so the intent row overwrites the classic one;
- hold the incremental window back far enough that
lo is always settled before the swap is written.
Happy to send a PR for whichever direction you prefer, and to add a dbt test that fails on a classic+intent pair so this cannot come back silently.
What's wrong
oneinch.project_swaps(and the per-chainoneinch_<chain>.project_swaps) contains two rows for the same swap when that swap is settled against a 1inch limit/Fusion order: one row withintent = false/mode = 'intra-chain: classic: direct'and no order attached, and one row withintent = true/mode = 'intra-chain: intents: auction'carrying a realorder_hash.Both rows carry the same
tx_hash, the samecall_trace_addressand the sameamount_usd, so any consumer that sumsamount_usd— market-share reporting being the obvious one — double-counts that volume.They differ in
call_trade_id: the classic row usestx_hash || call_trace_address, the intent row usesorder_hash. Sincecall_trade_idis part of the model's mergeunique_keythe later write cannot replace the earlier one, and both survive.
Scale (ethereum, measured 2026-08-21)
Counting affected pairs per day over 2026-06-01 .. 2026-08-20:
amount_usd— ethereum,project = '1inch',second_side = falseonly.min(amount_usd) = max(amount_usd)within the pair, i.e. it is a plain double count, not two legs of one trade.The striking part is when the pairs occur. On each day they all fall inside a ~10-minute window, and that window moves as a block over time:
(two days also show a single second cluster: 2026-07-07 at 14:16 and 2026-08-06 at 15:15)
Hypothesis — explicitly not verified
The clustering behaves like a scheduled job rather than like a property of the data, including the step changes in time-of-day. That fits a daily incremental run boundary: a swap whose
loorder has not landed inoneinch_<chain>.loat run time gets written as classic, and is written again as intent on a later run under a differentcall_trade_id, leaving the classic row behind.I cannot verify this — it needs visibility into the scheduler that runs
daily_spellbook. Everything above the line is measured; this paragraph is a guess that fits the measurements.Verified against the source: for the affected rows, the intent-side
order_hashvalues do exist inoneinch_ethereum.loat the sameblock_number, so the order-attached row is the correct representation and the classic row is the stale one.Reproduce
To see a single pair side by side (2026-08-19):
How this was found
While porting the
project_orders/project_swapstree into our own dbt project, we reconciled our ethereum build against the live Spellbook tables for 2026-08-19. Everything matched to the cent on 24 of 26 projects; the only unexplained gap was1inchbeing 17 rows / $24,735 lower on our side, with the same distinct-tx count. Those 17 rows turned out to be these duplicates. A fresh full-window build produces only the order-attached row, which is what pointed at the incremental path rather than the decode logic.Possible fixes
I did not open a PR because the right fix depends on intent (pun accepted) that maintainers know better than I do:
(tx_hash, call_trace_address)after the merge, preferring the order-attached row;call_trade_idfromunique_key, so the intent row overwrites the classic one;lois always settled before the swap is written.Happy to send a PR for whichever direction you prefer, and to add a dbt test that fails on a classic+intent pair so this cannot come back silently.