Skip to content

Commit 1ec9274

Browse files
committed
fix issues with arm swap renderer, utlizie transaction_logs field in config
1 parent 1a0dfae commit 1ec9274

6 files changed

Lines changed: 63 additions & 141 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
66

77
This is a Subsquid-based notification system for Origin Protocol. It monitors blockchain events and traces across Ethereum mainnet, Base, and Sonic chains, persists them to a database, and sends notifications to Discord webhooks based on configurable alert rules stored in a separate Postgres database.
88

9+
## User Interaction
10+
11+
You are encouraged to ask the user questions in order to save time and effort.
12+
913
## Common Commands
1014

1115
```shell

src/alert-config/config-loader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const loadRules = async (): Promise<AlertRule[]> => {
118118
topic: row.topic as Topic,
119119
severity: row.severity as Severity, // DB enum guarantees valid value
120120
notifyTargets: row.notify_targets,
121+
transactionLogs: row.transaction_logs ?? false,
121122
displayName: row.display_name,
122123
description: row.description,
123124
}),

src/alert-config/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ export interface AlertRule {
103103
severity: Severity
104104
notifyTargets: NotifyTarget | null
105105

106+
// Subscription options
107+
transactionLogs: boolean
108+
106109
// Display
107110
displayName: string | null
108111
description: string | null

src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { persistenceProcessor } from './processors/persistence';
1919
import { load } from './topics';
2020

2121

22-
const from = 21_540_000
22+
const from = Number(process.env.BLOCK_FROM) || 21_540_000
2323
process.env.BLOCK_FROM = from.toString()
2424

2525
const start = async () => {

src/notify/event/renderers/arm.ts

Lines changed: 53 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { ContractBase, event, viewFun } from '@subsquid/evm-abi'
1+
import { event } from '@subsquid/evm-abi'
22
import * as p from '@subsquid/evm-codec'
33

44
import * as erc20Abi from '../../../abi/erc20'
55
import { addresses, arms } from '../../../utils/addresses'
66
import { sonicAddresses } from '../../../utils/addresses/addresses-sonic'
77
import { getAddressesPyName } from '../../../utils/addresses/names'
88
import { formatAmount } from '../../../utils/formatAmount'
9+
import { discordIconOrName } from '../../const'
910
import { explorerUrl } from '../../format'
1011
import { registerEventRenderer } from '../event'
1112
import { defaultEventRenderer } from './default'
@@ -17,39 +18,12 @@ const TraderateChanged = event(
1718
{ traderate0: p.uint256, traderate1: p.uint256 },
1819
)
1920

20-
const armFunctions = {
21-
traderate0: viewFun('0x45059a6b', 'traderate0()', {}, p.uint256),
22-
traderate1: viewFun('0xcf1de5d8', 'traderate1()', {}, p.uint256),
23-
}
24-
25-
const erc4626Functions = {
26-
convertToAssets: viewFun('0x07a2d13a', 'convertToAssets(uint256)', { shares: p.uint256 }, p.uint256),
27-
}
28-
29-
class ArmContract extends ContractBase {
30-
traderate0() {
31-
return this.eth_call(armFunctions.traderate0, {})
32-
}
33-
traderate1() {
34-
return this.eth_call(armFunctions.traderate1, {})
35-
}
36-
}
37-
38-
class ERC4626Contract extends ContractBase {
39-
convertToAssets(shares: bigint) {
40-
return this.eth_call(erc4626Functions.convertToAssets, { shares })
41-
}
42-
}
43-
4421
interface ArmConfig {
4522
address: string
4623
symbol0: string
4724
symbol1: string
4825
token0: string
4926
token1: string
50-
// ERC4626 vault token (e.g. sUSDe). When set, traderates are raw spread rates
51-
// and the vault's convertToAssets rate must be applied for output estimation.
52-
vaultToken?: string
5327
}
5428

5529
// Build lookup from ARM contract address to config
@@ -81,7 +55,6 @@ registerArm({
8155
symbol1: 'sUSDe',
8256
token0: addresses.tokens.USDe,
8357
token1: addresses.tokens.sUSDe,
84-
vaultToken: addresses.tokens.sUSDe,
8558
})
8659

8760
registerArm({
@@ -92,60 +65,13 @@ registerArm({
9265
token1: sonicAddresses.tokens.OS,
9366
})
9467

95-
// Cache traderates per ARM address
96-
const traderateCache = new Map<string, { traderate0: bigint; traderate1: bigint }>()
97-
98-
// Cache vault exchange rate (e.g. sUSDe → USDe via convertToAssets)
99-
const vaultRateCache = new Map<string, bigint>()
100-
const ONE = 10n ** 18n
101-
102-
type RendererCtx = Parameters<Parameters<typeof registerEventRenderer>[1]>[0]['ctx']
103-
type RendererBlock = Parameters<Parameters<typeof registerEventRenderer>[1]>[0]['log']['block']
104-
105-
async function getVaultRate(ctx: RendererCtx, block: RendererBlock, vaultToken: string): Promise<bigint> {
106-
const cached = vaultRateCache.get(vaultToken)
107-
if (cached) return cached
108-
try {
109-
const contract = new ERC4626Contract(ctx, block, vaultToken)
110-
const rate = await contract.convertToAssets(ONE)
111-
vaultRateCache.set(vaultToken, rate)
112-
return rate
113-
} catch {
114-
return ONE // Fallback: assume 1:1
115-
}
116-
}
117-
118-
async function getTraderates(
119-
ctx: RendererCtx,
120-
block: RendererBlock,
121-
armAddress: string,
122-
): Promise<{ traderate0: bigint; traderate1: bigint } | undefined> {
123-
const cached = traderateCache.get(armAddress)
124-
if (cached) return cached
125-
try {
126-
const contract = new ArmContract(ctx, block, armAddress)
127-
const [traderate0, traderate1] = await Promise.all([contract.traderate0(), contract.traderate1()])
128-
const rates = { traderate0, traderate1 }
129-
traderateCache.set(armAddress, rates)
130-
return rates
131-
} catch {
132-
return undefined
133-
}
134-
}
135-
13668
// TraderateChanged renderer — shows raw ARM spread rates
13769
registerEventRenderer(TraderateChanged.topic, async (params) => {
13870
const arm = armConfigs.get(params.log.address.toLowerCase())
13971
if (!arm) return defaultEventRenderer(params)
14072

14173
const data = TraderateChanged.decode(params.log)
14274

143-
// Cache for swap calculations
144-
traderateCache.set(params.log.address.toLowerCase(), {
145-
traderate0: data.traderate0,
146-
traderate1: data.traderate1,
147-
})
148-
14975
const sellRate = 10n ** 72n / data.traderate0 // 1e36 precision
15076
const buyRate = data.traderate1 // 1e36 precision
15177
const rateLabel = `${arm.symbol1}/${arm.symbol0}`
@@ -164,7 +90,9 @@ registerEventRenderer(TraderateChanged.topic, async (params) => {
16490
})
16591
})
16692

167-
// Transfer renderer — renders ARM swaps, falls back to default for non-ARM transfers
93+
// Transfer renderer — renders ARM swaps using actual transaction logs.
94+
// Requires transactionLogs: true on the alert rule to access sibling logs.
95+
// Falls back to default for non-ARM transfers or deposits/withdrawals.
16896
registerEventRenderer(erc20Abi.events.Transfer.topic, async (params) => {
16997
// Check if topic2 (the `to` address) is a known ARM
17098
const topic2 = params.log.topics[2]
@@ -174,83 +102,68 @@ registerEventRenderer(erc20Abi.events.Transfer.topic, async (params) => {
174102
const arm = armConfigs.get(toAddress)
175103
if (!arm) return defaultEventRenderer(params)
176104

177-
const data = erc20Abi.events.Transfer.decode(params.log)
178-
const tokenAddress = params.log.address.toLowerCase()
179-
const isToken0In = tokenAddress === arm.token0.toLowerCase()
180-
const isToken1In = tokenAddress === arm.token1.toLowerCase()
181-
if (!isToken0In && !isToken1In) return defaultEventRenderer(params)
105+
// Need transaction logs to find the counterpart transfer
106+
const txLogs = params.log.transaction?.logs
107+
if (!txLogs) return defaultEventRenderer(params)
182108

183-
const inSymbol = isToken0In ? arm.symbol0 : arm.symbol1
184-
const outSymbol = isToken0In ? arm.symbol1 : arm.symbol0
109+
// Find the transfer IN (to ARM) and transfer OUT (from ARM) in this transaction
110+
const transferInLog = txLogs.find(
111+
(l) => l.topics[0] === erc20Abi.events.Transfer.topic && l.topics[2] && ('0x' + l.topics[2].slice(26)).toLowerCase() === arm.address.toLowerCase(),
112+
)
113+
const transferOutLog = txLogs.find(
114+
(l) => l.topics[0] === erc20Abi.events.Transfer.topic && l.topics[1] && ('0x' + l.topics[1].slice(26)).toLowerCase() === arm.address.toLowerCase(),
115+
)
185116

186-
const rates = await getTraderates(params.ctx, params.log.block, toAddress)
187-
const explorer = explorerUrl(params.ctx.chain)
117+
// If we don't have both in and out, it's a deposit/withdrawal — not a swap
118+
if (!transferInLog || !transferOutLog) return
188119

189-
// Source address from topic1 (the `from` field)
190-
const fromAddress = params.log.topics[1] ? ('0x' + params.log.topics[1].slice(26)).toLowerCase() : undefined
191-
const sourceName = fromAddress
192-
? getAddressesPyName(fromAddress) ?? `${fromAddress.slice(0, 6)}...${fromAddress.slice(-4)}`
193-
: undefined
120+
// Only render once per swap (on the inbound transfer)
121+
if (params.log.logIndex !== transferInLog.logIndex) return
194122

195-
const fields: { name: string; value: string; inline?: boolean }[] = [
196-
{
197-
name: formatAmount(data.value, 18, { maximumFractionDigits: 6 }),
198-
value: `${inSymbol} in`,
199-
inline: true,
200-
},
201-
]
123+
const transferInData = erc20Abi.events.Transfer.decode(transferInLog)
124+
const transferOutData = erc20Abi.events.Transfer.decode(transferOutLog)
202125

203-
if (rates) {
204-
// Traderates are raw spread rates (~1.0). For vault tokens (e.g. sUSDe),
205-
// the ARM contract applies the vault conversion internally during swaps,
206-
// so we must apply it here too for output estimation.
207-
//
208-
// traderate0: for 1 token0 from trader, how many token1 the pool sends (raw)
209-
// traderate1: for 1 token1 from trader, how many token0 the pool sends (raw)
210-
let outAmount: bigint
126+
const explorer = explorerUrl(params.ctx.chain)
211127

212-
if (isToken0In) {
213-
// token0 → token1: traderate0 is a price (token0/token1), so divide
214-
// For vault: further divide by vaultRate (fewer vault tokens per unit)
215-
outAmount = (data.value * 10n ** 36n) / rates.traderate0
216-
if (arm.vaultToken) {
217-
const vaultRate = await getVaultRate(params.ctx, params.log.block, arm.vaultToken)
218-
outAmount = (outAmount * ONE) / vaultRate
219-
}
220-
} else {
221-
// token1 → token0: outAmount = inAmount * traderate1 / 1e36
222-
// For vault: actual token0 out = inAmount * vaultRate * traderate1 / 1e36 / 1e18
223-
outAmount = (data.value * rates.traderate1) / 10n ** 36n
224-
if (arm.vaultToken) {
225-
const vaultRate = await getVaultRate(params.ctx, params.log.block, arm.vaultToken)
226-
outAmount = (outAmount * vaultRate) / ONE
227-
}
228-
}
128+
// Source address from the inbound transfer's `from` field
129+
const fromAddress = transferInData.from.toLowerCase()
130+
const sourceName =
131+
getAddressesPyName(fromAddress) ?? discordIconOrName(fromAddress) ?? `${fromAddress.slice(0, 6)}...${fromAddress.slice(-4)}`
229132

230-
// Rate = token0/token1, derived from actual in/out amounts
231-
const rate = isToken0In
232-
? Number(data.value) / Number(outAmount)
233-
: Number(outAmount) / Number(data.value)
133+
const inTokenAddress = transferInLog.address.toLowerCase()
134+
const outTokenAddress = transferOutLog.address.toLowerCase()
135+
const inSymbol = discordIconOrName(inTokenAddress) ?? (inTokenAddress === arm.token0.toLowerCase() ? arm.symbol0 : arm.symbol1)
136+
const outSymbol = discordIconOrName(outTokenAddress) ?? (outTokenAddress === arm.token0.toLowerCase() ? arm.symbol0 : arm.symbol1)
234137

235-
fields.push({
236-
name: formatAmount(outAmount, 18, { maximumFractionDigits: 6 }),
237-
value: `${outSymbol} out`,
238-
inline: true,
239-
})
240-
fields.push({
241-
name: rate.toLocaleString('en-US', { maximumFractionDigits: 6 }),
242-
value: 'Rate',
243-
inline: true,
244-
})
245-
}
138+
// Rate = token0/token1, matching original renderer behavior
139+
const isToken0In = inTokenAddress === arm.token0.toLowerCase()
140+
const rate = isToken0In
141+
? Number(transferInData.value) / Number(transferOutData.value)
142+
: Number(transferOutData.value) / Number(transferInData.value)
246143

247144
renderDiscordEmbed({
248145
sortId: `${params.log.block.height}:${params.log.transactionIndex}:${params.log.logIndex}`,
249146
topic: params.topic,
250147
severity: params.severity,
251148
title: `${params.name} - Swap`,
252149
titleUrl: `${explorer}/tx/${params.log.transactionHash}`,
253-
description: sourceName ? `Source: [${sourceName}](${explorer}/address/${fromAddress})` : undefined,
254-
fields,
150+
description: `Source: [${sourceName}](${explorer}/address/${fromAddress})`,
151+
fields: [
152+
{
153+
name: formatAmount(transferInData.value, 18, { maximumFractionDigits: 6 }),
154+
value: `${inSymbol} in`,
155+
inline: true,
156+
},
157+
{
158+
name: formatAmount(transferOutData.value, 18, { maximumFractionDigits: 6 }),
159+
value: `${outSymbol} out`,
160+
inline: true,
161+
},
162+
{
163+
name: rate.toLocaleString('en-US', { maximumFractionDigits: 6 }),
164+
value: 'Rate',
165+
inline: true,
166+
},
167+
],
255168
})
256169
})

src/processors/config-alert.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export function buildSubscriptions(rules: AlertRule[]) {
183183
topic2: rule.topic2s ?? undefined,
184184
topic3: rule.topic3s ?? undefined,
185185
transaction: true,
186+
transactionLogs: rule.transactionLogs || undefined,
186187
}),
187188
)
188189
} else if (rule.matchType === 'trace') {

0 commit comments

Comments
 (0)