Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/client/trades.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -40,6 +41,30 @@ 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). 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(
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
Expand Down Expand Up @@ -96,9 +121,18 @@ impl PerpClient {
.await?;

let pos_id = parse_minted_token_id(&receipt)?;
// 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,
perp_delta,
usd_delta,
};
tracing::debug!(pos_id = %result.pos_id, "taker position opened");
Ok(result)
Expand Down Expand Up @@ -163,6 +197,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)
Expand Down Expand Up @@ -204,8 +241,18 @@ impl PerpClient {
.await?;

tracing::debug!(pos_id = %params.pos_id, "taker position adjusted");
// 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,
usd_delta,
})
}

Expand Down
14 changes: 12 additions & 2 deletions src/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
26 changes: 21 additions & 5 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
44 changes: 44 additions & 0 deletions tests/anvil_fork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...");
Expand Down Expand Up @@ -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();
Expand Down
Loading