Skip to content

Commit dd2443a

Browse files
authored
Merge pull request #2 from Moonsong-Labs/feat/withdraw-command
2 parents b2ed947 + bb361a3 commit dd2443a

File tree

3 files changed

+277
-2
lines changed

3 files changed

+277
-2
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,42 @@ Signer Wallet Balances
117117

118118
#### `withdraw`
119119

120+
> [!NOTE]
121+
> This will withdraw USDC balance via the Arbitrum bridge. When no `destination` is provided it will default to the HL account address.
122+
123+
```sh
124+
uv run hlexec withdraw 2 --no-confirm
125+
Session Info
126+
Environment testnet
127+
HL Account 0xb764428a29EAEbe8e2301F5924746F818b331F5A
128+
Signer 0x57FbAe717f5712C3Bd612f34482832c86D9b17f2
129+
HyperLiquid Core Balance
130+
Account 0xb764428a29EAEbe8e2301F5924746F818b331F5A
131+
Balance $908.93
132+
133+
💸 Withdrawal Amount: $2.00
134+
💰 Amount after fee: $1.00 (fee: $1.00)
135+
📍 Destination: 0x57FbAe717f5712C3Bd612f34482832c86D9b17f2
136+
137+
✅ Withdrawal initiated successfully
138+
⠙ Waiting for balance update...
139+
140+
141+
Withdrawal Summary
142+
╔═════════════════╦═══════════════╗
143+
║ Requested ║ $2.00 ║
144+
║ Net Amount ║ $1.00 ║
145+
║ Fee ║ $1.00 ║
146+
║ ║ ║
147+
║ Initial Balance ║ $908.93 ║
148+
║ Final Balance ║ $908.74 ║
149+
║ Balance Change ║ -$0.19 ║
150+
║ Status ║ ⏳ PROCESSING ║
151+
╚═════════════════╩═══════════════╝
152+
153+
⏳ Note: Withdrawal to Arbitrum typically takes ~5 minutes to finalize.
154+
```
155+
120156
#### `transfer`
121157

122158
#### `order new`

src/handlers/withdraw.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,190 @@
1+
from __future__ import annotations
2+
from .setup import setup
3+
import click
4+
from rich.console import Console
5+
from rich.progress import Progress, SpinnerColumn, TextColumn
6+
from typing import Any
7+
from rich.table import Table
8+
from rich.text import Text
9+
from rich import box
10+
from decimal import Decimal
11+
from web3 import Web3
12+
import time
113

14+
15+
def run(
16+
production: bool,
17+
private_key: str | None,
18+
account_address: str | None,
19+
amount: str,
20+
no_confirm: bool = False,
21+
destination_address: str | None = None,
22+
) -> None:
23+
"""Withdraw USDC from HyperLiquid Core to EVM (Arbitrum)"""
24+
25+
info, exchange, address, account = setup(production, private_key, account_address)
26+
27+
console = Console()
28+
initial_hl_balance = _get_hl_usd_balance(info, address)
29+
withdrawable_balance = _get_withdrawable_balance(info, address)
30+
31+
if destination_address:
32+
destination = Web3.to_checksum_address(destination_address)
33+
else:
34+
destination = address
35+
36+
try:
37+
withdraw_amount = Decimal(amount)
38+
except Exception as e:
39+
raise click.ClickException(f"Invalid amount format: {e}")
40+
41+
if withdraw_amount < 2:
42+
raise click.ClickException(
43+
f"Minimum withdrawal amount is $2 (includes $1 fee). Requested: ${withdraw_amount:.2f}"
44+
)
45+
46+
if withdrawable_balance < float(withdraw_amount):
47+
raise click.ClickException(
48+
f"Insufficient withdrawable balance. Available: ${withdrawable_balance:.2f}, Requested: ${withdraw_amount:.2f}"
49+
)
50+
51+
_render_initial_balance(console, initial_hl_balance, withdrawable_balance, address)
52+
53+
net_amount = float(withdraw_amount) - 1.0 # Subtract $1 fee
54+
console.print(f"\n💸 Withdrawal Amount: ${withdraw_amount:.2f}")
55+
console.print(f"💰 Amount after fee: ${net_amount:.2f} (fee: $1.00)")
56+
console.print(f"📍 Destination: {destination}\n")
57+
58+
if not no_confirm and not click.confirm("Proceed with withdrawal?"):
59+
raise click.ClickException("Withdrawal cancelled")
60+
61+
with Progress(
62+
SpinnerColumn(),
63+
TextColumn("[progress.description]{task.description}"),
64+
console=console,
65+
) as progress:
66+
task = progress.add_task("Initiating withdrawal...", total=None)
67+
68+
try:
69+
result = exchange.withdraw_from_bridge(float(withdraw_amount), destination)
70+
71+
if result and result.get("status") == "ok":
72+
console.print("✅ Withdrawal initiated successfully")
73+
tx_hash = result.get("response", {}).get("txHash", "N/A")
74+
if tx_hash != "N/A":
75+
console.print(f"📝 Transaction hash: {tx_hash}")
76+
else:
77+
error_msg = (
78+
result.get("response", "Unknown error") if result else "No response"
79+
)
80+
raise click.ClickException(f"Withdrawal failed: {error_msg}")
81+
82+
except Exception as e:
83+
if "Insufficient" in str(e):
84+
raise click.ClickException(f"Insufficient balance for withdrawal: {e}")
85+
elif "rate limit" in str(e).lower():
86+
raise click.ClickException("Rate limited. Please try again later.")
87+
else:
88+
raise click.ClickException(f"Withdrawal failed: {e}")
89+
90+
progress.update(task, description="Waiting for balance update...")
91+
92+
time.sleep(3)
93+
94+
final_hl_balance = _get_hl_usd_balance(info, address)
95+
balance_change = initial_hl_balance - final_hl_balance
96+
97+
_render_summary(
98+
console,
99+
float(withdraw_amount),
100+
net_amount,
101+
balance_change,
102+
initial_hl_balance,
103+
final_hl_balance,
104+
)
105+
106+
console.print(
107+
"\n⏳ Note: Withdrawal to Arbitrum typically takes ~5 minutes to finalize."
108+
)
109+
110+
111+
def _get_hl_usd_balance(info: Any, address: str) -> float:
112+
"""Get the USD balance from HyperLiquid Core (perps)"""
113+
try:
114+
state = info.user_state(address)
115+
balances = state.get("marginSummary", {})
116+
account_value = float(balances.get("accountValue", 0))
117+
return account_value
118+
except Exception:
119+
return 0.0
120+
121+
122+
def _get_withdrawable_balance(info: Any, address: str) -> float:
123+
"""Get the withdrawable USD balance from HyperLiquid Core"""
124+
try:
125+
state = info.user_state(address)
126+
withdrawable = float(state.get("withdrawable", 0))
127+
return withdrawable
128+
except Exception:
129+
return 0.0
130+
131+
132+
def _render_initial_balance(
133+
console: Console, total_balance: float, withdrawable: float, address: str
134+
) -> None:
135+
"""Render the initial balance table"""
136+
table = Table(
137+
show_header=False,
138+
box=None,
139+
padding=(0, 1),
140+
title="HyperLiquid Core Balance",
141+
title_style="bold bright_cyan",
142+
title_justify="left",
143+
)
144+
table.add_column("Field", style="bold cyan", no_wrap=True)
145+
table.add_column("Value", justify="right")
146+
table.add_row("Account", address)
147+
table.add_row("Total Balance", f"${total_balance:.2f}")
148+
table.add_row("Withdrawable", f"${withdrawable:.2f}")
149+
console.print(table)
150+
151+
152+
def _render_summary(
153+
console: Console,
154+
requested: float,
155+
net_amount: float,
156+
balance_change: float,
157+
initial_balance: float,
158+
final_balance: float,
159+
) -> None:
160+
"""Render the withdrawal summary"""
161+
table = Table(
162+
show_header=False,
163+
box=box.DOUBLE,
164+
padding=(0, 1),
165+
title="Withdrawal Summary",
166+
title_style="bold bright_green",
167+
border_style="green",
168+
)
169+
table.add_column("Field", style="bold green", no_wrap=True)
170+
table.add_column("Value", justify="right")
171+
table.add_row("Requested", f"${requested:.2f}")
172+
table.add_row("Net Amount", f"${net_amount:.2f}")
173+
table.add_row("Fee", "$1.00")
174+
table.add_row("", "")
175+
table.add_row("Initial Balance", f"${initial_balance:.2f}")
176+
table.add_row("Final Balance", f"${final_balance:.2f}")
177+
table.add_row("Balance Change", f"-${balance_change:.2f}")
178+
179+
expected_change = requested
180+
if abs(balance_change - expected_change) < 0.01:
181+
status = Text("✅ SUCCESS", style="bold green")
182+
elif balance_change > 0:
183+
status = Text("⏳ PROCESSING", style="bold yellow")
184+
else:
185+
status = Text("❌ ERROR", style="bold red")
186+
187+
table.add_row("Status", status)
188+
189+
console.print("\n")
190+
console.print(table)

src/hl_executor.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from handlers.status import run as status_run
55
from handlers.deposit import run as deposit_run
66
from handlers.place_order import cancel_order_run, new_order_run, modify_order_run
7+
from handlers.withdraw import run as withdraw_run
78

89

910
@click.group()
@@ -295,9 +296,58 @@ def leverage():
295296

296297

297298
@cli.command()
298-
def withdraw():
299+
@click.argument(
300+
"amount",
301+
type=str,
302+
)
303+
@click.argument(
304+
"destination",
305+
type=str,
306+
required=False,
307+
)
308+
@click.option(
309+
"--private-key",
310+
"private_key",
311+
type=str,
312+
required=False,
313+
help="Private key for signing transactions",
314+
)
315+
@click.option(
316+
"--production",
317+
"production",
318+
is_flag=True,
319+
help="Connect to the production environment (default is testnet)",
320+
)
321+
@click.option(
322+
"--address",
323+
"account_address",
324+
type=str,
325+
required=False,
326+
help="This the HL account address which the Action will be performed on",
327+
)
328+
@click.option(
329+
"--no-confirm",
330+
"no_confirm",
331+
is_flag=True,
332+
help="Skip confirmation prompt and proceed directly with withdrawal",
333+
)
334+
def withdraw(
335+
amount: str,
336+
private_key: str | None,
337+
production: bool,
338+
account_address: str | None,
339+
no_confirm: bool,
340+
destination: str | None,
341+
):
299342
"""Withdraw Funds from Core -> EVM"""
300-
click.echo("Welcome to the Withdraw Funds from Core -> EVM command!")
343+
withdraw_run(
344+
production,
345+
private_key,
346+
account_address,
347+
amount,
348+
no_confirm,
349+
destination,
350+
)
301351

302352

303353
@cli.command()

0 commit comments

Comments
 (0)