diff --git a/CHANGELOG.md b/CHANGELOG.md index 43caa02..fb9c007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Feature parity with the TypeScript SDK ([#18](https://github.com/decibeltrade/python-sdk/pull/18)): + - Write methods: `update_order`, `withdraw_non_collateral`, `admin_create_subaccount`, and `claim_campaign_reward` (with a new `campaign_package` field on `Deployment`), on both `DecibelWriteDex` and `DecibelWriteDexSync`. + - Read-client on-chain view helpers: `global_perp_engine_state`, `collateral_balance_decimals`, `usdc_decimals`, `usdc_balance`, `token_balance`, `account_balance`, `position_size`, and `get_crossed_position`. + - Read namespaces: `campaigns`, `points_leaderboard`, `streaks`, `trading_amps`, `tier`, `global_points_stats`, `referrals`, `user_fees`, and `withdraw_queue` (with WebSocket subscription, on-chain pending-withdrawals fallback, and merge helpers). + - Utilities: `calculate_liquidation_price`, `to_checksum_address` (EIP-55), and `derive_aptos_from_eth` / `derive_aptos_from_solana` (derivable accounts). + ## [0.2.1] - 2026-04-08 ### Fixed @@ -19,5 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Configurable transaction timeouts, Aptos contract ABI and registry updates for testnet and mainnet, custom submit/confirm exceptions, and CI improvements including release tagging. See the [v0.2.0](https://github.com/decibeltrade/python-sdk/releases/tag/v0.2.0) release on GitHub. +[Unreleased]: https://github.com/decibeltrade/python-sdk/compare/v0.2.1...HEAD [0.2.1]: https://github.com/decibeltrade/python-sdk/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/decibeltrade/python-sdk/releases/tag/v0.2.0 diff --git a/README.md b/README.md index 36b70b4..3c73a30 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,41 @@ read.leaderboard.get_leaderboard() read.portfolio_chart.get_by_addr(sub_addr=sub_addr, time_range="7d", data_type="pnl") read.vaults.get_vaults() read.trading_points.get_by_owner(owner_addr=addr) +read.user_fees.get_by_addr(sub_addr=sub_addr) + +# Points, streaks & tiers +read.points_leaderboard.get_points_leaderboard(limit=50, sort_key="total_amps") +read.trading_amps.get_by_owner(owner_addr=addr, season="season1", days=7) +read.tier.get_by_owner(owner_addr=addr) +read.streaks.get_by_owner(owner_addr=addr) +read.global_points_stats.get() + +# Campaigns & rewards +read.campaigns.get_active() +read.campaigns.get_summary(account_address=addr) + +# Referrals +read.referrals.validate_code(code) +read.referrals.get_account_referral(account) +read.referrals.get_referrer_stats(account) +read.referrals.get_user_referrals(referrer_account=account) +read.referrals.get_affiliate_codes(account) +read.referrals.get_affiliate_earnings(account) +read.referrals.redeem_code(referral_code=code, account=account) + +# Withdrawal queue (indexed HTTP + on-chain fallback) +read.withdraw_queue.get_by_addr(sub_addr=sub_addr, status="Queued") +read.withdraw_queue.get_pending_withdrawals(user_addr) + +# On-chain view / resource helpers +read.usdc_balance(addr) +read.token_balance(addr, token_addr, token_decimals) +read.account_balance(addr) +read.position_size(addr, market_addr) +read.get_crossed_position(addr) +read.usdc_decimals() +read.collateral_balance_decimals() +read.global_perp_engine_state() # WebSocket subscriptions read.market_prices.subscribe_by_name(market_name, callback) @@ -244,6 +279,7 @@ read.user_trade_history.subscribe_by_addr(sub_addr, callback) read.user_bulk_orders.subscribe_by_addr(sub_addr, callback) read.user_active_twaps.subscribe_by_addr(sub_addr, callback) read.user_notifications.subscribe_by_addr(sub_addr, callback) +read.withdraw_queue.subscribe_by_addr(sub_addr, callback) ``` ### Write Client @@ -255,6 +291,7 @@ write = DecibelWriteDex(config, account, opts) # Orders write.place_order(market_name=..., price=..., size=..., is_buy=..., time_in_force=..., is_reduce_only=...) +write.update_order(order_id=..., market_addr=..., price=..., size=..., is_buy=..., time_in_force=..., is_reduce_only=...) write.cancel_order(order_id=..., market_name=...) write.cancel_client_order(client_order_id=..., market_name=...) write.place_bulk_orders(market_name=..., sequence_number=..., bid_prices=..., bid_sizes=..., ask_prices=..., ask_sizes=...) @@ -272,6 +309,7 @@ write.cancel_twap_order(market_addr=..., order_id=...) # Collateral write.deposit(amount) write.withdraw(amount) +write.withdraw_non_collateral(asset_addr, amount) # Vaults write.deposit_to_vault(vault_address=..., amount=..., subaccount_addr=...) @@ -279,7 +317,47 @@ write.withdraw_from_vault(vault_address=..., shares=...) # Subaccounts write.create_subaccount() +write.admin_create_subaccount(owner_address) write.deactivate_subaccount(subaccount_addr=...) + +# Rewards +write.claim_campaign_reward(campaign_id) +``` + +> A synchronous client, `DecibelWriteDexSync`, exposes the same methods without `async`/`await`. + +### Utilities + +```python +from decibel import ( + calculate_liquidation_price, + LiquidationPriceInput, + LiquidationPosition, + LiquidationMarket, + LiquidationMarketContext, + to_checksum_address, + derive_aptos_from_eth, + derive_aptos_from_solana, +) + +# Estimate a liquidation price (for an existing position or a simulated order). +liq_price = calculate_liquidation_price( + LiquidationPriceInput( + account_equity=1000.0, + positions=[LiquidationPosition(market_addr="0xBTC", size=0.5, entry_price=100_000)], + markets=[LiquidationMarket(market_addr="0xBTC", market_name="BTC/USD", max_leverage=10)], + market_contexts=[LiquidationMarketContext(market_name="BTC/USD", mark_price=100_000)], + target_market_addr="0xBTC", + order_size=0, # 0 = current position; non-zero simulates an order + ) +) + +# EIP-55 checksum an Ethereum address (zero-dependency keccak-256). +checksummed = to_checksum_address("0xd8da6bf26964af9d7eed9e03e53415d37aa96045") + +# Derive an Aptos address from an EVM / Solana wallet (derivable accounts). +aptos_addr = derive_aptos_from_eth("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") +aptos_addr = derive_aptos_from_solana("11111111111111111111111111111111") ``` ## Development diff --git a/examples/read/get_campaigns.py b/examples/read/get_campaigns.py new file mode 100644 index 0000000..d7a73b6 --- /dev/null +++ b/examples/read/get_campaigns.py @@ -0,0 +1,23 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +ACCOUNT_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + active = await read.campaigns.get_active() + print(f"Active campaigns: {len(active)}") + for c in active: + print(f" [{c.campaign_id}] {c.title} ({c.status})") + + summary = await read.campaigns.get_summary(account_address=ACCOUNT_ADDR) + print(f"\nLifetime earned: {summary.lifetime_earned}, ready to claim: {summary.ready_to_claim}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_global_points_stats.py b/examples/read/get_global_points_stats.py new file mode 100644 index 0000000..fee4ea6 --- /dev/null +++ b/examples/read/get_global_points_stats.py @@ -0,0 +1,17 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + stats = await read.global_points_stats.get() + print(f"Total users: {stats.total_users}") + print(f"Total amps distributed: {stats.total_amps_distributed}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_points_leaderboard.py b/examples/read/get_points_leaderboard.py new file mode 100644 index 0000000..8bd81ab --- /dev/null +++ b/examples/read/get_points_leaderboard.py @@ -0,0 +1,18 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + page = await read.points_leaderboard.get_points_leaderboard(limit=10, sort_key="total_amps") + print(f"Points leaderboard (total {page.total_count}):") + for item in page.items: + print(f" #{item.rank} {item.owner}: {item.total_amps} amps") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_referrals.py b/examples/read/get_referrals.py new file mode 100644 index 0000000..60dfb38 --- /dev/null +++ b/examples/read/get_referrals.py @@ -0,0 +1,22 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +ACCOUNT_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + stats = await read.referrals.get_referrer_stats(ACCOUNT_ADDR) + print(f"Total referrals: {stats.total_referrals}, codes: {stats.codes}") + + referred = await read.referrals.get_user_referrals(referrer_account=ACCOUNT_ADDR, limit=10) + for user in referred: + print(f" {user.account} via {user.referral_code}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_streaks.py b/examples/read/get_streaks.py new file mode 100644 index 0000000..4ed535c --- /dev/null +++ b/examples/read/get_streaks.py @@ -0,0 +1,20 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +OWNER_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + streaks = await read.streaks.get_by_owner(owner_addr=OWNER_ADDR) + print(f"Current streak: {streaks.current_streak}") + print(f"Grace days: {streaks.grace_days_used}/{streaks.grace_days_available} used") + print(f"Qualifying dates: {streaks.qualifying_dates}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_tier.py b/examples/read/get_tier.py new file mode 100644 index 0000000..e0a2e45 --- /dev/null +++ b/examples/read/get_tier.py @@ -0,0 +1,20 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +OWNER_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + tier = await read.tier.get_by_owner(owner_addr=OWNER_ADDR) + print(f"Tier for {tier.owner}: {tier.current_tier} (rank {tier.rank})") + for t in tier.tiers: + print(f" {t.name}: threshold={t.hz_threshold}, progress={t.progress}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_trading_amps.py b/examples/read/get_trading_amps.py new file mode 100644 index 0000000..f4f682e --- /dev/null +++ b/examples/read/get_trading_amps.py @@ -0,0 +1,21 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +OWNER_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + amps = await read.trading_amps.get_by_owner(owner_addr=OWNER_ADDR, days=7) + print(f"Trading amps for {amps.owner}: {amps.total_amps}") + if amps.breakdown: + for sub in amps.breakdown: + print(f" {sub.account}: {sub.total_amps}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_user_fees.py b/examples/read/get_user_fees.py new file mode 100644 index 0000000..bd7d0c5 --- /dev/null +++ b/examples/read/get_user_fees.py @@ -0,0 +1,19 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +SUB_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + fees = await read.user_fees.get_by_addr(sub_addr=SUB_ADDR) + print(f"Taker rate: {fees.user_taker_rate}, maker rate: {fees.user_maker_rate}") + print(f"Fee tier: {fees.fee_tier}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/get_withdraw_queue.py b/examples/read/get_withdraw_queue.py new file mode 100644 index 0000000..bb48c43 --- /dev/null +++ b/examples/read/get_withdraw_queue.py @@ -0,0 +1,23 @@ +import asyncio +import os + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +SUB_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + page = await read.withdraw_queue.get_by_addr(sub_addr=SUB_ADDR, status="Queued") + print(f"Withdraw queue entries: {page.total_count}") + for entry in page.items: + print(f" [{entry.request_id}] {entry.status}: {entry.fungible_amount}") + + pending = await read.withdraw_queue.get_pending_withdrawals(SUB_ADDR) + print(f"On-chain pending: {len(pending)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/read/ws/subscribe_withdraw_queue.py b/examples/read/ws/subscribe_withdraw_queue.py new file mode 100644 index 0000000..e350fb1 --- /dev/null +++ b/examples/read/ws/subscribe_withdraw_queue.py @@ -0,0 +1,29 @@ +import asyncio +import os +from typing import Any + +from decibel import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +SUB_ADDR = "0x123..." + + +async def main() -> None: + read = DecibelReadDex(TESTNET_CONFIG, api_key=os.environ.get("APTOS_NODE_API_KEY")) + + def on_data(msg: Any) -> None: + print(f"Withdraw queue update for {SUB_ADDR}: {len(msg.entries)} entries") + for entry in msg.entries: + print(f" [{entry.request_id}] {entry.status}: {entry.fungible_amount}") + + # Subscribe first, then seed via read.withdraw_queue.get_by_addr(...) and merge + # with merge_withdraw_queue_entries to avoid missing events during the race window. + unsubscribe = read.withdraw_queue.subscribe_by_addr(SUB_ADDR, on_data) + + await asyncio.sleep(30) + unsubscribe() + await read.ws.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/decibel/__init__.py b/src/decibel/__init__.py index 3228380..125515f 100644 --- a/src/decibel/__init__.py +++ b/src/decibel/__init__.py @@ -2,6 +2,13 @@ BaseSDKOptions, BaseSDKOptionsSync, ) +from decibel._calculations import ( + LiquidationMarket, + LiquidationMarketContext, + LiquidationPosition, + LiquidationPriceInput, + calculate_liquidation_price, +) from decibel._constants import ( DEFAULT_COMPAT_VERSION, DOCKER_CONFIG, @@ -13,10 +20,16 @@ DecibelConfig, Deployment, Network, + get_campaign_package, get_perp_engine_global_address, get_testc_address, get_usdc_address, ) +from decibel._derivable_account import ( + derive_aptos_from_eth, + derive_aptos_from_solana, +) +from decibel._eip55 import to_checksum_address from decibel._exceptions import ( TxnConfirmError, TxnSubmitError, @@ -134,6 +147,7 @@ PlaceTwapOrderArgs, RevokeBuilderFeeArgs, RevokeDelegationArgs, + UpdateOrderArgs, UpdateSlOrderArgs, UpdateTpOrderArgs, ) @@ -183,6 +197,15 @@ "get_abi_data", "get_default_abi_data", "get_market_addr", + "LiquidationMarket", + "LiquidationMarketContext", + "LiquidationPosition", + "LiquidationPriceInput", + "calculate_liquidation_price", + "derive_aptos_from_eth", + "derive_aptos_from_solana", + "to_checksum_address", + "get_campaign_package", "get_perp_engine_global_address", "get_primary_subaccount_addr", "get_request", @@ -246,6 +269,7 @@ "TimeInForce", "TransactionPayloadOrderless", "TwapEvent", + "UpdateOrderArgs", "UpdateSlOrderArgs", "UpdateTpOrderArgs", ] diff --git a/src/decibel/_calculations.py b/src/decibel/_calculations.py new file mode 100644 index 0000000..9b5a215 --- /dev/null +++ b/src/decibel/_calculations.py @@ -0,0 +1,189 @@ +"""Liquidation price calculation (concentrated margin buffer strategy). + +Uses a per-position leverage factor to determine the price buffer. Matches the +on-chain liquidation price calculation; conservative for both longs and shorts. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +__all__ = [ + "LiquidationMarket", + "LiquidationMarketContext", + "LiquidationPosition", + "LiquidationPriceInput", + "calculate_liquidation_price", +] + +# PRICE_SCALE matches the on-chain price_divisor for 6-decimal collateral (USDC). +_PRICE_SCALE = 1_000_000 + + +@dataclass +class LiquidationPosition: + market_addr: str + size: float + entry_price: float + + +@dataclass +class LiquidationMarket: + market_addr: str + market_name: str + max_leverage: float + + +@dataclass +class LiquidationMarketContext: + market_name: str + mark_price: float + + +@dataclass +class LiquidationPriceInput: + account_equity: float + positions: list[LiquidationPosition] + markets: list[LiquidationMarket] + market_contexts: list[LiquidationMarketContext] + target_market_addr: str + # Size of simulated order (0 for current liquidation price). + order_size: float + # Execution price for the simulated order (defaults to mark price). + execution_price: float | None = field(default=None) + + +def _sign(value: float) -> int: + if value > 0: + return 1 + if value < 0: + return -1 + return 0 + + +def _maintenance_margin( + size_abs: float, + mark_price: float, + max_leverage: float, + mm_multiplier: float = 1, + mm_divisor: float = 2, +) -> float: + if max_leverage <= 0: + raise ValueError(f"Invalid max_leverage: {max_leverage}. Must be positive.") + position_notional = size_abs * mark_price + mm_fraction = mm_multiplier / (max_leverage * mm_divisor) + return position_notional * mm_fraction + + +def calculate_liquidation_price(input: LiquidationPriceInput) -> float: + """Calculate the liquidation price for a position or simulated order. + + For ``order_size == 0`` returns the current liquidation price. For a + non-zero order size, simulates the order and adjusts equity/entry price. + """ + positions = input.positions + market_by_addr = {m.market_addr: m for m in input.markets} + context_by_name = {c.market_name: c for c in input.market_contexts} + + position = next( + (p for p in positions if p.market_addr == input.target_market_addr), + None, + ) + market = market_by_addr.get(input.target_market_addr) + if market is None: + raise ValueError(f"Market not found for address: {input.target_market_addr}") + + market_context = context_by_name.get(market.market_name) + if market_context is None: + raise ValueError( + f"Market context not found for {market.market_name}: {input.target_market_addr}" + ) + + if input.order_size == 0 and position is None: + raise ValueError( + f"No position found for {market.market_name}: " + f"{input.target_market_addr} and order_size is 0" + ) + + mark_price = market_context.mark_price + current_pos_size = position.size if position else 0.0 + new_pos_size = current_pos_size + input.order_size + + # If position would be closed or near-zero, no liquidation price. + if abs(new_pos_size) < 1e-12: + return 0.0 + + account_equity_adjusted = input.account_equity + + if input.order_size != 0: + execution_price = input.execution_price if input.execution_price is not None else mark_price + current_entry_price = position.entry_price if position else execution_price + old_position_pnl = ( + current_pos_size * (mark_price - current_entry_price) if position else 0.0 + ) + + is_partial_reduction = _sign(current_pos_size) == _sign(new_pos_size) and abs( + new_pos_size + ) < abs(current_pos_size) + + if current_pos_size == 0 or _sign(current_pos_size) != _sign(new_pos_size): + new_entry_price = execution_price + elif is_partial_reduction: + new_entry_price = current_entry_price + else: + new_entry_price = ( + current_pos_size * current_entry_price + input.order_size * execution_price + ) / new_pos_size + new_position_pnl = new_pos_size * (mark_price - new_entry_price) + + # When the order reduces or flips the position, the closed portion + # realizes PnL at execution_price, added to collateral. + is_reducing = current_pos_size != 0 and _sign(current_pos_size) != _sign(input.order_size) + closed_size = min(abs(input.order_size), abs(current_pos_size)) if is_reducing else 0.0 + realized_pnl = ( + closed_size * (execution_price - current_entry_price) * _sign(current_pos_size) + ) + + pnl_difference = realized_pnl + new_position_pnl - old_position_pnl + account_equity_adjusted += pnl_difference + + maintenance_margin_requirement = 0.0 + for pos in positions: + if pos.market_addr == input.target_market_addr: + continue + pos_market = market_by_addr.get(pos.market_addr) + if pos_market is None: + continue + pos_market_context = context_by_name.get(pos_market.market_name) + if pos_market_context is None: + continue + maintenance_margin_requirement += _maintenance_margin( + abs(pos.size), + pos_market_context.mark_price, + pos_market.max_leverage, + ) + + maintenance_margin_requirement += _maintenance_margin( + abs(new_pos_size), + mark_price, + market.max_leverage, + ) + + # Margin buffer = excess equity above maintenance margin. + margin_buffer = account_equity_adjusted - maintenance_margin_requirement + if margin_buffer <= 0: + return mark_price + + is_long = new_pos_size > 0 + abs_new_size = abs(new_pos_size) + + mmr_ratio = 1 / (market.max_leverage * 2) + leverage_factor = 1 - mmr_ratio if is_long else 1 + mmr_ratio + # Floor truncation to 6 decimal places matches on-chain rounding. + price_buffer = ( + math.floor((margin_buffer / (abs_new_size * leverage_factor)) * _PRICE_SCALE) / _PRICE_SCALE + ) + + liquidation_price = mark_price - price_buffer if is_long else mark_price + price_buffer + return max(liquidation_price, 0.0) diff --git a/src/decibel/_constants.py b/src/decibel/_constants.py index 6de2097..b78697d 100644 --- a/src/decibel/_constants.py +++ b/src/decibel/_constants.py @@ -24,6 +24,7 @@ "get_usdc_address", "get_testc_address", "get_perp_engine_global_address", + "get_campaign_package", ] # Configurable timeout for transaction confirmation @@ -55,6 +56,9 @@ class Deployment: usdc: str testc: str perp_engine_global: str + # Package hosting the campaign_manager module (rewards). Only deployed on + # testnet/mainnet; empty string on networks where campaigns are unavailable. + campaign_package: str = "" @dataclass(frozen=True) @@ -88,26 +92,43 @@ def get_perp_engine_global_address(package: str) -> str: return str(AccountAddress.for_named_object(creator, b"GlobalPerpEngine")) +_MAINNET_PACKAGE = "0x50ead22afd6ffd9769e3b3d6e0e64a2a350d68e8b102c4e72e33d0b8cfdfdb06" +_MAINNET_USDC = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" +_TESTNET_PACKAGE = "0xe7da2794b1d8af76532ed95f38bfdf1136abfd8ea3a240189971988a83101b7f" +_LOCAL_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95" +_DOCKER_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95" + +# campaign_manager package addresses (hosts the rewards module). These are fixed +# per network and are not derived from the DEX package address. +_CAMPAIGN_TESTNET_PACKAGE = "0x6c8e3171c638045f765e1d66e8e71e819eaf0afa54d1914db3c37f3b02a9c5b3" +_CAMPAIGN_MAINNET_PACKAGE = "0x7dd60d4445490318a073a600d6109ff587d21e634a22ea08a1b154cc591b3cf4" + + +def get_campaign_package(package: str) -> str: + """Return the campaign_manager package for a DEX package, or "" if none.""" + if package == _TESTNET_PACKAGE: + return _CAMPAIGN_TESTNET_PACKAGE + if package == _MAINNET_PACKAGE: + return _CAMPAIGN_MAINNET_PACKAGE + return "" + + def _create_deployment(package: str) -> Deployment: return Deployment( package=package, usdc=get_usdc_address(package), testc=get_testc_address(package), perp_engine_global=get_perp_engine_global_address(package), + campaign_package=get_campaign_package(package), ) -_MAINNET_PACKAGE = "0x50ead22afd6ffd9769e3b3d6e0e64a2a350d68e8b102c4e72e33d0b8cfdfdb06" -_MAINNET_USDC = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b" -_TESTNET_PACKAGE = "0xe7da2794b1d8af76532ed95f38bfdf1136abfd8ea3a240189971988a83101b7f" -_LOCAL_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95" -_DOCKER_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95" - MAINNET_DEPLOYMENT = Deployment( package=_MAINNET_PACKAGE, usdc=_MAINNET_USDC, testc=get_testc_address(_MAINNET_PACKAGE), perp_engine_global=get_perp_engine_global_address(_MAINNET_PACKAGE), + campaign_package=get_campaign_package(_MAINNET_PACKAGE), ) MAINNET_CONFIG = DecibelConfig( diff --git a/src/decibel/_derivable_account.py b/src/decibel/_derivable_account.py new file mode 100644 index 0000000..22a4806 --- /dev/null +++ b/src/decibel/_derivable_account.py @@ -0,0 +1,60 @@ +"""Derive Aptos account addresses from EVM/Solana wallets (scheme byte 0x05).""" + +from __future__ import annotations + +import hashlib + +from aptos_sdk.account_address import AccountAddress +from aptos_sdk.bcs import Serializer + +from ._eip55 import to_checksum_address + +__all__ = [ + "derive_aptos_from_eth", + "derive_aptos_from_solana", +] + +_DOMAIN = "app.decibel.trade" +_ETH_AUTH_FN = "0x1::ethereum_derivable_account::authenticate" +_SOL_AUTH_FN = "0x1::solana_derivable_account::authenticate" + +# Derivable abstraction authentication-key scheme byte. +_DERIVABLE_ABSTRACTION_SCHEME = 0x05 + + +def _derive_aptos_address(auth_fn: str, identity: str) -> str: + parts = auth_fn.split("::") + if len(parts) != 3: + raise ValueError(f"Invalid auth function: {auth_fn}") + + # Serialize the FunctionInfo (module address, module name, function name). + s1 = Serializer() + AccountAddress.from_str(parts[0]).serialize(s1) + s1.str(parts[1]) + s1.str(parts[2]) + + # Serialize the DerivableAbstractPublicKey (identity, domain), then wrap as bytes. + apk = Serializer() + apk.str(identity) + apk.str(_DOMAIN) + + s2 = Serializer() + s2.to_bytes(apk.output()) + + data = hashlib.sha3_256( + s1.output() + s2.output() + bytes([_DERIVABLE_ABSTRACTION_SCHEME]) + ).digest() + return str(AccountAddress(data)) + + +def derive_aptos_from_eth(eth_address: str) -> str: + """Derive an Aptos address from an Ethereum wallet address. + + The ETH address is checksummed via EIP-55 before derivation. + """ + return _derive_aptos_address(_ETH_AUTH_FN, to_checksum_address(eth_address)) + + +def derive_aptos_from_solana(sol_address: str) -> str: + """Derive an Aptos address from a Solana wallet address (base58, used as-is).""" + return _derive_aptos_address(_SOL_AUTH_FN, sol_address) diff --git a/src/decibel/_eip55.py b/src/decibel/_eip55.py new file mode 100644 index 0000000..62faa8c --- /dev/null +++ b/src/decibel/_eip55.py @@ -0,0 +1,123 @@ +"""Minimal keccak-256 + EIP-55 checksum — zero external dependencies. + +Note: EIP-55 requires the original Keccak-256 (padding byte 0x01), which is +distinct from NIST SHA3-256 (padding byte 0x06) available via ``hashlib``. +""" + +from __future__ import annotations + +import re + +__all__ = ["to_checksum_address"] + +_MASK64 = (1 << 64) - 1 +_RATE = 136 # bytes (keccak-256 rate = 1600 - 2*256 bits) + +# Round constants for keccak-f[1600]. +_RC: tuple[int, ...] = ( + 0x0000000000000001, + 0x0000000000008082, + 0x800000000000808A, + 0x8000000080008000, + 0x000000000000808B, + 0x0000000080000001, + 0x8000000080008081, + 0x8000000000008009, + 0x000000000000008A, + 0x0000000000000088, + 0x0000000080008009, + 0x000000008000000A, + 0x000000008000808B, + 0x800000000000008B, + 0x8000000000008089, + 0x8000000000008003, + 0x8000000000008002, + 0x8000000000000080, + 0x000000000000800A, + 0x800000008000000A, + 0x8000000080008081, + 0x8000000000008080, + 0x0000000080000001, + 0x8000000080008008, +) + +# rho rotation offsets indexed by [x + 5y]. +_ROT: tuple[int, ...] = ( + 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, + 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14, +) # fmt: skip + + +def _rot64(x: int, n: int) -> int: + return ((x << n) | (x >> (64 - n))) & _MASK64 + + +def _keccak_f(s: list[int]) -> None: + for r in range(24): + # theta — column parity + c = [s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20] for x in range(5)] + d = [c[(x + 4) % 5] ^ _rot64(c[(x + 1) % 5], 1) for x in range(5)] + for y in range(0, 25, 5): + for x in range(5): + s[y + x] ^= d[x] + + # rho + pi — rotate lanes and move to new positions + t = [0] * 25 + for x in range(5): + for y in range(5): + i = x + 5 * y + t[y + 5 * ((2 * x + 3 * y) % 5)] = _rot64(s[i], _ROT[i]) + + # chi — non-linear step + for y in range(0, 25, 5): + t0, t1, t2, t3, t4 = t[y], t[y + 1], t[y + 2], t[y + 3], t[y + 4] + s[y] = t0 ^ ((~t1 & _MASK64) & t2) + s[y + 1] = t1 ^ ((~t2 & _MASK64) & t3) + s[y + 2] = t2 ^ ((~t3 & _MASK64) & t4) + s[y + 3] = t3 ^ ((~t4 & _MASK64) & t0) + s[y + 4] = t4 ^ ((~t0 & _MASK64) & t1) + + # iota — round constant + s[0] ^= _RC[r] + + +def _keccak256_hex(data: bytes) -> str: + """Keccak-256 hash -> hex string (no 0x prefix).""" + # Pad: data || 0x01 || 0x00...0x00 || 0x80 (keccak padding, NOT SHA-3 0x06). + blocks = (len(data) + 1 + _RATE - 1) // _RATE + padded = bytearray(blocks * _RATE) + padded[: len(data)] = data + padded[len(data)] = 0x01 + padded[-1] |= 0x80 + + s = [0] * 25 + for off in range(0, len(padded), _RATE): + for i in range(17): + s[i] ^= int.from_bytes(padded[off + i * 8 : off + i * 8 + 8], "little") + _keccak_f(s) + + out = b"".join(s[i].to_bytes(8, "little") for i in range(4)) + return out.hex() + + +_ETH_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") + + +def to_checksum_address(address: str) -> str: + """EIP-55 mixed-case checksum encoding for Ethereum addresses. + + Equivalent to ``getAddress`` from ethers — zero dependencies. + + See https://eips.ethereum.org/EIPS/eip-55 + """ + if not _ETH_ADDRESS_RE.match(address): + raise ValueError(f"Invalid Ethereum address: {address}") + + lower = address[2:].lower() + hash_hex = _keccak256_hex(lower.encode("ascii")) + + out = ["0x"] + for i in range(40): + # Hex digits a-f are uppercased when the corresponding hash nibble >= 8. + out.append(lower[i].upper() if int(hash_hex[i], 16) >= 8 else lower[i]) + return "".join(out) diff --git a/src/decibel/read/__init__.py b/src/decibel/read/__init__.py index 1ee3003..27ad034 100644 --- a/src/decibel/read/__init__.py +++ b/src/decibel/read/__init__.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import json +from typing import TYPE_CHECKING, Any import httpx +from aptos_sdk.account_address import AccountAddress from aptos_sdk.async_client import RestClient from .._constants import HTTP_LIMITS, HTTP_TIMEOUT @@ -13,6 +15,16 @@ VolumeWindow, ) from ._base import ReaderDeps +from ._campaigns import ( + CampaignClaim, + CampaignMetadataHttp, + CampaignsReader, + CampaignStatusName, + CampaignSummary, + CampaignTypeName, + TypeBreakdown, + WeeklyEarning, +) from ._candlesticks import ( Candlestick, CandlestickInterval, @@ -20,6 +32,7 @@ CandlestickWsMessage, ) from ._delegations import Delegation, DelegationsReader +from ._global_points_stats import GlobalPointsStats, GlobalPointsStatsReader from ._leaderboard import ( LeaderboardItem, LeaderboardReader, @@ -54,12 +67,35 @@ PerpMarketConfig, SzPrecision, ) +from ._points_leaderboard import ( + PointsLeaderboardItem, + PointsLeaderboardReader, + PointsLeaderboardSortKey, + PointsLeaderboardTierFilter, +) from ._portfolio_chart import ( PortfolioChartItem, PortfolioChartReader, PortfolioChartTimeRange, PortfolioChartType, ) +from ._referrals import ( + AccountReferral, + AffiliateCode, + AffiliateCodesResponse, + AffiliateEarningsBreakdown, + AffiliateEarningsResponse, + AffiliateReferredUser, + RedeemReferralResponse, + ReferralCodeSource, + ReferralCodeValidation, + ReferralsReader, + ReferrerStats, + UserReferral, +) +from ._streaks import AccountStreaks, StreaksReader +from ._tier import TierInfo, TierReader, TierThreshold +from ._trading_amps import OwnerTradingAmps, SubaccountAmps, TradingAmpsReader from ._trading_points import ( OwnerTradingPoints, SubaccountPoints, @@ -93,6 +129,15 @@ UserBulkOrdersReader, UserBulkOrderWsMessage, ) +from ._user_fees import ( + DailyUserVolume, + FeeSchedule, + FeeTiers, + MarketMakerTier, + UserFees, + UserFeesReader, + VipTier, +) from ._user_fund_history import ( FundMovementType, UserFund, @@ -147,6 +192,17 @@ VaultType, VaultWithdrawal, ) +from ._withdraw_queue import ( + KnownWithdrawCancelReason, + PendingWithdrawRequest, + WithdrawQueueEntry, + WithdrawQueueReader, + WithdrawQueueResponse, + WithdrawQueueStatus, + WithdrawQueueUpdate, + is_known_cancel_reason, + merge_withdraw_queue_entries, +) from ._ws import DecibelWsSubscription, Unsubscribe if TYPE_CHECKING: @@ -166,6 +222,9 @@ def __init__( aptos = RestClient(config.fullnode_url) ws = DecibelWsSubscription(config, api_key, on_ws_error) self._http_client = httpx.AsyncClient(limits=HTTP_LIMITS, timeout=HTTP_TIMEOUT) + self._config = config + self._aptos = aptos + self._usdc_decimals_cache: int | None = None deps = ReaderDeps( config=config, ws=ws, @@ -198,6 +257,117 @@ def __init__( self.user_notifications = UserNotificationsReader(deps) self.vaults = VaultsReader(deps) self.trading_points = TradingPointsReader(deps) + self.campaigns = CampaignsReader(deps) + self.points_leaderboard = PointsLeaderboardReader(deps) + self.streaks = StreaksReader(deps) + self.trading_amps = TradingAmpsReader(deps) + self.tier = TierReader(deps) + self.global_points_stats = GlobalPointsStatsReader(deps) + self.referrals = ReferralsReader(deps) + self.user_fees = UserFeesReader(deps) + self.withdraw_queue = WithdrawQueueReader(deps) + + # ----------------------------------------------------------------- + # On-chain view / resource helpers + # ----------------------------------------------------------------- + async def _view( + self, + function: str, + type_arguments: list[str], + arguments: list[Any], + ) -> list[Any]: + result_bytes = await self._aptos.view(function, type_arguments, arguments) + return json.loads(result_bytes.decode("utf-8")) + + async def global_perp_engine_state(self) -> dict[str, Any] | bool: + """Return the global perp_engine state resource, or False if unavailable.""" + pkg = self._config.deployment.package + try: + return await self._aptos.account_resource( + AccountAddress.from_str(pkg), + f"{pkg}::perp_engine::Global", + ) + except Exception: + return False + + async def collateral_balance_decimals(self) -> int: + """Return the on-chain decimal precision of the collateral balance.""" + pkg = self._config.deployment.package + result = await self._view(f"{pkg}::perp_engine::collateral_balance_decimals", [], []) + return int(result[0]) + + async def usdc_decimals(self) -> int: + """Return the USDC fungible-asset decimals (cached after the first call).""" + if self._usdc_decimals_cache is not None: + return self._usdc_decimals_cache + result = await self._view( + "0x1::fungible_asset::decimals", + ["0x1::fungible_asset::Metadata"], + [self._config.deployment.usdc], + ) + self._usdc_decimals_cache = int(result[0]) + return self._usdc_decimals_cache + + async def usdc_balance(self, addr: str | AccountAddress) -> float: + """Return an address's USDC balance in human-readable units.""" + usdc_decimals = await self.usdc_decimals() + result = await self._view( + "0x1::primary_fungible_store::balance", + ["0x1::fungible_asset::Metadata"], + [str(addr), self._config.deployment.usdc], + ) + return int(result[0]) / 10**usdc_decimals + + async def token_balance( + self, + addr: str | AccountAddress, + token_addr: str | AccountAddress, + token_decimals: int, + ) -> float: + """Return an address's balance of a fungible asset in human-readable units.""" + result = await self._view( + "0x1::primary_fungible_store::balance", + ["0x1::fungible_asset::Metadata"], + [str(addr), str(token_addr)], + ) + return int(result[0]) / 10**token_decimals + + async def account_balance(self, addr: str | AccountAddress) -> int: + """Return the account's total cross collateral value (raw chain units).""" + pkg = self._config.deployment.package + result = await self._view( + f"{pkg}::perp_engine::get_cross_total_collateral_value", + [], + [str(addr)], + ) + return int(result[0]) + + async def position_size( + self, + addr: str | AccountAddress, + market_addr: str, + ) -> list[Any]: + """Return the position size view result for an account in a market.""" + pkg = self._config.deployment.package + return await self._view( + f"{pkg}::perp_engine::get_position_size", + [], + [str(addr), market_addr], + ) + + async def get_crossed_position(self, addr: str | AccountAddress) -> CrossedPosition | None: + """Return the crossed position resource for an account, or None if absent.""" + pkg = self._config.deployment.package + creator = addr if isinstance(addr, AccountAddress) else AccountAddress.from_str(addr) + crossed_position_addr = AccountAddress.for_named_object(creator, b"perp_position") + try: + resource = await self._aptos.account_resource( + crossed_position_addr, + f"{pkg}::perp_positions::CrossedPosition", + ) + return CrossedPosition.model_validate(resource) + except Exception: + return None async def close(self) -> None: try: @@ -220,6 +390,56 @@ async def __aexit__( __all__ = [ "AccountOverview", "AccountOverviewWsMessage", + "AccountReferral", + "AccountStreaks", + "AffiliateCode", + "AffiliateCodesResponse", + "AffiliateEarningsBreakdown", + "AffiliateEarningsResponse", + "AffiliateReferredUser", + "CampaignClaim", + "CampaignMetadataHttp", + "CampaignStatusName", + "CampaignSummary", + "CampaignTypeName", + "CampaignsReader", + "DailyUserVolume", + "FeeSchedule", + "FeeTiers", + "GlobalPointsStats", + "GlobalPointsStatsReader", + "KnownWithdrawCancelReason", + "MarketMakerTier", + "OwnerTradingAmps", + "PendingWithdrawRequest", + "PointsLeaderboardItem", + "PointsLeaderboardReader", + "PointsLeaderboardSortKey", + "PointsLeaderboardTierFilter", + "RedeemReferralResponse", + "ReferralCodeSource", + "ReferralCodeValidation", + "ReferralsReader", + "ReferrerStats", + "StreaksReader", + "SubaccountAmps", + "TierInfo", + "TierReader", + "TierThreshold", + "TradingAmpsReader", + "TypeBreakdown", + "UserFees", + "UserFeesReader", + "UserReferral", + "VipTier", + "WeeklyEarning", + "WithdrawQueueEntry", + "WithdrawQueueReader", + "WithdrawQueueResponse", + "WithdrawQueueStatus", + "WithdrawQueueUpdate", + "is_known_cancel_reason", + "merge_withdraw_queue_entries", "ActivateVaultArgs", "AllMarketPricesWsMessage", "AssetType", diff --git a/src/decibel/read/_campaigns.py b/src/decibel/read/_campaigns.py new file mode 100644 index 0000000..db86b1c --- /dev/null +++ b/src/decibel/read/_campaigns.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, RootModel + +from ._base import BaseReader + +__all__ = [ + "CampaignClaim", + "CampaignMetadataHttp", + "CampaignStatusName", + "CampaignSummary", + "CampaignTypeName", + "CampaignsReader", + "TypeBreakdown", + "WeeklyEarning", +] + +CampaignTypeName = Literal[ + "fee_rebate", + "maker_incentive", + "liquidation_rebate", + "volume_milestone", +] + +CampaignStatusName = Literal[ + "draft", + "funded", + "active", + "expired", + "reclaimed", + "cancelled", +] + + +class CampaignMetadataHttp(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + campaign_id: int + campaign_type: CampaignTypeName + status: CampaignStatusName + title: str + reward_asset: str + start_ts_sec: int + end_ts_sec: int + claim_start_ts_sec: int + claim_end_ts_sec: int + total_funded: float + description: str | None = None + + +class CampaignClaim(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + campaign_id: int + campaign_type: CampaignTypeName + status: CampaignStatusName + title: str + reward_asset: str + start_ts_sec: int + end_ts_sec: int + claim_start_ts_sec: int + claim_end_ts_sec: int + total_funded: float + description: str | None = None + has_allocation: bool + claimable_amount: float + claimed_amount: float + ready_to_claim: float + claimed_at_ts_sec: float | None = None + claim_tx_hash: str | None = None + + +class WeeklyEarning(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + week_start_ts_sec: int + reward_amount: float + + +class TypeBreakdown(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + campaign_type: CampaignTypeName + lifetime_earned: float + ready_to_claim: float + total_claimed: float + + +class CampaignSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + lifetime_earned: float + ready_to_claim: float + total_claimed: float + breakdown_by_type: list[TypeBreakdown] + claims: list[CampaignClaim] + year_to_date: float + weekly_wow_bps: float + weekly_breakdown: list[WeeklyEarning] + total_claims: float + + +class _ActiveCampaigns(RootModel[list[CampaignMetadataHttp]]): + pass + + +class CampaignsReader(BaseReader): + """Read reward-campaign metadata and per-account campaign summaries.""" + + async def get_active(self) -> list[CampaignMetadataHttp]: + """Return all currently active reward campaigns. + + GET ``/api/v1/campaigns/active``. + """ + response, _, _ = await self.get_request( + model=_ActiveCampaigns, + url=f"{self.config.trading_http_url}/api/v1/campaigns/active", + ) + return response.root + + async def get_summary( + self, + *, + account_address: str, + limit: int | None = None, + offset: int | None = None, + ) -> CampaignSummary: + """Return an account's campaign earnings summary and claim history. + + GET ``/api/v1/campaigns/account``. ``limit``/``offset`` page the + embedded claims list. + """ + params: dict[str, str] = {"account": account_address} + if limit is not None: + params["limit"] = str(limit) + if offset is not None: + params["offset"] = str(offset) + response, _, _ = await self.get_request( + model=CampaignSummary, + url=f"{self.config.trading_http_url}/api/v1/campaigns/account", + params=params, + ) + return response diff --git a/src/decibel/read/_global_points_stats.py b/src/decibel/read/_global_points_stats.py new file mode 100644 index 0000000..1631f4c --- /dev/null +++ b/src/decibel/read/_global_points_stats.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from ._base import BaseReader + +__all__ = [ + "GlobalPointsStats", + "GlobalPointsStatsReader", +] + + +class GlobalPointsStats(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + total_users: float + total_amps_distributed: float + + +class GlobalPointsStatsReader(BaseReader): + """Read protocol-wide points statistics.""" + + async def get(self) -> GlobalPointsStats: + """Return global points stats (total users, total amps distributed). + + GET ``/api/v1/points/global``. + """ + response, _, _ = await self.get_request( + model=GlobalPointsStats, + url=f"{self.config.trading_http_url}/api/v1/points/global", + ) + return response diff --git a/src/decibel/read/_points_leaderboard.py b/src/decibel/read/_points_leaderboard.py new file mode 100644 index 0000000..1113e63 --- /dev/null +++ b/src/decibel/read/_points_leaderboard.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from .._pagination import PaginatedResponse +from ._base import BaseReader + +__all__ = [ + "PointsLeaderboardItem", + "PointsLeaderboardReader", + "PointsLeaderboardSortKey", + "PointsLeaderboardTierFilter", +] + +PointsLeaderboardSortKey = Literal["total_amps", "realized_pnl"] +PointsLeaderboardTierFilter = Literal["top20", "diamond", "doublePlatinum", "gold"] + + +class PointsLeaderboardItem(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + rank: int + owner: str + total_amps: float + realized_pnl: float + referral_amps: float + vault_amps: float + streak_amps: float + bonus_amps: float = 0 + + +class PointsLeaderboardReader(BaseReader): + """Read the points (Hz/Amps) leaderboard rankings.""" + + async def get_points_leaderboard( + self, + *, + limit: int | None = None, + offset: int | None = None, + search_term: str | None = None, + sort_key: PointsLeaderboardSortKey | None = None, + sort_dir: Literal["ASC", "DESC"] | None = None, + tier: PointsLeaderboardTierFilter | None = None, + ) -> PaginatedResponse[PointsLeaderboardItem]: + """Return a paginated points leaderboard (Hz/Amps rankings). + + GET ``/api/v1/points_leaderboard``. Supports paging, free-text search, + sort key/direction, and an optional ``tier`` filter. + """ + params: dict[str, str] = {} + if limit is not None: + params["limit"] = str(limit) + if offset is not None: + params["offset"] = str(offset) + if search_term is not None: + params["search_term"] = search_term + if sort_key is not None: + params["sort_key"] = sort_key + if sort_dir is not None: + params["sort_dir"] = sort_dir + if tier is not None: + params["tier"] = tier + + response, _, _ = await self.get_request( + model=PaginatedResponse[PointsLeaderboardItem], + url=f"{self.config.trading_http_url}/api/v1/points_leaderboard", + params=params if params else None, + ) + return response diff --git a/src/decibel/read/_referrals.py b/src/decibel/read/_referrals.py new file mode 100644 index 0000000..461ad5c --- /dev/null +++ b/src/decibel/read/_referrals.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from typing import Literal +from urllib.parse import quote + +from pydantic import BaseModel, ConfigDict, RootModel + +from ._base import BaseReader + +__all__ = [ + "AccountReferral", + "AffiliateCode", + "AffiliateCodesResponse", + "AffiliateEarningsBreakdown", + "AffiliateEarningsResponse", + "AffiliateReferredUser", + "RedeemReferralResponse", + "ReferralCodeSource", + "ReferralCodeValidation", + "ReferralsReader", + "ReferrerStats", + "UserReferral", +] + +ReferralCodeSource = Literal["admin", "auto", "reusable", "predeposit", "unknown"] + + +class ReferralCodeValidation(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + referral_code: str + is_valid: bool + is_active: bool + + +class RedeemReferralResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + referral_code: str + account: str + + +class AccountReferral(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + account: str + referrer_account: str + referral_code: str + is_affiliate_referral: bool + referred_at_ms: int + is_active: bool + + +class ReferrerStats(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + referrer_account: str + total_referrals: int + total_codes_created: int + is_affiliate: bool + codes: list[str] + volume_threshold_met: bool + + +class UserReferral(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + account: str + referrer_account: str + referral_code: str + is_affiliate_referral: bool + referred_at_ms: int + + +class AffiliateCode(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + referral_code: str + owner_account: str + max_usage: int + usage_count: int + is_active: bool + is_affiliate: bool + source: ReferralCodeSource + created_at_ms: int + + +class AffiliateCodesResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + owner_account: str + codes: list[AffiliateCode] + volume_threshold_met: bool + + +class AffiliateReferredUser(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + account: str + level: Literal["L1", "L2"] + referred_by: str | None = None + total_amps: float + affiliate_amps_earned: float + total_volume: float + active: bool + + +class AffiliateEarningsBreakdown(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + l1_amps: float + l2_amps: float + total_amps: float + l1_count: int + l2_count: int + + +class _AffiliateEarningsUsers(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + items: list[AffiliateReferredUser] + total_count: int + + +class AffiliateEarningsResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + affiliate_account: str + is_affiliate: bool + earnings: AffiliateEarningsBreakdown + users: _AffiliateEarningsUsers + + +class _UserReferralsResponse(RootModel[list[UserReferral]]): + pass + + +class ReferralsReader(BaseReader): + """Read referral codes, account referrals, and affiliate stats/earnings.""" + + async def validate_code(self, code: str) -> ReferralCodeValidation: + """Validate a referral code (existence and active status). + + GET ``/api/v1/referrals/code/{code}``. + """ + response, _, _ = await self.get_request( + model=ReferralCodeValidation, + url=f"{self.config.trading_http_url}/api/v1/referrals/code/{quote(code)}", + ) + return response + + async def get_account_referral(self, account: str) -> AccountReferral: + """Return referral information for a specific account. + + GET ``/api/v1/referrals/account/{account}``. + """ + response, _, _ = await self.get_request( + model=AccountReferral, + url=f"{self.config.trading_http_url}/api/v1/referrals/account/{account}", + ) + return response + + async def redeem_code(self, *, referral_code: str, account: str) -> RedeemReferralResponse: + """Redeem a referral code for an account. + + POST ``/api/v1/referrals/redeem``. + """ + response, _, _ = await self.post_request( + model=RedeemReferralResponse, + url=f"{self.config.trading_http_url}/api/v1/referrals/redeem", + body={"referral_code": referral_code, "account": account}, + ) + return response + + async def get_referrer_stats(self, account: str) -> ReferrerStats: + """Return aggregate referral statistics for a referrer. + + GET ``/api/v1/referrals/stats/{account}``. + """ + response, _, _ = await self.get_request( + model=ReferrerStats, + url=f"{self.config.trading_http_url}/api/v1/referrals/stats/{account}", + ) + return response + + async def get_user_referrals( + self, + *, + referrer_account: str, + limit: int | None = None, + offset: int | None = None, + ) -> list[UserReferral]: + """Return the (paginated) list of users referred by a referrer. + + GET ``/api/v1/referrals/users``. + """ + params: dict[str, str] = {"referrer_account": referrer_account} + if limit is not None: + params["limit"] = str(limit) + if offset is not None: + params["offset"] = str(offset) + response, _, _ = await self.get_request( + model=_UserReferralsResponse, + url=f"{self.config.trading_http_url}/api/v1/referrals/users", + params=params, + ) + return response.root + + async def get_affiliate_codes(self, account: str) -> AffiliateCodesResponse: + """Return all referral codes owned by an account with per-code usage stats. + + GET ``/api/v1/affiliates/codes/{account}``. + """ + response, _, _ = await self.get_request( + model=AffiliateCodesResponse, + url=f"{self.config.trading_http_url}/api/v1/affiliates/codes/{account}", + ) + return response + + async def get_affiliate_earnings(self, account: str) -> AffiliateEarningsResponse: + """Return the affiliate earnings breakdown and referred users for an account. + + GET ``/api/v1/affiliates/earnings/{account}``. + """ + response, _, _ = await self.get_request( + model=AffiliateEarningsResponse, + url=f"{self.config.trading_http_url}/api/v1/affiliates/earnings/{account}", + params={"limit": "1000"}, + ) + return response diff --git a/src/decibel/read/_streaks.py b/src/decibel/read/_streaks.py new file mode 100644 index 0000000..6d92332 --- /dev/null +++ b/src/decibel/read/_streaks.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from ._base import BaseReader + +__all__ = [ + "AccountStreaks", + "StreaksReader", +] + + +class AccountStreaks(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + owner: str + current_streak: float = Field(alias="currentStreak") + streak_ipoints: float = Field(alias="streakIpoints") + streak_amps_estimate: float = Field(alias="streakAmpsEstimate") + grace_days_available: float = Field(alias="graceDaysAvailable") + grace_days_used: float = Field(alias="graceDaysUsed") + qualifying_dates: list[str] = Field(alias="qualifyingDates") + + +class StreaksReader(BaseReader): + """Read trading-streak data (qualifying dates, grace days) for an owner.""" + + async def get_by_owner(self, *, owner_addr: str) -> AccountStreaks: + """Return streak data for an owner, including qualifying dates and grace days. + + GET ``/api/v1/streaks/account``. + """ + response, _, _ = await self.get_request( + model=AccountStreaks, + url=f"{self.config.trading_http_url}/api/v1/streaks/account", + params={"owner": owner_addr}, + ) + return response diff --git a/src/decibel/read/_tier.py b/src/decibel/read/_tier.py new file mode 100644 index 0000000..601f06c --- /dev/null +++ b/src/decibel/read/_tier.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from ._base import BaseReader + +__all__ = [ + "TierInfo", + "TierReader", + "TierThreshold", +] + + +class TierThreshold(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + name: str + hz_threshold: float + progress: float + + +class TierInfo(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + owner: str + total_amps: float + rank: int | None = None + current_tier: str | None = None + tiers: list[TierThreshold] + + +class TierReader(BaseReader): + """Read percentile-based tier info and progress for an owner.""" + + async def get_by_owner(self, *, owner_addr: str) -> TierInfo: + """Return tier info for an owner with progress toward each tier. + + GET ``/api/v1/points/tier``. + """ + response, _, _ = await self.get_request( + model=TierInfo, + url=f"{self.config.trading_http_url}/api/v1/points/tier", + params={"owner": owner_addr}, + ) + return response diff --git a/src/decibel/read/_trading_amps.py b/src/decibel/read/_trading_amps.py new file mode 100644 index 0000000..7faea91 --- /dev/null +++ b/src/decibel/read/_trading_amps.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from ._base import BaseReader + +__all__ = [ + "OwnerTradingAmps", + "SubaccountAmps", + "TradingAmpsReader", +] + + +class SubaccountAmps(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + account: str + total_amps: float + + +class OwnerTradingAmps(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + owner: str + total_amps: float + breakdown: list[SubaccountAmps] | None = None + + +class TradingAmpsReader(BaseReader): + """Read aggregated trading Hz (Amps) for an owner across subaccounts.""" + + async def get_by_owner( + self, + *, + owner_addr: str, + season: str | None = None, + days: int | None = None, + ) -> OwnerTradingAmps: + """Return aggregated trading Hz (Amps) for an owner with a per-subaccount breakdown. + + GET ``/api/v1/points/trading/amps``. ``season`` filters to a single + season; ``days`` looks back N days (omit for lifetime totals). + """ + params: dict[str, str] = {"owner": owner_addr} + if season is not None: + params["season"] = season + if days is not None: + params["days"] = str(days) + response, _, _ = await self.get_request( + model=OwnerTradingAmps, + url=f"{self.config.trading_http_url}/api/v1/points/trading/amps", + params=params, + ) + return response diff --git a/src/decibel/read/_user_fees.py b/src/decibel/read/_user_fees.py new file mode 100644 index 0000000..310cfca --- /dev/null +++ b/src/decibel/read/_user_fees.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from ._base import BaseReader + +__all__ = [ + "DailyUserVolume", + "FeeSchedule", + "FeeTiers", + "MarketMakerTier", + "UserFees", + "UserFeesReader", + "VipTier", +] + + +class DailyUserVolume(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + date: str + volume: str + maker_volume: str + taker_volume: str + + +class VipTier(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + volume_threshold: str + taker: float + maker: float + + +class MarketMakerTier(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + maker_fraction_threshold: str + maker: float + + +class FeeTiers(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + vip: list[VipTier] + market_maker: list[MarketMakerTier] + + +class FeeSchedule(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + taker: float + maker: float + tiers: FeeTiers + referral_discount: float + + +class UserFees(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + account: str + daily_user_volume: list[DailyUserVolume] + fee_schedule: FeeSchedule + user_taker_rate: float + user_maker_rate: float + fee_tier: int + active_referral_discount: float + + +class UserFeesReader(BaseReader): + """Read a subaccount's effective fee rates and the full fee schedule.""" + + async def get_by_addr(self, *, sub_addr: str) -> UserFees: + """Return the user's fee rates, current tier, and full fee schedule. + + GET ``/api/v1/user_fee_rates``. Includes effective maker/taker rates, + the current fee tier (from the on-chain fee window), all VIP tiers, and + the daily volume history for that window. Rates are decimals + (e.g. ``0.000340`` = 0.034%). + """ + response, _, _ = await self.get_request( + model=UserFees, + url=f"{self.config.trading_http_url}/api/v1/user_fee_rates", + params={"account": sub_addr}, + ) + return response diff --git a/src/decibel/read/_withdraw_queue.py b/src/decibel/read/_withdraw_queue.py new file mode 100644 index 0000000..bbaff8d --- /dev/null +++ b/src/decibel/read/_withdraw_queue.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Literal, cast + +from aptos_sdk.account_address import AccountAddress +from pydantic import BaseModel, ConfigDict, field_validator + +from .._pagination import PaginatedResponse +from ._base import BaseReader + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from ._ws import Unsubscribe + +__all__ = [ + "KnownWithdrawCancelReason", + "PendingWithdrawRequest", + "WithdrawQueueEntry", + "WithdrawQueueReader", + "WithdrawQueueResponse", + "WithdrawQueueStatus", + "WithdrawQueueUpdate", + "is_known_cancel_reason", + "merge_withdraw_queue_entries", +] + +WithdrawQueueStatus = Literal["Queued", "Processed", "Cancelled"] + +KnownWithdrawCancelReason = Literal[ + "CancelledByUser", + "InsufficientWithdrawableBalance", + "DepositCheckFailed", +] + +_KNOWN_CANCEL_REASONS: frozenset[str] = frozenset( + ("CancelledByUser", "InsufficientWithdrawableBalance", "DepositCheckFailed") +) + + +def is_known_cancel_reason(reason: str) -> bool: + """Return True when the cancel reason is one of the known enum values.""" + return reason in _KNOWN_CANCEL_REASONS + + +class PendingWithdrawRequest(BaseModel): + """On-chain view shape (liveness-check fallback), raw chain units.""" + + model_config = ConfigDict(populate_by_name=True) + + request_id: str + user: str + recipient: str + # Move `Option` decoded: `{"vec": [...]}` -> first element or None. + market: str | None = None + metadata: str + fungible_amount: str + created_at: str + + @field_validator("market", mode="before") + @classmethod + def _unwrap_option(cls, value: Any) -> Any: + if isinstance(value, dict) and "vec" in value: + vec = cast("list[Any]", value["vec"]) + return vec[0] if vec else None + return cast("Any", value) + + +class WithdrawQueueEntry(BaseModel): + """Indexed entry from the trading API (normalized amounts, all statuses).""" + + model_config = ConfigDict(populate_by_name=True) + + user: str + recipient: str | None = None + market: str | None = None + fungible_amount: float + processed_amount: float + request_id: str + status: WithdrawQueueStatus + cancel_reason: str | None = None + timestamp_ms: int + queued_at_ms: int | None = None + transaction_version: int + + +WithdrawQueueResponse = PaginatedResponse[WithdrawQueueEntry] + + +class WithdrawQueueUpdate(BaseModel): + """WS payload shape (topic stripped by the ws layer before parsing).""" + + model_config = ConfigDict(populate_by_name=True) + + entries: list[WithdrawQueueEntry] + + +def merge_withdraw_queue_entries( + *, + existing: list[WithdrawQueueEntry], + delta: list[WithdrawQueueEntry], +) -> list[WithdrawQueueEntry]: + """Merge incremental WS deltas into an existing entry list. + + Matches by ``request_id``; applies a delta only when its + ``transaction_version`` is strictly greater. Field-level merge preserves + non-null ``recipient`` / ``market`` / ``queued_at_ms`` from existing entries. + Returns a new list sorted by queue time descending. + """ + grouped: dict[str, list[WithdrawQueueEntry]] = {} + for entry in existing: + grouped.setdefault(entry.request_id, []).append(entry) + + merged: dict[str, WithdrawQueueEntry] = {} + for request_id, rows in grouped.items(): + best = rows[0] + for row in rows[1:]: + if row.transaction_version > best.transaction_version: + best = row + recipient = best.recipient + market = best.market + queued_at_ms = best.queued_at_ms + for row in rows: + if recipient is None and row.recipient is not None: + recipient = row.recipient + if market is None and row.market is not None: + market = row.market + if queued_at_ms is None and row.queued_at_ms is not None: + queued_at_ms = row.queued_at_ms + merged[request_id] = best.model_copy( + update={"recipient": recipient, "market": market, "queued_at_ms": queued_at_ms} + ) + + for update in delta: + prev = merged.get(update.request_id) + if prev is None: + merged[update.request_id] = update + continue + if update.transaction_version <= prev.transaction_version: + continue + if update.status == "Cancelled": + cancel_reason = ( + update.cancel_reason + if update.cancel_reason is not None + else (prev.cancel_reason if prev.status == "Cancelled" else None) + ) + else: + cancel_reason = None + merged[update.request_id] = update.model_copy( + update={ + "recipient": update.recipient if update.recipient is not None else prev.recipient, + "market": update.market if update.market is not None else prev.market, + "queued_at_ms": ( + update.queued_at_ms if update.queued_at_ms is not None else prev.queued_at_ms + ), + "cancel_reason": cancel_reason, + } + ) + + def _queue_time(entry: WithdrawQueueEntry) -> int: + if entry.queued_at_ms is not None: + return entry.queued_at_ms + return entry.timestamp_ms if entry.status == "Queued" else 0 + + result = list(merged.values()) + result.sort(key=_queue_time, reverse=True) + return result + + +class WithdrawQueueReader(BaseReader): + """Read withdrawal-queue entries via the indexed HTTP/WS API or on-chain view. + + Three data paths: ``get_by_addr`` / ``subscribe_by_addr`` (indexed API, + primary source) and ``get_pending_withdrawals`` (on-chain RPC view, + liveness-check fallback in raw chain units). + """ + + async def get_by_addr( + self, + *, + sub_addr: str, + status: WithdrawQueueStatus | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> PaginatedResponse[WithdrawQueueEntry]: + """Return withdrawal-queue entries for an account from the indexed API. + + GET ``/api/v1/withdraw_queue``. Use ``request_id`` as the stable key to + reconcile entries with WS updates. Without a ``status`` filter the + response may contain multiple rows per ``request_id`` (one per state + transition); dedupe by keeping the highest ``transaction_version``. + """ + params: dict[str, str] = {"account": sub_addr} + if limit is not None: + params["limit"] = str(limit) + if offset is not None: + params["offset"] = str(offset) + if status is not None: + params["status"] = status + response, _, _ = await self.get_request( + model=PaginatedResponse[WithdrawQueueEntry], + url=f"{self.config.trading_http_url}/api/v1/withdraw_queue", + params=params, + ) + return response + + def subscribe_by_addr( + self, + sub_addr: str, + on_data: ( + Callable[[WithdrawQueueUpdate], None] | Callable[[WithdrawQueueUpdate], Awaitable[None]] + ), + ) -> Unsubscribe: + """Subscribe to real-time withdrawal-queue deltas for a user address. + + Streaming-only (no initial snapshot): subscribe first, then seed via + ``get_by_addr`` and combine with :func:`merge_withdraw_queue_entries`. + Each message carries incremental deltas keyed by ``request_id``. Returns + an unsubscribe callable. + """ + topic = f"withdraw_queue:{sub_addr}" + return self.ws.subscribe(topic, WithdrawQueueUpdate, on_data) + + async def get_pending_withdrawals( + self, + user_addr: str | AccountAddress, + ) -> list[PendingWithdrawRequest]: + """Return currently-queued withdrawals from the on-chain view (fallback). + + Returns an empty list when the queue module/resource is not found. + """ + normalized = ( + user_addr + if isinstance(user_addr, AccountAddress) + else AccountAddress.from_str(user_addr) + ) + pkg = self.config.deployment.package + try: + result_bytes = await self.aptos.view( + f"{pkg}::async_withdraw_queue::get_pending_withdrawals", + [], + [str(normalized)], + ) + result: list[Any] = json.loads(result_bytes.decode("utf-8")) + return [PendingWithdrawRequest.model_validate(item) for item in result[0]] + except Exception as e: + msg = str(e) + if any( + code in msg + for code in ("module_not_found", "function_not_found", "resource_not_found") + ): + return [] + raise diff --git a/src/decibel/write/__init__.py b/src/decibel/write/__init__.py index 285d3ee..e8bc925 100644 --- a/src/decibel/write/__init__.py +++ b/src/decibel/write/__init__.py @@ -148,6 +148,17 @@ async def create_subaccount(self) -> dict[str, Any]: ) ) + async def admin_create_subaccount(self, owner_address: str) -> dict[str, Any]: + """Create a new subaccount on behalf of ``owner_address`` (admin action).""" + pkg = self._config.deployment.package + return await self._send_tx( + InputEntryFunctionData( + function=f"{pkg}::dex_accounts_entry::admin_create_new_subaccount", + type_arguments=[], + function_arguments=[owner_address], + ) + ) + async def deposit( self, amount: int, @@ -194,6 +205,30 @@ async def _send(addr: str) -> dict[str, Any]: return await self.send_subaccount_tx(_send, subaccount_addr) + async def withdraw_non_collateral( + self, + asset_addr: str, + amount: int, + subaccount_addr: str | None = None, + txn_submit_timeout: float | None = None, + txn_confirm_timeout: float | None = None, + ) -> dict[str, Any]: + """Withdraw a non-collateral asset (``asset_addr``) from a subaccount.""" + pkg = self._config.deployment.package + + async def _send(addr: str) -> dict[str, Any]: + return await self._send_tx( + InputEntryFunctionData( + function=f"{pkg}::dex_accounts_entry::withdraw_from_non_collateral", + type_arguments=[], + function_arguments=[addr, asset_addr, amount], + ), + txn_submit_timeout=txn_submit_timeout, + txn_confirm_timeout=txn_confirm_timeout, + ) + + return await self.send_subaccount_tx(_send, subaccount_addr) + async def configure_user_settings_for_market( self, *, @@ -432,6 +467,60 @@ async def _send(addr: str) -> dict[str, Any]: return await self.send_subaccount_tx(_send, subaccount_addr) + async def update_order( + self, + *, + order_id: int | str, + market_addr: str, + price: int | float, + size: int | float, + is_buy: bool, + time_in_force: TimeInForce, + is_reduce_only: bool, + tp_trigger_price: int | float | None = None, + tp_limit_price: int | float | None = None, + sl_trigger_price: int | float | None = None, + sl_limit_price: int | float | None = None, + subaccount_addr: str | None = None, + account_override: Account | None = None, + txn_submit_timeout: float | None = None, + txn_confirm_timeout: float | None = None, + ) -> dict[str, Any]: + """Replace a resting order's price/size/side/TIF and optional TP/SL. + + Wraps the ``update_order_to_subaccount`` entry function. + """ + pkg = self._config.deployment.package + + async def _send(addr: str) -> dict[str, Any]: + return await self._send_tx( + InputEntryFunctionData( + function=f"{pkg}::dex_accounts_entry::update_order_to_subaccount", + type_arguments=[], + function_arguments=[ + addr, + int(order_id), + market_addr, + price, + size, + is_buy, + time_in_force, + is_reduce_only, + tp_trigger_price, + tp_limit_price, + sl_trigger_price, + sl_limit_price, + None, # builder_address + None, # builder_fees + ], + ), + account_override, + txn_submit_timeout=txn_submit_timeout, + txn_confirm_timeout=txn_confirm_timeout, + ) + + return await self.send_subaccount_tx(_send, subaccount_addr) + async def place_bulk_orders( self, *, @@ -1088,6 +1177,17 @@ async def _send(addr: str) -> dict[str, Any]: return await self.send_subaccount_tx(_send, subaccount_addr) + async def claim_campaign_reward(self, campaign_id: int) -> dict[str, Any]: + """Claim the reward for a campaign by id (``campaign_manager::claim_by_id``).""" + campaign_pkg = self._config.deployment.campaign_package + return await self._send_tx( + InputEntryFunctionData( + function=f"{campaign_pkg}::campaign_manager::claim_by_id", + type_arguments=[], + function_arguments=[campaign_id], + ) + ) + class DecibelWriteDexSync(BaseSDKSync): def __init__( @@ -1183,6 +1283,17 @@ def create_subaccount(self) -> dict[str, Any]: ) ) + def admin_create_subaccount(self, owner_address: str) -> dict[str, Any]: + """Create a new subaccount on behalf of ``owner_address`` (admin action).""" + pkg = self._config.deployment.package + return self._send_tx( + InputEntryFunctionData( + function=f"{pkg}::dex_accounts_entry::admin_create_new_subaccount", + type_arguments=[], + function_arguments=[owner_address], + ) + ) + def deposit( self, amount: int, @@ -1229,6 +1340,30 @@ def _send(addr: str) -> dict[str, Any]: return self.send_subaccount_tx(_send, subaccount_addr) + def withdraw_non_collateral( + self, + asset_addr: str, + amount: int, + subaccount_addr: str | None = None, + txn_submit_timeout: float | None = None, + txn_confirm_timeout: float | None = None, + ) -> dict[str, Any]: + """Withdraw a non-collateral asset (``asset_addr``) from a subaccount.""" + pkg = self._config.deployment.package + + def _send(addr: str) -> dict[str, Any]: + return self._send_tx( + InputEntryFunctionData( + function=f"{pkg}::dex_accounts_entry::withdraw_from_non_collateral", + type_arguments=[], + function_arguments=[addr, asset_addr, amount], + ), + txn_submit_timeout=txn_submit_timeout, + txn_confirm_timeout=txn_confirm_timeout, + ) + + return self.send_subaccount_tx(_send, subaccount_addr) + def configure_user_settings_for_market( self, *, @@ -1467,6 +1602,60 @@ def _send(addr: str) -> dict[str, Any]: return self.send_subaccount_tx(_send, subaccount_addr) + def update_order( + self, + *, + order_id: int | str, + market_addr: str, + price: int | float, + size: int | float, + is_buy: bool, + time_in_force: TimeInForce, + is_reduce_only: bool, + tp_trigger_price: int | float | None = None, + tp_limit_price: int | float | None = None, + sl_trigger_price: int | float | None = None, + sl_limit_price: int | float | None = None, + subaccount_addr: str | None = None, + account_override: Account | None = None, + txn_submit_timeout: float | None = None, + txn_confirm_timeout: float | None = None, + ) -> dict[str, Any]: + """Replace a resting order's price/size/side/TIF and optional TP/SL. + + Wraps the ``update_order_to_subaccount`` entry function. + """ + pkg = self._config.deployment.package + + def _send(addr: str) -> dict[str, Any]: + return self._send_tx( + InputEntryFunctionData( + function=f"{pkg}::dex_accounts_entry::update_order_to_subaccount", + type_arguments=[], + function_arguments=[ + addr, + int(order_id), + market_addr, + price, + size, + is_buy, + time_in_force, + is_reduce_only, + tp_trigger_price, + tp_limit_price, + sl_trigger_price, + sl_limit_price, + None, # builder_address + None, # builder_fees + ], + ), + account_override, + txn_submit_timeout=txn_submit_timeout, + txn_confirm_timeout=txn_confirm_timeout, + ) + + return self.send_subaccount_tx(_send, subaccount_addr) + def place_bulk_orders( self, *, @@ -2118,3 +2307,14 @@ def _send(addr: str) -> dict[str, Any]: ) return self.send_subaccount_tx(_send, subaccount_addr) + + def claim_campaign_reward(self, campaign_id: int) -> dict[str, Any]: + """Claim the reward for a campaign by id (``campaign_manager::claim_by_id``).""" + campaign_pkg = self._config.deployment.campaign_package + return self._send_tx( + InputEntryFunctionData( + function=f"{campaign_pkg}::campaign_manager::claim_by_id", + type_arguments=[], + function_arguments=[campaign_id], + ) + ) diff --git a/src/decibel/write/_types.py b/src/decibel/write/_types.py index 11046b8..fd17a96 100644 --- a/src/decibel/write/_types.py +++ b/src/decibel/write/_types.py @@ -17,6 +17,7 @@ "DelegateTradingArgs", "RevokeDelegationArgs", "PlaceTpSlOrderArgs", + "UpdateOrderArgs", "UpdateTpOrderArgs", "UpdateSlOrderArgs", "CancelTpSlOrderArgs", @@ -122,6 +123,22 @@ class PlaceTpSlOrderArgs(TypedDict, total=False): tick_size: float | None +class UpdateOrderArgs(TypedDict, total=False): + order_id: int | str + market_addr: str + price: float + size: float + is_buy: bool + time_in_force: TimeInForce + is_reduce_only: bool + tp_trigger_price: float | None + tp_limit_price: float | None + sl_trigger_price: float | None + sl_limit_price: float | None + subaccount_addr: str | None + account_override: Account | None + + class UpdateTpOrderArgs(TypedDict, total=False): market_addr: str prev_order_id: int | str diff --git a/tests/read/test_read_view_helpers.py b/tests/read/test_read_view_helpers.py new file mode 100644 index 0000000..232ebf0 --- /dev/null +++ b/tests/read/test_read_view_helpers.py @@ -0,0 +1,162 @@ +"""Tests for DecibelReadDex on-chain view / resource helpers.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from decibel.read import DecibelReadDex +from decibel.read._types import CrossedPosition + +if TYPE_CHECKING: + from decibel._constants import DecibelConfig + +# Reader attributes DecibelReadDex.__init__ must wire up. +_READER_ATTRS = ( + "account_overview", + "candlesticks", + "delegations", + "leaderboard", + "markets", + "market_prices", + "market_depth", + "market_trades", + "market_contexts", + "portfolio_chart", + "user_positions", + "user_open_orders", + "user_order_history", + "user_trade_history", + "user_bulk_orders", + "user_subaccounts", + "user_fund_history", + "user_funding_history", + "user_active_twaps", + "user_twap_history", + "user_notifications", + "vaults", + "trading_points", + "campaigns", + "points_leaderboard", + "streaks", + "trading_amps", + "tier", + "global_points_stats", + "referrals", + "user_fees", + "withdraw_queue", +) + + +@pytest.fixture +def read_dex(test_config: DecibelConfig) -> DecibelReadDex: + """A DecibelReadDex with only the attributes the view helpers need.""" + dex = DecibelReadDex.__new__(DecibelReadDex) + dex._config = test_config + dex._aptos = AsyncMock() + dex._usdc_decimals_cache = None + return dex + + +def _view_bytes(value: list[object]) -> bytes: + return json.dumps(value).encode("utf-8") + + +class TestGlobalPerpEngineState: + async def test_returns_resource(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.account_resource = AsyncMock(return_value={"foo": "bar"}) + result = await read_dex.global_perp_engine_state() + assert result == {"foo": "bar"} + call = read_dex._aptos.account_resource.call_args + pkg = read_dex._config.deployment.package + assert call.args[1] == f"{pkg}::perp_engine::Global" + + async def test_returns_false_on_error(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.account_resource = AsyncMock(side_effect=Exception("missing")) + assert await read_dex.global_perp_engine_state() is False + + +class TestDecimalsAndBalances: + async def test_collateral_balance_decimals(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.view = AsyncMock(return_value=_view_bytes([6])) + assert await read_dex.collateral_balance_decimals() == 6 + + async def test_usdc_decimals_is_cached(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.view = AsyncMock(return_value=_view_bytes([6])) + assert await read_dex.usdc_decimals() == 6 + assert await read_dex.usdc_decimals() == 6 + # Cached: only one view call. + read_dex._aptos.view.assert_awaited_once() + + async def test_usdc_balance_scales_by_decimals(self, read_dex: DecibelReadDex) -> None: + read_dex._usdc_decimals_cache = 6 + read_dex._aptos.view = AsyncMock(return_value=_view_bytes(["2500000"])) + assert await read_dex.usdc_balance("0x" + "aa" * 32) == 2.5 + + async def test_token_balance_scales_by_decimals(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.view = AsyncMock(return_value=_view_bytes(["1000"])) + result = await read_dex.token_balance("0x" + "aa" * 32, "0x" + "bb" * 32, 3) + assert result == 1.0 + + async def test_account_balance(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.view = AsyncMock(return_value=_view_bytes(["123456"])) + assert await read_dex.account_balance("0x" + "aa" * 32) == 123456 + pkg = read_dex._config.deployment.package + assert ( + read_dex._aptos.view.call_args.args[0] + == f"{pkg}::perp_engine::get_cross_total_collateral_value" + ) + + async def test_position_size(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.view = AsyncMock(return_value=_view_bytes(["42"])) + result = await read_dex.position_size("0x" + "aa" * 32, "0x" + "11" * 32) + assert result == ["42"] + + +class TestGetCrossedPosition: + async def test_returns_parsed_position(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.account_resource = AsyncMock(return_value={"positions": []}) + result = await read_dex.get_crossed_position("0x" + "aa" * 32) + assert isinstance(result, CrossedPosition) + assert result.positions == [] + + async def test_returns_none_on_error(self, read_dex: DecibelReadDex) -> None: + read_dex._aptos.account_resource = AsyncMock(side_effect=Exception("no resource")) + assert await read_dex.get_crossed_position("0x" + "aa" * 32) is None + + +class TestConstructionAndLifecycle: + def _build(self, config: DecibelConfig) -> DecibelReadDex: + with ( + patch("decibel.read.RestClient"), + patch("decibel.read.DecibelWsSubscription"), + patch("decibel.read.httpx.AsyncClient"), + ): + return DecibelReadDex(config, api_key="k") + + def test_constructor_wires_all_readers(self, test_config: DecibelConfig) -> None: + dex = self._build(test_config) + for attr in _READER_ATTRS: + assert hasattr(dex, attr), attr + assert dex._config is test_config + assert dex._usdc_decimals_cache is None + + async def test_close_closes_ws_and_http(self, test_config: DecibelConfig) -> None: + dex = self._build(test_config) + dex.ws.close = AsyncMock() + dex._http_client.aclose = AsyncMock() + await dex.close() + dex.ws.close.assert_awaited_once() + dex._http_client.aclose.assert_awaited_once() + + async def test_async_context_manager(self, test_config: DecibelConfig) -> None: + dex = self._build(test_config) + dex.ws.close = AsyncMock() + dex._http_client.aclose = AsyncMock() + async with dex as ctx: + assert ctx is dex + dex.ws.close.assert_awaited_once() + dex._http_client.aclose.assert_awaited_once() diff --git a/tests/read/test_readers_parity.py b/tests/read/test_readers_parity.py new file mode 100644 index 0000000..1f8c831 --- /dev/null +++ b/tests/read/test_readers_parity.py @@ -0,0 +1,556 @@ +"""Tests for the TS-SDK parity readers added to decibel.read.*""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from decibel.read._base import ReaderDeps + +if TYPE_CHECKING: + from decibel._constants import DecibelConfig + + +@pytest.fixture +def reader_deps(test_config: DecibelConfig) -> ReaderDeps: + return ReaderDeps( + config=test_config, + ws=MagicMock(), + aptos=MagicMock(), + api_key="test-key", + http_client=AsyncMock(spec=httpx.AsyncClient), + http_client_sync=MagicMock(spec=httpx.Client), + ) + + +def _base_url(reader_deps: ReaderDeps) -> str: + return reader_deps.config.trading_http_url + + +# --------------------------------------------------------------------------- +# CampaignsReader +# --------------------------------------------------------------------------- + + +class TestCampaignsReader: + async def test_get_active(self, reader_deps: ReaderDeps) -> None: + from decibel.read._campaigns import CampaignsReader + + reader = CampaignsReader(reader_deps) + root = MagicMock() + root.root = ["campaign"] + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (root, 200, "OK") + result = await reader.get_active() + assert result == ["campaign"] + assert ( + mock_req.call_args.kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/campaigns/active" + ) + + async def test_get_summary(self, reader_deps: ReaderDeps) -> None: + from decibel.read._campaigns import CampaignsReader + + reader = CampaignsReader(reader_deps) + summary = MagicMock() + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (summary, 200, "OK") + result = await reader.get_summary(account_address="0xabc", limit=5, offset=10) + assert result is summary + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/campaigns/account" + assert kwargs["params"] == {"account": "0xabc", "limit": "5", "offset": "10"} + + +# --------------------------------------------------------------------------- +# PointsLeaderboardReader +# --------------------------------------------------------------------------- + + +class TestPointsLeaderboardReader: + async def test_get_points_leaderboard(self, reader_deps: ReaderDeps) -> None: + from decibel.read._points_leaderboard import PointsLeaderboardReader + + reader = PointsLeaderboardReader(reader_deps) + page = MagicMock() + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (page, 200, "OK") + result = await reader.get_points_leaderboard( + limit=20, tier="diamond", sort_key="total_amps" + ) + assert result is page + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/points_leaderboard" + assert kwargs["params"] == {"limit": "20", "sort_key": "total_amps", "tier": "diamond"} + + +# --------------------------------------------------------------------------- +# StreaksReader +# --------------------------------------------------------------------------- + + +class TestStreaksReader: + async def test_get_by_owner(self, reader_deps: ReaderDeps) -> None: + from decibel.read._streaks import AccountStreaks, StreaksReader + + reader = StreaksReader(reader_deps) + streaks = AccountStreaks.model_validate( + { + "owner": "0xabc", + "currentStreak": 3, + "streakIpoints": 10, + "streakAmpsEstimate": 5, + "graceDaysAvailable": 2, + "graceDaysUsed": 1, + "qualifyingDates": ["2026-01-01"], + } + ) + assert streaks.current_streak == 3 + assert streaks.qualifying_dates == ["2026-01-01"] + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (streaks, 200, "OK") + result = await reader.get_by_owner(owner_addr="0xabc") + assert result is streaks + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/streaks/account" + assert kwargs["params"] == {"owner": "0xabc"} + + +# --------------------------------------------------------------------------- +# TradingAmpsReader +# --------------------------------------------------------------------------- + + +class TestTradingAmpsReader: + async def test_get_by_owner_with_filters(self, reader_deps: ReaderDeps) -> None: + from decibel.read._trading_amps import OwnerTradingAmps, TradingAmpsReader + + reader = TradingAmpsReader(reader_deps) + amps = OwnerTradingAmps(owner="0xabc", total_amps=100.0, breakdown=None) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (amps, 200, "OK") + result = await reader.get_by_owner(owner_addr="0xabc", season="season1", days=7) + assert result is amps + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/points/trading/amps" + assert kwargs["params"] == {"owner": "0xabc", "season": "season1", "days": "7"} + + +# --------------------------------------------------------------------------- +# TierReader +# --------------------------------------------------------------------------- + + +class TestTierReader: + async def test_get_by_owner(self, reader_deps: ReaderDeps) -> None: + from decibel.read._tier import TierInfo, TierReader + + reader = TierReader(reader_deps) + tier = TierInfo(owner="0xabc", total_amps=1.0, rank=None, current_tier=None, tiers=[]) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (tier, 200, "OK") + result = await reader.get_by_owner(owner_addr="0xabc") + assert result is tier + assert mock_req.call_args.kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/points/tier" + + +# --------------------------------------------------------------------------- +# GlobalPointsStatsReader +# --------------------------------------------------------------------------- + + +class TestGlobalPointsStatsReader: + async def test_get(self, reader_deps: ReaderDeps) -> None: + from decibel.read._global_points_stats import GlobalPointsStats, GlobalPointsStatsReader + + reader = GlobalPointsStatsReader(reader_deps) + stats = GlobalPointsStats(total_users=10, total_amps_distributed=1000) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (stats, 200, "OK") + result = await reader.get() + assert result is stats + assert mock_req.call_args.kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/points/global" + + +# --------------------------------------------------------------------------- +# ReferralsReader +# --------------------------------------------------------------------------- + + +class TestReferralsReader: + async def test_validate_code_url_encodes(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.validate_code("A B") + assert mock_req.call_args.kwargs["url"].endswith("/api/v1/referrals/code/A%20B") + + async def test_redeem_code_posts_body(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + with patch.object(reader, "post_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.redeem_code(referral_code="CODE", account="0xabc") + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/referrals/redeem" + assert kwargs["body"] == {"referral_code": "CODE", "account": "0xabc"} + + async def test_get_user_referrals_returns_root(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + root = MagicMock() + root.root = ["u1", "u2"] + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (root, 200, "OK") + result = await reader.get_user_referrals(referrer_account="0xabc", limit=2) + assert result == ["u1", "u2"] + kwargs = mock_req.call_args.kwargs + assert kwargs["params"] == {"referrer_account": "0xabc", "limit": "2"} + + +# --------------------------------------------------------------------------- +# UserFeesReader +# --------------------------------------------------------------------------- + + +class TestUserFeesReader: + async def test_get_by_addr(self, reader_deps: ReaderDeps) -> None: + from decibel.read._user_fees import UserFeesReader + + reader = UserFeesReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_by_addr(sub_addr="0xabc") + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/user_fee_rates" + assert kwargs["params"] == {"account": "0xabc"} + + +# --------------------------------------------------------------------------- +# WithdrawQueueReader +# --------------------------------------------------------------------------- + + +def _entry(**overrides: object) -> object: + from decibel.read._withdraw_queue import WithdrawQueueEntry + + base = { + "user": "0xuser", + "recipient": "0xrecipient", + "market": "0xmarket", + "fungible_amount": 10.0, + "processed_amount": 0.0, + "request_id": "1", + "status": "Queued", + "cancel_reason": None, + "timestamp_ms": 1000, + "queued_at_ms": 1000, + "transaction_version": 1, + } + base.update(overrides) + return WithdrawQueueEntry.model_validate(base) + + +class TestWithdrawQueueReader: + async def test_get_by_addr(self, reader_deps: ReaderDeps) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader + + reader = WithdrawQueueReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_by_addr(sub_addr="0xabc", status="Queued", limit=50, offset=0) + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_base_url(reader_deps)}/api/v1/withdraw_queue" + assert kwargs["params"] == { + "account": "0xabc", + "limit": "50", + "offset": "0", + "status": "Queued", + } + + def test_subscribe_by_addr(self, reader_deps: ReaderDeps) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader, WithdrawQueueUpdate + + reader = WithdrawQueueReader(reader_deps) + on_data = MagicMock() + reader.subscribe_by_addr("0xabc", on_data) + reader_deps.ws.subscribe.assert_called_once() + call_args = reader_deps.ws.subscribe.call_args + assert call_args[0][0] == "withdraw_queue:0xabc" + assert call_args[0][1] is WithdrawQueueUpdate + + async def test_get_pending_withdrawals(self, reader_deps: ReaderDeps) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader + + reader = WithdrawQueueReader(reader_deps) + item = { + "request_id": "7", + "user": "0xuser", + "recipient": "0xrecipient", + "market": {"vec": ["0xmarket"]}, + "metadata": "0x", + "fungible_amount": "1000000", + "created_at": "1700000000", + } + reader_deps.aptos.view = AsyncMock(return_value=json.dumps([[item]]).encode("utf-8")) + result = await reader.get_pending_withdrawals("0x" + "aa" * 32) + assert len(result) == 1 + assert result[0].request_id == "7" + assert result[0].market == "0xmarket" + + async def test_get_pending_withdrawals_empty_option(self, reader_deps: ReaderDeps) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader + + reader = WithdrawQueueReader(reader_deps) + item = { + "request_id": "8", + "user": "0xuser", + "recipient": "0xrecipient", + "market": {"vec": []}, + "metadata": "0x", + "fungible_amount": "5", + "created_at": "1700000000", + } + reader_deps.aptos.view = AsyncMock(return_value=json.dumps([[item]]).encode("utf-8")) + result = await reader.get_pending_withdrawals("0x" + "aa" * 32) + assert result[0].market is None + + async def test_get_pending_withdrawals_swallows_not_found( + self, reader_deps: ReaderDeps + ) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader + + reader = WithdrawQueueReader(reader_deps) + reader_deps.aptos.view = AsyncMock(side_effect=Exception("module_not_found: nope")) + assert await reader.get_pending_withdrawals("0x" + "aa" * 32) == [] + + async def test_get_pending_withdrawals_reraises_unexpected( + self, reader_deps: ReaderDeps + ) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader + + reader = WithdrawQueueReader(reader_deps) + reader_deps.aptos.view = AsyncMock(side_effect=Exception("boom")) + with pytest.raises(Exception, match="boom"): + await reader.get_pending_withdrawals("0x" + "aa" * 32) + + +# --------------------------------------------------------------------------- +# withdraw_queue helpers (pure logic) +# --------------------------------------------------------------------------- + + +class TestWithdrawQueueHelpers: + def test_is_known_cancel_reason(self) -> None: + from decibel.read._withdraw_queue import is_known_cancel_reason + + assert is_known_cancel_reason("CancelledByUser") is True + assert is_known_cancel_reason("SomethingElse") is False + + def test_merge_applies_higher_version(self) -> None: + from decibel.read._withdraw_queue import merge_withdraw_queue_entries + + existing = [_entry(request_id="1", status="Queued", transaction_version=1)] + delta = [ + _entry( + request_id="1", + status="Processed", + processed_amount=10.0, + transaction_version=2, + queued_at_ms=None, + ) + ] + merged = merge_withdraw_queue_entries(existing=existing, delta=delta) # type: ignore[arg-type] + assert len(merged) == 1 + assert merged[0].status == "Processed" + # queued_at_ms preserved from the existing enriched entry + assert merged[0].queued_at_ms == 1000 + + def test_merge_ignores_stale_version(self) -> None: + from decibel.read._withdraw_queue import merge_withdraw_queue_entries + + existing = [_entry(request_id="1", status="Processed", transaction_version=5)] + delta = [_entry(request_id="1", status="Queued", transaction_version=2)] + merged = merge_withdraw_queue_entries(existing=existing, delta=delta) # type: ignore[arg-type] + assert merged[0].status == "Processed" + + def test_merge_clears_cancel_reason_for_non_cancelled(self) -> None: + from decibel.read._withdraw_queue import merge_withdraw_queue_entries + + existing = [ + _entry( + request_id="1", + status="Cancelled", + cancel_reason="CancelledByUser", + transaction_version=1, + ) + ] + delta = [_entry(request_id="1", status="Processed", transaction_version=2)] + merged = merge_withdraw_queue_entries(existing=existing, delta=delta) # type: ignore[arg-type] + assert merged[0].cancel_reason is None + + def test_merge_sorts_queued_before_terminal(self) -> None: + from decibel.read._withdraw_queue import merge_withdraw_queue_entries + + terminal = _entry( + request_id="1", + status="Processed", + queued_at_ms=None, + timestamp_ms=9999, + transaction_version=2, + ) + queued = _entry(request_id="2", status="Queued", queued_at_ms=5000, transaction_version=1) + merged = merge_withdraw_queue_entries(existing=[terminal, queued], delta=[]) # type: ignore[arg-type] + assert [e.request_id for e in merged] == ["2", "1"] + + +class TestReferralsExtraMethods: + async def test_get_account_referral(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_account_referral("0xabc") + assert mock_req.call_args.kwargs["url"].endswith("/api/v1/referrals/account/0xabc") + + async def test_get_referrer_stats(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_referrer_stats("0xabc") + assert mock_req.call_args.kwargs["url"].endswith("/api/v1/referrals/stats/0xabc") + + async def test_get_affiliate_codes(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_affiliate_codes("0xabc") + assert mock_req.call_args.kwargs["url"].endswith("/api/v1/affiliates/codes/0xabc") + + async def test_get_affiliate_earnings(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_affiliate_earnings("0xabc") + kwargs = mock_req.call_args.kwargs + assert kwargs["url"].endswith("/api/v1/affiliates/earnings/0xabc") + assert kwargs["params"] == {"limit": "1000"} + + async def test_get_user_referrals_no_page_params(self, reader_deps: ReaderDeps) -> None: + from decibel.read._referrals import ReferralsReader + + reader = ReferralsReader(reader_deps) + root = MagicMock() + root.root = [] + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (root, 200, "OK") + await reader.get_user_referrals(referrer_account="0xabc") + assert mock_req.call_args.kwargs["params"] == {"referrer_account": "0xabc"} + + +class TestPointsLeaderboardAllParams: + async def test_all_query_params(self, reader_deps: ReaderDeps) -> None: + from decibel.read._points_leaderboard import PointsLeaderboardReader + + reader = PointsLeaderboardReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_points_leaderboard( + limit=5, + offset=10, + search_term="alice", + sort_key="realized_pnl", + sort_dir="ASC", + tier="gold", + ) + assert mock_req.call_args.kwargs["params"] == { + "limit": "5", + "offset": "10", + "search_term": "alice", + "sort_key": "realized_pnl", + "sort_dir": "ASC", + "tier": "gold", + } + + async def test_no_params(self, reader_deps: ReaderDeps) -> None: + from decibel.read._points_leaderboard import PointsLeaderboardReader + + reader = PointsLeaderboardReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_points_leaderboard() + assert mock_req.call_args.kwargs["params"] is None + + +class TestWithdrawQueueExtra: + async def test_get_by_addr_minimal(self, reader_deps: ReaderDeps) -> None: + from decibel.read._withdraw_queue import WithdrawQueueReader + + reader = WithdrawQueueReader(reader_deps) + with patch.object(reader, "get_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = (MagicMock(), 200, "OK") + await reader.get_by_addr(sub_addr="0xabc") + assert mock_req.call_args.kwargs["params"] == {"account": "0xabc"} + + def test_pending_withdraw_market_passthrough_string(self) -> None: + from decibel.read._withdraw_queue import PendingWithdrawRequest + + req = PendingWithdrawRequest.model_validate( + { + "request_id": "1", + "user": "0xuser", + "recipient": "0xr", + "market": "0xmarket", + "metadata": "0x", + "fungible_amount": "5", + "created_at": "1700000000", + } + ) + assert req.market == "0xmarket" + + def test_merge_new_entry_from_delta(self) -> None: + from decibel.read._withdraw_queue import merge_withdraw_queue_entries + + delta = [_entry(request_id="9", status="Queued", transaction_version=1)] + merged = merge_withdraw_queue_entries(existing=[], delta=delta) # type: ignore[arg-type] + assert [e.request_id for e in merged] == ["9"] + + def test_merge_dedup_existing_fills_enrichment(self) -> None: + from decibel.read._withdraw_queue import merge_withdraw_queue_entries + + enriched = _entry( + request_id="1", + status="Queued", + recipient="0xr", + market="0xm", + queued_at_ms=100, + transaction_version=1, + ) + terminal = _entry( + request_id="1", + status="Processed", + recipient=None, + market=None, + queued_at_ms=None, + transaction_version=2, + ) + merged = merge_withdraw_queue_entries(existing=[enriched, terminal], delta=[]) # type: ignore[arg-type] + assert len(merged) == 1 + assert merged[0].status == "Processed" + assert merged[0].recipient == "0xr" + assert merged[0].market == "0xm" + assert merged[0].queued_at_ms == 100 diff --git a/tests/test_calculations.py b/tests/test_calculations.py new file mode 100644 index 0000000..4e487ae --- /dev/null +++ b/tests/test_calculations.py @@ -0,0 +1,136 @@ +"""Tests for decibel._calculations (liquidation price), ported from the TS SDK.""" + +from __future__ import annotations + +import pytest + +from decibel._calculations import ( + LiquidationMarket, + LiquidationMarketContext, + LiquidationPosition, + LiquidationPriceInput, + calculate_liquidation_price, +) + + +def _make_input(**overrides: object) -> LiquidationPriceInput: + base: dict[str, object] = { + "account_equity": 100.0, + "positions": [], + "markets": [LiquidationMarket("0xBTC", "BTC/USD", 10)], + "market_contexts": [LiquidationMarketContext("BTC/USD", 100)], + "target_market_addr": "0xBTC", + "order_size": 1, + "execution_price": 100, + } + base.update(overrides) + return LiquidationPriceInput(**base) # type: ignore[arg-type] + + +class TestCalculateLiquidationPrice: + def test_long_below_mark(self) -> None: + result = calculate_liquidation_price(_make_input(account_equity=50, order_size=5)) + assert result == pytest.approx(94.736843, abs=1e-5) + + def test_short_above_mark(self) -> None: + result = calculate_liquidation_price(_make_input(order_size=-1)) + assert result == pytest.approx(190.47619, abs=1e-4) + + def test_zero_buffer_returns_mark(self) -> None: + assert calculate_liquidation_price(_make_input(account_equity=5)) == 100 + + def test_negative_buffer_returns_mark(self) -> None: + assert calculate_liquidation_price(_make_input(account_equity=1)) == 100 + + def test_closing_position_returns_zero(self) -> None: + result = calculate_liquidation_price( + _make_input( + positions=[LiquidationPosition("0xBTC", 1, 100)], + order_size=-1, + ) + ) + assert result == 0 + + def test_clamps_to_zero(self) -> None: + assert calculate_liquidation_price(_make_input(account_equity=100000)) == 0 + + def test_direction_flip_long_to_short(self) -> None: + result = calculate_liquidation_price( + _make_input( + positions=[LiquidationPosition("0xBTC", 3, 95)], + order_size=-8, + execution_price=105, + ) + ) + assert result == pytest.approx(121.904761, abs=1e-5) + + def test_direction_flip_short_to_long(self) -> None: + result = calculate_liquidation_price( + _make_input( + positions=[LiquidationPosition("0xBTC", -3, 105)], + order_size=8, + execution_price=95, + ) + ) + assert result == pytest.approx(75.789474, abs=1e-5) + + def test_partial_reduction_keeps_entry(self) -> None: + result = calculate_liquidation_price( + _make_input( + positions=[LiquidationPosition("0xBTC", 10, 90)], + order_size=-3, + execution_price=110, + ) + ) + assert result == pytest.approx(85.714286, abs=1e-5) + + def test_raises_when_market_missing(self) -> None: + with pytest.raises(ValueError, match="Market not found"): + calculate_liquidation_price(_make_input(target_market_addr="0xNONE")) + + def test_raises_when_no_position_and_zero_order(self) -> None: + with pytest.raises(ValueError, match="No position found"): + calculate_liquidation_price(_make_input(order_size=0)) + + def test_raises_on_invalid_max_leverage(self) -> None: + with pytest.raises(ValueError, match="Invalid max_leverage"): + calculate_liquidation_price( + _make_input(markets=[LiquidationMarket("0xBTC", "BTC/USD", 0)]) + ) + + +class TestCalculateLiquidationPriceExtra: + def test_size_increase_uses_vwap_entry(self) -> None: + # Existing +2 @100, add +2 @110 -> net +4, VWAP entry = 105. + result = calculate_liquidation_price( + _make_input( + positions=[LiquidationPosition("0xBTC", 2, 100)], + order_size=2, + execution_price=110, + ) + ) + assert result == pytest.approx(84.210527, abs=1e-5) + + def test_multi_position_adds_maintenance_margin(self) -> None: + # An ETH position contributes maintenance margin against the BTC liq price. + result = calculate_liquidation_price( + _make_input( + positions=[LiquidationPosition("0xETH", 2, 50)], + markets=[ + LiquidationMarket("0xBTC", "BTC/USD", 10), + LiquidationMarket("0xETH", "ETH/USD", 5), + ], + market_contexts=[ + LiquidationMarketContext("BTC/USD", 100), + LiquidationMarketContext("ETH/USD", 50), + ], + order_size=5, + ) + ) + assert result == pytest.approx(86.31579, abs=1e-5) + + def test_raises_when_market_context_missing(self) -> None: + with pytest.raises(ValueError, match="Market context not found"): + calculate_liquidation_price( + _make_input(market_contexts=[LiquidationMarketContext("OTHER", 100)]) + ) diff --git a/tests/test_eip55_derivable.py b/tests/test_eip55_derivable.py new file mode 100644 index 0000000..188eabd --- /dev/null +++ b/tests/test_eip55_derivable.py @@ -0,0 +1,85 @@ +"""Tests for EIP-55 checksums and derivable-account address derivation.""" + +from __future__ import annotations + +import re + +import pytest + +from decibel._derivable_account import derive_aptos_from_eth, derive_aptos_from_solana +from decibel._eip55 import to_checksum_address + +_APTOS_ADDR_RE = re.compile(r"^0x[a-f0-9]{64}$") + +# Well-known EIP-55 checksummed addresses (from the EIP-55 spec / ethers). +_EIP55_VECTORS = [ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", +] + + +class TestToChecksumAddress: + @pytest.mark.parametrize("checksummed", _EIP55_VECTORS) + def test_checksums_match_from_lowercase(self, checksummed: str) -> None: + lower = checksummed.lower() + assert to_checksum_address(lower) == checksummed + + def test_idempotent_on_checksummed(self) -> None: + addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + assert to_checksum_address(addr) == addr + + @pytest.mark.parametrize("bad", ["not-an-address", "0x123", "d8da6bf2", "0x" + "z" * 40]) + def test_raises_on_invalid(self, bad: str) -> None: + with pytest.raises(ValueError, match="Invalid Ethereum address"): + to_checksum_address(bad) + + +class TestDeriveAptosFromEth: + def test_produces_aptos_address(self) -> None: + result = derive_aptos_from_eth("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") + assert _APTOS_ADDR_RE.match(result) + assert len(result) == 66 + + def test_is_deterministic(self) -> None: + addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + assert derive_aptos_from_eth(addr) == derive_aptos_from_eth(addr) + + def test_normalizes_case(self) -> None: + lower = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" + mixed = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + assert derive_aptos_from_eth(lower) == derive_aptos_from_eth(mixed) + + def test_different_inputs_differ(self) -> None: + a = derive_aptos_from_eth("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") + b = derive_aptos_from_eth("0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B") + assert a != b + + def test_raises_on_invalid(self) -> None: + with pytest.raises(ValueError): + derive_aptos_from_eth("not-an-address") + + +class TestDeriveAptosFromSolana: + def test_produces_aptos_address(self) -> None: + result = derive_aptos_from_solana("11111111111111111111111111111111") + assert _APTOS_ADDR_RE.match(result) + assert len(result) == 66 + + def test_is_deterministic(self) -> None: + addr = "11111111111111111111111111111111" + assert derive_aptos_from_solana(addr) == derive_aptos_from_solana(addr) + + def test_different_inputs_differ(self) -> None: + a = derive_aptos_from_solana("11111111111111111111111111111111") + b = derive_aptos_from_solana("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + assert a != b + + +def test_derive_rejects_malformed_auth_function() -> None: + from decibel._derivable_account import _derive_aptos_address # type: ignore[attr-defined] + + with pytest.raises(ValueError, match="Invalid auth function"): + _derive_aptos_address("0x1::only_two_parts", "identity") diff --git a/tests/write/test_write_dex.py b/tests/write/test_write_dex.py index 1c82a6e..cd882c3 100644 --- a/tests/write/test_write_dex.py +++ b/tests/write/test_write_dex.py @@ -1803,3 +1803,163 @@ def fn(addr: str) -> str: result = write_dex_sync.with_subaccount(fn, subaccount_addr=TEST_SUBACCOUNT_ADDR) assert called_with == [TEST_SUBACCOUNT_ADDR] assert result == "done" + + +# =========================================================================== +# Tests for TS-SDK parity write methods (async + sync) +# =========================================================================== + +TEST_UPDATE_MARKET_ADDR = "0x" + "11" * 32 +TEST_ASSET_ADDR = "0x" + "ee" * 32 +TEST_CAMPAIGN_PKG = "0x" + "77" * 32 + + +def _with_campaign_pkg(config, pkg: str = TEST_CAMPAIGN_PKG): + from dataclasses import replace + + return replace(config, deployment=replace(config.deployment, campaign_package=pkg)) + + +class TestAdminCreateSubaccount: + async def test_async_uses_correct_function(self, write_dex: DecibelWriteDex) -> None: + await write_dex.admin_create_subaccount(TEST_ACCOUNT_ADDR) + payload: InputEntryFunctionData = write_dex._send_tx.call_args.args[0] + assert ( + payload.function == f"{TEST_PACKAGE}::dex_accounts_entry::admin_create_new_subaccount" + ) + assert payload.function_arguments == [TEST_ACCOUNT_ADDR] + + def test_sync_uses_correct_function(self, write_dex_sync: DecibelWriteDexSync) -> None: + write_dex_sync.admin_create_subaccount(TEST_ACCOUNT_ADDR) + payload: InputEntryFunctionData = write_dex_sync._send_tx.call_args.args[0] + assert ( + payload.function == f"{TEST_PACKAGE}::dex_accounts_entry::admin_create_new_subaccount" + ) + assert payload.function_arguments == [TEST_ACCOUNT_ADDR] + + +class TestWithdrawNonCollateral: + async def test_async_uses_correct_function(self, write_dex: DecibelWriteDex) -> None: + await write_dex.withdraw_non_collateral( + TEST_ASSET_ADDR, 500, subaccount_addr=TEST_SUBACCOUNT_ADDR + ) + payload: InputEntryFunctionData = write_dex._send_tx.call_args.args[0] + assert ( + payload.function == f"{TEST_PACKAGE}::dex_accounts_entry::withdraw_from_non_collateral" + ) + assert payload.function_arguments == [TEST_SUBACCOUNT_ADDR, TEST_ASSET_ADDR, 500] + + async def test_async_defaults_to_primary_subaccount(self, write_dex: DecibelWriteDex) -> None: + with patch("decibel.write.get_primary_subaccount_addr", return_value=TEST_SUBACCOUNT_ADDR): + await write_dex.withdraw_non_collateral(TEST_ASSET_ADDR, 25) + payload: InputEntryFunctionData = write_dex._send_tx.call_args.args[0] + assert payload.function_arguments == [TEST_SUBACCOUNT_ADDR, TEST_ASSET_ADDR, 25] + + def test_sync_uses_correct_function(self, write_dex_sync: DecibelWriteDexSync) -> None: + write_dex_sync.withdraw_non_collateral( + TEST_ASSET_ADDR, 500, subaccount_addr=TEST_SUBACCOUNT_ADDR + ) + payload: InputEntryFunctionData = write_dex_sync._send_tx.call_args.args[0] + assert ( + payload.function == f"{TEST_PACKAGE}::dex_accounts_entry::withdraw_from_non_collateral" + ) + assert payload.function_arguments == [TEST_SUBACCOUNT_ADDR, TEST_ASSET_ADDR, 500] + + +class TestUpdateOrder: + async def test_async_sends_correct_payload(self, write_dex: DecibelWriteDex) -> None: + await write_dex.update_order( + order_id="42", + market_addr=TEST_UPDATE_MARKET_ADDR, + price=100, + size=5, + is_buy=True, + time_in_force=TimeInForce.GoodTillCanceled, + is_reduce_only=False, + subaccount_addr=TEST_SUBACCOUNT_ADDR, + ) + payload: InputEntryFunctionData = write_dex._send_tx.call_args.args[0] + assert payload.function == f"{TEST_PACKAGE}::dex_accounts_entry::update_order_to_subaccount" + assert payload.function_arguments == [ + TEST_SUBACCOUNT_ADDR, + 42, + TEST_UPDATE_MARKET_ADDR, + 100, + 5, + True, + TimeInForce.GoodTillCanceled, + False, + None, + None, + None, + None, + None, + None, + ] + + async def test_async_passes_tp_sl_and_account_override( + self, write_dex: DecibelWriteDex + ) -> None: + await write_dex.update_order( + order_id=7, + market_addr=TEST_UPDATE_MARKET_ADDR, + price=200, + size=3, + is_buy=False, + time_in_force=TimeInForce.PostOnly, + is_reduce_only=True, + tp_trigger_price=210, + tp_limit_price=211, + sl_trigger_price=190, + sl_limit_price=189, + subaccount_addr=TEST_SUBACCOUNT_ADDR, + ) + payload: InputEntryFunctionData = write_dex._send_tx.call_args.args[0] + assert payload.function_arguments == [ + TEST_SUBACCOUNT_ADDR, + 7, + TEST_UPDATE_MARKET_ADDR, + 200, + 3, + False, + TimeInForce.PostOnly, + True, + 210, + 211, + 190, + 189, + None, + None, + ] + + def test_sync_sends_correct_payload(self, write_dex_sync: DecibelWriteDexSync) -> None: + write_dex_sync.update_order( + order_id="42", + market_addr=TEST_UPDATE_MARKET_ADDR, + price=100, + size=5, + is_buy=True, + time_in_force=TimeInForce.GoodTillCanceled, + is_reduce_only=False, + subaccount_addr=TEST_SUBACCOUNT_ADDR, + ) + payload: InputEntryFunctionData = write_dex_sync._send_tx.call_args.args[0] + assert payload.function == f"{TEST_PACKAGE}::dex_accounts_entry::update_order_to_subaccount" + assert payload.function_arguments[:3] == [TEST_SUBACCOUNT_ADDR, 42, TEST_UPDATE_MARKET_ADDR] + assert payload.function_arguments[-2:] == [None, None] + + +class TestClaimCampaignReward: + async def test_async_uses_campaign_package(self, write_dex: DecibelWriteDex) -> None: + write_dex._config = _with_campaign_pkg(write_dex._config) + await write_dex.claim_campaign_reward(3) + payload: InputEntryFunctionData = write_dex._send_tx.call_args.args[0] + assert payload.function == f"{TEST_CAMPAIGN_PKG}::campaign_manager::claim_by_id" + assert payload.function_arguments == [3] + + def test_sync_uses_campaign_package(self, write_dex_sync: DecibelWriteDexSync) -> None: + write_dex_sync._config = _with_campaign_pkg(write_dex_sync._config) + write_dex_sync.claim_campaign_reward(9) + payload: InputEntryFunctionData = write_dex_sync._send_tx.call_args.args[0] + assert payload.function == f"{TEST_CAMPAIGN_PKG}::campaign_manager::claim_by_id" + assert payload.function_arguments == [9]