From dbd966e23d32b701323fe5246b87a84bf6ecc487 Mon Sep 17 00:00:00 2001 From: Prashanth Ramakrishna Date: Mon, 29 Jun 2026 19:07:36 -0400 Subject: [PATCH 1/2] Return realized swap deltas on trade results; fix TakerClosed binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_taker/adjust_taker now decode the taker swap from the receipt (reusing the feed's decode_log + SwapInfo) and return realized perp_delta/usd_delta on OpenResult/AdjustTakerResult — actual fills, not estimates. Both 0.0 for maker opens / margin-only adjusts (no swap). Fixes the TakerClosed binding: the deployed contract (ahead of the v0.2.1 tag) unifies close+liquidation, so the event carries two extra fields (uint256 liqFee, bool isLiquidation). Verified against the live topic0 0xc6d156…; added a SIGNATURE_HASH assertion to abi_lock so deployment drift is caught (the prior test only checked binding self-consistency). Fork test now asserts realized deltas on open/adjust/close. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client/trades.rs | 32 ++++++++++++++++++++++++++++++++ src/contracts.rs | 14 ++++++++++++-- src/types.rs | 26 +++++++++++++++++++++----- tests/anvil_fork.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/client/trades.rs b/src/client/trades.rs index 83ebff2..1bae2df 100644 --- a/src/client/trades.rs +++ b/src/client/trades.rs @@ -7,6 +7,7 @@ use crate::constants::TICK_SPACING; use crate::contracts::{IERC20, Perp}; use crate::convert::scale_to_6dec; use crate::errors::{ContractError, Result, ValidationError}; +use crate::feeds::{MarketEvent, decode_log}; use crate::hft::gas::{GasLimits, Urgency}; use crate::math::tick::{align_tick_down, align_tick_up, price_to_tick}; use crate::types::{ @@ -40,6 +41,28 @@ fn parse_minted_token_id( }) } +/// Extract the realized swap `(perp_delta, usd_delta)` from a taker +/// open/adjust/close receipt. +/// +/// Reuses the market feed's [`decode_log`] to find the `TakerOpened` / +/// `TakerAdjusted` / `TakerClosed` event and reads its decoded `SwapInfo` +/// (already unpacked from the `BalanceDelta` and scaled to f64). Returns +/// `None` for receipts with no taker swap — maker opens (no swap event) and +/// margin-only adjusts both fall through to the caller's `0.0` default. +fn parse_taker_swap(receipt: &alloy::rpc::types::TransactionReceipt) -> Option<(f64, f64)> { + for log in receipt.inner.logs() { + if let Some( + MarketEvent::TakerOpened { swap, .. } + | MarketEvent::TakerAdjusted { swap, .. } + | MarketEvent::TakerClosed { swap, .. }, + ) = decode_log(log) + { + return Some((swap.perp_delta, swap.usd_delta)); + } + } + None +} + /// Scale an f64 perp delta to the accounting token's 6-decimal fixed point. /// /// The perp token (Uniswap V4 `currency0`) is an `AccountingToken` with 6 @@ -96,9 +119,12 @@ impl PerpClient { .await?; let pos_id = parse_minted_token_id(&receipt)?; + let (perp_delta, usd_delta) = parse_taker_swap(&receipt).unwrap_or((0.0, 0.0)); let result = OpenResult { tx_hash: receipt.transaction_hash, pos_id, + perp_delta, + usd_delta, }; tracing::debug!(pos_id = %result.pos_id, "taker position opened"); Ok(result) @@ -163,6 +189,9 @@ impl PerpClient { let result = OpenResult { tx_hash: receipt.transaction_hash, pos_id, + // Maker opens emit no taker swap (`MakerOpened` carries no deltas). + perp_delta: 0.0, + usd_delta: 0.0, }; tracing::debug!(pos_id = %result.pos_id, "maker position opened"); Ok(result) @@ -204,8 +233,11 @@ impl PerpClient { .await?; tracing::debug!(pos_id = %params.pos_id, "taker position adjusted"); + let (perp_delta, usd_delta) = parse_taker_swap(&receipt).unwrap_or((0.0, 0.0)); Ok(AdjustTakerResult { tx_hash: receipt.transaction_hash, + perp_delta, + usd_delta, }) } diff --git a/src/contracts.rs b/src/contracts.rs index b4ed48b..486ff9a 100644 --- a/src/contracts.rs +++ b/src/contracts.rs @@ -255,7 +255,7 @@ sol! { ); event TakerOpened(uint256 posId, SwapResult sr); event TakerAdjusted(uint256 posId, SwapResult sr, int256 funding, uint256 utilFees); - event TakerClosed(uint256 posId, SwapResult sr, int256 funding, uint256 utilFees); + event TakerClosed(uint256 posId, SwapResult sr, int256 funding, uint256 utilFees, uint256 liqFee, bool isLiquidation); event TakerLiquidated(uint256 indexed posId, uint128 perpAmount, uint256 liqFee); event TakerBackstopped(uint256 posId, uint128 marginIn, address posRecipient, int256 funding, uint256 utilFees); @@ -659,9 +659,19 @@ mod abi_lock { Perp::TakerAdjusted::SIGNATURE, format!("TakerAdjusted(uint256,{SWAP_RESULT},int256,uint256)") ); + // Verified against the deployed contract: topic0 of this signature is + // 0xc6d1565765c65beb63cd0a76e37c058e0908e6e24a609d0dc1a724106ae0576e, + // matching the `TakerClosed` log emitted on Arbitrum Sepolia. The + // deployed event unifies close + liquidation (`liqFee`, `isLiquidation`). assert_eq!( Perp::TakerClosed::SIGNATURE, - format!("TakerClosed(uint256,{SWAP_RESULT},int256,uint256)") + format!("TakerClosed(uint256,{SWAP_RESULT},int256,uint256,uint256,bool)") + ); + assert_eq!( + Perp::TakerClosed::SIGNATURE_HASH, + alloy::primitives::b256!( + "c6d1565765c65beb63cd0a76e37c058e0908e6e24a609d0dc1a724106ae0576e" + ) ); assert_eq!( Perp::TakerLiquidated::SIGNATURE, diff --git a/src/types.rs b/src/types.rs index 057e3a6..26d572b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -168,24 +168,36 @@ pub struct AdjustMakerParams { /// Result of opening a taker or maker position. /// -/// The new contracts emit parameterless events (`TakerOpened`, `MakerOpened`), -/// so detailed position data must be read via view functions after confirmation. -/// The `pos_id` comes from the function return value. +/// `pos_id` is the minted position NFT id. For taker opens, `perp_delta` and +/// `usd_delta` are the realized swap amounts decoded from the `TakerOpened` +/// event (signed: positive = received, negative = paid). Maker opens emit no +/// swap, so both are `0.0`. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct OpenResult { /// Transaction hash. pub tx_hash: B256, /// Minted position NFT token ID. pub pos_id: U256, + /// Realized perp-token delta from the open swap (taker only; `0.0` for makers). + pub perp_delta: f64, + /// Realized USD delta from the open swap (taker only; `0.0` for makers). + pub usd_delta: f64, } /// Result of adjusting a taker position (margin, notional, or both). /// -/// Events are parameterless — read position state via view functions if needed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// `perp_delta` and `usd_delta` are the realized swap amounts decoded from the +/// `TakerAdjusted` event — or `TakerClosed`, when the adjust reverses the full +/// delta and closes the position (signed: positive = received, negative = +/// paid). Both are `0.0` for a margin-only adjust, which performs no swap. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct AdjustTakerResult { /// Transaction hash. pub tx_hash: B256, + /// Realized perp-token delta from the adjust swap (`0.0` if margin-only). + pub perp_delta: f64, + /// Realized USD delta from the adjust swap (`0.0` if margin-only). + pub usd_delta: f64, } /// Result of adjusting a maker position (margin, liquidity, or both). @@ -245,6 +257,8 @@ mod tests { let result = OpenResult { tx_hash: B256::ZERO, pos_id: U256::from(42), + perp_delta: 0.0681, + usd_delta: -500.0, }; let json = serde_json::to_string(&result).unwrap(); let recovered: OpenResult = serde_json::from_str(&json).unwrap(); @@ -255,6 +269,8 @@ mod tests { fn adjust_taker_result_serde_roundtrip() { let result = AdjustTakerResult { tx_hash: B256::ZERO, + perp_delta: -0.0681, + usd_delta: 499.5, }; let json = serde_json::to_string(&result).unwrap(); let recovered: AdjustTakerResult = serde_json::from_str(&json).unwrap(); diff --git a/tests/anvil_fork.rs b/tests/anvil_fork.rs index a028d45..93ead75 100644 --- a/tests/anvil_fork.rs +++ b/tests/anvil_fork.rs @@ -303,6 +303,24 @@ async fn open_and_close_taker_on_fork() { let pos_id = open_result.pos_id; println!("Position opened! ID: {pos_id}"); println!(" tx_hash: {}", open_result.tx_hash); + println!( + " realized perp_delta: {} usd_delta: {}", + open_result.perp_delta, open_result.usd_delta + ); + + // Realized swap decoded from the TakerOpened event. Opening a long + // receives perp (+) and pays USD (-); the realized perp size should match + // the requested 0.001 closely (small price impact on this tiny trade). + assert!( + (open_result.perp_delta - 0.001).abs() < 1e-4, + "realized perp_delta {} should be ~0.001", + open_result.perp_delta + ); + assert!( + open_result.usd_delta < 0.0, + "opening a long should pay USD (negative usd_delta), got {}", + open_result.usd_delta + ); // 9. Read position on-chain. `delta` is a packed BalanceDelta; `margin` is // in USDC 6-decimal units. Price-impact / live position details deferred @@ -331,6 +349,19 @@ async fn open_and_close_taker_on_fork() { .await .unwrap(); println!(" tx_hash: {}", adjust_result.tx_hash); + println!( + " realized perp_delta: {} usd_delta: {}", + adjust_result.perp_delta, adjust_result.usd_delta + ); + + // Realized swap decoded from the TakerAdjusted event. Reducing a long + // sells perp (negative perp_delta) and receives USD (positive usd_delta). + assert!( + (adjust_result.perp_delta - (-0.0005)).abs() < 1e-4 && adjust_result.usd_delta > 0.0, + "realized adjust deltas wrong: perp_delta={} usd_delta={}", + adjust_result.perp_delta, + adjust_result.usd_delta + ); // 11. Adjust margin — deposit 2 more USDC (margin-only adjustment) println!("\nAdjusting margin +2 USDC..."); @@ -363,6 +394,19 @@ async fn open_and_close_taker_on_fork() { .unwrap(); println!("Position closed! tx: {}", close_result.tx_hash); + println!( + " realized perp_delta: {} usd_delta: {}", + close_result.perp_delta, close_result.usd_delta + ); + + // Closing a long reverses the delta: sells perp (negative perp_delta) and + // receives USD (positive usd_delta), decoded from the TakerClosed event. + assert!( + close_result.perp_delta < 0.0 && close_result.usd_delta > 0.0, + "close should sell perp and receive USD, got perp_delta={} usd_delta={}", + close_result.perp_delta, + close_result.usd_delta + ); // 13. Check final balance client.invalidate_fast_cache(); From cc51535b85c862daac91a6f43023ca04cd5b7aa0 Mon Sep 17 00:00:00 2001 From: Prashanth Ramakrishna Date: Mon, 29 Jun 2026 19:28:47 -0400 Subject: [PATCH 2/2] Fail loudly when a taker swap event can't be decoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_taker / adjust_taker no longer fall back to (0.0, 0.0) when parse_taker_swap returns None — that fallback silently masked the TakerClosed binding bug (a full close reported a zero fill). Both taker paths always emit a decodable event (a margin-only adjust emits a zero-delta TakerAdjusted), so a missing decode is an ABI error: surface ContractError::EventNotFound instead. open_maker's explicit 0.0 (it emits no taker swap) is unchanged. Addresses CodeRabbit review. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/client/trades.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/client/trades.rs b/src/client/trades.rs index 1bae2df..16be01e 100644 --- a/src/client/trades.rs +++ b/src/client/trades.rs @@ -46,9 +46,11 @@ fn parse_minted_token_id( /// /// Reuses the market feed's [`decode_log`] to find the `TakerOpened` / /// `TakerAdjusted` / `TakerClosed` event and reads its decoded `SwapInfo` -/// (already unpacked from the `BalanceDelta` and scaled to f64). Returns -/// `None` for receipts with no taker swap — maker opens (no swap event) and -/// margin-only adjusts both fall through to the caller's `0.0` default. +/// (already unpacked from the `BalanceDelta` and scaled to f64). Every taker +/// open/adjust/close emits one of these — a margin-only adjust still emits a +/// `TakerAdjusted` with a zero-delta swap — so on the taker paths `None` means +/// a decode/ABI failure, which the caller surfaces as an error rather than a +/// zero fill. (Maker opens emit no taker swap, but they don't call this.) fn parse_taker_swap(receipt: &alloy::rpc::types::TransactionReceipt) -> Option<(f64, f64)> { for log in receipt.inner.logs() { if let Some( @@ -119,7 +121,13 @@ impl PerpClient { .await?; let pos_id = parse_minted_token_id(&receipt)?; - let (perp_delta, usd_delta) = parse_taker_swap(&receipt).unwrap_or((0.0, 0.0)); + // A taker open always emits a decodable `TakerOpened`; a missing one + // signals an ABI/decode problem, so fail loudly rather than recording a + // bogus zero fill. + let (perp_delta, usd_delta) = + parse_taker_swap(&receipt).ok_or(ContractError::EventNotFound { + event_name: "TakerOpened".into(), + })?; let result = OpenResult { tx_hash: receipt.transaction_hash, pos_id, @@ -233,7 +241,14 @@ impl PerpClient { .await?; tracing::debug!(pos_id = %params.pos_id, "taker position adjusted"); - let (perp_delta, usd_delta) = parse_taker_swap(&receipt).unwrap_or((0.0, 0.0)); + // Every taker adjust emits a decodable event — `TakerAdjusted` (a + // margin-only adjust carries a zero-delta swap) or `TakerClosed` on a + // full close. A missing one signals an ABI/decode problem, so fail + // loudly rather than recording a bogus zero fill. + let (perp_delta, usd_delta) = + parse_taker_swap(&receipt).ok_or(ContractError::EventNotFound { + event_name: "TakerAdjusted/TakerClosed".into(), + })?; Ok(AdjustTakerResult { tx_hash: receipt.transaction_hash, perp_delta,