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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { privateKeyToAccount } from "viem/accounts";
import type { Config } from "../lib/config.js";
import type { Address, Hex } from "viem";
import { ServerClient, tryConnectToServer } from "../client/index.js";
import { createCwpWallet } from "../lib/cwp.js";

export interface CLIContext {
config: Config;
Expand All @@ -15,7 +16,6 @@ export interface CLIContext {
getWalletAddress(): Address;
getServerClient(): Promise<ServerClient | null>;
hasAccount(): boolean;
requiresAccountSetup(): boolean;
}

export function createContext(config: Config): CLIContext {
Expand All @@ -39,19 +39,22 @@ export function createContext(config: Config): CLIContext {

getWalletClient(): ExchangeClient {
if (!walletClient) {
if (!config.privateKey) {
if (config.account?.type === "readonly") {
throw new Error(
`Account "${config.account.alias}" is read-only and cannot perform trading operations.\n` +
"Run 'hl account add' to set up an API wallet for trading."
);
}
if (config.account?.type === "walletconnect" && config.cwpProvider && config.walletAddress) {
const wallet = createCwpWallet(config.cwpProvider, config.walletAddress);
walletClient = new ExchangeClient({ transport, wallet });
} else if (config.privateKey) {
const account = privateKeyToAccount(config.privateKey as Hex);
walletClient = new ExchangeClient({ transport, wallet: account });
} else if (config.account?.type === "readonly") {
throw new Error(
`Account "${config.account.alias}" is read-only and cannot perform trading operations.\n` +
"Run 'hl account add' to set up an API wallet for trading."
);
} else {
throw new Error(
"No account configured. Run 'hl account add' to set up your account."
);
}
const account = privateKeyToAccount(config.privateKey as Hex);
walletClient = new ExchangeClient({ transport, wallet: account });
}
return walletClient;
},
Expand Down Expand Up @@ -82,9 +85,5 @@ export function createContext(config: Config): CLIContext {
hasAccount(): boolean {
return !!(config.walletAddress || config.privateKey);
},

requiresAccountSetup(): boolean {
return !config.walletAddress && !config.privateKey;
},
};
}
65 changes: 64 additions & 1 deletion src/commands/account/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { validateAddress } from "../../lib/validation.js"
import { prompt, select, confirm, pressEnterOrEsc } from "../../lib/prompts.js"
import { createAccount, getAccountCount, isAliasTaken } from "../../lib/db/index.js"
import { validateApiKey } from "../../lib/api-wallet.js"
import { isCwpAvailable, connectCwp } from "../../lib/cwp.js"
import type { Hex } from "viem"

const REFERRAL_LINK = "https://app.hyperliquid.xyz/join/CHRISLING"
const CWP_BINARY = "walletconnect"

type SetupMethod = "existing" | "new" | "readonly"
type SetupMethod = "existing" | "new" | "readonly" | "walletconnect"

export function registerAddCommand(account: Command): void {
account
Expand All @@ -36,6 +38,11 @@ export function registerAddCommand(account: Command): void {
label: "Create new wallet",
description: "Generate a new wallet with encrypted keystore (Coming Soon)",
},
{
value: "walletconnect",
label: "Connect via WalletConnect",
description: "Scan QR code with mobile wallet — no private key needed",
},
{
value: "readonly",
label: "Add read-only account",
Expand All @@ -51,6 +58,8 @@ export function registerAddCommand(account: Command): void {

if (setupMethod === "existing") {
await handleExistingWallet(isTestnet, outputOpts)
} else if (setupMethod === "walletconnect") {
await handleWalletConnect(outputOpts)
} else {
await handleReadOnly(outputOpts)
}
Expand Down Expand Up @@ -193,6 +202,60 @@ async function handleReadOnly(outputOpts: { json: boolean }): Promise<void> {
}
}

async function handleWalletConnect(outputOpts: { json: boolean }): Promise<void> {
// Step 1: Check if walletconnect binary is available
const available = await isCwpAvailable(CWP_BINARY)
if (!available) {
throw new Error(
`"${CWP_BINARY}" CLI not found on PATH.\n` +
"Install it with: npm install -g @anthropic/walletconnect-cli",
)
}

// Step 2: Connect via WalletConnect (QR code displayed in terminal)
console.log("\nStarting WalletConnect session...\n")
const userAddress = await connectCwp(CWP_BINARY)
console.log(`\nConnected wallet: ${userAddress.slice(0, 6)}...${userAddress.slice(-4)}`)

// Step 3: Get alias
const alias = await promptForAlias()

// Step 4: Check if user wants to set as default
let setAsDefault = false
const existingCount = getAccountCount()

if (existingCount > 0) {
setAsDefault = await confirm("\nSet this as your default account?", true)
}

// Step 5: Save account
const newAccount = createAccount({
alias,
userAddress,
type: "walletconnect",
source: "cli_import",
cwpProvider: CWP_BINARY,
setAsDefault,
})

if (outputOpts.json) {
output(newAccount, outputOpts)
} else {
console.log("")
outputSuccess(`Account "${alias}" added successfully!`)
console.log("")
console.log("Account details:")
console.log(` Alias: ${newAccount.alias}`)
console.log(` Address: ${newAccount.userAddress}`)
console.log(` Type: ${newAccount.type}`)
console.log(` Provider: ${newAccount.cwpProvider}`)
console.log(` Default: ${newAccount.isDefault ? "Yes" : "No"}`)
console.log("")
console.log("Trading orders will require approval on your mobile wallet.")
console.log("")
}
}

async function promptForAlias(): Promise<string> {
while (true) {
const alias = await prompt("Enter an alias for this account (e.g., 'main', 'trading'): ")
Expand Down
6 changes: 6 additions & 0 deletions src/commands/account/ls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface AccountRow {
address: string
type: string
apiWallet: string
provider: string
default: string
}

Expand All @@ -36,15 +37,19 @@ function AccountsList({ accounts }: { accounts: Account[] }): React.ReactElement
apiWallet: acc.apiWalletPublicKey
? formatAddress(acc.apiWalletPublicKey)
: "-",
provider: acc.cwpProvider || "-",
default: acc.isDefault ? "*" : "",
}))

const hasWalletConnect = accounts.some((acc) => acc.type === "walletconnect")

const columns: Column<AccountRow>[] = [
{ key: "default", header: "", width: 2 },
{ key: "alias", header: "Alias" },
{ key: "address", header: "Address" },
{ key: "type", header: "Type" },
{ key: "apiWallet", header: "API Wallet" },
...(hasWalletConnect ? [{ key: "provider" as const, header: "Provider" }] : []),
]

return (
Expand Down Expand Up @@ -75,6 +80,7 @@ export function registerLsCommand(account: Command): void {
type: acc.type,
source: acc.source,
apiWalletPublicKey: acc.apiWalletPublicKey,
cwpProvider: acc.cwpProvider,
isDefault: acc.isDefault,
createdAt: acc.createdAt,
updatedAt: acc.updatedAt,
Expand Down
189 changes: 189 additions & 0 deletions src/commands/fund.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Command } from "commander"
import { getContext, getOutputOptions } from "../cli/program.js"
import { output, outputError, outputSuccess } from "../cli/output.js"
import { cwpExec } from "../lib/cwp.js"

// Arbitrum USDC (native)
const ARB_USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
const ARB_USDC_TESTNET = "0x1baAbB04529D43a73232B713C0FE471f7c7334d5"

// Hyperliquid Bridge2 contract on Arbitrum
const BRIDGE_MAINNET = "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7"
const BRIDGE_TESTNET = "0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89"

const ARB_CHAIN = "eip155:42161"
const ARB_CHAIN_TESTNET = "eip155:421614"
const ARB_RPC = "https://arb1.arbitrum.io/rpc"
const ARB_RPC_TESTNET = "https://sepolia-rollup.arbitrum.io/rpc"

interface TxReceipt { status: string; gasUsed: string; transactionHash: string }

/** Pad an address to 32-byte ABI-encoded form */
function padAddress(addr: string): string {
return addr.slice(2).toLowerCase().padStart(64, "0")
}

async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise<unknown> {
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
})
if (!res.ok) {
throw new Error(`RPC request failed: ${res.status} ${res.statusText}`)
}
const json = (await res.json()) as { result?: unknown; error?: { code: number; message: string } }
if (json.error) {
throw new Error(`RPC error: ${json.error.message} (code ${json.error.code})`)
}
return json.result
}

async function getUsdcBalance(rpc: string, usdcAddress: string, walletAddress: string): Promise<number> {
// balanceOf(address) = 0x70a08231
const data = "0x70a08231" + padAddress(walletAddress)
const result = await rpcCall(rpc, "eth_call", [{ to: usdcAddress, data }, "latest"]) as string
return parseInt(result, 16) / 1e6
}

async function pollReceipt(rpc: string, txHash: string, timeoutMs = 60_000): Promise<TxReceipt | null> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const receipt = await rpcCall(rpc, "eth_getTransactionReceipt", [txHash]) as TxReceipt | null
if (receipt) return receipt
} catch { /* retry */ }
await new Promise((r) => setTimeout(r, 2_000))
}
return null
}

/**
* Send a transaction via CWP binary, poll for receipt, and verify success.
* Returns the confirmed transaction hash.
*/
async function sendAndConfirm(
binary: string,
rpc: string,
tx: { to: string; data: string; value: string; gas: string; chainId: string },
label: string,
): Promise<string> {
const result = await cwpExec(binary, ["send-transaction", JSON.stringify(tx)], 120_000) as
{ transactionHash?: string }

const txHash = result?.transactionHash
if (!txHash) {
throw new Error(`${label} failed — no transaction hash returned.`)
}
console.log(` Tx: ${txHash}`)
console.log(" Waiting for confirmation...")

const receipt = await pollReceipt(rpc, txHash)
if (!receipt) {
throw new Error(`${label} timed out waiting for receipt.`)
}
if (receipt.status !== "0x1") {
throw new Error(`${label} reverted on-chain.\n Tx: ${txHash}`)
}
console.log(" Confirmed!")
return txHash
}

export function registerFundCommand(program: Command): void {
program
.command("fund <amount>")
.description("Deposit USDC into Hyperliquid (requires WalletConnect account)")
.action(async function (this: Command, amountStr: string) {
const ctx = getContext(this)
const outputOpts = getOutputOptions(this)

try {
if (ctx.config.account?.type !== "walletconnect") {
throw new Error(
"The 'fund' command requires a WalletConnect account.\n" +
"Run 'hl account add' and select 'Connect via WalletConnect'.",
)
}

if (!ctx.config.cwpProvider) {
throw new Error("No WalletConnect provider configured for this account.")
}

const amount = parseFloat(amountStr)
if (isNaN(amount) || amount < 5) {
throw new Error("Minimum deposit is 5 USDC. Amounts below 5 USDC will be lost.")
}

const isTestnet = ctx.config.testnet
const usdcAddress = isTestnet ? ARB_USDC_TESTNET : ARB_USDC
const bridgeAddress = isTestnet ? BRIDGE_TESTNET : BRIDGE_MAINNET
const rpc = isTestnet ? ARB_RPC_TESTNET : ARB_RPC
const chainId = isTestnet ? ARB_CHAIN_TESTNET : ARB_CHAIN
const binary = ctx.config.cwpProvider
const walletAddress = ctx.config.walletAddress
if (!walletAddress) {
throw new Error("No wallet address configured.")
}

// Pre-flight: check USDC balance on Arbitrum
console.log("\nChecking USDC balance on Arbitrum...")
const balance = await getUsdcBalance(rpc, usdcAddress, walletAddress)
console.log(` Balance: ${balance.toFixed(2)} USDC`)

if (balance < amount) {
throw new Error(
balance < 5
? `Insufficient USDC on Arbitrum (${balance.toFixed(2)} USDC).\n` +
"You need at least 5 USDC on Arbitrum to deposit.\n" +
"Use 'walletconnect swidge' to bridge USDC to Arbitrum first."
: `Insufficient USDC on Arbitrum: ${balance.toFixed(2)} USDC available, ${amount} USDC requested.\n` +
"Use 'walletconnect swidge' to bridge more USDC to Arbitrum.",
)
}

// USDC has 6 decimals
const amountWei = Math.round(amount * 1e6)
const amountHex = amountWei.toString(16).padStart(64, "0")
const bridgePadded = padAddress(bridgeAddress)

console.log(`\nDepositing ${amount} USDC into Hyperliquid...\n`)

const baseTx = { to: usdcAddress, value: "0x0", gas: "0x186a0", chainId }

// Step 1: Approve USDC to bridge contract
// approve(address spender, uint256 amount) = 0x095ea7b3
console.log("Step 1/2: Approving USDC transfer...")
const approveHash = await sendAndConfirm(binary, rpc, {
...baseTx,
data: "0x095ea7b3" + bridgePadded + amountHex,
}, "Approval transaction")

// Step 2: Transfer USDC to bridge contract
// transfer(address to, uint256 amount) = 0xa9059cbb
console.log("\nStep 2/2: Sending USDC to Hyperliquid bridge...")
const txHash = await sendAndConfirm(binary, rpc, {
...baseTx,
data: "0xa9059cbb" + bridgePadded + amountHex,
}, "Transfer transaction")

if (outputOpts.json) {
output({
status: "deposited",
amount,
approveTxHash: approveHash,
transferTxHash: txHash,
bridge: bridgeAddress,
}, outputOpts)
} else {
console.log("")
outputSuccess(`Deposited ${amount} USDC to Hyperliquid!`)
console.log(` Tx: ${txHash}`)
console.log(" Funds will appear in your account within ~1 minute.")
console.log("")
}
} catch (err: unknown) {
outputError(err instanceof Error ? err.message : String(err))
process.exit(1)
}
})
}
4 changes: 4 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { registerAssetCommands } from "./asset/index.js"
import { registerOrderCommands } from "./order/index.js"
import { registerServerCommands } from "./server.js"
import { registerUpgradeCommand } from "./upgrade.js"
import { registerFundCommand } from "./fund.js"
import { registerWithdrawCommand } from "./withdraw.js"

export function registerCommands(program: Command): void {
registerAccountCommands(program)
registerMarketsCommands(program)
registerAssetCommands(program)
registerOrderCommands(program)
registerFundCommand(program)
registerWithdrawCommand(program)
registerServerCommands(program)
registerUpgradeCommand(program)
}
Loading