Skip to content

Commit 6976521

Browse files
quasystaty1claude
andcommitted
fix(core): use on-chain balance diff for native ETH realized amounts
Native ETH legs don't emit ERC20 Transfer events, so the previous code fell back to the simulated amounts from strategy::Swap for both input and output. This caused realized profit to reflect expected values rather than actual on-chain outcomes. Now try_from_receipts queries the signer's ETH balance at block N-1 and N: - ETH in: amount_in = pre − post − gas_cost - ETH out: amount_out = post − pre + gas_cost The function is now async and takes a generic Provider, which calculate_realized_profit in Trade constructs from each chain's rpc_url. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4944c30 commit 6976521

2 files changed

Lines changed: 96 additions & 37 deletions

File tree

crates/core/src/state/swap.rs

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,23 @@
55
//! [`strategy::Swap`] which holds simulated/expected values computed before
66
//! execution. Used by the execution worker to compute `RealizedProfit`.
77
//!
8-
//! Native ETH inputs/outputs do not emit Transfer events — for those legs the
9-
//! expected amounts from the simulation are used as a fallback (see TODO in
10-
//! `try_from_receipts`).
8+
//! Native ETH inputs/outputs do not emit Transfer events. For those legs the
9+
//! actual amount is derived from the signer's balance diff across the receipt
10+
//! block: `pre_balance − post_balance − gas_cost` for ETH in, and
11+
//! `post_balance − pre_balance + gas_cost` for ETH out.
1112
1213
use crate::{
1314
state::{erc20::Transfer, pair::Pair},
1415
strategy,
1516
};
16-
use alloy::{primitives::Address, rpc::types::TransactionReceipt};
17+
use alloy::{
18+
primitives::Address,
19+
providers::Provider,
20+
rpc::types::TransactionReceipt,
21+
};
1722
use color_eyre::eyre::{self};
1823
use num_bigint::BigUint;
19-
use num_traits::CheckedAdd as _;
24+
use num_traits::{CheckedAdd as _, CheckedSub as _};
2025
use serde::{Deserialize, Serialize};
2126
use tycho_simulation::tycho_common::models::token::Token;
2227

@@ -29,11 +34,11 @@ use tycho_simulation::tycho_common::models::token::Token;
2934
pub struct Swap {
3035
/// The token that was sold in the swap.
3136
pub token_in: Token,
32-
/// Actual amount of token_in transferred (from Transfer events).
37+
/// Actual amount of token_in transferred (from Transfer events or balance diff).
3338
pub amount_in: BigUint,
3439
/// The token that was received in the swap.
3540
pub token_out: Token,
36-
/// Actual amount of token_out received (from Transfer events).
41+
/// Actual amount of token_out received (from Transfer events or balance diff).
3742
pub amount_out: BigUint,
3843
/// Gas cost in wei (gas_used * effective_gas_price from receipt).
3944
pub gas_cost_eth: BigUint,
@@ -42,31 +47,66 @@ pub struct Swap {
4247
impl Swap {
4348
/// Parse actual swap amounts from a transaction receipt.
4449
///
45-
/// Iterates through the transaction logs to find ERC20 Transfer events for
46-
/// the input and output tokens, summing the transferred amounts. Also extracts
47-
/// the actual gas cost from the receipt.
48-
pub fn try_from_receipts(
50+
/// For ERC20 tokens, iterates through the transaction logs to find Transfer
51+
/// events, summing the transferred amounts. For native ETH legs, queries the
52+
/// signer's balance at block N-1 and block N to derive the actual amount from
53+
/// the on-chain balance diff (assumes this is the only tx from the signer in
54+
/// that block, which holds for kuma's serial execution model).
55+
pub async fn try_from_receipts<P: Provider>(
4956
receipt: &TransactionReceipt,
5057
swap: strategy::Swap,
58+
provider: &P,
5159
) -> eyre::Result<Self> {
5260
let mut amount_in = BigUint::default();
5361
let mut amount_out = BigUint::default();
5462

5563
let token_in_addr = Address::from_slice(&swap.token_in.address);
5664
let token_out_addr = Address::from_slice(&swap.token_out.address);
5765

58-
// Handle native ETH input (doesn't emit Transfer events)
59-
if token_in_addr == Address::ZERO {
60-
// TODO: For ETH input, we should get the actual transaction value from receipt
61-
// For now, use the expected amount from the swap simulation
62-
amount_in = swap.amount_in.clone();
63-
}
66+
let gas_units = BigUint::from(receipt.gas_used);
67+
let wei_per_gas = BigUint::from(receipt.effective_gas_price);
68+
let gas_cost_eth = gas_units * wei_per_gas;
69+
70+
if token_in_addr == Address::ZERO || token_out_addr == Address::ZERO {
71+
let from = receipt.from;
72+
let block_number = receipt
73+
.block_number
74+
.ok_or_else(|| eyre::eyre!("receipt missing block number"))?;
75+
let pre_block = block_number
76+
.checked_sub(1)
77+
.ok_or_else(|| eyre::eyre!("block number is 0, cannot query pre-block balance"))?;
78+
79+
let pre_balance = provider
80+
.get_balance(from)
81+
.block_id(pre_block.into())
82+
.await
83+
.map_err(|e| eyre::eyre!("failed to get pre-block balance: {e}"))?;
84+
let post_balance = provider
85+
.get_balance(from)
86+
.block_id(block_number.into())
87+
.await
88+
.map_err(|e| eyre::eyre!("failed to get post-block balance: {e}"))?;
89+
90+
let pre = BigUint::from_bytes_be(&pre_balance.to_be_bytes::<32>());
91+
let post = BigUint::from_bytes_be(&post_balance.to_be_bytes::<32>());
6492

65-
// Handle native ETH output (doesn't emit Transfer events)
66-
if token_out_addr == Address::ZERO {
67-
// For ETH output, we need to calculate from balance changes
68-
// This is more complex - for now use the expected amount from swap
69-
amount_out = swap.amount_out.clone();
93+
if token_in_addr == Address::ZERO {
94+
// Signer spent amount_in + gas_cost, so balance dropped by that amount.
95+
amount_in = pre
96+
.checked_sub(&post)
97+
.ok_or_else(|| eyre::eyre!("ETH balance increased unexpectedly on ETH-in swap"))?
98+
.checked_sub(&gas_cost_eth)
99+
.ok_or_else(|| eyre::eyre!("balance diff smaller than gas cost on ETH-in swap"))?;
100+
}
101+
102+
if token_out_addr == Address::ZERO {
103+
// Signer received amount_out and paid gas_cost, net change is amount_out - gas_cost.
104+
amount_out = post
105+
.checked_sub(&pre)
106+
.ok_or_else(|| eyre::eyre!("ETH balance decreased unexpectedly on ETH-out swap"))?
107+
.checked_add(&gas_cost_eth)
108+
.ok_or_else(|| eyre::eyre!("overflow computing ETH amount_out"))?;
109+
}
70110
}
71111

72112
for log in receipt.logs() {
@@ -85,11 +125,6 @@ impl Swap {
85125
}
86126
}
87127

88-
let gas_units = BigUint::from(receipt.gas_used);
89-
let wei_per_gas = BigUint::from(receipt.effective_gas_price);
90-
// cost in wei, e.g. 5 × 10^14 wei = 500,000 Gwei ~ 0.0005 ETH
91-
let gas_cost_eth = gas_units * wei_per_gas;
92-
93128
Ok(Swap {
94129
token_in: swap.token_in,
95130
amount_in,

crates/core/src/trade.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,38 @@ impl Trade {
160160
}));
161161
}
162162

163-
let realized_profit = self.calculate_realized_profit(&slow_receipt, &fast_receipt)?;
163+
let slow_provider = alloy::providers::ProviderBuilder::new()
164+
.connect_http(
165+
self.signal
166+
.slow_chain
167+
.rpc_url
168+
.parse()
169+
.wrap_err("invalid slow chain RPC URL")?,
170+
);
171+
let fast_provider = alloy::providers::ProviderBuilder::new()
172+
.connect_http(
173+
self.signal
174+
.fast_chain
175+
.rpc_url
176+
.parse()
177+
.wrap_err("invalid fast chain RPC URL")?,
178+
);
179+
let slow_swap = state::swap::Swap::try_from_receipts(
180+
&slow_receipt,
181+
self.signal.slow_swap_sim.clone(),
182+
&slow_provider,
183+
)
184+
.await
185+
.wrap_err("failed to parse slow swap from receipt")?;
186+
let fast_swap = state::swap::Swap::try_from_receipts(
187+
&fast_receipt,
188+
self.signal.fast_swap_sim.clone(),
189+
&fast_provider,
190+
)
191+
.await
192+
.wrap_err("failed to parse fast swap from receipt")?;
193+
194+
let realized_profit = self.calculate_realized_profit(&slow_swap, &fast_swap)?;
164195

165196
Ok(TradeResult::Successful(TradeSuccess {
166197
signal: self.signal,
@@ -180,16 +211,9 @@ impl Trade {
180211

181212
fn calculate_realized_profit(
182213
&self,
183-
slow_receipt: &TransactionReceipt,
184-
fast_receipt: &TransactionReceipt,
214+
slow_swap: &state::Swap,
215+
fast_swap: &state::Swap,
185216
) -> eyre::Result<RealizedProfit> {
186-
let slow_swap =
187-
state::swap::Swap::try_from_receipts(slow_receipt, self.signal.slow_swap_sim.clone())
188-
.wrap_err("failed to parse slow swap from receipt")?;
189-
let fast_swap =
190-
state::swap::Swap::try_from_receipts(fast_receipt, self.signal.fast_swap_sim.clone())
191-
.wrap_err("failed to parse fast swap from receipt")?;
192-
193217
let profit = RealizedProfit::try_from_swaps(
194218
&slow_swap,
195219
&fast_swap,

0 commit comments

Comments
 (0)