xaut-trade
Buy or sell XAUT (Tether Gold) on Ethereum. Supports market orders (Uniswap V3) and limit orders (UniswapX). Wallet modes: Foundry keystore or WDK. Triggers: buy XAUT, XAUT trade, swap USDT for XAUT, sell XAUT, swap XAUT for USDT, limit order, limit buy XAUT, limit sell XAUT, check limit order, cancel limit order, XAUT when, create wallet, setup wallet.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-aurehub-xaut-trade
Repository
Skill path: skills/aure-duncan/aurehub-xaut-trade
Buy or sell XAUT (Tether Gold) on Ethereum. Supports market orders (Uniswap V3) and limit orders (UniswapX). Wallet modes: Foundry keystore or WDK. Triggers: buy XAUT, XAUT trade, swap USDT for XAUT, sell XAUT, swap XAUT for USDT, limit order, limit buy XAUT, limit sell XAUT, check limit order, cancel limit order, XAUT when, create wallet, setup wallet.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: MIT.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install xaut-trade into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding xaut-trade to shared team environments
- Use xaut-trade for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: xaut-trade
description: "Buy or sell XAUT (Tether Gold) on Ethereum. Supports market orders (Uniswap V3) and limit orders (UniswapX). Wallet modes: Foundry keystore or WDK. Triggers: buy XAUT, XAUT trade, swap USDT for XAUT, sell XAUT, swap XAUT for USDT, limit order, limit buy XAUT, limit sell XAUT, check limit order, cancel limit order, XAUT when, create wallet, setup wallet."
license: MIT
compatibility: "Requires Node.js >= 18, ~/.aurehub/ config directory, Ethereum RPC (HTTPS), and UniswapX API access. Reads/writes encrypted wallet vault and password files under ~/.aurehub/. Foundry (cast) required only for foundry wallet mode."
metadata:
author: aurehub
version: "2.1.1"
---
# xaut-trade
Execute `USDT -> XAUT` buy and `XAUT -> USDT` sell flows via Uniswap V3.
## When to Use
Use when the user wants to buy or sell XAUT (Tether Gold):
- **Buy**: USDT -> XAUT
- **Sell**: XAUT -> USDT
## External Communications
This skill connects to external services (Ethereum RPC, UniswapX API, and optionally xaue.com rankings). On first setup, it may install dependencies via npm. Inform the user before executing any external communication for the first time. See the README for a full list.
## Environment & Security Declaration
### Required config files (under `~/.aurehub/`)
| File | Purpose | Required |
|------|---------|----------|
| `.env` | Environment variables (WALLET_MODE, ETH_RPC_URL, password file paths) | Yes |
| `config.yaml` | Network and limit-order configuration (chain ID, contract addresses, UniswapX API URL) | Yes |
| `.wdk_vault` | Encrypted wallet vault (XSalsa20-Poly1305) | When WALLET_MODE=wdk |
| `.wdk_password` | Vault decryption password (file mode 0600) | When WALLET_MODE=wdk |
### Environment variables
| Variable | Purpose | Required |
|----------|---------|----------|
| `WALLET_MODE` | Wallet type: `wdk` (encrypted vault) or `foundry` (keystore) | Yes |
| `ETH_RPC_URL` | Ethereum JSON-RPC endpoint (HTTPS) | Yes |
| `WDK_PASSWORD_FILE` | Path to WDK vault password file (mode 0600) | When WALLET_MODE=wdk |
| `KEYSTORE_PASSWORD_FILE` | Path to Foundry keystore password file (mode 0600) | When WALLET_MODE=foundry |
| `UNISWAPX_API_KEY` | UniswapX API key for limit orders | When using limit orders |
| `ETH_RPC_URL_FALLBACK` | Optional fallback RPC endpoint | No |
### Network access
- **Ethereum JSON-RPC** (ETH_RPC_URL) — blockchain reads and transaction submission
- **UniswapX API** (HTTPS) — limit order nonce, submission, status, cancellation
- **xaue.com Rankings API** (HTTPS, opt-in) — leaderboard registration
### Shell commands
- `node scripts/*.js` — all trading operations run via Node.js subprocesses
- `cast` (foundry mode only) — keystore signing
### Security safeguards
- Runtime `PRIVATE_KEY` is explicitly rejected; only file-based wallet modes are supported
- Seed phrase export is TTY-gated and requires interactive confirmation
- Vault and password files enforce 0600 permissions
- Decrypted key material is zeroed from memory after use
## Environment Readiness Check (run first on every session)
**Before handling any user intent** (except knowledge queries), run these checks:
1. Does `~/.aurehub/.env` exist: `ls ~/.aurehub/.env`
Fail -> redirect to the **Setup / Create Wallet Flow** below.
2. Read `WALLET_MODE` from .env: `source ~/.aurehub/.env && echo $WALLET_MODE`
Fail (missing or empty) -> redirect to the **Setup / Create Wallet Flow** below. Do NOT auto-detect or infer the wallet mode from installed tools (e.g. do not assume Foundry mode just because `cast` is installed). The user must explicitly choose.
3. Does `~/.aurehub/config.yaml` exist: `ls ~/.aurehub/config.yaml`
Fail -> copy from config.example.yaml (see onboarding Step C1) or redirect to setup.
4. **If `WALLET_MODE=wdk`:**
- Check `~/.aurehub/.wdk_vault` exists: `ls ~/.aurehub/.wdk_vault`
- Check `WDK_PASSWORD_FILE` in .env and file readable: `source ~/.aurehub/.env && test -r "$WDK_PASSWORD_FILE" && echo OK || echo FAIL`
- Check Node.js >= 18: `node -v`
- WDK mode has zero `cast` dependency
5. **If `WALLET_MODE=foundry`:**
- Check `cast --version` available
- Check keystore exists: `source ~/.aurehub/.env && ls ~/.foundry/keystores/$FOUNDRY_ACCOUNT`
(Optional: `cast wallet list` can verify the account name appears in Foundry's keystore)
- Check `KEYSTORE_PASSWORD_FILE` readable: `source ~/.aurehub/.env && test -r "$KEYSTORE_PASSWORD_FILE" && echo OK || echo FAIL`
- Check Node.js >= 18: `node -v` (needed for market module)
6. **Both modes**: verify wallet loads by resolving `SCRIPTS_DIR` (see **Resolving SCRIPTS_DIR** below) and running:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js address
```
This outputs JSON: `{ "address": "0x..." }`. If it fails, the wallet is not configured correctly.
> **Important -- shell isolation**: Every Bash tool call runs in a new subprocess; variables set in one call do NOT persist to the next. Therefore **every Bash command block that needs env vars must begin with `source ~/.aurehub/.env`** (or `set -a; source ~/.aurehub/.env; set +a` to auto-export all variables).
>
> **WALLET_ADDRESS**: derive it from `node swap.js address` (works for both wallet modes):
> ```bash
> source ~/.aurehub/.env
> cd "$SCRIPTS_DIR"
> WALLET_ADDRESS=$(node swap.js address | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).address")
> ```
> Alternatively, `node swap.js balance` also includes the address in its output.
If **all pass**: source `~/.aurehub/.env`, then proceed to intent detection.
If **any fail**: do not continue with the original intent. Note which checks failed, then present the following to the user (fill in [original intent] with a one-sentence summary of what the user originally asked for):
**First, if `WALLET_MODE` is missing or empty** (check 2 failed), ask the user to choose before showing setup options:
---
Environment not ready ([specific failing items]).
First, choose your wallet mode:
> **[1] WDK (recommended)** — seed-phrase based, encrypted vault, no external tools needed
> **[2] Foundry** — requires Foundry installed, keystore-based
---
Default to WDK if the user just presses enter or says "recommended". Remember the choice for the next step.
**Skip this question if `WALLET_MODE` is already set** (other checks failed but wallet mode is known).
**Then, present the setup method options:**
---
Please choose how to set up:
**[1] Recommended: let the Agent guide setup step by step**
Agent-guided mode (default behavior):
- The Agent runs all safe/non-sensitive checks and commands automatically
- The Agent pauses only when manual input is required (interactive key import / password entry / wallet funding)
- After each manual step, the Agent resumes automatically and continues original intent
**[2] Fallback: run setup.sh manually**
Before showing this option, silently resolve the setup.sh path (try in order, stop at first match):
```bash
# 1. Saved path from previous run (validate it still exists)
_saved=$(cat ~/.aurehub/.setup_path 2>/dev/null); [ -f "$_saved" ] && SETUP_PATH="$_saved"
# 2. Git repo (fallback)
[ -z "$SETUP_PATH" ] && { GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$GIT_ROOT" ] && [ -f "$GIT_ROOT/skills/xaut-trade/scripts/setup.sh" ] && SETUP_PATH="$GIT_ROOT/skills/xaut-trade/scripts/setup.sh"; }
# 3. Bounded home search fallback
[ -z "$SETUP_PATH" ] && SETUP_PATH=$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)
echo "$SETUP_PATH"
```
Then show the user only the resolved absolute path:
```bash
bash /resolved/absolute/path/to/setup.sh
```
Once setup is done in option 2, continue original request ([original intent]).
---
Wait for the user's reply:
- User chooses **1** -> load [references/onboarding.md](references/onboarding.md) and follow the agent-guided steps, passing the already-chosen wallet mode (skip Step 0 if wallet mode was selected above)
- User chooses **2** or completes setup.sh and reports back -> re-run all environment checks; if all pass, continue original intent; if any still fail, report the specific item and show the options again
Proceed to intent detection.
**Resolving SCRIPTS_DIR** (used throughout this skill for running Node.js scripts):
Resolve `SCRIPTS_DIR` in this order:
- `dirname "$(cat ~/.aurehub/.setup_path 2>/dev/null)"` (if file exists)
- git fallback: `$(git rev-parse --show-toplevel 2>/dev/null)/skills/xaut-trade/scripts` (if valid)
- bounded home-search fallback: `dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)"`
All `node swap.js` commands assume CWD is `$SCRIPTS_DIR`.
**Extra checks for limit orders** (only when the intent is limit buy / sell / query / cancel):
7. Are limit order dependencies installed: `ls "$SCRIPTS_DIR/node_modules"`
Fail -> run `cd "$SCRIPTS_DIR" && npm install`, then continue
8. Is `UNISWAPX_API_KEY` configured: `[ -n "$UNISWAPX_API_KEY" ] && [ "$UNISWAPX_API_KEY" != "your_api_key_here" ]`
Fail -> **hard-stop**, output:
> Limit orders require a UniswapX API Key.
> How to get one (about 5 minutes, free):
> 1. Visit https://developers.uniswap.org/dashboard
> 2. Sign in with Google / GitHub
> 3. Generate a Token (choose Free tier)
> 4. Add the key to ~/.aurehub/.env: `UNISWAPX_API_KEY=your_key`
> 5. Re-submit your request
## Config & Local Files
- Global config directory: `~/.aurehub/` (persists across sessions, not inside the skill directory)
- `.env` path: `~/.aurehub/.env`
- `config.yaml` path: `~/.aurehub/config.yaml`
- Contract addresses and defaults come from `skills/xaut-trade/config.example.yaml`; copy to `~/.aurehub/config.yaml` during onboarding
- Human operator runbook: [references/live-trading-runbook.md](references/live-trading-runbook.md)
## Interaction & Execution Principles (semi-automated)
1. Run pre-flight checks first, then quote.
2. Show a complete command preview before any on-chain write.
3. Trade execution confirmation follows USD thresholds:
- `< risk.confirm_trade_usd`: show full preview, then execute without blocking confirmation
- `>= risk.confirm_trade_usd` and `< risk.large_trade_usd`: single confirmation
- `>= risk.large_trade_usd` or estimated slippage exceeds `risk.max_slippage_bps_warn`: double confirmation
4. Approval confirmation follows `risk.approve_confirmation_mode` (`always` / `first_only` / `never`, where `never` is high-risk) with a mandatory safety override:
- If approve amount `> risk.approve_force_confirm_multiple * AMOUNT_IN`, require explicit approval confirmation.
## Mandatory Safety Gates
- When amount exceeds `risk.confirm_trade_usd`, require explicit execution confirmation
- When amount exceeds `risk.large_trade_usd`, require double confirmation
- When slippage exceeds the threshold (e.g. `risk.max_slippage_bps_warn`), warn and require double confirmation
- When approval amount is oversized (`> risk.approve_force_confirm_multiple * AMOUNT_IN`), force approval confirmation regardless of mode
- When ETH gas balance is insufficient, hard-stop and prompt to top up
- When the network or pair is unsupported, hard-stop
- When the pair is not in the whitelist (currently: USDT_XAUT / XAUT_USDT), hard-stop and reply "Only USDT/XAUT pairs are supported; [user's token] is not supported"
## RPC Fallback
After sourcing `~/.aurehub/.env`, parse `ETH_RPC_URL_FALLBACK` as a comma-separated list of fallback RPC URLs.
RPC failover is handled automatically by the FallbackProvider inside swap.js for **read operations** (balance, quote, allowance). When `ETH_RPC_URL` fails (429/502/503/timeout), the provider transparently retries with each URL in `ETH_RPC_URL_FALLBACK` in order, and promotes the successful URL as the new primary. **Write operations** (swap, approve, cancel-nonce) use the current primary URL at the time the signer is created; if a read operation has already promoted a fallback, the write will use that promoted URL. No agent action is needed for RPC switching.
If all RPCs fail, swap.js will exit with an error containing network-related messages. In that case, hard-stop with:
> RPC unavailable. All configured nodes failed (primary + fallbacks).
> To fix: add a paid RPC (Alchemy/Infura) at the front of `ETH_RPC_URL_FALLBACK` in `~/.aurehub/.env`
Do NOT treat non-network errors (insufficient balance, contract revert, invalid parameters, nonce mismatch) as RPC failures. Report these directly to the user.
## Intent Detection
Determine the operation from the user's message:
- **Buy**: contains "buy", "purchase", "swap USDT for", etc. -> run buy flow
- **Sell**: contains "sell", "swap XAUT for", etc. -> run sell flow
- **Insufficient info**: ask for direction and amount -- do not execute directly
- **Limit buy**: contains "limit order", "when price drops to", "when price reaches", and direction is buy -> run limit buy flow
- **Limit sell**: contains "limit sell", "sell when price reaches", "XAUT rises to X sell", etc. -> run limit sell flow
- **Query limit order**: contains "check order", "order status" -> run query flow
- **Cancel limit order**: contains "cancel order", "cancel limit" -> run cancel flow
- **Setup / Create wallet**: contains "setup", "create wallet", "initialize", "init wallet" -> skip environment readiness check, go to Setup / Create Wallet Flow below.
- **XAUT knowledge query**: contains "troy ounce", "grams", "conversion", "what is XAUT" -> answer directly, no on-chain operations or environment checks needed
## Setup / Create Wallet Flow
When the user explicitly requests setup or wallet creation:
### Step 1: Ask wallet mode
Present the choice:
> Which wallet mode would you like?
>
> **[1] WDK (recommended)** — seed-phrase based, encrypted vault, no external tools needed
> **[2] Foundry** — requires Foundry installed, keystore-based
Default to WDK if user just presses enter or says "recommended".
### Step 2: Check if wallet already exists for selected mode
**If user chose WDK:**
```bash
ls ~/.aurehub/.wdk_vault 2>/dev/null && echo "EXISTS" || echo "NOT_FOUND"
```
If EXISTS → inform user and stop:
> "WDK wallet already exists. No action needed. To use it, run a trade command (e.g. 'buy 100 USDT of XAUT')."
If the current `WALLET_MODE` in .env is different (e.g. `foundry`), update it to `wdk` and inform:
> "WDK wallet already exists. Switched wallet mode to WDK."
**If user chose Foundry:**
```bash
source ~/.aurehub/.env 2>/dev/null
ls ~/.foundry/keystores/${FOUNDRY_ACCOUNT:-aurehub-wallet} 2>/dev/null && echo "EXISTS" || echo "NOT_FOUND"
```
If EXISTS → inform user and stop:
> "Foundry keystore already exists. No action needed."
If the current `WALLET_MODE` in .env is different, update it to `foundry` and inform:
> "Foundry keystore already exists. Switched wallet mode to Foundry."
### Step 3: Create wallet (only if NOT_FOUND)
If the wallet does not exist for the selected mode, proceed with wallet creation:
- Load [references/onboarding.md](references/onboarding.md) and follow the setup steps for the selected mode
- After completion, update `WALLET_MODE` in `~/.aurehub/.env`
### Step 4: Security reminder (WDK mode only)
After WDK wallet creation succeeds, **always** display this security notice:
> **IMPORTANT: Back up your seed phrase**
>
> Your wallet is protected by an encrypted vault, but if the vault file or password is lost, **your funds cannot be recovered**.
>
> Export your 12-word seed phrase now and store it safely (paper or hardware backup — never in cloud storage or chat).
>
> Run this command in a **private** terminal:
> ```
> node <scripts_dir>/lib/export-seed.js
> ```
>
> Write down the 12 words and keep them offline. **Never share your seed phrase with anyone.**
Do NOT skip this step. Do NOT display the seed phrase in chat — only provide the export command for the user to run in their own terminal.
---
## Buy Flow (USDT -> XAUT)
### Step 1: Pre-flight Checks
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
BALANCE_JSON=$(node swap.js balance)
echo "$BALANCE_JSON"
```
The output is JSON: `{ "address": "0x...", "ETH": "0.05", "USDT": "1000.0", "XAUT": "0.5" }`
Parse and check:
- ETH balance: if below `risk.min_eth_for_gas`, hard-stop
- USDT balance: if insufficient for the buy amount, hard-stop and report the shortfall
### Step 2: Quote & Risk Warnings
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
QUOTE_JSON=$(node swap.js quote --side buy --amount <USDT_AMOUNT>)
echo "$QUOTE_JSON"
```
The output is JSON: `{ "side": "buy", "amountIn": "...", "amountOut": "...", "amountOutRaw": "...", "sqrtPriceX96": "...", "gasEstimate": "..." }`
Parse the JSON to extract:
- `amountOut`: estimated XAUT to receive (human-readable)
- `gasEstimate`: estimated gas cost
- Derive `minAmountOut` yourself: `amountOut * (1 - slippageBps / 10000)` using `risk.default_slippage_bps` from config.yaml
- Derive reference rate: `amountIn / amountOut` (both tokens have 6 decimals)
Display:
- Wallet address (from balance or address output)
- Input amount (human-readable)
- Estimated XAUT received
- Reference rate: `1 XAUT ~ X USDT`
- Slippage setting and `minAmountOut`
- Risk indicators (large trade / slippage / gas)
Determine confirmation level by USD notional and risk:
- `< risk.confirm_trade_usd`: show full preview, then execute without blocking confirmation
- `>= risk.confirm_trade_usd` and `< risk.large_trade_usd`: single confirmation
- `>= risk.large_trade_usd` or estimated slippage exceeds `risk.max_slippage_bps_warn`: double confirmation
### Step 3: Buy Execution
Follow [references/buy.md](references/buy.md):
**Allowance check:**
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
ALLOWANCE_JSON=$(node swap.js allowance --token USDT)
echo "$ALLOWANCE_JSON"
```
Output: `{ "address": "0x...", "token": "USDT", "allowance": "...", "spender": "0x..." }`
If allowance < amount needed, approve first.
**Approve (if needed):**
USDT requires reset-to-zero before approving (non-standard). The swap.js approve command handles this automatically:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
APPROVE_JSON=$(node swap.js approve --token USDT --amount <AMOUNT>)
echo "$APPROVE_JSON"
```
Output: `{ "address": "0x...", "token": "USDT", "amount": "...", "spender": "0x...", "txHash": "0x..." }`
**Swap execution:**
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
SWAP_JSON=$(node swap.js swap --side buy --amount <USDT_AMOUNT> --min-out <MIN_XAUT>)
echo "$SWAP_JSON"
```
Output: `{ "address": "0x...", "side": "buy", "amountIn": "...", "minAmountOut": "...", "txHash": "0x...", "status": "success", "gasUsed": "..." }`
- Before executing, remind the user: "About to execute an on-chain write"
- Execute with the confirmation level required by thresholds/policy
- Return tx hash and Etherscan link: `https://etherscan.io/tx/<txHash>`
**Swap error recovery (CRITICAL — see [references/buy.md](references/buy.md) Section 3a):**
If the swap command returns an error or `"status": "unconfirmed"`: **do NOT retry**. First check `node swap.js balance` and compare USDT balance against the pre-swap value. If USDT decreased, the swap succeeded — proceed to verification. Only retry if balance is unchanged.
**Result verification:**
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
Return:
- tx hash
- post-trade XAUT balance
- on failure, return retry suggestions
## Sell Flow (XAUT -> USDT)
### Step 1: Pre-flight Checks
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
BALANCE_JSON=$(node swap.js balance)
echo "$BALANCE_JSON"
```
Parse and check:
- ETH balance: if below `risk.min_eth_for_gas`, hard-stop
- **XAUT balance check (required)**: hard-stop if insufficient for the sell amount
**Precision check**: if the input has more than 6 decimal places (e.g. `0.0000001`), hard-stop:
> XAUT supports a maximum of 6 decimal places. The minimum tradeable unit is 0.000001 XAUT. Please adjust the input amount.
### Step 2: Quote & Risk Warnings
Follow [references/sell.md](references/sell.md):
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
QUOTE_JSON=$(node swap.js quote --side sell --amount <XAUT_AMOUNT>)
echo "$QUOTE_JSON"
```
Output JSON: `{ "side": "sell", "amountIn": "...", "amountOut": "...", "amountOutRaw": "...", "sqrtPriceX96": "...", "gasEstimate": "..." }`
Parse and display:
- Wallet address (from balance or address output)
- Input amount (user-provided form)
- Estimated USDT received (`amountOut`)
- Reference rate: `1 XAUT ~ X USDT`
- Slippage setting and `minAmountOut`
- Risk indicators (large trade / slippage / gas)
**Large-trade check**: convert `amountOut` (USDT) to USD value; if it exceeds `risk.large_trade_usd`, require double confirmation.
### Step 3: Sell Execution
Follow [references/sell.md](references/sell.md):
**Allowance check:**
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
ALLOWANCE_JSON=$(node swap.js allowance --token XAUT)
echo "$ALLOWANCE_JSON"
```
Output: `{ "address": "0x...", "token": "XAUT", "allowance": "...", "spender": "0x..." }`
If allowance < amount needed, approve first.
**Approve (if needed):**
XAUT is standard ERC-20 -- **no prior reset needed**, approve directly:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
APPROVE_JSON=$(node swap.js approve --token XAUT --amount <AMOUNT>)
echo "$APPROVE_JSON"
```
Output: `{ "address": "0x...", "token": "XAUT", "amount": "...", "spender": "0x...", "txHash": "0x..." }`
**Swap execution:**
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
SWAP_JSON=$(node swap.js swap --side sell --amount <XAUT_AMOUNT> --min-out <MIN_USDT>)
echo "$SWAP_JSON"
```
Output: `{ "address": "0x...", "side": "sell", "amountIn": "...", "minAmountOut": "...", "txHash": "0x...", "status": "success", "gasUsed": "..." }`
- Before executing, remind the user: "About to execute an on-chain write"
- Execute with the confirmation level required by thresholds/policy
- Return tx hash and Etherscan link
**Swap error recovery (CRITICAL — see [references/sell.md](references/sell.md) Section 7a):**
If the swap command returns an error or `"status": "unconfirmed"`: **do NOT retry**. First check `node swap.js balance` and compare XAUT balance against the pre-swap value. If XAUT decreased, the swap succeeded — proceed to verification. Only retry if balance is unchanged.
**Result verification:**
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
Return:
- tx hash
- post-trade USDT balance
- on failure, return retry suggestions (reduce sell amount / increase slippage tolerance / check nonce and gas)
## Post-Trade Registration
After **any** on-chain trade completes successfully (buy swap, sell swap, or limit order placed):
1. `source ~/.aurehub/.env`
2. Derive WALLET_ADDRESS:
```bash
cd "$SCRIPTS_DIR"
WALLET_ADDRESS=$(node swap.js address | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).address")
```
3. `REGISTERED=$(cat ~/.aurehub/.registered 2>/dev/null)`
4. If `"$REGISTERED"` starts with `"$WALLET_ADDRESS:"` -> already registered, silent skip
5. If `RANKINGS_OPT_IN` != `"true"`:
- Check marker: `PROMPTED=$(cat ~/.aurehub/.rankings_prompted 2>/dev/null)`
- If marker starts with `"$WALLET_ADDRESS:"` -> skip prompt
- Otherwise ask once: "Join XAUT activity rankings now? (yes/no)"
- If user says `no`: `echo "$WALLET_ADDRESS:declined" > ~/.aurehub/.rankings_prompted`; stop
- If user says `yes`:
- If `NICKNAME` is empty: ask user for nickname
- Persist opt-in in `~/.aurehub/.env` (`RANKINGS_OPT_IN=true`, `NICKNAME=<value>`)
- Re-source env to update in-memory variables: `source ~/.aurehub/.env`
6. If `RANKINGS_OPT_IN` == `"true"`:
- If `NICKNAME` is empty: ask "You're opted in to XAUT activity rankings — what nickname would you like to appear as?", then persist to `~/.aurehub/.env` and re-source: `source ~/.aurehub/.env`
- Register:
```bash
NICKNAME_ESC=$(printf '%s' "$NICKNAME" | sed 's/\\/\\\\/g; s/"/\\"/g')
REGISTER_RESP=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
https://xaue.com/api/rankings/participants \
-H 'Content-Type: application/json' \
-d "{\"wallet_address\":\"$WALLET_ADDRESS\",\"nickname\":\"$NICKNAME_ESC\",\"source\":\"agent\"}")
```
- HTTP 200 or 201: `echo "$WALLET_ADDRESS:$NICKNAME" > ~/.aurehub/.registered`; inform: "Registered with nickname: $NICKNAME"
- Any other status: silent continue, do not write marker file
Only prompt once per wallet when rankings are not enabled yet.
## Limit Buy Flow (USDT -> XAUT via UniswapX)
Follow [references/limit-order-buy-place.md](references/limit-order-buy-place.md).
## Limit Sell Flow (XAUT -> USDT via UniswapX)
Follow [references/limit-order-sell-place.md](references/limit-order-sell-place.md).
## Limit Order Query Flow
Follow [references/limit-order-status.md](references/limit-order-status.md).
## Limit Order Cancel Flow
Follow [references/limit-order-cancel.md](references/limit-order-cancel.md).
## Output Format
Output must include:
- `Wallet`: wallet address (always show early in preview)
- `Stage`: `Preview` or `Ready to Execute`
- `Input`: token, amount, chain
- `Quote`: estimated XAUT amount, slippage setting, `minAmountOut`
- `Reference rate`: `1 XAUT ~ X USDT` (for comparison with spot price; shown for both buy and sell)
- `Risk warnings`: large trade / slippage / gas
- `Command`: the `node swap.js` command to be executed
- `Result`: tx hash, post-trade balance (after execution)
## Error Handling
- Missing prerequisite variable: prompt to add the variable to `.env` and stop
- RPC network error (429/502/timeout): trigger RPC fallback sequence (see RPC Fallback section)
- Insufficient balance: report minimum top-up amount and stop
- User has not confirmed: stay in Preview -- do not execute
- Transaction failed: return failure reason and retry suggestions (reduce amount / increase slippage tolerance / check nonce and gas)
- **Swap error or `"status": "unconfirmed"`**: **NEVER retry without first checking balance.** RPC errors can occur even when the transaction was successfully mined. Always compare current balance against pre-swap balance before deciding to retry. See buy.md Section 3a / sell.md Section 7a.
## XAUT Knowledge Base
- 1 XAUT = 1 troy ounce = 31.1035 grams
- Minimum precision: 0.000001 XAUT (on-chain minimum unit: 1, i.e. 10^-6)
- Conversion: X grams / 31.1035 = XAUT amount
- Examples: 1g ~ 0.032151 XAUT; 10g ~ 0.32151 XAUT
- Contract address (Ethereum mainnet): 0x68749665FF8D2d112Fa859AA293F07A622782F38
Answer knowledge queries directly using the data above -- no on-chain commands needed.
## First-Turn Contract (for testing)
1. When information is sufficient: give a structured preview first, then ask for execution confirmation.
2. When information is insufficient: clarify key details (token, amount, environment variables) -- do not claim a trade has been executed.
## About
This skill is developed by Duncan.Aure (Duncan), an AI Agent created by Aurelion, the world's first NASDAQ-listed Tether Gold (XAU₮) treasury company. Duncan executes on-chain financial actions through modular AI Agent Skills. Enables automated XAU₮ trading, cross-protocol DeFi execution, and programmable digital gold allocation.
## Stay Connected
For updates, new skills, and ecosystem developments:
- **X**: [Aure_duncan](https://x.com/Aure_duncan)
- **Telegram**: [@aure_duncanbot](https://t.me/aure_duncanbot)
- **Aurelion**: [aurelion.com](https://www.aurelion.com/)
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/onboarding.md
```markdown
# Environment Initialization (Onboarding)
Run this on first use or when the environment is incomplete. Return to the original user intent after completion.
---
## Automated Setup (recommended)
Run the setup script — it handles all steps automatically and clearly marks the steps that require manual action:
```bash
_saved=$(cat ~/.aurehub/.setup_path 2>/dev/null); [ -f "$_saved" ] && SETUP_PATH="$_saved"
[ -z "$SETUP_PATH" ] && { GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$GIT_ROOT" ] && [ -f "$GIT_ROOT/skills/xaut-trade/scripts/setup.sh" ] && SETUP_PATH="$GIT_ROOT/skills/xaut-trade/scripts/setup.sh"; }
[ -z "$SETUP_PATH" ] && SETUP_PATH=$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)
[ -n "$SETUP_PATH" ] && [ -f "$SETUP_PATH" ] && bash "$SETUP_PATH"
```
If the script exits with an error, follow the manual steps below for the failed step only.
---
## Manual Steps (fallback)
### Step 0: Choose Wallet Mode
**Skip this step if wallet mode was already chosen** (e.g. selected during the environment readiness prompt before entering this flow). Use the previously chosen mode and proceed to the appropriate branch.
Otherwise, two wallet modes are available. Choose one:
**WDK (recommended)** — lightweight, no external tools:
- Encrypted vault stored at `~/.aurehub/.wdk_vault`
- Requires only Node.js >= 18
- See [wallet-modes.md](wallet-modes.md) for details
**Foundry** — uses Foundry keystore:
- Requires Foundry (`cast`) to be installed
- Standard Web3 keystore at `~/.foundry/keystores/`
- See [wallet-modes.md](wallet-modes.md) for details
Ask the user which mode they prefer. Default to WDK if they have no preference.
---
### WDK Branch (if user chose WDK)
#### Step W1: Check Node.js
```bash
node -v
# Must be >= 18. If not found or < 18: https://nodejs.org
```
#### Step W2: Prepare Password File
Check if `~/.aurehub/.wdk_password` exists and is non-empty:
```bash
mkdir -p ~/.aurehub
[ -s ~/.aurehub/.wdk_password ] && echo "ready" || echo "missing or empty"
```
If missing or empty, instruct the user to run in their terminal (password will not appear in chat):
```
Please run the following in your terminal (password input will be hidden):
bash -c 'read -rsp "WDK password (min 12 chars): " p </dev/tty; echo; printf "%s" "$p" > ~/.aurehub/.wdk_password; chmod 600 ~/.aurehub/.wdk_password; echo "✓ Password saved to ~/.aurehub/.wdk_password"'
Copy the entire line above, paste into your terminal, and press Enter.
Tell me when done.
```
Wait for user confirmation, then verify:
```bash
[ -s ~/.aurehub/.wdk_password ] && echo "ready" || echo "still empty"
```
If still empty, repeat the prompt.
#### Step W3: Create WDK Wallet
First check if a WDK vault already exists:
```bash
[ -f ~/.aurehub/.wdk_vault ] && echo "EXISTS" || echo "NOT_FOUND"
```
If `EXISTS`: the wallet is already created. Skip to Step W4. Inform the user:
> "WDK wallet already exists at ~/.aurehub/.wdk_vault. Skipping creation."
If `NOT_FOUND`: resolve scripts directory and create the wallet:
```bash
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SCRIPTS_DIR=$(dirname "$SETUP_PATH")
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -d "$GIT_ROOT/skills/xaut-trade/scripts" ]; then
SCRIPTS_DIR="$GIT_ROOT/skills/xaut-trade/scripts"
else
SCRIPTS_DIR=$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")
fi
cd "$SCRIPTS_DIR" && npm install
node "$SCRIPTS_DIR/lib/create-wallet.js" --password-file ~/.aurehub/.wdk_password
```
This creates `~/.aurehub/.wdk_vault` with the encrypted seed.
#### Step W3b: Security reminder — back up your seed phrase
**IMPORTANT**: After wallet creation, always present this security notice to the user:
> **Back up your seed phrase now.**
>
> Your wallet is protected by an encrypted vault. If the vault file (`~/.aurehub/.wdk_vault`) or password file (`~/.aurehub/.wdk_password`) is lost or corrupted, **your funds cannot be recovered** without the seed phrase.
>
> Run this command in a **private** terminal to display your 12-word mnemonic:
>
> ```bash
> node <scripts_dir>/lib/export-seed.js
> ```
>
> - Write the 12 words on paper and store offline (safe, lockbox).
> - **Never** save the seed phrase in cloud storage, screenshots, or chat.
> - **Never** share it with anyone — no legitimate service will ask for it.
Do NOT print the seed phrase in the chat. Only provide the export command.
#### Step W4: Write .env for WDK
If `~/.aurehub/.env` does not exist, create it:
```bash
cat > ~/.aurehub/.env << 'EOF'
WALLET_MODE=wdk
ETH_RPC_URL=https://eth.llamarpc.com
ETH_RPC_URL_FALLBACK=https://eth.merkle.io,https://rpc.flashbots.net/fast,https://eth.drpc.org,https://ethereum.publicnode.com
WDK_PASSWORD_FILE=~/.aurehub/.wdk_password
EOF
chmod 600 ~/.aurehub/.env
```
If `~/.aurehub/.env` already exists (e.g. switching from Foundry), only update the wallet-related fields — do NOT overwrite the entire file (to preserve UNISWAPX_API_KEY, RANKINGS, etc.):
```bash
# Update or add each key, preserving other settings
for kv in "WALLET_MODE=wdk" "WDK_PASSWORD_FILE=~/.aurehub/.wdk_password"; do
key="${kv%%=*}"
grep -q "^${key}=" ~/.aurehub/.env && sed -i.bak "s|^${key}=.*|${kv}|" ~/.aurehub/.env || echo "$kv" >> ~/.aurehub/.env
done
rm -f ~/.aurehub/.env.bak
grep -q "^ETH_RPC_URL=" ~/.aurehub/.env || echo "ETH_RPC_URL=https://eth.llamarpc.com" >> ~/.aurehub/.env
```
> If the user has a paid RPC (e.g. Alchemy/Infura), replace `ETH_RPC_URL` or prepend it to `ETH_RPC_URL_FALLBACK` for automatic failover.
Now skip to **Step C1: Write config.yaml** below.
---
### Foundry Branch (if user chose Foundry)
#### Step F1: Install Foundry (if `cast` is unavailable)
```bash
curl -L https://foundry.paradigm.xyz | bash && \
export PATH="$HOME/.foundry/bin:$PATH" && \
foundryup
cast --version # Expected output: cast Version: x.y.z
```
> After installation, open a new terminal or run `source ~/.zshrc` (zsh) / `source ~/.bashrc` (bash) so `cast` is available in future sessions.
Skip this step if `cast --version` succeeds.
#### Step F2: Prepare Password File
Check if `~/.aurehub/.wallet.password` exists and is non-empty:
```bash
mkdir -p ~/.aurehub
[ -s ~/.aurehub/.wallet.password ] && echo "ready" || echo "missing or empty"
```
If missing or empty, instruct the user to run in their terminal (password will not appear in chat):
```
Please run the following in your terminal (password input will be hidden):
bash -c 'read -rsp "Keystore password: " p </dev/tty; echo; printf "%s" "$p" > ~/.aurehub/.wallet.password; chmod 600 ~/.aurehub/.wallet.password; echo "✓ Password saved to ~/.aurehub/.wallet.password"'
Copy the entire line above, paste into your terminal, and press Enter.
Tell me when done.
```
Wait for user confirmation, then verify:
```bash
[ -s ~/.aurehub/.wallet.password ] && echo "ready" || echo "still empty"
```
If still empty, repeat the prompt.
#### Step F3: Wallet Setup
**Auto-detect**: if the keystore account already exists, skip this step.
```bash
# Use defaults here because ~/.aurehub/.env may not be created yet in manual flow.
FOUNDRY_ACCOUNT=${FOUNDRY_ACCOUNT:-aurehub-wallet}
KEYSTORE_PASSWORD_FILE=${KEYSTORE_PASSWORD_FILE:-~/.aurehub/.wallet.password}
cast wallet list 2>/dev/null | grep -qF "$FOUNDRY_ACCOUNT" && echo "exists" || echo "missing"
```
If missing, choose one method:
Import an existing private key into keystore:
```bash
cast wallet import "$FOUNDRY_ACCOUNT" --interactive
```
Or create a new wallet directly in keystore:
```bash
mkdir -p ~/.foundry/keystores
cast wallet new ~/.foundry/keystores "$FOUNDRY_ACCOUNT" \
--password-file "$KEYSTORE_PASSWORD_FILE"
```
> Default values: `FOUNDRY_ACCOUNT=aurehub-wallet`, `KEYSTORE_PASSWORD_FILE=~/.aurehub/.wallet.password`
#### Step F4: Write .env for Foundry
If `~/.aurehub/.env` does not exist, create it:
```bash
cat > ~/.aurehub/.env << 'EOF'
WALLET_MODE=foundry
ETH_RPC_URL=https://eth.llamarpc.com
ETH_RPC_URL_FALLBACK=https://eth.merkle.io,https://rpc.flashbots.net/fast,https://eth.drpc.org,https://ethereum.publicnode.com
FOUNDRY_ACCOUNT=aurehub-wallet
KEYSTORE_PASSWORD_FILE=~/.aurehub/.wallet.password
EOF
chmod 600 ~/.aurehub/.env
```
If `~/.aurehub/.env` already exists (e.g. switching from WDK), only update the wallet-related fields:
```bash
for kv in "WALLET_MODE=foundry" "FOUNDRY_ACCOUNT=aurehub-wallet" "KEYSTORE_PASSWORD_FILE=~/.aurehub/.wallet.password"; do
key="${kv%%=*}"
grep -q "^${key}=" ~/.aurehub/.env && sed -i.bak "s|^${key}=.*|${kv}|" ~/.aurehub/.env || echo "$kv" >> ~/.aurehub/.env
done
rm -f ~/.aurehub/.env.bak
grep -q "^ETH_RPC_URL=" ~/.aurehub/.env || echo "ETH_RPC_URL=https://eth.llamarpc.com" >> ~/.aurehub/.env
```
> If the user has a paid RPC (e.g. Alchemy/Infura), replace `ETH_RPC_URL` or prepend it to `ETH_RPC_URL_FALLBACK` for automatic failover.
Now continue to **Step C1: Write config.yaml** below.
---
### Common Steps (both modes converge here)
#### Step C1: Write config.yaml
Copy contract config (defaults are ready to use — no user edits needed):
```bash
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SKILL_DIR=$(cd "$(dirname "$SETUP_PATH")/.." && pwd)
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f "$GIT_ROOT/skills/xaut-trade/config.example.yaml" ]; then
SKILL_DIR="$GIT_ROOT/skills/xaut-trade"
else
SKILL_DIR=$(cd "$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")/.." && pwd)
fi
cp "$SKILL_DIR/config.example.yaml" ~/.aurehub/config.yaml
```
`WALLET_MODE` is already set in `~/.aurehub/.env` (written in Step W4 or F4). No config.yaml change needed.
#### Step C2: Install Node.js dependencies
```bash
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SCRIPTS_DIR=$(dirname "$SETUP_PATH")
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -d "$GIT_ROOT/skills/xaut-trade/scripts" ]; then
SCRIPTS_DIR="$GIT_ROOT/skills/xaut-trade/scripts"
else
SCRIPTS_DIR=$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")
fi
cd "$SCRIPTS_DIR" && npm install
# Save scripts path for future sessions
printf '%s/setup.sh\n' "$SCRIPTS_DIR" > ~/.aurehub/.setup_path
```
#### Step C3: Verify
```bash
source ~/.aurehub/.env
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SCRIPTS_DIR=$(dirname "$SETUP_PATH")
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -d "$GIT_ROOT/skills/xaut-trade/scripts" ]; then
SCRIPTS_DIR="$GIT_ROOT/skills/xaut-trade/scripts"
else
SCRIPTS_DIR=$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")
fi
cd "$SCRIPTS_DIR" && node swap.js address
```
Expected output: `{ "address": "0x..." }`
If successful, inform the user:
```
Environment initialized. Wallet address: <address from output>
Make sure the wallet holds a small amount of ETH (>= 0.005) for gas.
```
---
## Runtime Dependencies (required for market and limit orders)
### 1. Install Node.js and scripts dependencies (>= 18)
```bash
node --version # If version < 18 or command not found: https://nodejs.org
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SCRIPTS_DIR=$(dirname "$SETUP_PATH")
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -d "$GIT_ROOT/skills/xaut-trade/scripts" ]; then
SCRIPTS_DIR="$GIT_ROOT/skills/xaut-trade/scripts"
else
SCRIPTS_DIR=$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")
fi
cd "$SCRIPTS_DIR" && npm install
```
### 2. Get a UniswapX API Key (required for limit orders only)
Limit orders require a UniswapX API Key to submit and query orders.
How to obtain (about 5 minutes, free):
1. Visit https://developers.uniswap.org/dashboard
2. Sign in with Google / GitHub
3. Generate a Token (choose Free tier)
Add the key to `~/.aurehub/.env`:
```bash
echo 'UNISWAPX_API_KEY=your_key_here' >> ~/.aurehub/.env
```
Node.js and npm dependencies are required for both market and limit orders.
Only `UNISWAPX_API_KEY` is limit-order specific.
---
## Activity Rankings (optional)
To join the XAUT trade activity rankings, add the following to `~/.aurehub/.env`:
```bash
echo 'RANKINGS_OPT_IN=true' >> ~/.aurehub/.env
echo 'NICKNAME=YourName' >> ~/.aurehub/.env
```
This shares your wallet address and nickname with https://xaue.com after your first trade. You can disable it anytime by setting `RANKINGS_OPT_IN=false`.
If you do not add these lines, no data is sent — rankings are opt-in only.
```
### references/live-trading-runbook.md
```markdown
# Live Trading Runbook (Chat-first, Mainnet)
Use this runbook when you want a real on-chain trade flow with minimal manual work.
Core principle:
- The Agent drives the workflow end-to-end.
- You only intervene at mandatory checkpoints.
---
## 1) What You Need to Do vs What the Agent Does
Agent (automatic):
- Environment checks (wallet mode, `.env`, password file, RPC reachability via `node swap.js address`)
- Balance check via `node swap.js balance`
- Quote via `node swap.js quote --side <buy|sell> --amount <N>`
- Risk preview and command preparation
- Approve via `node swap.js approve --token <TOKEN> --amount <N>`
- Swap via `node swap.js swap --side <buy|sell> --amount <N> --min-out <M>`
- Post-trade result summary via `node swap.js balance`
User (manual checkpoints only):
1. Sensitive wallet input (interactive key import / password input during setup)
2. Wallet funding (ETH for gas, USDT/XAUT as needed)
3. Confirmation when required by thresholds/policy
---
## 2) Start the Live Flow
In chat, send your intent directly:
```text
buy 10 USDT worth of XAUT
```
or
```text
sell 0.001 XAUT to USDT
```
The Agent will:
1. Run readiness checks
2. If environment is incomplete, switch to agent-guided setup
3. Return a Preview (quote, risk warnings, full command)
4. Apply confirmation policy:
- small trades: light/optional confirmation
- medium trades: single confirmation
- large/high-risk trades: double confirmation
---
## 3) Mandatory Manual Checkpoints
### Checkpoint A: Wallet Sensitive Input
If wallet is missing or locked, the Agent will pause and ask you to complete interactive wallet steps.
Typical examples:
- Setting a password for WDK or Foundry wallet
- Running `node lib/create-wallet.js` (WDK mode)
- `cast wallet import <account> --interactive` (Foundry mode)
After you finish, tell the Agent to continue.
### Checkpoint B: Wallet Funding
Before live execution, ensure your wallet has:
- ETH for gas (recommended `>= 0.005 ETH`)
- USDT for buy flow
- XAUT for sell flow
### Checkpoint C: Final Execution Confirmation
When confirmation is required by policy, explicitly confirm:
```text
confirm approve
confirm swap
```
---
## 4) Expected Conversation Shape
1. You: trade intent
2. Agent: checks + preview
3. You: handle mandatory manual checkpoint(s) if prompted
4. Agent: updated preview / ready state
5. You: `confirm approve` / `confirm swap` when prompted
6. Agent: tx hash + post-trade balances/result
---
## 5) Optional: Limit Orders
For limit orders, add `UNISWAPX_API_KEY` to `~/.aurehub/.env` first.
Then ask in chat to:
- place limit buy/sell
- check order status
- cancel order
---
## 6) Common Failure Cases
1. RPC/network instability (`429/502/timeout`)
- Add a paid node to `ETH_RPC_URL` or put it first in `ETH_RPC_URL_FALLBACK`.
2. Password file mismatch
- Recreate the password file with correct password (`chmod 600`).
3. Insufficient balances
- Top up ETH/USDT/XAUT, then retry.
4. Runtime `PRIVATE_KEY` detected
- Remove `PRIVATE_KEY` from `.env`; runtime uses wallet mode (WDK or Foundry).
---
## 7) Fallback (Manual Script Path)
If agent-guided setup is blocked by local terminal constraints, run setup manually:
```bash
_saved=$(cat ~/.aurehub/.setup_path 2>/dev/null); [ -f "$_saved" ] && SETUP_PATH="$_saved"
[ -z "$SETUP_PATH" ] && { GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$GIT_ROOT" ] && [ -f "$GIT_ROOT/skills/xaut-trade/scripts/setup.sh" ] && SETUP_PATH="$GIT_ROOT/skills/xaut-trade/scripts/setup.sh"; }
[ -z "$SETUP_PATH" ] && SETUP_PATH=$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)
if [ -n "$SETUP_PATH" ] && [ -f "$SETUP_PATH" ]; then
bash "$SETUP_PATH"
else
echo "setup.sh not found. Run:"
echo ' find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1'
exit 1
fi
```
Then return to chat and continue your original trade intent.
```
### references/buy.md
```markdown
# Buy Execution (USDT -> XAUT)
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with `source ~/.aurehub/.env` and `cd "$SCRIPTS_DIR"`.
## 0. Pre-execution Declaration
- Current stage must be `Ready to Execute`
- Quote and explicit user confirmation must already be complete
- Full command must be displayed before execution
## 1. Allowance Check
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js allowance --token USDT
```
Output:
```json
{
"address": "0x...",
"token": "USDT",
"allowance": "0.0",
"spender": "0x..."
}
```
If allowance < intended `AMOUNT_IN`, approve first.
## 2. Approve (USDT)
USDT requires a reset-to-zero before approving (handled internally by swap.js when `token_rules.USDT.requires_reset_approve: true` in config):
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
APPROVE_RESULT=$(node swap.js approve --token USDT --amount <AMOUNT_IN>)
echo "$APPROVE_RESULT"
```
Output:
```json
{
"address": "0x...",
"token": "USDT",
"amount": "<AMOUNT_IN>",
"spender": "0x...",
"txHash": "0x..."
}
```
The reset-to-zero step is handled automatically — no separate call needed.
Report: `Approve tx: https://etherscan.io/tx/<txHash>`
## 3. Swap Execution
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
SWAP_RESULT=$(node swap.js swap --side buy --amount <AMOUNT_IN> --min-out <MIN_AMOUNT_OUT>)
echo "$SWAP_RESULT"
```
Output:
```json
{
"address": "0x...",
"side": "buy",
"amountIn": "<AMOUNT_IN>",
"minAmountOut": "<MIN_AMOUNT_OUT>",
"txHash": "0x...",
"status": "success",
"gasUsed": "180000"
}
```
Report: `Swap tx: https://etherscan.io/tx/<txHash>`
> On failure (`"status": "failed"`), report retry suggestions (reduce trade amount / increase slippage tolerance / check nonce and gas).
## 3a. Swap Error Recovery
**CRITICAL**: If the swap command returns an error, exits with a non-zero code, or returns `"status": "unconfirmed"`:
1. **Do NOT retry immediately.** The transaction may have been broadcast and mined despite the RPC error.
2. Check the current balance:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
3. Compare the USDT balance against the pre-swap balance (from pre-flight Step 1):
- **If USDT balance decreased by the trade amount** → the swap **succeeded**. Proceed to Result Verification (Step 4). Do NOT re-approve or re-swap.
- **If USDT balance is unchanged** → the swap did **not** execute. Safe to retry from Step 1 (Allowance Check).
4. If a `txHash` was returned (including in the `"unconfirmed"` response), verify on Etherscan: `https://etherscan.io/tx/<txHash>`
> **Why this matters**: RPC nodes can return errors (e.g. 400, 504, "Unknown block") even when the transaction was successfully broadcast and mined. Retrying without checking balance will execute a **duplicate trade**, costing the user double.
## 4. Result Verification
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
Return:
- tx hash
- post-trade XAUT balance (from balance output)
- on failure, return retry suggestions
## 5. Mandatory Rules
- Before every on-chain write (approve, swap), remind the user: "About to execute an on-chain write"
- Trade execution confirmation follows:
- `< risk.confirm_trade_usd`: show full preview, then execute without blocking confirmation
- `>= risk.confirm_trade_usd` and `< risk.large_trade_usd`: single confirmation
- `>= risk.large_trade_usd` or estimated slippage exceeds `risk.max_slippage_bps_warn`: double confirmation
- Approval confirmation follows `risk.approve_confirmation_mode` with force override:
- If approve amount `> risk.approve_force_confirm_multiple * AMOUNT_IN`, require explicit approval confirmation
- Accepted confirmation phrases: "confirm approve", "confirm swap"
```
### references/sell.md
```markdown
# Sell Execution (XAUT -> USDT)
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with `source ~/.aurehub/.env` and `cd "$SCRIPTS_DIR"`.
## 0. Pre-execution Declaration
- Current stage must be `Ready to Execute`
- Quote and explicit user confirmation must already be complete
- Full command must be displayed before execution
## 1. Input Validation
User provides XAUT amount (e.g. `0.01`).
**Precision check**: if the input has more than 6 decimal places (e.g. `0.0000001`), hard-stop:
> XAUT supports a maximum of 6 decimal places. The minimum tradeable unit is 0.000001 XAUT. Please adjust the input amount.
## 2. Quote
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
RESULT=$(node swap.js quote --side sell --amount <XAUT_AMOUNT>)
echo "$RESULT"
```
Output:
```json
{
"side": "sell",
"amountIn": "<XAUT_AMOUNT>",
"amountOut": "30.5",
"amountOutRaw": "30500000",
"sqrtPriceX96": "...",
"gasEstimate": "150000"
}
```
Extract values:
```bash
AMOUNT_OUT=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).amountOut")
AMOUNT_OUT_RAW=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).amountOutRaw")
```
Calculate `minAmountOut` using `risk.default_slippage_bps` from config.yaml:
```bash
DEFAULT_SLIPPAGE_BPS=$(node -e "const c=require('js-yaml').load(require('fs').readFileSync(require('os').homedir()+'/.aurehub/config.yaml','utf8')); console.log((c.risk||{}).default_slippage_bps||50)")
MIN_AMOUNT_OUT=$(node -p "Math.trunc($AMOUNT_OUT_RAW * (10000 - $DEFAULT_SLIPPAGE_BPS) / 10000)")
```
Reference rate (for Preview display; both tokens have 6 decimals so divide directly):
```
Reference rate = amountOut / amountIn (USDT/XAUT, human-readable)
```
## 3. Preview Output
Must include at minimum:
- Input amount (user-provided form)
- Estimated USDT received (`amountOut`, human-readable)
- Reference rate: `1 XAUT ~ X USDT`
- Slippage setting and `minAmountOut`
- Risk indicators (large trade / slippage / gas)
**Large-trade check**: convert `amountOut` (USDT) to USD value; if it exceeds `risk.large_trade_usd`, require double confirmation.
## 4. Confirmation Gate
Trade execution confirmation follows the threshold-based policy (see Section 9 — Mandatory Rules):
- `< risk.confirm_trade_usd`: show full preview, then execute without blocking confirmation
- `>= risk.confirm_trade_usd` and `< risk.large_trade_usd`: single confirmation
- `>= risk.large_trade_usd` or estimated slippage exceeds `risk.max_slippage_bps_warn`: double confirmation
Accepted confirmation phrases: "confirm approve", "confirm swap"
## 5. Allowance Check
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js allowance --token XAUT
```
Output:
```json
{
"address": "0x...",
"token": "XAUT",
"allowance": "0.0",
"spender": "0x..."
}
```
If allowance < `AMOUNT_IN`, approve first.
## 6. Approve (XAUT is standard ERC-20)
**XAUT does not require a prior reset** — approve directly:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
APPROVE_RESULT=$(node swap.js approve --token XAUT --amount <XAUT_AMOUNT>)
echo "$APPROVE_RESULT"
```
Output:
```json
{
"address": "0x...",
"token": "XAUT",
"amount": "<XAUT_AMOUNT>",
"spender": "0x...",
"txHash": "0x..."
}
```
Report: `Approve tx: https://etherscan.io/tx/<txHash>`
> Note: Unlike USDT, XAUT does not require `approve(0)` to reset before approving.
## 7. Swap Execution
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
SWAP_RESULT=$(node swap.js swap --side sell --amount <XAUT_AMOUNT> --min-out <MIN_AMOUNT_OUT>)
echo "$SWAP_RESULT"
```
Output:
```json
{
"address": "0x...",
"side": "sell",
"amountIn": "<XAUT_AMOUNT>",
"minAmountOut": "<MIN_AMOUNT_OUT>",
"txHash": "0x...",
"status": "success",
"gasUsed": "180000"
}
```
Report: `Swap tx: https://etherscan.io/tx/<txHash>`
> On failure (`"status": "failed"`), report retry suggestions.
## 7a. Swap Error Recovery
**CRITICAL**: If the swap command returns an error, exits with a non-zero code, or returns `"status": "unconfirmed"`:
1. **Do NOT retry immediately.** The transaction may have been broadcast and mined despite the RPC error.
2. Check the current balance:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
3. Compare the XAUT balance against the pre-swap balance (from pre-flight):
- **If XAUT balance decreased by the trade amount** → the swap **succeeded**. Proceed to Result Verification (Step 8). Do NOT re-approve or re-swap.
- **If XAUT balance is unchanged** → the swap did **not** execute. Safe to retry from Step 5 (Allowance Check).
4. If a `txHash` was returned (including in the `"unconfirmed"` response), verify on Etherscan: `https://etherscan.io/tx/<txHash>`
> **Why this matters**: RPC nodes can return errors (e.g. 400, 504, "Unknown block") even when the transaction was successfully broadcast and mined. Retrying without checking balance will execute a **duplicate trade**, costing the user double.
## 8. Result Verification
Post-swap balance:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
Return:
- tx hash
- Post-trade balances (XAUT, USDT, ETH)
- on failure, return retry suggestions (reduce sell amount / increase slippage tolerance / check nonce and gas)
## 9. Mandatory Rules
- Before every on-chain write (approve, swap), remind the user: "About to execute an on-chain write"
- Trade execution confirmation follows:
- `< risk.confirm_trade_usd`: show full preview, then execute without blocking confirmation
- `>= risk.confirm_trade_usd` and `< risk.large_trade_usd`: single confirmation
- `>= risk.large_trade_usd` or estimated slippage exceeds `risk.max_slippage_bps_warn`: double confirmation
- Approval confirmation follows `risk.approve_confirmation_mode` with force override:
- If approve amount `> risk.approve_force_confirm_multiple * AMOUNT_IN`, require explicit approval confirmation
- Hard-stop if input precision exceeds 6 decimal places
```
### references/limit-order-buy-place.md
```markdown
# Limit Order Placement (USDT → XAUT via UniswapX)
## 0. Pre-execution Declaration
- Current stage must be `Ready to Execute`
- Parameters must be confirmed and user must have explicitly confirmed
- Full command must be displayed before execution
## 1. Pre-flight Checks
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
```
> `--api-url` and `--chain-id` are omitted from `limit-order.js` calls — the script reads defaults from `limit_order.uniswapx_api` and `networks.ethereum_mainnet.chain_id` in `~/.aurehub/config.yaml`.
```bash
node --version # If not found, hard-stop and prompt to install https://nodejs.org (Node is required for all script commands)
node swap.js balance # ETH balance check + tokenIn (USDT) balance check
```
## 2. Parameter Confirmation (Preview)
Display at minimum:
- Pair: USDT → XAUT
- Limit price: `1 XAUT = X USDT` (i.e. amountIn / minAmountOut, human-readable)
- Amount: `amountIn` USDT → at least `minAmountOut` XAUT
- Expiry: `expiry` seconds / deadline in local time
- UniswapX Filler risk notice: XAUT is a low-liquidity token; if no Filler fills the order, it expires automatically after the deadline with no loss of funds
## 3. Large-Trade Double Confirmation
If amountIn (USDT converted to USD) > `risk.large_trade_usd`, double confirmation is required.
## 4. Approve Permit2 (if allowance is insufficient)
Check USDT allowance for Permit2:
```bash
node swap.js allowance --token USDT --spender 0x000000000022D473030F116dDEE9F6B43aC78BA3
```
If insufficient, approve (USDT requires reset-to-zero; swap.js handles this automatically via token_rules):
```bash
node swap.js approve --token USDT --amount <AMOUNT_IN> --spender 0x000000000022D473030F116dDEE9F6B43aC78BA3
```
## 5. Place Order
> **Important — raw units**: Unlike `swap.js` (which accepts human-readable amounts), `limit-order.js` requires **raw integer amounts in smallest token units**.
> Both USDT and XAUT have 6 decimals, so multiply by `10^6`:
> - 1 USDT → `1000000`
> - 0.0005 XAUT → `500`
> - Formula: `raw = human_amount * 1000000` (drop any fractional remainder)
Resolve contract addresses and wallet before placing:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
USDT=$(node -e "const c=require('js-yaml').load(require('fs').readFileSync(require('os').homedir()+'/.aurehub/config.yaml','utf8')); console.log(c.tokens.USDT.address)")
XAUT=$(node -e "const c=require('js-yaml').load(require('fs').readFileSync(require('os').homedir()+'/.aurehub/config.yaml','utf8')); console.log(c.tokens.XAUT.address)")
WALLET_ADDRESS=$(node swap.js address | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).address")
# Convert human-readable amounts to raw integers (6 decimals): raw = human_amount * 1000000
# AMOUNT_IN: user's USDT spend amount; MIN_AMOUNT_OUT: minimum XAUT to receive (from limit price)
AMOUNT_IN=$(node -e "console.log(Math.trunc(parseFloat('<USDT_AMOUNT>') * 1e6))")
MIN_AMOUNT_OUT=$(node -e "console.log(Math.trunc(parseFloat('<MIN_XAUT_AMOUNT>') * 1e6))")
```
```bash
# EXPIRY_SECONDS: use the user-specified expiry, or fall back to 86400 (1 day).
RESULT=$(node limit-order.js place \
--token-in "$USDT" \
--token-out "$XAUT" \
--amount-in "$AMOUNT_IN" \
--min-amount-out "$MIN_AMOUNT_OUT" \
--expiry "$EXPIRY_SECONDS" \
--wallet "$WALLET_ADDRESS")
```
Parse result:
```bash
ORDER_HASH=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).orderHash")
DEADLINE=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).deadline")
NONCE=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).nonce")
```
## 6. Output
Return to user:
- `orderHash`: for querying / cancelling the order
- `deadline`: order expiry in local time
- Note: order details (including nonce) have been auto-saved to `~/.aurehub/orders/`. Cancellation can be done via `--order-hash` without needing to record the nonce manually.
- Reminder: order has been submitted to UniswapX; the computer does not need to stay online — the Filler network fills automatically when the price is reached
## 7. Error Handling
| Error | Action |
|-------|--------|
| `node` not found | Hard-stop, prompt to install Node.js >= 18 (required for all script commands) |
| UniswapX API returns 4xx | Hard-stop, note XAUT may not be in the supported list, suggest market order |
| Limit price deviates > 50% from current market | Warn + double confirmation (prevent price typos) |
| Approve failed | Return failure reason, suggest retry |
```
### references/limit-order-sell-place.md
```markdown
# Limit Sell Order Placement (XAUT → USDT via UniswapX)
## 0. Pre-execution Declaration
- Current stage must be `Ready to Execute`
- Parameters must be confirmed and user must have explicitly confirmed
- Full command must be displayed before execution
## 1. Pre-flight Checks
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
```
> `--api-url` and `--chain-id` are omitted from `limit-order.js` calls — the script reads defaults from `limit_order.uniswapx_api` and `networks.ethereum_mainnet.chain_id` in `~/.aurehub/config.yaml`.
```bash
node --version # If not found, hard-stop and prompt to install https://nodejs.org (Node is required for all script commands)
node swap.js balance # ETH balance check + XAUT balance check (hard-stop if insufficient)
```
## 2. Parameter Confirmation (Preview)
Display at minimum:
- Pair: XAUT → USDT
- Limit price: `1 XAUT = X USDT` (i.e. minAmountOut / amountIn, human-readable)
- Amount: sell `amountIn` XAUT → receive at least `minAmountOut` USDT
- Expiry: `expiry` seconds / deadline in local time
- UniswapX Filler risk notice: XAUT is a low-liquidity token; if no Filler fills the order, it expires automatically after the deadline with no loss of funds
## 3. Large-Trade Double Confirmation
If `minAmountOut` (USDT) > `risk.large_trade_usd`, double confirmation is required.
## 4. Approve Permit2 (if allowance is insufficient)
XAUT is a standard ERC-20 — **approve directly, no reset needed**:
```bash
node swap.js allowance --token XAUT --spender 0x000000000022D473030F116dDEE9F6B43aC78BA3
```
If insufficient, approve directly:
```bash
node swap.js approve --token XAUT --amount <AMOUNT_IN> --spender 0x000000000022D473030F116dDEE9F6B43aC78BA3
```
## 5. Place Order
> **Important — raw units**: Unlike `swap.js` (which accepts human-readable amounts), `limit-order.js` requires **raw integer amounts in smallest token units**.
> Both XAUT and USDT have 6 decimals, so multiply by `10^6`:
> - 0.001 XAUT → `1000`
> - 5000 USDT → `5000000000`
> - Formula: `raw = human_amount * 1000000` (drop any fractional remainder)
Resolve contract addresses and wallet before placing:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
XAUT=$(node -e "const c=require('js-yaml').load(require('fs').readFileSync(require('os').homedir()+'/.aurehub/config.yaml','utf8')); console.log(c.tokens.XAUT.address)")
USDT=$(node -e "const c=require('js-yaml').load(require('fs').readFileSync(require('os').homedir()+'/.aurehub/config.yaml','utf8')); console.log(c.tokens.USDT.address)")
WALLET_ADDRESS=$(node swap.js address | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).address")
# Convert human-readable amounts to raw integers (6 decimals): raw = human_amount * 1000000
# AMOUNT_IN: user's XAUT sell amount; MIN_AMOUNT_OUT: minimum USDT to receive (from limit price)
AMOUNT_IN=$(node -e "console.log(Math.trunc(parseFloat('<XAUT_AMOUNT>') * 1e6))")
MIN_AMOUNT_OUT=$(node -e "console.log(Math.trunc(parseFloat('<MIN_USDT_AMOUNT>') * 1e6))")
```
```bash
# EXPIRY_SECONDS: use the user-specified expiry, or fall back to 86400 (1 day).
RESULT=$(node limit-order.js place \
--token-in "$XAUT" \
--token-out "$USDT" \
--amount-in "$AMOUNT_IN" \
--min-amount-out "$MIN_AMOUNT_OUT" \
--expiry "$EXPIRY_SECONDS" \
--wallet "$WALLET_ADDRESS")
```
Parse result:
```bash
ORDER_HASH=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).orderHash")
DEADLINE=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).deadline")
NONCE=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).nonce")
```
## 6. Output
Return to user:
- `orderHash`: for querying / cancelling the order
- `deadline`: order expiry in local time
- Note: order details (including nonce) have been auto-saved to `~/.aurehub/orders/`. Cancellation can be done via `--order-hash` without needing to record the nonce manually.
- Reminder: order has been submitted to UniswapX; the computer does not need to stay online — the Filler network fills automatically when the price is reached
## 7. Error Handling
| Error | Action |
|-------|--------|
| `node` not found | Hard-stop, prompt to install Node.js >= 18 (required for all script commands) |
| XAUT precision > 6 decimals | Script-level hard-stop (exit 1), report minimum precision of 0.000001 |
| USDT minAmountOut precision > 6 decimals | Script-level hard-stop (exit 1), report maximum precision |
| XAUT balance insufficient | Hard-stop, report shortfall |
| Limit price deviates > 50% from current market | Warn + double confirmation (prevent price typos) |
| UniswapX API returns 4xx | Hard-stop, note XAUT may not be in the supported list, suggest market order |
| Approve failed | Return failure reason, suggest retry |
```
### references/limit-order-status.md
```markdown
# Limit Order Query
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
```
> `--api-url` and `--chain-id` are omitted from examples below — the script reads defaults from `limit_order.uniswapx_api` and `networks.ethereum_mainnet.chain_id` in `~/.aurehub/config.yaml`.
## 1. Query a Single Order (by orderHash)
```bash
RESULT=$(node limit-order.js status \
--order-hash "$ORDER_HASH")
STATUS=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).status")
```
## 2. List All Open Orders (by wallet address)
```bash
WALLET_ADDRESS=$(node swap.js address | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).address")
RESULT=$(node limit-order.js list \
--wallet "$WALLET_ADDRESS" \
--order-status open) # Optional: open / filled / expired / cancelled — omit to return all
```
Returns JSON: `{ address, total, orders: [{ orderHash, status, inputToken, inputAmount, outputToken, outputAmount, txHash, createdAt }] }`
## 3. Status Display
| status | Display |
|--------|---------|
| `open` | Order active; remaining time = deadline - current time |
| `filled` | Filled: show txHash and actual settled amounts (settledAmounts) |
| `expired` | Expired; can re-place the order |
| `cancelled` | Cancelled |
| `not_found` | orderHash does not exist or has been purged from the API (may be cleared after expiry) |
## 4. Error Handling
- API unreachable: prompt to check network, suggest retrying later
- `not_found`: suggest the orderHash may be wrong, or the order has expired and been purged
```
### references/limit-order-cancel.md
```markdown
# Limit Order Cancellation
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
```
## 0. Pre-confirmation
Cancelling a limit order is an on-chain operation (gas required). Confirm before cancelling:
- orderHash
- Current order status (recommended: query first to avoid cancelling an already-filled or expired order)
## 1. Fetch Cancellation Parameters
**Preferred: use `--order-hash`** (auto-reads nonce from `~/.aurehub/orders/`):
```bash
CANCEL_PARAMS=$(node limit-order.js cancel \
--order-hash "$ORDER_HASH")
WORD_POS=$(echo "$CANCEL_PARAMS" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).wordPos")
MASK=$(echo "$CANCEL_PARAMS" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).mask")
```
Supports prefix matching (e.g. `0x9079c4f2` matches the full hash). Falls back to `--nonce` if no local order file is found.
**Fallback: use `--nonce` directly** (if local order file is missing):
```bash
CANCEL_PARAMS=$(node limit-order.js cancel \
--nonce "$NONCE")
```
## 2. Execute Cancellation
Display the command and wait for user confirmation:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
CANCEL_JSON=$(node swap.js cancel-nonce --word-pos "$WORD_POS" --mask "$MASK")
STATUS=$(echo "$CANCEL_JSON" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).status")
TX_HASH=$(echo "$CANCEL_JSON" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).txHash")
echo "Cancel tx: https://etherscan.io/tx/$TX_HASH"
if [ "$STATUS" != "success" ]; then echo "WARNING: on-chain cancellation failed (status=$STATUS)"; fi
```
## 3. Output
- tx hash
- Note: No assets were locked — Permit2 uses signature-based authorization, not asset custody. Cancellation revokes the signature on-chain; no token return operation is needed.
## 4. Special Cases
| Case | Action |
|------|--------|
| Order already filled | No cancellation needed; inform the user |
| Order already expired | Nonce has auto-invalidated; no on-chain cancellation needed |
| Cancel succeeds but Filler is still processing | Very low probability; the Filler transaction will revert once the nonce is invalidated on-chain |
```
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### README.md
```markdown
# xaut-trade
Our skills are developed by Duncan.Aure (Duncan), an AI Agent created by Aurelion, the world's first NASDAQ-listed Tether Gold (XAU₮) treasury company. Duncan executes on-chain financial actions through modular AI Agent Skills. Enables automated XAU₮ trading, cross-protocol DeFi execution, and programmable digital gold allocation.
Buy and sell XAUT (Tether Gold) on Ethereum mainnet via AI Agent, using Uniswap V3 + Node.js (ethers.js) under the hood.
## Supported Pairs
| Direction | Pair | Description |
|-----------|------|-------------|
| Buy | USDT → XAUT | Swap USDT for gold token |
| Sell | XAUT → USDT | Swap gold token back to USDT |
## Setup
### Automated (recommended)
Run the setup script — it handles wallet creation, config file generation, and optional Foundry installation interactively:
```bash
_saved=$(cat ~/.aurehub/.setup_path 2>/dev/null); [ -f "$_saved" ] && SETUP_PATH="$_saved"
[ -z "$SETUP_PATH" ] && { GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$GIT_ROOT" ] && [ -f "$GIT_ROOT/skills/xaut-trade/scripts/setup.sh" ] && SETUP_PATH="$GIT_ROOT/skills/xaut-trade/scripts/setup.sh"; }
[ -z "$SETUP_PATH" ] && SETUP_PATH=$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)
if [ -n "$SETUP_PATH" ] && [ -f "$SETUP_PATH" ]; then
bash "$SETUP_PATH"
else
echo "setup.sh not found. Run:"
echo ' find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1'
exit 1
fi
```
If the command above cannot find setup.sh (first-time install with a non-standard agent), locate it manually:
```bash
find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1
```
The script walks you through each step, clearly marks actions that require manual intervention, and explains the reason for each one.
After the script completes, follow the manual steps it prints at the end (fund wallet, get API key if needed).
For a chat-first real-mainnet walkthrough (Agent-driven, minimal manual steps), see:
- `references/live-trading-runbook.md`
### Manual (fallback)
If you prefer to configure everything yourself, or if the script fails at a specific step:
First choose a wallet mode: **WDK (recommended)** or **Foundry**.
#### Option A: WDK mode (recommended)
**1. Create password file**
```bash
mkdir -p ~/.aurehub
bash -c 'read -rsp "WDK password (min 12 chars): " p </dev/tty; echo; printf "%s" "$p" > ~/.aurehub/.wdk_password; chmod 600 ~/.aurehub/.wdk_password; echo "Password saved."'
```
**2. Create WDK wallet** (requires Node.js >= 18)
```bash
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SCRIPTS_DIR=$(dirname "$SETUP_PATH")
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -d "$GIT_ROOT/skills/xaut-trade/scripts" ]; then
SCRIPTS_DIR="$GIT_ROOT/skills/xaut-trade/scripts"
else
SCRIPTS_DIR=$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")
fi
cd "$SCRIPTS_DIR" && npm install
node "$SCRIPTS_DIR/lib/create-wallet.js" --password-file ~/.aurehub/.wdk_password
```
**3. Create .env**
```bash
cat > ~/.aurehub/.env << EOF
WALLET_MODE=wdk
ETH_RPC_URL=https://eth.llamarpc.com
ETH_RPC_URL_FALLBACK=https://eth.merkle.io,https://rpc.flashbots.net/fast,https://eth.drpc.org,https://ethereum.publicnode.com
WDK_PASSWORD_FILE=~/.aurehub/.wdk_password
# UNISWAPX_API_KEY=your_key_here # required for limit orders only
EOF
chmod 600 ~/.aurehub/.env
```
#### Option B: Foundry mode
**1. Install Foundry**
```bash
curl -L https://foundry.paradigm.xyz | bash
foundryup
source ~/.zshrc # or ~/.bashrc
```
**2. Create password file and configure wallet**
```bash
mkdir -p ~/.aurehub
bash -c 'read -rsp "Keystore password: " p </dev/tty; echo; printf "%s" "$p" > ~/.aurehub/.wallet.password; chmod 600 ~/.aurehub/.wallet.password; echo "Password saved."'
```
Then choose one initialization method:
```bash
# Import existing private key (interactive)
cast wallet import aurehub-wallet --interactive
# Or create a new wallet
mkdir -p ~/.foundry/keystores
cast wallet new ~/.foundry/keystores aurehub-wallet \
--password-file ~/.aurehub/.wallet.password
```
**3. Create .env**
```bash
cat > ~/.aurehub/.env << EOF
WALLET_MODE=foundry
ETH_RPC_URL=https://eth.llamarpc.com
ETH_RPC_URL_FALLBACK=https://eth.merkle.io,https://rpc.flashbots.net/fast,https://eth.drpc.org,https://ethereum.publicnode.com
FOUNDRY_ACCOUNT=aurehub-wallet
KEYSTORE_PASSWORD_FILE=~/.aurehub/.wallet.password
# UNISWAPX_API_KEY=your_key_here # required for limit orders only
EOF
chmod 600 ~/.aurehub/.env
```
#### Common steps (both modes)
**4. Copy config and install dependencies**
```bash
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SKILL_DIR=$(cd "$(dirname "$SETUP_PATH")/.." && pwd)
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f "$GIT_ROOT/skills/xaut-trade/config.example.yaml" ]; then
SKILL_DIR="$GIT_ROOT/skills/xaut-trade"
else
SKILL_DIR=$(cd "$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")/.." && pwd)
fi
cp "$SKILL_DIR/config.example.yaml" ~/.aurehub/config.yaml
```
**5. Install runtime dependencies (required for market and limit orders)**
```bash
node --version # requires >= 18; install from https://nodejs.org if missing
SETUP_PATH=$(cat ~/.aurehub/.setup_path 2>/dev/null)
if [ -f "$SETUP_PATH" ]; then
SCRIPTS_DIR=$(dirname "$SETUP_PATH")
elif GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [ -d "$GIT_ROOT/skills/xaut-trade/scripts" ]; then
SCRIPTS_DIR="$GIT_ROOT/skills/xaut-trade/scripts"
else
SCRIPTS_DIR=$(dirname "$(find "$HOME" -maxdepth 6 -type f -path "*/xaut-trade/scripts/setup.sh" 2>/dev/null | head -1)")
fi
cd "$SCRIPTS_DIR" && npm install
```
**6. Get a UniswapX API Key (limit orders only)**
How to obtain (about 5 minutes, free):
1. Visit [developers.uniswap.org/dashboard](https://developers.uniswap.org/dashboard)
2. Sign in with Google or GitHub
3. Generate a Token (Free tier)
```bash
echo 'UNISWAPX_API_KEY=your_key_here' >> ~/.aurehub/.env
```
Market orders do not require an API Key.
**7. Fund the wallet**
- A small amount of ETH (≥ 0.005) for gas
- USDT (for buying XAUT)
- XAUT (for selling)
## Usage
Just talk to the Agent in natural language:
### Buy
```
buy XAUT with 100 USDT
buy 200 USDT worth of XAUT
```
### Sell
```
sell 0.01 XAUT
swap 0.05 XAUT for USDT
sell 0.1 XAUT
```
### Limit Buy
```
buy 0.01 XAUT when price drops to 3000 USDT
limit order: buy 0.01 XAUT when price reaches 3000 USDT
limit buy XAUT at 3000, amount 0.01, valid 3 days
```
### Limit Sell
```
sell 0.01 XAUT when price rises to 4000 USDT
limit sell 0.01 XAUT at target price 3800 USDT, valid 3 days
sell 0.01 XAUT when price reaches 4000
```
### Check Limit Order
```
check my limit order status, orderHash is 0x...
```
### Cancel Limit Order
```
cancel limit order, orderHash is 0x...
```
### Check Balance
```
check my XAUT balance
```
## Trade Flow
For both buy and sell, the Agent follows this semi-automated flow:
```
Pre-flight checks → On-chain quote → Preview display → [Threshold-based confirmation] → Approve (mode-based confirmation) → Swap → Result verification
```
Before on-chain writes, the Agent always displays full commands. Confirmation level is policy-driven:
- Trade confirmation uses USD thresholds (`confirm_trade_usd`, `large_trade_usd`):
- `< confirm_trade_usd`: preview shown, no blocking confirmation required
- `>= confirm_trade_usd` and `< large_trade_usd`: single confirmation required
- `>= large_trade_usd` or estimated slippage exceeds `max_slippage_bps_warn`: double confirmation required
- Approval confirmation uses `approve_confirmation_mode` with oversize safety override
## Risk Controls
| Rule | Default Threshold | Behavior |
|------|-------------------|----------|
| Trade confirm threshold | `confirm_trade_usd = $10` | Above threshold requires confirmation |
| Large trade | >= $1,000 USD | Double confirmation required |
| High slippage | > 50 bps (0.5%) | Warning + double confirmation |
| Oversized approval | `approve > 10x amount_in` | Force approval confirmation |
| Insufficient gas | ETH < 0.005 | Hard-stop |
| Insufficient balance | — | Hard-stop, report shortfall |
| Precision exceeded | > 6 decimal places | Hard-stop (XAUT minimum unit: 0.000001) |
| UniswapX Filler unavailable | XAUT is a low-liquidity token | Order expires after deadline; funds safe |
Thresholds can be customized in the `risk` section of `config.yaml`.
## Configuration
### .env (required)
| Variable | Description | Example |
|----------|-------------|---------|
| `WALLET_MODE` | Wallet backend: `wdk` (recommended) or `foundry` | `wdk` |
| `ETH_RPC_URL` | Ethereum RPC URL | `https://eth.llamarpc.com` |
| `ETH_RPC_URL_FALLBACK` | Comma-separated fallback RPCs tried in order on network error (429/502/timeout) | `https://eth.merkle.io,...` |
| `WDK_PASSWORD_FILE` | Path to WDK vault password file (**WDK mode**) | `~/.aurehub/.wdk_password` |
| `WDK_VAULT_FILE` | Override default vault path (**WDK mode**, optional) | `~/.aurehub/.wdk_vault` |
| `FOUNDRY_ACCOUNT` | Foundry keystore account name (**Foundry mode**) | `aurehub-wallet` |
| `KEYSTORE_PASSWORD_FILE` | Path to keystore password file (**Foundry mode**) | `~/.aurehub/.wallet.password` |
| `UNISWAPX_API_KEY` | UniswapX API Key (**limit orders only**, not needed for market orders) | Get at: developers.uniswap.org/dashboard |
| `RANKINGS_OPT_IN` | Join activity rankings — opt-in only (default: `false`) | `true` or `false` |
| `NICKNAME` | Display name for activity rankings (required if `RANKINGS_OPT_IN=true`) | `Alice` |
### config.yaml (optional)
Key adjustable parameters:
```yaml
risk:
default_slippage_bps: 50 # Default slippage protection 0.5%
max_slippage_bps_warn: 50 # Slippage warning threshold
confirm_trade_usd: 10 # Single-confirm threshold (USD)
large_trade_usd: 1000 # Large trade threshold (USD)
approve_confirmation_mode: "first_only" # always | first_only | never (never is high-risk)
approve_force_confirm_multiple: 10 # Force confirm if approve > 10x amount_in
min_eth_for_gas: "0.005" # Minimum ETH for gas
deadline_seconds: 300 # Swap transaction timeout (seconds)
token_rules:
USDT:
requires_reset_approve: true # USDT needs approve(0) before approve(amount)
limit_order:
default_expiry_seconds: 86400 # Default order expiry: 1 day
min_expiry_seconds: 300 # Minimum: 5 minutes
max_expiry_seconds: 2592000 # Maximum: 30 days
uniswapx_api: "https://api.uniswap.org/v2" # Override for local mock testing
```
## Local Testing (Anvil Fork)
> **Note: Limit orders cannot be tested with Anvil fork** because the UniswapX API does not recognize local chain IDs.
> For limit orders, use a very small amount (e.g. 1 USDT → XAUT) on mainnet for end-to-end verification.
> Signature format can be validated against a local mock service via `limit_order.uniswapx_api` in `config.yaml`.
Use Anvil to fork mainnet state locally for zero-cost testing of the full buy/sell flow without spending real assets.
### 1. Start Anvil Fork
```bash
# Fork Ethereum mainnet locally (requires a mainnet RPC)
anvil --fork-url https://eth.llamarpc.com
# Optionally pin a block (for reproducible state)
anvil --fork-url https://eth.llamarpc.com --fork-block-number 19500000
```
Anvil starts with 10 pre-funded accounts, each with 10,000 ETH. Default: `http://127.0.0.1:8545`.
### 2. Point .env to Local
```bash
# .env
WALLET_MODE=foundry
ETH_RPC_URL=http://127.0.0.1:8545
FOUNDRY_ACCOUNT=aurehub-wallet
KEYSTORE_PASSWORD_FILE=~/.aurehub/.wallet.password
```
If you want to use Anvil account #0, import it once into keystore:
```bash
cast wallet import aurehub-wallet --interactive
```
### 3. Fund the Test Account with USDT
Anvil pre-funded accounts only have ETH. Use `cast` to impersonate a whale and transfer tokens:
```bash
# Find a USDT whale (e.g. Binance Hot Wallet)
USDT=0xdAC17F958D2ee523a2206206994597C13D831ec7
WHALE=0xF977814e90dA44bFA03b6295A0616a897441aceC # Binance hot wallet
# Impersonate whale, transfer 10,000 USDT to test account
cast send $USDT "transfer(address,uint256)" \
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 10000000000 \
--from $WHALE \
--unlocked \
--rpc-url http://127.0.0.1:8545
# Verify balance
cast call $USDT "balanceOf(address)" \
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
--rpc-url http://127.0.0.1:8545
```
### 4. Run Test Trades
Once configured, just use the skill normally:
```
buy XAUT with 100 USDT
```
The Agent will run the full flow (quote → confirm → approve → swap), all on the local fork — no real funds spent.
### 5. Notes
- Anvil fork state is **temporary** and resets on restart (unless using `anvil --state` for persistence)
- Local testing may use `--unlocked` + `--from` for manual token funding, while skill runtime signing remains keystore-only (`--account` + `--password-file`)
- If the fork runs for a long time, on-chain state may diverge from current mainnet; restart the fork to refresh
- Whale addresses may change over time; if the transfer fails, check the latest top holders on [Etherscan](https://etherscan.io/token/0xdAC17F958D2ee523a2206206994597C13D831ec7#balances)
## Security & Privacy
> For the complete environment and security declaration (required config files, environment variables, filesystem access, security safeguards), see the **Environment & Security Declaration** section in [SKILL.md](SKILL.md).
This skill communicates with external services during setup and trading:
| Service | When | Data Sent |
|---------|------|-----------|
| foundry.paradigm.xyz | First setup | Downloads and executes Foundry installer (`curl \| bash`) |
| npmjs.com | Limit order setup | Downloads Node.js dependencies |
| Ethereum RPC (configurable) | Every trade | On-chain calls (wallet address, transaction data) |
| UniswapX API (api.uniswap.org) | Limit orders | Order data, wallet address |
| xaue.com Rankings API | Opt-in only | Wallet address, nickname |
- **Foundry installation** uses `curl | bash`. Review the source at [github.com/foundry-rs/foundry](https://github.com/foundry-rs/foundry) before proceeding. The setup script asks for confirmation before running.
- **Rankings registration** remains opt-in (`RANKINGS_OPT_IN=false` by default). If not enabled during setup, the Agent can prompt once after your first successful trade.
- **All API calls use HTTPS.**
## FAQ
**Q: What if a transaction gets stuck or fails?**
The Agent will provide retry suggestions: reduce amount, increase slippage tolerance, or check nonce and gas.
**Q: Why does USDT approval require two steps?**
USDT's non-standard implementation requires `approve(0)` to reset the allowance before `approve(amount)`. XAUT does not.
**Q: Are other chains supported?**
Only Ethereum mainnet (chain_id: 1) is currently supported. Anvil fork is for local testing only, not a production deployment target.
**Q: I see `Device not configured (os error 6)` in Foundry mode — what do I do?**
This happens on macOS when Foundry's keystore cannot access the system Keychain in a non-interactive environment. Fix:
1. Create a password file and set permissions:
```bash
echo "your_keystore_password" > ~/.aurehub/.wallet.password
chmod 600 ~/.aurehub/.wallet.password
```
2. Set `KEYSTORE_PASSWORD_FILE` to point to this file in `.env`.
3. Re-run the trade flow.
**Q: What is a Skill package? How does it drive the AI to trade gold?**
A Skill package is a set of structured AI instruction files (`SKILL.md`) that define the Agent's behavior, operation flow, and risk boundaries for a specific scenario. The `xaut-trade` Skill tells the Agent how to check prerequisites, call the Uniswap V3 quote contract, execute trades via Node.js scripts, handle USDT's non-standard approval, and more. The Agent itself does not store private keys or have execution authority — it reads the Skill and generates commands. Depending on the configured risk thresholds, the Agent requests the required confirmation(s) before signing and broadcasting.
**Q: Do I need a computer running 24/7?**
- **Market orders (buy/sell)**: No. Market trades are one-shot interactions — you send the instruction → Agent quotes → you confirm → trade completes. No need to stay online.
- **Limit orders**: No. After signing, the order is submitted to the UniswapX network, where third-party Filler nodes automatically fill it when the price is met. Your computer can be off. Note: if no Filler fills the order before the `deadline`, it expires naturally with no loss of funds.
**Q: Does it only work with Claude Code?**
No. The Skill supports two main runners:
- **Claude Code** (recommended): install locally and use directly via Claude chat — no server needed
- **OpenClaw**: use via Slack / Telegram etc.; each user must configure their own wallet credentials independently
The primary test target is Claude (Sonnet / Opus series); other LLMs that can follow Skill instructions and call shell commands should work in theory but are not verified.
**Q: Will you read my API Key or private key from `.env`?**
No. The Skill package runs entirely locally. The only optional external data sharing is the activity rankings feature (opt-in during setup or first-success prompt, sends wallet address and nickname to xaue.com). All trades are executed via local Node.js scripts — no intermediary servers. Private keys are encrypted locally (WDK vault or Foundry keystore); `.env` only stores the account name and other config (wallet address is derived at runtime). Never commit `.env` to version control.
**Q: Will the Agent auto-buy based on price movements?**
No. The Agent does not monitor prices or make autonomous decisions. It is an execution assistant that acts only when you explicitly give an instruction:
- **Market order**: you say "buy XAUT with 100 USDT" → Agent quotes → you confirm → executes
- **Limit order**: you set "buy 0.01 when XAUT drops to 3000" → Agent signs and submits the order → UniswapX Fillers fill it when the condition is met
**Q: Do I need to manually confirm each trade? Can it spend my money without confirmation?**
By default, confirmation is threshold-based: small trades show full preview and can execute without blocking confirmation, medium trades require one confirmation, and large/high-risk trades require double confirmation. Approval confirmations are controlled by `approve_confirmation_mode`, with a mandatory override for oversized approvals. `approve_confirmation_mode=never` is high-risk and intended for advanced users only. The Agent cannot sign without your local keystore/password setup.
**Q: Can I use multiple wallets simultaneously?**
The current Skill is designed for a single wallet per instance. For multi-wallet use, prepare a separate `.env` for each wallet (with distinct `FOUNDRY_ACCOUNT` and `KEYSTORE_PASSWORD_FILE`), and switch config files before each operation. There is no built-in multi-wallet concurrent management.
**Q: Do I need to reinstall after a Skill update?**
Yes. Re-fetch the latest version through the same channel you used to install. Updates will not overwrite your local config (`.env`, `config.yaml`).
## Stay Connected
For our updates, new skills, and ecosystem developments. Check out:
- **X**: [Aure_duncan](https://x.com/Aure_duncan)
- **Telegram**: [@aure_duncanbot](https://t.me/aure_duncanbot)
- **Aurelion**: [aurelion.com](https://www.aurelion.com/)
```
### _meta.json
```json
{
"owner": "aure-duncan",
"slug": "aurehub-xaut-trade",
"displayName": "xaut-trade",
"latest": {
"version": "2.1.1",
"publishedAt": 1773619748279,
"commit": "https://github.com/openclaw/skills/commit/2aa9e07e1e5494dd0c656743cf917b89159ef361"
},
"history": [
{
"version": "2.0.1",
"publishedAt": 1772898671116,
"commit": "https://github.com/openclaw/skills/commit/8883fedc74a05787e82f9062ed6d580ee1eaa61c"
},
{
"version": "1.0.0",
"publishedAt": 1772759808731,
"commit": "https://github.com/openclaw/skills/commit/7a5fc54999d1401bfe15edc31310ccc530b5ccd3"
}
]
}
```
### references/balance.md
```markdown
# Balance & Pre-flight Checks
Complete the following steps in order before any quote or execution.
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced. Each Bash block must begin with:
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
```
## 1. Environment Check
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js address
```
If the command fails, stop and prompt:
- Node.js not installed or < 18: install Node.js first
- Config missing: trigger onboarding
- RPC unavailable: trigger RPC fallback sequence (see RPC Fallback section in SKILL.md)
## 2. Wallet Mode Validation
Check `WALLET_MODE` in `~/.aurehub/.env`:
- **If `WALLET_MODE=wdk`**: verify `WDK_PASSWORD_FILE` is set and readable:
```bash
source ~/.aurehub/.env
test -r "$WDK_PASSWORD_FILE" && echo "OK" || echo "FAIL"
```
If `FAIL`, hard-stop:
> Password file not readable: `$WDK_PASSWORD_FILE`
> Create it with: `bash -c 'read -rsp "WDK password: " p </dev/tty; echo; printf "%s" "$p" > ~/.aurehub/.wdk_password; chmod 600 ~/.aurehub/.wdk_password; echo "Saved."'`
- **If `WALLET_MODE=foundry`**: verify `FOUNDRY_ACCOUNT` and `KEYSTORE_PASSWORD_FILE` are set:
```bash
source ~/.aurehub/.env
[ -n "$FOUNDRY_ACCOUNT" ] && [ -n "$KEYSTORE_PASSWORD_FILE" ] && echo "OK" || echo "FAIL"
```
If `FAIL`, hard-stop:
> Missing keystore signing config. Set both `FOUNDRY_ACCOUNT` and `KEYSTORE_PASSWORD_FILE` in `.env`.
Verify password file is readable:
```bash
source ~/.aurehub/.env
test -r "$KEYSTORE_PASSWORD_FILE" && echo "OK" || echo "FAIL"
```
If `FAIL`, hard-stop:
> Password file not readable: `$KEYSTORE_PASSWORD_FILE`
If `PRIVATE_KEY` exists in `.env`, hard-stop immediately:
> `PRIVATE_KEY` runtime mode is no longer supported.
> Remove `PRIVATE_KEY` from `.env` and use either WDK or Foundry wallet mode.
## 3. Derive Wallet Address
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
WALLET_ADDRESS=$(node swap.js address | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).address")
echo "$WALLET_ADDRESS"
```
## 4. Full Balance Check (ETH + USDT + XAUT)
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
node swap.js balance
```
Output is JSON with all balances pre-formatted (human-readable):
```json
{
"address": "0x...",
"ETH": "0.05",
"USDT": "1000.0",
"XAUT": "0.5"
}
```
- If ETH balance is below `risk.min_eth_for_gas`, hard-stop
- **Buy flow**: if USDT balance is insufficient for the intended trade, hard-stop and report the shortfall
- **Sell flow**: if XAUT balance is insufficient for the intended trade, hard-stop and report the shortfall
```
### references/quote.md
```markdown
# Quote & Slippage Protection
All commands below assume CWD is `$SCRIPTS_DIR` and env is sourced.
## 1. Fetch Quote
Example: buy with 100 USDT
```bash
source ~/.aurehub/.env
cd "$SCRIPTS_DIR"
RESULT=$(node swap.js quote --side buy --amount 100)
echo "$RESULT"
```
Output is JSON:
```json
{
"side": "buy",
"amountIn": "100",
"amountOut": "0.033",
"amountOutRaw": "33000",
"sqrtPriceX96": "...",
"gasEstimate": "150000"
}
```
For sell direction, use `--side sell --amount <XAUT_amount>`.
Extract values for downstream use:
```bash
AMOUNT_OUT=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).amountOut")
AMOUNT_OUT_RAW=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).amountOutRaw")
GAS_ESTIMATE=$(echo "$RESULT" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).gasEstimate")
```
## 2. Calculate minAmountOut
Read `default_slippage_bps` from config.yaml (e.g. 50 bps = 0.5%):
```bash
# Read slippage from config.yaml, default to 50 bps
DEFAULT_SLIPPAGE_BPS=$(node -e "const c=require('js-yaml').load(require('fs').readFileSync(require('os').homedir()+'/.aurehub/config.yaml','utf8')); console.log((c.risk||{}).default_slippage_bps||50)")
# Use node to avoid bash integer overflow on large trades
MIN_AMOUNT_OUT=$(node -p "Math.trunc($AMOUNT_OUT_RAW * (10000 - $DEFAULT_SLIPPAGE_BPS) / 10000)")
```
## 3. Preview Output
Must include at minimum:
- Input amount (human-readable)
- Estimated output received (`amountOut`)
- Slippage setting and `minAmountOut`
- Risk indicators (large trade / slippage / gas)
## 4. Execution Confirmation Gate
Determine confirmation level by USD notional and risk:
- `< risk.confirm_trade_usd`: show full preview, then execute without blocking confirmation
- `>= risk.confirm_trade_usd` and `< risk.large_trade_usd`: single confirmation
- `>= risk.large_trade_usd` or estimated slippage exceeds `risk.max_slippage_bps_warn`: double confirmation
Accepted confirmation phrases:
- "confirm approve"
- "confirm swap"
```
### references/wallet-modes.md
```markdown
# Wallet Modes
## WDK Mode (Recommended)
- **Storage**: Encrypted vault (`~/.aurehub/.wdk_vault`) using PBKDF2-SHA256 + XSalsa20-Poly1305
- **Encryption**: PBKDF2 with 100k iterations, seed never stored as plaintext
- **Dependencies**: Node.js >= 18 only — no external tools required
- **Setup**: Choose password → encrypted vault created automatically
- **Config**: `WALLET_MODE=wdk` + `WDK_PASSWORD_FILE` in `.env`
## Foundry Mode (Advanced)
- **Storage**: Foundry keystore (`~/.foundry/keystores/<account>`) — standard Web3 Secret Storage
- **Encryption**: Scrypt-based (Foundry default)
- **Dependencies**: Foundry (`cast`) must be installed
- **Setup**: Install Foundry → import/create keystore → set password file
- **Config**: `WALLET_MODE=foundry` + `FOUNDRY_ACCOUNT` + `KEYSTORE_PASSWORD_FILE` in `.env`
## Switching Modes
Re-run `setup.sh` and select the other mode. Existing wallet data is not deleted.
## Security Comparison
| Feature | WDK | Foundry |
|---------|-----|---------|
| Seed/key encryption at rest | PBKDF2 + XSalsa20-Poly1305 | Scrypt |
| Password file | `~/.aurehub/.wdk_password` | `~/.aurehub/.wallet.password` |
| External tool required | No | Yes (Foundry) |
| Key derivation | BIP-39/BIP-44 (HD wallet) | Single key per keystore |
```
### scripts/__tests__/helpers.test.js
```javascript
import { describe, it, expect } from 'vitest';
import { computeNonceComponents, resolveExpiry, checkPrecision } from '../helpers.js';
// --- computeNonceComponents ---
// Permit2 invalidateUnorderedNonces(wordPos, mask):
// wordPos = nonce >> 8n (which 256-bit word)
// mask = 1n << (nonce & 0xFFn) (which bit within the word)
describe('computeNonceComponents', () => {
it('nonce 0 → wordPos 0, mask 1', () => {
const { wordPos, mask } = computeNonceComponents(0n);
expect(wordPos).toBe(0n);
expect(mask).toBe(1n);
});
it('nonce 1 → wordPos 0, mask 2', () => {
const { wordPos, mask } = computeNonceComponents(1n);
expect(wordPos).toBe(0n);
expect(mask).toBe(2n);
});
it('nonce 255 → wordPos 0, mask 2^255', () => {
const { wordPos, mask } = computeNonceComponents(255n);
expect(wordPos).toBe(0n);
expect(mask).toBe(2n ** 255n);
});
it('nonce 256 → wordPos 1, mask 1', () => {
const { wordPos, mask } = computeNonceComponents(256n);
expect(wordPos).toBe(1n);
expect(mask).toBe(1n);
});
it('nonce 257 → wordPos 1, mask 2', () => {
const { wordPos, mask } = computeNonceComponents(257n);
expect(wordPos).toBe(1n);
expect(mask).toBe(2n);
});
});
// --- resolveExpiry ---
describe('resolveExpiry', () => {
const limits = { defaultSeconds: 86400, minSeconds: 300, maxSeconds: 2592000 };
it('null → default', () => {
expect(resolveExpiry(null, limits)).toBe(86400);
});
it('undefined → default', () => {
expect(resolveExpiry(undefined, limits)).toBe(86400);
});
it('value within range → unchanged', () => {
expect(resolveExpiry(3600, limits)).toBe(3600);
});
it('below min → clamped to min', () => {
expect(resolveExpiry(60, limits)).toBe(300);
});
it('above max → clamped to max', () => {
expect(resolveExpiry(9999999, limits)).toBe(2592000);
});
it('exactly min → allowed', () => {
expect(resolveExpiry(300, limits)).toBe(300);
});
it('exactly max → allowed', () => {
expect(resolveExpiry(2592000, limits)).toBe(2592000);
});
});
// --- checkPrecision ---
describe('checkPrecision', () => {
it('integer → valid', () => {
expect(checkPrecision('1000000', 6)).toBe(true);
});
it('exactly 6 decimals → valid', () => {
expect(checkPrecision('0.000001', 6)).toBe(true);
});
it('7 decimals → invalid', () => {
expect(checkPrecision('0.0000001', 6)).toBe(false);
});
it('no dot → valid', () => {
expect(checkPrecision('42', 6)).toBe(true);
});
it('5 decimals → valid', () => {
expect(checkPrecision('1.12345', 6)).toBe(true);
});
it('boundary: maxDecimals=0 integer → valid', () => {
expect(checkPrecision('5', 0)).toBe(true);
});
});
```
### scripts/__tests__/integration.test.js
```javascript
import { describe, it, expect } from 'vitest';
describe('module imports', () => {
it('imports config', async () => {
const m = await import('../lib/config.js');
expect(typeof m.loadConfig).toBe('function');
expect(typeof m.resolveToken).toBe('function');
});
it('imports provider', async () => {
const m = await import('../lib/provider.js');
expect(typeof m.createProvider).toBe('function');
expect(typeof m.FallbackProvider).toBe('function');
});
it('imports signer', async () => {
const m = await import('../lib/signer.js');
expect(typeof m.createSigner).toBe('function');
});
it('imports erc20', async () => {
const m = await import('../lib/erc20.js');
expect(typeof m.getBalance).toBe('function');
expect(typeof m.getAllowance).toBe('function');
expect(typeof m.approve).toBe('function');
});
it('imports uniswap', async () => {
const m = await import('../lib/uniswap.js');
expect(typeof m.quote).toBe('function');
expect(typeof m.buildSwap).toBe('function');
});
it('imports swap CLI parser', async () => {
const m = await import('../swap.js');
expect(typeof m.parseCliArgs).toBe('function');
});
});
```
### scripts/__tests__/swap-cli.test.js
```javascript
import { describe, it, expect } from 'vitest';
import { parseCliArgs } from '../swap.js';
describe('parseCliArgs', () => {
it('parses quote subcommand with --side and --amount', () => {
const result = parseCliArgs(['quote', '--side', 'buy', '--amount', '100']);
expect(result.command).toBe('quote');
expect(result.side).toBe('buy');
expect(result.amount).toBe('100');
});
it('parses approve with --token and --amount', () => {
const result = parseCliArgs(['approve', '--token', 'USDT', '--amount', '1000']);
expect(result.command).toBe('approve');
expect(result.token).toBe('USDT');
expect(result.amount).toBe('1000');
});
it('parses swap with --side, --amount, --min-out', () => {
const result = parseCliArgs(['swap', '--side', 'sell', '--amount', '0.5', '--min-out', '1500']);
expect(result.command).toBe('swap');
expect(result.side).toBe('sell');
expect(result.amount).toBe('0.5');
expect(result.minOut).toBe('1500');
});
it('parses balance (no args needed)', () => {
const result = parseCliArgs(['balance']);
expect(result.command).toBe('balance');
});
it('parses allowance with --token', () => {
const result = parseCliArgs(['allowance', '--token', 'XAUT']);
expect(result.command).toBe('allowance');
expect(result.token).toBe('XAUT');
});
it('parses allowance with --token and --spender', () => {
const result = parseCliArgs(['allowance', '--token', 'USDT', '--spender', '0x000000000022D473030F116dDEE9F6B43aC78BA3']);
expect(result.command).toBe('allowance');
expect(result.token).toBe('USDT');
expect(result.spender).toBe('0x000000000022D473030F116dDEE9F6B43aC78BA3');
});
it('parses approve with --token, --amount, and --spender', () => {
const result = parseCliArgs(['approve', '--token', 'USDT', '--amount', '1000', '--spender', '0x000000000022D473030F116dDEE9F6B43aC78BA3']);
expect(result.command).toBe('approve');
expect(result.token).toBe('USDT');
expect(result.amount).toBe('1000');
expect(result.spender).toBe('0x000000000022D473030F116dDEE9F6B43aC78BA3');
});
it('parses address', () => {
const result = parseCliArgs(['address']);
expect(result.command).toBe('address');
});
it('parses sign with --data-file', () => {
const result = parseCliArgs(['sign', '--data-file', '/tmp/typed-data.json']);
expect(result.command).toBe('sign');
expect(result.dataFile).toBe('/tmp/typed-data.json');
});
it('parses cancel-nonce with --word-pos and --mask', () => {
const result = parseCliArgs(['cancel-nonce', '--word-pos', '42', '--mask', '115792089237316195423570985008687907853269984665640564039457584007913129639935']);
expect(result.command).toBe('cancel-nonce');
expect(result.wordPos).toBe('42');
expect(result.mask).toBe('115792089237316195423570985008687907853269984665640564039457584007913129639935');
});
it('errors on unknown subcommand', () => {
expect(() => parseCliArgs(['unknown-cmd'])).toThrow(/unknown command/i);
});
it('normalizes --side for quote/swap', () => {
expect(parseCliArgs(['quote', '--side', 'BUY', '--amount', '1']).side).toBe('buy');
expect(parseCliArgs(['swap', '--side', ' Sell ', '--amount', '1', '--min-out', '1']).side).toBe('sell');
});
it('errors when --side is not buy/sell for quote/swap', () => {
expect(() => parseCliArgs(['quote', '--side', 'long', '--amount', '1'])).toThrow(/invalid --side/i);
expect(() => parseCliArgs(['swap', '--side', 'short', '--amount', '1', '--min-out', '1'])).toThrow(/invalid --side/i);
});
it('parses --config-dir override', () => {
const result = parseCliArgs(['balance', '--config-dir', '/tmp/myconfig']);
expect(result.command).toBe('balance');
expect(result.configDir).toBe('/tmp/myconfig');
});
});
```
### scripts/__tests__/wallet-init.test.js
```javascript
/**
* wallet-init.test.js — Simulate wallet initialization and mode switching.
*
* Tests:
* 1. WDK vault creation → decrypt → derive same address (round-trip)
* 2. createSigner() with WDK mode
* 3. createSigner() with Foundry mode (using ethers-generated keystore)
* 4. Mode switching: WDK → Foundry → WDK (same config object, different WALLET_MODE)
* 5. Error paths: missing WALLET_MODE, unknown mode, bad password, missing vault
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { Wallet } from 'ethers6';
const SCRIPTS_DIR = join(import.meta.dirname, '..');
const CREATE_WALLET = join(SCRIPTS_DIR, 'lib', 'create-wallet.js');
describe('wallet initialization & mode switching', () => {
let tmpDir;
let wdkPasswordFile;
let wdkVaultFile;
let foundryKeystoreDir;
let foundryPasswordFile;
let wdkAddress;
let foundryAddress;
const WDK_PASSWORD = 'test-password-long-enough';
const FOUNDRY_PASSWORD = 'foundry-pw-12chars';
const FOUNDRY_ACCOUNT = 'test-account';
beforeAll(async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'wallet-init-test-'));
// --- WDK setup ---
wdkPasswordFile = join(tmpDir, '.wdk_password');
wdkVaultFile = join(tmpDir, '.wdk_vault');
writeFileSync(wdkPasswordFile, WDK_PASSWORD, { mode: 0o600 });
// Create WDK vault via create-wallet.js CLI
const out = execFileSync('node', [
CREATE_WALLET,
'--password-file', wdkPasswordFile,
'--vault-file', wdkVaultFile,
], { encoding: 'utf8', cwd: SCRIPTS_DIR });
const result = JSON.parse(out.trim());
wdkAddress = result.address;
expect(wdkAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
// --- Foundry keystore setup (simulate with ethers) ---
foundryKeystoreDir = join(tmpDir, 'keystores');
mkdirSync(foundryKeystoreDir, { recursive: true });
foundryPasswordFile = join(tmpDir, '.foundry_password');
writeFileSync(foundryPasswordFile, FOUNDRY_PASSWORD, { mode: 0o600 });
const foundryWallet = Wallet.createRandom();
foundryAddress = foundryWallet.address;
const keystoreJson = await foundryWallet.encrypt(FOUNDRY_PASSWORD);
writeFileSync(join(foundryKeystoreDir, FOUNDRY_ACCOUNT), keystoreJson);
});
afterAll(() => {
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
});
// -----------------------------------------------------------------------
// 1. WDK round-trip: create → decrypt → same address
// -----------------------------------------------------------------------
it('WDK: vault round-trip produces consistent address', async () => {
const { createSigner } = await import('../lib/signer.js');
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: wdkVaultFile,
WDK_PASSWORD_FILE: wdkPasswordFile,
},
};
const wallet = await createSigner(cfg, null);
expect(wallet.address).toBe(wdkAddress);
});
// -----------------------------------------------------------------------
// 2. Foundry mode: keystore decrypt → correct address
// -----------------------------------------------------------------------
it('Foundry: keystore decrypt produces correct address', async () => {
const { createSigner } = await import('../lib/signer.js');
const cfg = {
env: {
WALLET_MODE: 'foundry',
FOUNDRY_ACCOUNT: FOUNDRY_ACCOUNT,
KEYSTORE_PASSWORD_FILE: foundryPasswordFile,
},
};
const wallet = await createSigner(cfg, null, { keystoreDir: foundryKeystoreDir });
expect(wallet.address).toBe(foundryAddress);
});
// -----------------------------------------------------------------------
// 3. Mode switching: WDK → Foundry → WDK
// -----------------------------------------------------------------------
it('switches from WDK to Foundry and back', async () => {
const { createSigner } = await import('../lib/signer.js');
// Start with WDK
const wdkCfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: wdkVaultFile,
WDK_PASSWORD_FILE: wdkPasswordFile,
},
};
const w1 = await createSigner(wdkCfg, null);
expect(w1.address).toBe(wdkAddress);
// Switch to Foundry (simulates user changing WALLET_MODE in .env)
const foundryCfg = {
env: {
WALLET_MODE: 'foundry',
FOUNDRY_ACCOUNT: FOUNDRY_ACCOUNT,
KEYSTORE_PASSWORD_FILE: foundryPasswordFile,
},
};
const w2 = await createSigner(foundryCfg, null, { keystoreDir: foundryKeystoreDir });
expect(w2.address).toBe(foundryAddress);
expect(w2.address).not.toBe(w1.address); // different wallets
// Switch back to WDK
const w3 = await createSigner(wdkCfg, null);
expect(w3.address).toBe(wdkAddress);
expect(w3.address).toBe(w1.address); // same wallet as before
});
// -----------------------------------------------------------------------
// 4. Config loader integration: loadConfig → createSigner
// -----------------------------------------------------------------------
it('loadConfig + createSigner integration for WDK', async () => {
const { loadConfig } = await import('../lib/config.js');
const { createSigner } = await import('../lib/signer.js');
// Write a .env in tmpDir
const envContent = [
`WALLET_MODE=wdk`,
`WDK_VAULT_FILE=${wdkVaultFile}`,
`WDK_PASSWORD_FILE=${wdkPasswordFile}`,
].join('\n');
writeFileSync(join(tmpDir, '.env'), envContent);
const cfg = loadConfig(tmpDir);
expect(cfg.env.WALLET_MODE).toBe('wdk');
const wallet = await createSigner(cfg, null);
expect(wallet.address).toBe(wdkAddress);
});
it('loadConfig + createSigner integration for Foundry', async () => {
const { loadConfig } = await import('../lib/config.js');
const { createSigner } = await import('../lib/signer.js');
const envContent = [
`WALLET_MODE=foundry`,
`FOUNDRY_ACCOUNT=${FOUNDRY_ACCOUNT}`,
`KEYSTORE_PASSWORD_FILE=${foundryPasswordFile}`,
].join('\n');
writeFileSync(join(tmpDir, '.env'), envContent);
const cfg = loadConfig(tmpDir);
expect(cfg.env.WALLET_MODE).toBe('foundry');
const wallet = await createSigner(cfg, null, { keystoreDir: foundryKeystoreDir });
expect(wallet.address).toBe(foundryAddress);
});
// -----------------------------------------------------------------------
// 5. Error paths
// -----------------------------------------------------------------------
describe('error handling', () => {
it('throws when WALLET_MODE is missing', async () => {
const { createSigner } = await import('../lib/signer.js');
await expect(createSigner({ env: {} }, null)).rejects.toThrow(/WALLET_MODE not set/);
});
it('throws when WALLET_MODE is empty', async () => {
const { createSigner } = await import('../lib/signer.js');
await expect(createSigner({ env: { WALLET_MODE: '' } }, null)).rejects.toThrow(/WALLET_MODE not set/);
});
it('throws on unknown wallet mode', async () => {
const { createSigner } = await import('../lib/signer.js');
await expect(createSigner({ env: { WALLET_MODE: 'metamask' } }, null)).rejects.toThrow(/Unknown wallet_mode "metamask"/);
});
it('throws when WDK vault file is missing', async () => {
const { createSigner } = await import('../lib/signer.js');
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: join(tmpDir, 'nonexistent_vault'),
WDK_PASSWORD_FILE: wdkPasswordFile,
},
};
await expect(createSigner(cfg, null)).rejects.toThrow(/vault not found/i);
});
it('throws when WDK password is wrong', async () => {
const { createSigner } = await import('../lib/signer.js');
const badPwFile = join(tmpDir, '.bad_password');
writeFileSync(badPwFile, 'wrong-password-here');
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: wdkVaultFile,
WDK_PASSWORD_FILE: badPwFile,
},
};
await expect(createSigner(cfg, null)).rejects.toThrow(/decryption failed|wrong password/i);
});
it('throws when Foundry keystore is missing', async () => {
const { createSigner } = await import('../lib/signer.js');
const cfg = {
env: {
WALLET_MODE: 'foundry',
FOUNDRY_ACCOUNT: 'nonexistent-account',
KEYSTORE_PASSWORD_FILE: foundryPasswordFile,
},
};
await expect(createSigner(cfg, null, { keystoreDir: foundryKeystoreDir })).rejects.toThrow(/keystore not found/i);
});
it('throws when Foundry password file is missing', async () => {
const { createSigner } = await import('../lib/signer.js');
const cfg = {
env: {
WALLET_MODE: 'foundry',
FOUNDRY_ACCOUNT: FOUNDRY_ACCOUNT,
KEYSTORE_PASSWORD_FILE: join(tmpDir, 'no-such-file'),
},
};
await expect(createSigner(cfg, null, { keystoreDir: foundryKeystoreDir })).rejects.toThrow(/Cannot read KEYSTORE_PASSWORD_FILE/);
});
it('create-wallet.js rejects short password', () => {
const shortPwFile = join(tmpDir, '.short_pw');
writeFileSync(shortPwFile, 'short');
expect(() => {
execFileSync('node', [
CREATE_WALLET,
'--password-file', shortPwFile,
'--vault-file', join(tmpDir, '.vault_short'),
], { encoding: 'utf8', cwd: SCRIPTS_DIR });
}).toThrow();
});
it('create-wallet.js rejects overwrite without --force', () => {
// wdkVaultFile already exists from beforeAll
expect(() => {
execFileSync('node', [
CREATE_WALLET,
'--password-file', wdkPasswordFile,
'--vault-file', wdkVaultFile,
], { encoding: 'utf8', cwd: SCRIPTS_DIR });
}).toThrow();
});
it('export-seed.js refuses to run in non-TTY environment', () => {
const EXPORT_SEED = join(SCRIPTS_DIR, 'lib', 'export-seed.js');
expect(() => {
execFileSync('node', [
EXPORT_SEED,
'--password-file', wdkPasswordFile,
'--vault-file', wdkVaultFile,
], { encoding: 'utf8', cwd: SCRIPTS_DIR });
}).toThrow(/interactive terminal/i);
});
it('create-wallet.js allows overwrite with --force', () => {
const out = execFileSync('node', [
CREATE_WALLET,
'--password-file', wdkPasswordFile,
'--vault-file', wdkVaultFile,
'--force',
], { encoding: 'utf8', cwd: SCRIPTS_DIR });
const result = JSON.parse(out.trim());
expect(result.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
// Address will differ since new entropy is generated
});
});
});
```
### scripts/helpers.js
```javascript
/**
* Compute Permit2 invalidateUnorderedNonces arguments from a UniswapX nonce.
* @param {bigint} nonce
* @returns {{ wordPos: bigint, mask: bigint }}
*/
function computeNonceComponents(nonce) {
const wordPos = nonce >> 8n;
const mask = 1n << (nonce & 0xFFn);
return { wordPos, mask };
}
/**
* Resolve expiry seconds with clamping. Returns default if input is null/undefined.
* @param {number|null|undefined} inputSeconds
* @param {{ defaultSeconds: number, minSeconds: number, maxSeconds: number }} limits
* @returns {number}
*/
function resolveExpiry(inputSeconds, limits) {
if (inputSeconds == null) return limits.defaultSeconds;
return Math.max(limits.minSeconds, Math.min(limits.maxSeconds, inputSeconds));
}
/**
* Check that an amount string does not exceed maxDecimals decimal places.
* @param {string} amount - must be a decimal string (not scientific notation)
* @param {number} maxDecimals
* @returns {boolean}
*/
function checkPrecision(amount, maxDecimals) {
const s = String(amount);
const dotIndex = s.indexOf('.');
if (dotIndex === -1) return true;
return s.length - dotIndex - 1 <= maxDecimals;
}
export { computeNonceComponents, resolveExpiry, checkPrecision };
```
### scripts/lib/__tests__/config.test.js
```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { loadConfig, resolveToken } from '../config.js';
let testDir;
beforeEach(() => {
testDir = join(tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
describe('loadConfig', () => {
it('parses .env file correctly (key=value, ignores comments and blanks)', () => {
const envContent = [
'# This is a comment',
'',
'PRIVATE_KEY=0xdeadbeef',
' ',
'RPC_URL=https://mainnet.example.com',
'# another comment',
'QUOTED_VAL="hello world"',
"SINGLE_QUOTED='foo bar'",
].join('\n');
writeFileSync(join(testDir, '.env'), envContent);
const config = loadConfig(testDir);
expect(config.env.PRIVATE_KEY).toBe('0xdeadbeef');
expect(config.env.RPC_URL).toBe('https://mainnet.example.com');
expect(config.env.QUOTED_VAL).toBe('hello world');
expect(config.env.SINGLE_QUOTED).toBe('foo bar');
expect(Object.keys(config.env)).not.toContain('');
});
it('resolves token symbol to address and decimals via resolveToken', () => {
const yamlContent = [
'tokens:',
' USDT:',
' address: "0xdAC17F958D2ee523a2206206994597C13D831ec7"',
' decimals: 6',
' XAUT:',
' address: "0x68749665FF8D2d112Fa859AA293F07A622782F38"',
' decimals: 6',
].join('\n');
writeFileSync(join(testDir, 'config.yaml'), yamlContent);
const config = loadConfig(testDir);
const token = resolveToken(config, 'USDT');
expect(token.address).toBe('0xdAC17F958D2ee523a2206206994597C13D831ec7');
expect(token.decimals).toBe(6);
});
it('throws on unknown token symbol', () => {
const yamlContent = [
'tokens:',
' USDT:',
' address: "0xdAC17F958D2ee523a2206206994597C13D831ec7"',
' decimals: 6',
].join('\n');
writeFileSync(join(testDir, 'config.yaml'), yamlContent);
const config = loadConfig(testDir);
expect(() => resolveToken(config, 'UNKNOWN')).toThrow(/unknown token/i);
});
it('returns undefined wallet_mode when not set in yaml', () => {
const yamlContent = [
'tokens:',
' USDT:',
' address: "0xdAC17F958D2ee523a2206206994597C13D831ec7"',
' decimals: 6',
].join('\n');
writeFileSync(join(testDir, 'config.yaml'), yamlContent);
const config = loadConfig(testDir);
expect(config.yaml.wallet_mode).toBeUndefined();
});
it('silently returns empty objects when files do not exist', () => {
// testDir exists but has no .env or config.yaml
const config = loadConfig(testDir);
expect(config.env).toEqual({});
expect(config.yaml).toEqual({});
expect(config.configDir).toBe(testDir);
});
});
```
### scripts/lib/__tests__/create-wallet.test.js
```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, existsSync, statSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// sodium-native and bip39-mnemonic are CJS; load via createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const CREATE_WALLET_SCRIPT = join(__dirname, '..', 'create-wallet.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function runCreateWallet(args, { expectSuccess = true } = {}) {
try {
const output = execFileSync(process.execPath, [CREATE_WALLET_SCRIPT, ...args], {
encoding: 'utf8',
env: { ...process.env },
});
if (!expectSuccess) {
throw new Error(`Expected failure but succeeded with output: ${output}`);
}
return { stdout: output, exitCode: 0 };
} catch (err) {
if (expectSuccess) {
throw err;
}
return {
stdout: err.stdout || '',
stderr: err.stderr || '',
exitCode: err.status || 1,
};
}
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
let testDir;
beforeEach(() => {
testDir = join(
tmpdir(),
`create-wallet-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('create-wallet.js', () => {
it('creates vault file and outputs valid Ethereum address', () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
writeFileSync(passwordFile, 'my-strong-password-123');
const { stdout } = runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
]);
const result = JSON.parse(stdout);
expect(result).toHaveProperty('address');
expect(result).toHaveProperty('vaultFile');
expect(result.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
expect(result.vaultFile).toBe(vaultFile);
expect(existsSync(vaultFile)).toBe(true);
});
it('vault file has correct JSON structure', () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
writeFileSync(passwordFile, 'my-strong-password-123');
runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
]);
const vault = JSON.parse(readFileSync(vaultFile, 'utf8'));
expect(vault).toHaveProperty('encryptedEntropy');
expect(vault).toHaveProperty('salt');
expect(typeof vault.encryptedEntropy).toBe('string');
expect(typeof vault.salt).toBe('string');
// salt should be 16 bytes = 32 hex chars
expect(vault.salt).toHaveLength(32);
});
it('errors if vault already exists without --force', () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
writeFileSync(passwordFile, 'my-strong-password-123');
writeFileSync(vaultFile, '{}'); // pre-existing vault
const result = runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
], { expectSuccess: false });
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/already exists/i);
});
it('overwrites existing vault with --force', () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
writeFileSync(passwordFile, 'my-strong-password-123');
writeFileSync(vaultFile, '{}'); // pre-existing vault
const { stdout } = runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
'--force',
]);
const result = JSON.parse(stdout);
expect(result.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
it('errors if password is too short (< 12 characters)', () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
writeFileSync(passwordFile, 'short');
const result = runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
], { expectSuccess: false });
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/password/i);
});
it('vault is decryptable by signer.js (round-trip test)', async () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
const password = 'my-strong-password-123';
writeFileSync(passwordFile, password);
const { stdout } = runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
]);
const { address: createdAddress } = JSON.parse(stdout);
// Now use signer.js to decrypt the vault and verify we get the same address
const { createSigner } = await import('../signer.js');
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: vaultFile,
WDK_PASSWORD_FILE: passwordFile,
},
yaml: {},
configDir: testDir,
};
const wallet = await createSigner(cfg, null);
expect(wallet.address.toLowerCase()).toBe(createdAddress.toLowerCase());
});
it('sets vault file permissions to 600', () => {
const passwordFile = join(testDir, '.wdk_password');
const vaultFile = join(testDir, '.wdk_vault');
writeFileSync(passwordFile, 'my-strong-password-123');
runCreateWallet([
'--password-file', passwordFile,
'--vault-file', vaultFile,
]);
const stat = statSync(vaultFile);
// mode & 0o777 should be 0o600
expect(stat.mode & 0o777).toBe(0o600);
});
});
```
### scripts/lib/__tests__/erc20.test.js
```javascript
import { describe, it, expect, vi } from 'vitest';
import { getBalance, getAllowance, approve } from '../erc20.js';
function mockProvider(returnValue) {
return { call: vi.fn().mockResolvedValue(returnValue) };
}
function mockSigner(address = '0x' + '1'.repeat(40)) {
return {
getAddress: vi.fn().mockResolvedValue(address),
sendTransaction: vi.fn().mockResolvedValue({
hash: '0xabc',
wait: vi.fn().mockResolvedValue({ status: 1 }),
}),
provider: mockProvider('0x' + '0'.repeat(64)),
};
}
const TOKEN_USDT = { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 };
const TOKEN_XAUT = { address: '0x68749665FF8D2d112Fa859AA293F07A622782F38', decimals: 6 };
describe('getBalance', () => {
it('calls provider.call and formats result with token decimals', async () => {
// 1000000000 raw / 10^6 = 1000.0
const raw = '0x' + (1000000000n).toString(16).padStart(64, '0');
const provider = mockProvider(raw);
const result = await getBalance(TOKEN_USDT, '0x' + '2'.repeat(40), provider);
expect(provider.call).toHaveBeenCalledOnce();
const callArg = provider.call.mock.calls[0][0];
expect(callArg.to).toBe(TOKEN_USDT.address);
expect(typeof callArg.data).toBe('string');
expect(result).toBe('1000.0');
});
it('returns "0.0" when balance is zero', async () => {
const provider = mockProvider('0x' + '0'.repeat(64));
const result = await getBalance(TOKEN_XAUT, '0x' + '3'.repeat(40), provider);
expect(result).toBe('0.0');
});
});
describe('getAllowance', () => {
it('returns formatted allowance value', async () => {
// 500000 raw / 10^6 = 0.5
const raw = '0x' + (500000n).toString(16).padStart(64, '0');
const provider = mockProvider(raw);
const owner = '0x' + '2'.repeat(40);
const spender = '0x' + '3'.repeat(40);
const result = await getAllowance(TOKEN_USDT, owner, spender, provider);
expect(provider.call).toHaveBeenCalledOnce();
const callArg = provider.call.mock.calls[0][0];
expect(callArg.to).toBe(TOKEN_USDT.address);
expect(result).toBe('0.5');
});
});
describe('approve', () => {
it('sends one transaction when requiresResetApprove is not set', async () => {
const signer = mockSigner();
const spender = '0x' + '3'.repeat(40);
const result = await approve(TOKEN_USDT, spender, '1000', signer);
expect(signer.sendTransaction).toHaveBeenCalledOnce();
const txArg = signer.sendTransaction.mock.calls[0][0];
expect(txArg.to).toBe(TOKEN_USDT.address);
expect(typeof txArg.data).toBe('string');
expect(result).toEqual({ hash: '0xabc' });
});
it('sends two transactions (reset + approve) when requiresResetApprove is true', async () => {
const signer = mockSigner();
const spender = '0x' + '3'.repeat(40);
const result = await approve(TOKEN_USDT, spender, '1000', signer, { requiresResetApprove: true });
expect(signer.sendTransaction).toHaveBeenCalledTimes(2);
// First call: approve(spender, 0)
const resetTxArg = signer.sendTransaction.mock.calls[0][0];
expect(resetTxArg.to).toBe(TOKEN_USDT.address);
// Second call: approve(spender, amount)
const approveTxArg = signer.sendTransaction.mock.calls[1][0];
expect(approveTxArg.to).toBe(TOKEN_USDT.address);
expect(result).toEqual({ hash: '0xabc' });
});
});
```
### scripts/lib/__tests__/provider.test.js
```javascript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FallbackProvider, createProvider } from '../provider.js';
describe('FallbackProvider', () => {
it('uses primary URL by default', async () => {
const provider = new FallbackProvider('https://primary.example.com');
provider._rawSend = vi.fn().mockResolvedValue('0x1');
const result = await provider.send('eth_blockNumber', []);
expect(result).toBe('0x1');
expect(provider._rawSend).toHaveBeenCalledWith(
'https://primary.example.com',
'eth_blockNumber',
[]
);
});
it('throws when no primary URL is provided', () => {
expect(() => new FallbackProvider()).toThrow(/primary/i);
expect(() => new FallbackProvider('')).toThrow(/primary/i);
});
it('falls back on retriable error codes (429, 502, 503)', async () => {
const provider = new FallbackProvider('https://primary.example.com', [
'https://fallback1.example.com',
]);
let callCount = 0;
provider._rawSend = vi.fn().mockImplementation(async (url) => {
callCount++;
if (url === 'https://primary.example.com') {
const err = new Error('Too Many Requests');
err.status = 429;
throw err;
}
return '0x2';
});
const result = await provider.send('eth_blockNumber', []);
expect(result).toBe('0x2');
expect(provider._rawSend).toHaveBeenCalledTimes(2);
expect(provider._rawSend).toHaveBeenNthCalledWith(
1,
'https://primary.example.com',
'eth_blockNumber',
[]
);
expect(provider._rawSend).toHaveBeenNthCalledWith(
2,
'https://fallback1.example.com',
'eth_blockNumber',
[]
);
});
it('is session-sticky: after fallback success, primary URL updates', async () => {
const provider = new FallbackProvider('https://primary.example.com', [
'https://fallback1.example.com',
]);
provider._rawSend = vi.fn().mockImplementation(async (url) => {
if (url === 'https://primary.example.com') {
const err = new Error('Service Unavailable');
err.status = 503;
throw err;
}
return '0x3';
});
// First call triggers fallback
await provider.send('eth_blockNumber', []);
// Reset mock to track next call
provider._rawSend.mockClear();
provider._rawSend.mockResolvedValue('0x4');
// Second call should use the new primary (fallback1)
await provider.send('eth_blockNumber', []);
expect(provider._rawSend).toHaveBeenCalledWith(
'https://fallback1.example.com',
'eth_blockNumber',
[]
);
});
it('throws after all URLs are exhausted', async () => {
const provider = new FallbackProvider('https://primary.example.com', [
'https://fallback1.example.com',
'https://fallback2.example.com',
]);
provider._rawSend = vi.fn().mockImplementation(async (url) => {
const err = new Error('Bad Gateway');
err.status = 502;
throw err;
});
await expect(provider.send('eth_blockNumber', [])).rejects.toThrow();
});
it('call() routes through _sendWithFallback with eth_call', async () => {
const provider = new FallbackProvider('https://primary.example.com');
provider._rawSend = vi.fn().mockResolvedValue('0xresult');
const tx = { to: '0xabc', data: '0x1234' };
const result = await provider.call(tx);
expect(result).toBe('0xresult');
expect(provider._rawSend).toHaveBeenCalledWith(
'https://primary.example.com',
'eth_call',
[tx, 'latest']
);
});
it('getBalance() routes through _sendWithFallback with eth_getBalance', async () => {
const provider = new FallbackProvider('https://primary.example.com');
provider._rawSend = vi.fn().mockResolvedValue('0xde0b6b3a7640000');
const address = '0xabcdef1234567890abcdef1234567890abcdef12';
const result = await provider.getBalance(address);
expect(result).toBe('0xde0b6b3a7640000');
expect(provider._rawSend).toHaveBeenCalledWith(
'https://primary.example.com',
'eth_getBalance',
[address, 'latest']
);
});
it('getBlockNumber() routes through _sendWithFallback', async () => {
const provider = new FallbackProvider('https://primary.example.com');
provider._rawSend = vi.fn().mockResolvedValue('0x10d4f');
const result = await provider.getBlockNumber();
expect(result).toBe('0x10d4f');
expect(provider._rawSend).toHaveBeenCalledWith(
'https://primary.example.com',
'eth_blockNumber',
[]
);
});
it('falls back on timeout and connection-refused errors', async () => {
const provider = new FallbackProvider('https://primary.example.com', [
'https://fallback1.example.com',
]);
provider._rawSend = vi.fn().mockImplementation(async (url) => {
if (url === 'https://primary.example.com') {
const err = new Error('Connection refused');
err.code = 'ECONNREFUSED';
throw err;
}
return '0x5';
});
const result = await provider.send('eth_blockNumber', []);
expect(result).toBe('0x5');
});
it('times out individual RPC requests and retries fallback', async () => {
const provider = new FallbackProvider(
'https://primary.example.com',
['https://fallback1.example.com'],
5
);
provider._rawSend = vi.fn().mockImplementation(async (url) => {
if (url === 'https://primary.example.com') {
const err = new Error('RPC request timeout after 5ms');
err.code = 'ETIMEDOUT';
throw err;
}
return '0x6';
});
const result = await provider.send('eth_blockNumber', []);
expect(result).toBe('0x6');
expect(provider._rawSend).toHaveBeenCalledTimes(2);
});
it('does not fall back on non-retriable errors', async () => {
const provider = new FallbackProvider('https://primary.example.com', [
'https://fallback1.example.com',
]);
provider._rawSend = vi.fn().mockImplementation(async () => {
const err = new Error('Invalid params');
err.code = -32602;
throw err;
});
await expect(provider.send('eth_call', [{}])).rejects.toThrow('Invalid params');
// Should only have tried primary, not fallback
expect(provider._rawSend).toHaveBeenCalledTimes(1);
});
it('getEthersProvider() returns underlying JsonRpcProvider', () => {
const provider = new FallbackProvider('https://primary.example.com');
const ethersProvider = provider.getEthersProvider();
// Should be an object with expected ethers Provider interface
expect(ethersProvider).toBeDefined();
expect(typeof ethersProvider.getBlockNumber).toBe('function');
});
});
describe('createProvider', () => {
it('creates provider from env with primary and fallbacks', () => {
const env = {
ETH_RPC_URL: 'https://primary.example.com',
ETH_RPC_URL_FALLBACK: 'https://fb1.example.com,https://fb2.example.com',
};
const provider = createProvider(env);
expect(provider).toBeInstanceOf(FallbackProvider);
});
it('creates provider with only primary when no fallback env set', () => {
const env = { ETH_RPC_URL: 'https://primary.example.com' };
const provider = createProvider(env);
expect(provider).toBeInstanceOf(FallbackProvider);
});
it('throws when ETH_RPC_URL is missing from env', () => {
const originalHome = process.env.HOME;
process.env.HOME = mkdtempSync(join(tmpdir(), 'xaut-provider-test-'));
try {
expect(() => createProvider({})).toThrow(/ETH_RPC_URL/i);
} finally {
process.env.HOME = originalHome;
}
});
});
```
### scripts/lib/__tests__/signer.test.js
```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { pbkdf2Sync } from 'crypto';
import { BaseWallet, HDNodeWallet, Wallet } from 'ethers6';
import { createSigner } from '../signer.js';
// sodium-native and bip39-mnemonic are CJS; load via createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const sodium = require('sodium-native');
const b4a = require('b4a');
const bip39 = require('bip39-mnemonic');
// ---------------------------------------------------------------------------
// Helpers to create test fixtures
// ---------------------------------------------------------------------------
/**
* Encrypt a buffer using the same algorithm as WdkSecretManager.
* (Replicates WdkSecretManager#encrypt — see signer.js for details.)
*/
function wdkEncrypt(buffer, key) {
const NONCEBYTES = sodium.crypto_secretbox_NONCEBYTES;
const MACBYTES = sodium.crypto_secretbox_MACBYTES;
const buffLength = buffer.byteLength;
const nonce = b4a.alloc(NONCEBYTES);
sodium.randombytes_buf(nonce);
const payload = b4a.alloc(1 + NONCEBYTES + 1 + buffLength + MACBYTES);
payload[0] = 0; // version
payload.set(nonce, 1);
const cipher = payload.subarray(1 + nonce.byteLength);
const plain = cipher.subarray(0, cipher.byteLength - MACBYTES);
plain[0] = buffLength;
plain.set(buffer, 1);
sodium.crypto_secretbox_easy(cipher, plain, nonce, key);
return payload;
}
/**
* Create a minimal WDK vault JSON string for a given password + mnemonic.
*/
function createWdkVault(password, mnemonic) {
const salt = b4a.alloc(16);
sodium.randombytes_buf(salt);
const key = pbkdf2Sync(password, salt, 100_000, 32, 'sha256');
// Convert mnemonic back to entropy
const entropy = Buffer.from(bip39.mnemonicToEntropy(mnemonic), 'hex');
const encryptedEntropy = wdkEncrypt(entropy, key);
return JSON.stringify({
encryptedEntropy: encryptedEntropy.toString('hex'),
salt: salt.toString('hex'),
});
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
let testDir;
beforeEach(() => {
testDir = join(
tmpdir(),
`signer-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('createSigner', () => {
it('throws when WALLET_MODE is not set in .env', async () => {
const cfg = { env: {}, yaml: {}, configDir: testDir };
await expect(createSigner(cfg, null)).rejects.toThrow(
/WALLET_MODE not set in \.env/i,
);
});
it('throws for unknown wallet_mode', async () => {
const cfg = { env: { WALLET_MODE: 'ledger' }, yaml: {}, configDir: testDir };
await expect(createSigner(cfg, null)).rejects.toThrow(
/unknown wallet_mode/i,
);
});
describe('foundry mode', () => {
it('creates a wallet from a Foundry keystore', async () => {
// Create a random wallet and encrypt its keystore
const originalWallet = Wallet.createRandom();
const password = 'test-password-foundry';
const keystoreJson = await originalWallet.encrypt(password);
// Write keystore to temp dir using account name
const accountName = 'test-account';
const keystoreDir = join(testDir, 'foundry-keystores');
mkdirSync(keystoreDir, { recursive: true });
writeFileSync(join(keystoreDir, accountName), keystoreJson);
// Write password file
const passwordFile = join(testDir, '.keystore-password');
writeFileSync(passwordFile, password);
const cfg = {
env: {
WALLET_MODE: 'foundry',
FOUNDRY_ACCOUNT: accountName,
KEYSTORE_PASSWORD_FILE: passwordFile,
},
yaml: {},
configDir: testDir,
};
const wallet = await createSigner(cfg, null, { keystoreDir });
// ethers v6 returns HDNodeWallet (a BaseWallet subclass) from both
// fromEncryptedJson and fromPhrase
expect(wallet).toBeInstanceOf(BaseWallet);
expect(wallet.address.toLowerCase()).toBe(
originalWallet.address.toLowerCase(),
);
});
it('throws when FOUNDRY_ACCOUNT is missing', async () => {
const cfg = {
env: { WALLET_MODE: 'foundry' },
yaml: {},
configDir: testDir,
};
await expect(createSigner(cfg, null)).rejects.toThrow(
/FOUNDRY_ACCOUNT not set/i,
);
});
it('throws when keystore file does not exist', async () => {
const passwordFile = join(testDir, '.pass');
writeFileSync(passwordFile, 'password');
const cfg = {
env: {
WALLET_MODE: 'foundry',
FOUNDRY_ACCOUNT: 'nonexistent-account',
KEYSTORE_PASSWORD_FILE: passwordFile,
},
yaml: {},
configDir: testDir,
};
await expect(
createSigner(cfg, null, { keystoreDir: testDir }),
).rejects.toThrow(/keystore not found/i);
});
});
describe('wdk mode', () => {
it('creates a wallet from a WDK encrypted vault', async () => {
// Create a deterministic mnemonic via a random wallet
const originalWallet = Wallet.createRandom();
const mnemonic = originalWallet.mnemonic.phrase;
const password = 'test-password-wdk';
const vaultJson = createWdkVault(password, mnemonic);
// Write vault and password files
const vaultFile = join(testDir, '.wdk_vault');
const passwordFile = join(testDir, '.wdk_password');
writeFileSync(vaultFile, vaultJson);
writeFileSync(passwordFile, password);
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: vaultFile,
WDK_PASSWORD_FILE: passwordFile,
},
yaml: {},
configDir: testDir,
};
const wallet = await createSigner(cfg, null);
expect(wallet).toBeInstanceOf(BaseWallet);
expect(wallet.address.toLowerCase()).toBe(
originalWallet.address.toLowerCase(),
);
});
it('throws when vault file does not exist', async () => {
const passwordFile = join(testDir, '.wdk_password');
writeFileSync(passwordFile, 'password');
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: join(testDir, 'nonexistent.vault'),
WDK_PASSWORD_FILE: passwordFile,
},
yaml: {},
configDir: testDir,
};
await expect(createSigner(cfg, null)).rejects.toThrow(
/wdk vault not found/i,
);
});
it('throws when decryption fails (wrong password)', async () => {
const originalWallet = Wallet.createRandom();
const mnemonic = originalWallet.mnemonic.phrase;
// Encrypt with one password, decrypt with another
const vaultJson = createWdkVault('correct-password', mnemonic);
const vaultFile = join(testDir, '.wdk_vault');
const passwordFile = join(testDir, '.wdk_password');
writeFileSync(vaultFile, vaultJson);
writeFileSync(passwordFile, 'wrong-password');
const cfg = {
env: {
WALLET_MODE: 'wdk',
WDK_VAULT_FILE: vaultFile,
WDK_PASSWORD_FILE: passwordFile,
},
yaml: {},
configDir: testDir,
};
await expect(createSigner(cfg, null)).rejects.toThrow(
/decryption failed/i,
);
});
});
});
```
### scripts/lib/__tests__/uniswap.test.js
```javascript
import { describe, it, expect, vi } from 'vitest';
import { AbiCoder } from 'ethers6';
import { quote, buildSwap } from '../uniswap.js';
// QuoterV2 returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)
function encodeQuoterResponse({ amountOut, sqrtPriceX96After, initializedTicksCrossed, gasEstimate }) {
const coder = AbiCoder.defaultAbiCoder();
return coder.encode(
['uint256', 'uint160', 'uint32', 'uint256'],
[amountOut, sqrtPriceX96After, initializedTicksCrossed, gasEstimate],
);
}
const TOKEN_WETH = { address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', decimals: 18 };
const TOKEN_XAUT = { address: '0x68749665FF8D2d112Fa859AA293F07A622782F38', decimals: 6 };
const CONTRACTS = {
quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
router: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
};
describe('quote', () => {
it('returns amountOut, amountOutRaw, sqrtPriceX96, and gasEstimate from QuoterV2', async () => {
// 1 XAUT (6 decimals) = 1_000_000 raw
const rawAmountOut = 1_000_000n;
const rawSqrtPrice = 79228162514264337593543950336n;
const rawGasEstimate = 150_000n;
const encodedResponse = encodeQuoterResponse({
amountOut: rawAmountOut,
sqrtPriceX96After: rawSqrtPrice,
initializedTicksCrossed: 1,
gasEstimate: rawGasEstimate,
});
const provider = { call: vi.fn().mockResolvedValue(encodedResponse) };
const result = await quote({
tokenIn: TOKEN_WETH,
tokenOut: TOKEN_XAUT,
amountIn: '1',
fee: 3000,
contracts: CONTRACTS,
provider,
});
// provider.call should have been called once with quoter address
expect(provider.call).toHaveBeenCalledOnce();
const callArg = provider.call.mock.calls[0][0];
expect(callArg.to).toBe(CONTRACTS.quoter);
expect(typeof callArg.data).toBe('string');
expect(callArg.data.startsWith('0x')).toBe(true);
// Result shape
expect(result.amountOutRaw).toBe(rawAmountOut);
expect(result.amountOut).toBe('1.0'); // 1_000_000 / 10^6
expect(result.sqrtPriceX96).toBe(rawSqrtPrice);
expect(result.gasEstimate).toBe(rawGasEstimate);
});
});
describe('buildSwap', () => {
it('returns tx params with to, data, and value fields', () => {
const recipient = '0x' + '1'.repeat(40);
const deadline = Math.floor(Date.now() / 1000) + 600;
const result = buildSwap({
tokenIn: TOKEN_WETH,
tokenOut: TOKEN_XAUT,
amountIn: '1',
minAmountOut: '0.99',
fee: 3000,
recipient,
deadline,
contracts: CONTRACTS,
});
expect(result.to).toBe(CONTRACTS.router);
expect(typeof result.data).toBe('string');
expect(result.data.startsWith('0x')).toBe(true);
expect(result.data.length).toBeGreaterThan(10);
// value should be a bigint (0 for ERC-20 → ERC-20 swaps)
expect(typeof result.value).toBe('bigint');
});
it('sets value to amountIn in wei for ETH input', () => {
const TOKEN_ETH = { address: '0x0000000000000000000000000000000000000000', decimals: 18 };
const recipient = '0x' + '1'.repeat(40);
const deadline = Math.floor(Date.now() / 1000) + 600;
const result = buildSwap({
tokenIn: TOKEN_ETH,
tokenOut: TOKEN_XAUT,
amountIn: '1',
minAmountOut: '0.99',
fee: 3000,
recipient,
deadline,
contracts: CONTRACTS,
});
expect(result.to).toBe(CONTRACTS.router);
expect(result.value).toBe(1_000_000_000_000_000_000n); // 1 ETH in wei
});
});
```
### scripts/lib/config.js
```javascript
import { readFileSync } from 'fs';
import { join } from 'path';
import yaml from 'js-yaml';
import { getAddress } from 'ethers6';
/**
* Parse a .env file content into a plain object.
* - Lines starting with # (after optional whitespace) are ignored.
* - Blank / whitespace-only lines are ignored.
* - Values may be surrounded by single or double quotes, which are stripped.
*/
function parseEnv(content) {
const result = {};
for (const raw of content.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eqIndex = line.indexOf('=');
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
let value = line.slice(eqIndex + 1).trim();
// Strip surrounding quotes (single or double)
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (key) {
result[key] = value;
}
}
return result;
}
/**
* Load configuration from configDir.
*
* Reads:
* <configDir>/.env — environment variables (key=value pairs)
* <configDir>/config.yaml — structured YAML config
*
* Both files are optional; missing files are silently treated as empty.
*
* @param {string} configDir Path to the config directory (defaults to ~/.aurehub)
* @returns {{ env: object, yaml: object, configDir: string }}
*/
export function loadConfig(configDir) {
// Default to ~/.aurehub when no directory is supplied
const dir = configDir ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.aurehub');
let env = {};
try {
const raw = readFileSync(join(dir, '.env'), 'utf8');
env = parseEnv(raw);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
let yamlConfig = {};
try {
const raw = readFileSync(join(dir, 'config.yaml'), 'utf8');
yamlConfig = yaml.load(raw, { schema: yaml.JSON_SCHEMA }) ?? {};
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
return { env, yaml: yamlConfig, configDir: dir };
}
/**
* Resolve a token symbol to its address and decimals from config.yaml's `tokens` section.
*
* @param {{ yaml: object }} config Config object returned by loadConfig
* @param {string} symbol Token symbol, e.g. "USDT"
* @returns {{ address: string, decimals: number }}
* @throws {Error} If the symbol is not found in the tokens section
*/
// Canonical mainnet addresses for tamper detection
const CANONICAL_TOKENS = {
XAUT: '0x68749665FF8D2d112Fa859AA293F07A622782F38',
USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
};
const CANONICAL_CONTRACTS = {
router: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
permit2: '0x000000000022D473030F116dDEE9F6B43aC78BA3',
};
export function resolveToken(config, symbol) {
const tokens = config?.yaml?.tokens ?? {};
const token = tokens[symbol];
if (!token) {
throw new Error(`Unknown token symbol: "${symbol}"`);
}
// Validate address against canonical value if known
const canonical = CANONICAL_TOKENS[symbol];
if (canonical && token.address.toLowerCase() !== canonical.toLowerCase()) {
throw new Error(`Token ${symbol} address mismatch: config has ${token.address}, expected ${canonical}. Check config.yaml for tampering.`);
}
// Validate decimals
if (typeof token.decimals !== 'number' || !Number.isInteger(token.decimals) || token.decimals < 0 || token.decimals > 18) {
throw new Error(`Token ${symbol} has invalid decimals: ${token.decimals}`);
}
// Normalize to EIP-55 checksum to tolerate old config files with non-standard casing.
return { address: getAddress(token.address.toLowerCase()), decimals: token.decimals };
}
export function validateContracts(config) {
const contracts = config?.yaml?.contracts ?? {};
for (const [name, canonical] of Object.entries(CANONICAL_CONTRACTS)) {
if (contracts[name] && contracts[name].toLowerCase() !== canonical.toLowerCase()) {
throw new Error(`Contract ${name} address mismatch: config has ${contracts[name]}, expected ${canonical}. Check config.yaml for tampering.`);
}
// Normalize to EIP-55 checksum to tolerate old config files with non-standard casing.
if (contracts[name]) {
contracts[name] = getAddress(contracts[name].toLowerCase());
}
}
}
```
### scripts/lib/create-wallet.js
```javascript
#!/usr/bin/env node
/**
* create-wallet.js — CLI script to create an encrypted WDK vault file.
*
* Usage:
* node lib/create-wallet.js --password-file <path> [--vault-file <path>] [--force]
*
* Outputs JSON to stdout: { address, vaultFile }
*
* The vault format is compatible with signer.js / WdkSecretManager:
* { encryptedEntropy: <hex>, salt: <hex> }
*
* encryptedEntropy hex encodes: [version=0][nonce(24b)][secretbox_easy(plain, nonce, key)]
* where plain = [length_byte, ...entropy_bytes]
*/
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
/** Expand leading ~ to the user's home directory. */
function expandTilde(p) {
if (typeof p === 'string' && p.startsWith('~/')) {
return join(homedir(), p.slice(2));
}
return p;
}
import { randomBytes, pbkdf2Sync } from 'crypto';
import { Wallet } from 'ethers6';
// sodium-native and bip39-mnemonic are CJS; load via createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const sodium = require('sodium-native');
const b4a = require('b4a');
const bip39 = require('bip39-mnemonic');
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = argv.slice(2);
const opts = {
passwordFile: null,
vaultFile: join(homedir(), '.aurehub', '.wdk_vault'),
force: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--password-file') {
opts.passwordFile = expandTilde(args[++i]);
} else if (arg === '--vault-file') {
opts.vaultFile = expandTilde(args[++i]);
} else if (arg === '--force') {
opts.force = true;
} else {
fatal(`Unknown argument: ${arg}`);
}
}
if (!opts.passwordFile) {
fatal('--password-file is required');
}
return opts;
}
// ---------------------------------------------------------------------------
// Error helpers
// ---------------------------------------------------------------------------
function fatal(msg) {
process.stderr.write(`Error: ${msg}\n`);
process.exit(1);
}
// ---------------------------------------------------------------------------
// WDK vault encryption
//
// Matches WdkSecretManager#encrypt() exactly so signer.js can decrypt.
//
// Encryption layout (encryptedEntropy hex):
// byte 0 : version = 0
// bytes 1..24 : nonce (crypto_secretbox_NONCEBYTES = 24)
// bytes 25..end : ciphertext from crypto_secretbox_easy
// plain = [length_byte(1), ...entropy]
// ---------------------------------------------------------------------------
function wdkEncrypt(entropy, key) {
const NONCEBYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
const MACBYTES = sodium.crypto_secretbox_MACBYTES; // 16
const buffLength = entropy.byteLength;
const nonce = b4a.alloc(NONCEBYTES);
sodium.randombytes_buf(nonce);
// Total payload: 1 (version) + 24 (nonce) + 1 (length) + buffLength + 16 (mac)
const payload = b4a.alloc(1 + NONCEBYTES + 1 + buffLength + MACBYTES);
payload[0] = 0; // version
payload.set(nonce, 1);
// cipher occupies the rest: 1 + buffLength + MACBYTES bytes
const cipher = payload.subarray(1 + NONCEBYTES);
// plain is cipher minus MAC trailer
const plain = cipher.subarray(0, cipher.byteLength - MACBYTES);
plain[0] = buffLength;
plain.set(entropy, 1);
sodium.crypto_secretbox_easy(cipher, plain, nonce, key);
return payload;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs(process.argv);
// 1. Read password
let password;
try {
password = readFileSync(opts.passwordFile, 'utf8').trim();
} catch (err) {
fatal(`Cannot read password file "${opts.passwordFile}": ${err.message}`);
}
// 2. Validate password length
if (password.length < 12) {
fatal('Password must be at least 12 characters long');
}
// 3. Check vault file existence
if (existsSync(opts.vaultFile) && !opts.force) {
fatal(`Vault file already exists at "${opts.vaultFile}". Use --force to overwrite.`);
}
// 4. Generate 16 bytes random entropy (BIP-39 seed)
const entropy = randomBytes(16);
// 5. Generate 16-byte random salt for PBKDF2 key derivation (matches WDK source)
const salt = randomBytes(16);
// 6. Derive 32-byte key via PBKDF2-SHA256
const key = pbkdf2Sync(password, salt, 100_000, 32, 'sha256');
// 7. Encrypt entropy using XSalsa20-Poly1305 (WDK format)
const encryptedEntropy = wdkEncrypt(entropy, key);
key.fill(0);
// 8. Assemble vault JSON
const vault = {
encryptedEntropy: encryptedEntropy.toString('hex'),
salt: salt.toString('hex'),
};
// 9. Ensure parent directory exists
const vaultDir = dirname(opts.vaultFile);
mkdirSync(vaultDir, { recursive: true });
// 10. Write vault file
writeFileSync(opts.vaultFile, JSON.stringify(vault, null, 2), { encoding: 'utf8' });
// 11. Set permissions to 600
chmodSync(opts.vaultFile, 0o600);
// 12. Derive wallet address: entropy → mnemonic → Wallet.fromPhrase
let wallet;
try {
const mnemonic = bip39.entropyToMnemonic(entropy);
wallet = Wallet.fromPhrase(mnemonic);
} finally {
sodium.sodium_memzero(entropy);
}
// 13. Output result as JSON to stdout
process.stdout.write(JSON.stringify({ address: wallet.address, vaultFile: opts.vaultFile }) + '\n');
}
main().catch(err => {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(1);
});
```
### scripts/lib/erc20.js
```javascript
import { Interface, formatUnits, parseUnits } from 'ethers6';
const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)',
'function approve(address,uint256) returns (bool)',
];
const iface = new Interface(ERC20_ABI);
/**
* Get the token balance of an address.
*
* @param {{ address: string, decimals: number }} token
* @param {string} address
* @param {object} provider ethers provider (or compatible mock with .call())
* @returns {Promise<string>} Human-readable balance formatted with token decimals
*/
export async function getBalance(token, address, provider) {
const data = iface.encodeFunctionData('balanceOf', [address]);
const raw = await provider.call({ to: token.address, data });
const [value] = iface.decodeFunctionResult('balanceOf', raw);
return formatUnits(value, token.decimals);
}
/**
* Get the ERC-20 allowance granted by owner to spender.
*
* @param {{ address: string, decimals: number }} token
* @param {string} owner
* @param {string} spender
* @param {object} provider
* @returns {Promise<string>} Human-readable allowance formatted with token decimals
*/
export async function getAllowance(token, owner, spender, provider) {
const data = iface.encodeFunctionData('allowance', [owner, spender]);
const raw = await provider.call({ to: token.address, data });
const [value] = iface.decodeFunctionResult('allowance', raw);
return formatUnits(value, token.decimals);
}
/**
* Approve a spender to transfer tokens on behalf of the signer.
*
* For tokens like USDT that revert when changing a non-zero allowance directly,
* pass `opts.requiresResetApprove = true` to first reset to 0 before setting the
* new value.
*
* @param {{ address: string, decimals: number }} token
* @param {string} spender
* @param {string} amount Human-readable amount (e.g. "1000")
* @param {object} signer ethers signer (or compatible mock)
* @param {{ requiresResetApprove?: boolean, fallbackProvider?: object }} opts
* @returns {Promise<{ hash: string }>}
*/
export async function approve(token, spender, amount, signer, opts = {}) {
const rawAmount = parseUnits(amount, token.decimals);
const timeoutMs = (opts.timeoutSeconds ?? 300) * 1000;
const waitForReceipt = async (sentTx) => {
// Use fallbackProvider.waitForTransaction when available for RPC resilience
if (opts.fallbackProvider?.waitForTransaction) {
return Promise.race([
opts.fallbackProvider.waitForTransaction(sentTx.hash, 1, timeoutMs),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(
`Approve tx not confirmed within ${timeoutMs / 1000}s (txHash: ${sentTx.hash}). It may still be pending.`
)), timeoutMs)
),
]);
}
return Promise.race([
sentTx.wait(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(
`Approve tx not confirmed within ${timeoutMs / 1000}s (txHash: ${sentTx.hash}). It may still be pending.`
)), timeoutMs)
),
]);
};
if (opts.requiresResetApprove) {
const resetData = iface.encodeFunctionData('approve', [spender, 0n]);
const resetTx = await signer.sendTransaction({ to: token.address, data: resetData });
const resetReceipt = await waitForReceipt(resetTx);
if (!resetReceipt || resetReceipt.status !== 1) {
throw new Error(`Allowance reset failed (txHash: ${resetTx.hash}). Approval not sent.`);
}
}
const data = iface.encodeFunctionData('approve', [spender, rawAmount]);
const tx = await signer.sendTransaction({ to: token.address, data });
const receipt = await waitForReceipt(tx);
if (!receipt || receipt.status !== 1) {
throw new Error(`Approval failed (txHash: ${tx.hash}). Check token contract and allowance.`);
}
return { hash: tx.hash };
}
```
### scripts/lib/export-seed.js
```javascript
#!/usr/bin/env node
/**
* export-seed.js — Decrypt a WDK vault and print the BIP-39 mnemonic.
*
* Usage:
* node lib/export-seed.js [--password-file <path>] [--vault-file <path>]
*
* Defaults:
* --password-file ~/.aurehub/.wdk_password
* --vault-file ~/.aurehub/.wdk_vault
*
* Security:
* - Requires an interactive terminal (TTY). Refuses to run when stdout is
* piped or captured (e.g. by an AI agent) to prevent seed leakage.
* - Prompts for confirmation before revealing the mnemonic.
* - Displays the seed briefly, then offers to clear the screen.
*/
import { readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { pbkdf2Sync } from 'crypto';
import { createInterface } from 'readline';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const sodium = require('sodium-native');
const b4a = require('b4a');
const bip39 = require('bip39-mnemonic');
function expandTilde(p) {
if (typeof p === 'string' && p.startsWith('~/')) {
return join(homedir(), p.slice(2));
}
return p;
}
function fatal(msg) {
process.stderr.write(`Error: ${msg}\n`);
process.exit(1);
}
function parseArgs(argv) {
const args = argv.slice(2);
const opts = {
passwordFile: join(homedir(), '.aurehub', '.wdk_password'),
vaultFile: join(homedir(), '.aurehub', '.wdk_vault'),
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--password-file') {
opts.passwordFile = expandTilde(args[++i]);
} else if (arg === '--vault-file') {
opts.vaultFile = expandTilde(args[++i]);
} else {
fatal(`Unknown argument: ${arg}`);
}
}
return opts;
}
/**
* Prompt the user on the TTY and return their answer.
* Prompts are written to stderr so stdout stays clean.
*/
function askTty(question) {
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stderr,
terminal: true,
});
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
function wdkDecrypt(payload, key) {
const NONCEBYTES = sodium.crypto_secretbox_NONCEBYTES;
const MACBYTES = sodium.crypto_secretbox_MACBYTES;
if (payload[0] !== 0) throw new Error('Unsupported encryption version');
const nonce = payload.subarray(1, 1 + NONCEBYTES);
const cipher = payload.subarray(1 + NONCEBYTES);
const plain = b4a.alloc(cipher.byteLength - MACBYTES);
if (!sodium.crypto_secretbox_open_easy(plain, cipher, nonce, key)) {
throw new Error('Decryption failed — wrong password or corrupted vault');
}
const bytes = plain[0];
const result = b4a.alloc(bytes);
result.set(plain.subarray(1, 1 + bytes));
sodium.sodium_memzero(plain);
return result;
}
async function main() {
const opts = parseArgs(process.argv);
// ── TTY gate: refuse to reveal seed in non-interactive contexts ──────────
if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stderr.isTTY) {
fatal(
'This command must be run in an interactive terminal.\n' +
'It cannot be executed by scripts, agents, or piped commands\n' +
'to prevent your seed phrase from leaking into logs or chat history.\n\n' +
'Open a terminal window and run the command directly.'
);
}
// ── Decrypt vault ────────────────────────────────────────────────────────
let password;
try {
password = readFileSync(opts.passwordFile, 'utf8').trim();
} catch (err) {
fatal(`Cannot read password file "${opts.passwordFile}": ${err.message}`);
}
let vaultJson;
try {
vaultJson = readFileSync(opts.vaultFile, 'utf8');
} catch (err) {
fatal(`Cannot read vault file "${opts.vaultFile}": ${err.message}`);
}
const vault = JSON.parse(vaultJson);
if (!vault.encryptedEntropy || !vault.salt) {
fatal('Vault is missing required fields: encryptedEntropy, salt');
}
const salt = Buffer.from(vault.salt, 'hex');
const encryptedEntropy = Buffer.from(vault.encryptedEntropy, 'hex');
const key = pbkdf2Sync(password, salt, 100_000, 32, 'sha256');
const entropy = wdkDecrypt(encryptedEntropy, key);
key.fill(0);
const mnemonic = bip39.entropyToMnemonic(entropy);
sodium.sodium_memzero(entropy);
// ── Interactive confirmation ─────────────────────────────────────────────
process.stderr.write('\n');
process.stderr.write(' ┌─────────────────────────────────────────────────────┐\n');
process.stderr.write(' │ WARNING: Your seed phrase is about to be displayed │\n');
process.stderr.write(' │ │\n');
process.stderr.write(' │ • Make sure no one can see your screen │\n');
process.stderr.write(' │ • Do not screenshot or copy to clipboard │\n');
process.stderr.write(' │ • Write the words on paper and store offline │\n');
process.stderr.write(' └─────────────────────────────────────────────────────┘\n');
process.stderr.write('\n');
const confirm = await askTty(' Type "yes" to reveal your seed phrase: ');
if (confirm.toLowerCase() !== 'yes') {
process.stderr.write(' Cancelled.\n');
process.exit(0);
}
// ── Display seed phrase ──────────────────────────────────────────────────
process.stderr.write('\n Your 12-word seed phrase:\n\n');
process.stderr.write(` ${mnemonic}\n`);
process.stderr.write('\n');
// ── Post-display: offer to clear screen ──────────────────────────────────
const clear = await askTty(' Press Enter to clear the screen (or type "keep" to leave it): ');
if (clear.toLowerCase() !== 'keep') {
// ANSI escape: clear screen + move cursor to top
process.stdout.write('\x1b[2J\x1b[H');
process.stderr.write(' Screen cleared. Your seed phrase is no longer visible.\n');
}
}
main().catch(err => {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(1);
});
```
### scripts/lib/provider.js
```javascript
import { JsonRpcProvider } from 'ethers6';
import { loadConfig } from './config.js';
/**
* Error codes and patterns that indicate a transient failure worth retrying
* on another RPC endpoint.
*/
const RETRIABLE_HTTP_STATUSES = new Set([429, 502, 503]);
const RETRIABLE_NODE_CODES = new Set(['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNRESET']);
const RETRIABLE_MSG_PATTERNS = [
/rate.?limit/i,
/too many requests/i,
/timeout/i,
/service unavailable/i,
/bad gateway/i,
/connection refused/i,
];
function isRetriable(err) {
if (err.status && RETRIABLE_HTTP_STATUSES.has(err.status)) return true;
if (err.code && RETRIABLE_NODE_CODES.has(err.code)) return true;
if (err.message) {
for (const pattern of RETRIABLE_MSG_PATTERNS) {
if (pattern.test(err.message)) return true;
}
}
return false;
}
export class FallbackProvider {
/**
* @param {string} primaryUrl - Primary RPC URL (required)
* @param {string[]} fallbackUrls - Ordered list of fallback URLs
* @param {number} requestTimeoutMs - Per-request timeout for JSON-RPC calls
*/
constructor(primaryUrl, fallbackUrls = [], requestTimeoutMs = 12_000) {
if (!primaryUrl) {
throw new Error('FallbackProvider requires a primary RPC URL');
}
this._primaryUrl = primaryUrl;
this._fallbackUrls = fallbackUrls;
this._requestTimeoutMs = requestTimeoutMs;
// Underlying ethers provider always points at the current primary
this._ethersProvider = new JsonRpcProvider(primaryUrl);
}
/**
* Send a single JSON-RPC request to `url`. Override in tests to avoid
* real network calls.
*
* @param {string} url
* @param {string} method
* @param {any[]} params
* @returns {Promise<any>}
*/
async _rawSend(url, method, params) {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params,
});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this._requestTimeoutMs);
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: controller.signal,
});
} catch (err) {
if (err?.name === 'AbortError') {
const timeoutErr = new Error(`RPC request timeout after ${this._requestTimeoutMs}ms`);
timeoutErr.code = 'ETIMEDOUT';
throw timeoutErr;
}
throw err;
} finally {
clearTimeout(timer);
}
if (!response.ok) {
const err = new Error(`HTTP ${response.status}: ${response.statusText}`);
err.status = response.status;
throw err;
}
const json = await response.json();
if (json.error) {
const err = new Error(json.error.message ?? 'RPC error');
err.code = json.error.code;
throw err;
}
return json.result;
}
/**
* Try the primary URL first; on retriable errors try each fallback in order.
* Session-sticky: the first URL that succeeds becomes the new primary.
*
* @param {string} method
* @param {any[]} params
* @returns {Promise<any>}
*/
async _sendWithFallback(method, params) {
const urls = [this._primaryUrl, ...this._fallbackUrls];
const errors = [];
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
try {
const result = await this._rawSend(url, method, params);
// Session-sticky: promote successful fallback to primary
if (i > 0) {
const oldPrimary = this._primaryUrl;
this._primaryUrl = url;
this._fallbackUrls = [
oldPrimary,
...urls.slice(1, i),
...urls.slice(i + 1),
];
this._ethersProvider = new JsonRpcProvider(this._primaryUrl);
}
return result;
} catch (err) {
if (!isRetriable(err) || i === urls.length - 1) {
// Non-retriable: throw immediately without trying fallbacks
if (!isRetriable(err)) throw err;
}
errors.push({ url, error: err });
}
}
// All URLs failed — redact API keys from URLs before logging
const redact = (u) => { try { const o = new URL(u); return `${o.protocol}//${o.host}${o.pathname.replace(/\/[^/]{20,}$/, '/***')}`; } catch { return '[invalid url]'; } };
const summary = errors
.map(({ url, error }) => `${redact(url)}: ${error.message}`)
.join('; ');
throw new Error(`All RPC endpoints failed — ${summary}`);
}
/**
* Generic JSON-RPC send.
*/
async send(method, params) {
return this._sendWithFallback(method, params);
}
/**
* eth_call — routes through fallback path.
*/
async call(tx) {
return this._sendWithFallback('eth_call', [tx, 'latest']);
}
/**
* eth_blockNumber — routes through fallback path.
*/
async getBlockNumber() {
return this._sendWithFallback('eth_blockNumber', []);
}
/**
* eth_getBalance — routes through fallback path.
*/
async getBalance(address) {
return this._sendWithFallback('eth_getBalance', [address, 'latest']);
}
/**
* Return the underlying ethers JsonRpcProvider (e.g. for Wallet.connect()).
* Always reflects the current primary URL (updated after fallback switches).
*/
getEthersProvider() {
return this._ethersProvider;
}
/**
* Wait for a transaction receipt with fallback support.
* When the current provider's waitForTransaction fails with a retriable
* error, try each fallback URL's provider instead.
*
* @param {string} txHash
* @param {number} [confirmations=1]
* @param {number} [timeoutMs=300000]
* @returns {Promise<import('ethers6').TransactionReceipt|null>}
*/
async waitForTransaction(txHash, confirmations = 1, timeoutMs = 300000) {
const urls = [this._primaryUrl, ...this._fallbackUrls];
// Race all URLs in parallel — whichever returns the receipt first wins.
// This prevents a slow/lagging primary RPC from blocking confirmation
// detection when faster fallback nodes already have the receipt indexed.
const providers = urls.map(url => {
const p = new JsonRpcProvider(url);
p.pollingInterval = 1000;
return p;
});
// Do NOT pass timeoutMs to individual providers — ethers6 creates an internal
// setTimeout for the timeout that destroy() cannot cancel, which would hold
// the event loop for the full duration. Manage the overall timeout ourselves.
const racePromises = providers.map((provider, i) =>
provider.waitForTransaction(txHash, confirmations)
.then(receipt => ({ receipt, index: i }))
);
let result;
let timer;
try {
result = await Promise.race([
Promise.any(racePromises),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(
`waitForTransaction timeout after ${timeoutMs / 1000}s`
)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
// Destroy all providers to stop their polling loops and free the event loop.
providers.forEach(p => p.destroy());
}
const { receipt, index } = result;
// Promote the winning URL to primary if it was a fallback
if (index > 0) {
const oldPrimary = this._primaryUrl;
this._primaryUrl = urls[index];
this._fallbackUrls = [
oldPrimary,
...urls.slice(1, index),
...urls.slice(index + 1),
];
this._ethersProvider = new JsonRpcProvider(this._primaryUrl);
}
return receipt;
}
}
/**
* Build a FallbackProvider from an env object.
*
* @param {Record<string, string>} env
* @returns {FallbackProvider}
*/
export function createProvider(env) {
let effectiveEnv = env;
if (!effectiveEnv?.ETH_RPC_URL) {
// Fallback: load from ~/.aurehub/.env when env vars are not exported
try {
const cfg = loadConfig();
effectiveEnv = { ...cfg.env, ...effectiveEnv };
} catch (_) {
// ignore — will throw below if still missing
}
}
const primaryUrl = effectiveEnv.ETH_RPC_URL;
if (!primaryUrl) {
throw new Error(
'ETH_RPC_URL is required in env to create a provider'
);
}
const fallbackUrls = effectiveEnv.ETH_RPC_URL_FALLBACK
? effectiveEnv.ETH_RPC_URL_FALLBACK.split(',').map((u) => u.trim()).filter(Boolean)
: [];
return new FallbackProvider(primaryUrl, fallbackUrls);
}
```
### scripts/lib/signer.js
```javascript
import { readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
/** Expand leading ~ to the user's home directory. */
function expandTilde(p) {
if (typeof p === 'string' && p.startsWith('~/')) {
return join(homedir(), p.slice(2));
}
return p;
}
import { pbkdf2Sync } from 'crypto';
import { Wallet } from 'ethers6';
// sodium-native is a CJS module; we import it via createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const sodium = require('sodium-native');
const b4a = require('b4a');
const bip39 = require('bip39-mnemonic');
// ---------------------------------------------------------------------------
// WDK vault decryption helpers
//
// @tetherto/wdk-secret-manager uses bare-crypto for PBKDF2 key derivation,
// which relies on Bare-runtime native bindings (.bare files) incompatible
// with standard Node.js. We replicate the same algorithm using Node.js
// built-in `crypto.pbkdf2Sync` + `sodium-native`, producing byte-for-byte
// identical results.
//
// Vault format:
// { encryptedEntropy: <hex>, salt: <hex> }
//
// Encryption format (from WdkSecretManager source):
// byte 0 : version (0)
// bytes 1..N : nonce (crypto_secretbox_NONCEBYTES = 24)
// bytes N+1..: ciphertext (1 + plaintextLen + MACBYTES)
// plain[0] = original payload length
// plain[1..]: payload bytes
// ---------------------------------------------------------------------------
/**
* Derive a 32-byte key from password + salt using PBKDF2-SHA256.
* Matches WdkSecretManager#deriveKeyFromPassKey() (100 000 iterations).
*
* @param {string|Buffer} password
* @param {Buffer} salt 16-byte salt
* @returns {Buffer}
*/
function wdkDeriveKey(password, salt) {
return pbkdf2Sync(password, salt, 100_000, 32, 'sha256');
}
/**
* Decrypt a WDK-encrypted payload.
* Matches WdkSecretManager.decrypt().
*
* @param {Buffer} payload Encrypted bytes
* @param {Buffer} key 32-byte derived key
* @returns {Buffer} Decrypted entropy bytes
*/
function wdkDecrypt(payload, key) {
const NONCEBYTES = sodium.crypto_secretbox_NONCEBYTES;
const MACBYTES = sodium.crypto_secretbox_MACBYTES;
if (payload[0] !== 0) throw new Error('WDK vault: unsupported encryption version');
const nonce = payload.subarray(1, 1 + NONCEBYTES);
const cipher = payload.subarray(1 + NONCEBYTES);
const plain = b4a.alloc(cipher.byteLength - MACBYTES);
if (!sodium.crypto_secretbox_open_easy(plain, cipher, nonce, key)) {
throw new Error('WDK vault: decryption failed — wrong password or corrupted vault');
}
const bytes = plain[0];
const result = b4a.alloc(bytes);
result.set(plain.subarray(1, 1 + bytes));
sodium.sodium_memzero(plain);
return result;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Create an ethers.Wallet connected to `provider` from the wallet backend
* specified by cfg.env.WALLET_MODE.
*
* Supported modes:
* 'foundry' — decrypt a Foundry keystore JSON file with KEYSTORE_PASSWORD_FILE
* 'wdk' — decrypt a WDK vault file with WDK_PASSWORD_FILE
*
* @param {{ env: object, yaml: object, configDir: string }} cfg
* Config object returned by loadConfig().
* @param {import('ethers').Provider|null} provider
* ethers provider to connect the wallet to (may be null/undefined).
* @param {{ keystoreDir?: string }} [opts]
* Optional overrides for testing (e.g. keystoreDir to override Foundry default).
* @returns {Promise<import('ethers').Wallet>}
*/
export async function createSigner(cfg, provider, opts = {}) {
const walletMode = cfg?.env?.WALLET_MODE;
if (!walletMode) {
throw new Error(
'WALLET_MODE not set in .env. Run setup to select a wallet mode.',
);
}
if (walletMode === 'foundry') {
return _createFoundrySigner(cfg, provider, opts);
}
if (walletMode === 'wdk') {
return _createWdkSigner(cfg, provider);
}
throw new Error(
`Unknown wallet_mode "${walletMode}". Expected "foundry" or "wdk".`,
);
}
// ---------------------------------------------------------------------------
// Foundry keystore backend
// ---------------------------------------------------------------------------
async function _createFoundrySigner(cfg, provider, opts) {
const accountName = cfg.env.FOUNDRY_ACCOUNT;
if (!accountName) {
throw new Error('FOUNDRY_ACCOUNT not set in .env');
}
const keystoreDir =
opts.keystoreDir ?? join(homedir(), '.foundry', 'keystores');
const keystorePath = join(expandTilde(keystoreDir), accountName);
let keystoreJson;
try {
keystoreJson = readFileSync(keystorePath, 'utf8');
} catch (err) {
throw new Error(
`Foundry keystore not found at "${keystorePath}": ${err.message}`,
);
}
const passwordFile = expandTilde(cfg.env.KEYSTORE_PASSWORD_FILE);
if (!passwordFile) {
throw new Error('KEYSTORE_PASSWORD_FILE not set in .env');
}
let password;
try {
password = readFileSync(passwordFile, 'utf8').trim();
} catch (err) {
throw new Error(
`Cannot read KEYSTORE_PASSWORD_FILE "${passwordFile}": ${err.message}`,
);
}
const wallet = await Wallet.fromEncryptedJson(keystoreJson, password);
return provider ? wallet.connect(provider) : wallet;
}
// ---------------------------------------------------------------------------
// WDK vault backend
// ---------------------------------------------------------------------------
async function _createWdkSigner(cfg, provider) {
const vaultPath = expandTilde(
cfg.env.WDK_VAULT_FILE ??
join(homedir(), '.aurehub', '.wdk_vault'),
);
let vaultJson;
try {
vaultJson = readFileSync(vaultPath, 'utf8');
} catch (err) {
throw new Error(`WDK vault not found at "${vaultPath}": ${err.message}`);
}
const vault = JSON.parse(vaultJson);
if (!vault.encryptedEntropy || !vault.salt) {
throw new Error(
'WDK vault is missing required fields: encryptedEntropy, salt',
);
}
const passwordFile = expandTilde(
cfg.env.WDK_PASSWORD_FILE ??
join(homedir(), '.aurehub', '.wdk_password'),
);
let password;
try {
password = readFileSync(passwordFile, 'utf8').trim();
} catch (err) {
throw new Error(
`Cannot read WDK_PASSWORD_FILE "${passwordFile}": ${err.message}`,
);
}
const salt = Buffer.from(vault.salt, 'hex');
const encryptedEntropy = Buffer.from(vault.encryptedEntropy, 'hex');
const key = wdkDeriveKey(password, salt);
const entropy = wdkDecrypt(encryptedEntropy, key);
key.fill(0);
let wallet;
try {
const mnemonic = bip39.entropyToMnemonic(entropy);
wallet = Wallet.fromPhrase(mnemonic);
} finally {
sodium.sodium_memzero(entropy);
}
return provider ? wallet.connect(provider) : wallet;
}
```
### scripts/lib/uniswap.js
```javascript
/**
* uniswap.js — Quote via QuoterV2 and build swap calldata via SwapRouter02
*
* Uses ethers v6 ABI encoding directly instead of @uniswap/v3-sdk SwapRouter
* to avoid a JSBI class identity mismatch between @uniswap/sdk-core@6 and
* @uniswap/[email protected] that causes Trade.minimumAmountOut to throw.
*/
import { Interface, AbiCoder, parseUnits } from 'ethers6';
// ---------------------------------------------------------------------------
// ABIs
// ---------------------------------------------------------------------------
const QUOTER_V2_ABI = [
'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96)) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
];
// SwapRouter02 exactInputSingle (used for ERC-20 → ERC-20)
const SWAP_ROUTER_ABI = [
'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96)) external payable returns (uint256 amountOut)',
];
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
// ---------------------------------------------------------------------------
// quote()
// ---------------------------------------------------------------------------
/**
* Get an on-chain quote from Uniswap V3 QuoterV2.
*
* @param {object} params
* @param {{ address: string, decimals: number }} params.tokenIn
* @param {{ address: string, decimals: number }} params.tokenOut
* @param {string} params.amountIn Human-readable amount (e.g. "1.5")
* @param {number} params.fee Pool fee tier (e.g. 3000)
* @param {{ quoter: string }} params.contracts
* @param {object} params.provider ethers-compatible provider
* @returns {Promise<{ amountOut: string, amountOutRaw: bigint, sqrtPriceX96: bigint, gasEstimate: bigint }>}
*/
export async function quote({ tokenIn, tokenOut, amountIn, fee, contracts, provider }) {
const iface = new Interface(QUOTER_V2_ABI);
const amountInRaw = parseUnits(amountIn, tokenIn.decimals);
const calldata = iface.encodeFunctionData('quoteExactInputSingle', [{
tokenIn: tokenIn.address,
tokenOut: tokenOut.address,
amountIn: amountInRaw,
fee,
sqrtPriceLimitX96: 0n,
}]);
const raw = await provider.call({ to: contracts.quoter, data: calldata });
const coder = AbiCoder.defaultAbiCoder();
const [amountOutRaw, sqrtPriceX96After, , gasEstimate] = coder.decode(
['uint256', 'uint160', 'uint32', 'uint256'],
raw,
);
// Convert decoded values to plain bigints (ethers returns BigInt natively in v6)
const amountOutBig = BigInt(amountOutRaw.toString());
const sqrtPriceBig = BigInt(sqrtPriceX96After.toString());
const gasEstimateBig = BigInt(gasEstimate.toString());
// Format human-readable output
const divisor = 10n ** BigInt(tokenOut.decimals);
const whole = amountOutBig / divisor;
const remainder = amountOutBig % divisor;
const fracStr = remainder.toString().padStart(tokenOut.decimals, '0').replace(/0+$/, '') || '0';
const amountOutFormatted = `${whole}.${fracStr}`;
return {
amountOut: amountOutFormatted,
amountOutRaw: amountOutBig,
sqrtPriceX96: sqrtPriceBig,
gasEstimate: gasEstimateBig,
};
}
// ---------------------------------------------------------------------------
// buildSwap()
// ---------------------------------------------------------------------------
/**
* Build SwapRouter02 exactInputSingle calldata.
*
* slippage is already baked into minAmountOut by the caller — we pass it
* directly as amountOutMinimum.
*
* @param {object} params
* @param {{ address: string, decimals: number }} params.tokenIn
* @param {{ address: string, decimals: number }} params.tokenOut
* @param {string} params.amountIn Human-readable amount in
* @param {string} params.minAmountOut Human-readable minimum amount out
* @param {number} params.fee Pool fee tier
* @param {string} params.recipient Recipient address
* @param {number} params.deadline Unix timestamp deadline
* @param {{ router: string }} params.contracts
* @returns {{ to: string, data: string, value: bigint }}
*/
export function buildSwap({ tokenIn, tokenOut, amountIn, minAmountOut, fee, recipient, deadline, contracts }) {
const isEth = tokenIn.address === ZERO_ADDRESS;
const amountInRaw = parseUnits(amountIn, tokenIn.decimals);
const amountOutMinRaw = parseUnits(minAmountOut, tokenOut.decimals);
const iface = new Interface(SWAP_ROUTER_ABI);
// When the input token is native ETH, use WETH address for the swap struct
// and send value with the transaction. SwapRouter02 handles WETH wrapping.
const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
const tokenInAddress = isEth ? WETH : tokenIn.address;
const data = iface.encodeFunctionData('exactInputSingle', [{
tokenIn: tokenInAddress,
tokenOut: tokenOut.address,
fee,
recipient,
amountIn: amountInRaw,
amountOutMinimum: amountOutMinRaw,
sqrtPriceLimitX96: 0n,
}]);
return {
to: contracts.router,
data,
value: isEth ? amountInRaw : 0n,
};
}
```
### scripts/limit-order.js
```javascript
#!/usr/bin/env node
// skills/xaut-trade/scripts/limit-order.js
//
// Usage:
// node limit-order.js place --token-in <addr> --token-out <addr> \
// --amount-in <uint> --min-amount-out <uint> \
// --expiry <seconds> --wallet <addr> \
// --chain-id <int> --api-url <url>
// node limit-order.js status --order-hash <0x...> --api-url <url> --chain-id <int>
// node limit-order.js list --wallet <addr> --api-url <url> --chain-id <int> [--order-status open|filled|expired|cancelled]
// node limit-order.js cancel --order-hash <0x...> (reads nonce from ~/.aurehub/orders/)
// node limit-order.js cancel --nonce <uint> (fallback)
//
// Signing mode: delegates to swap.js sign (supports both WDK and Foundry).
// PRIVATE_KEY runtime signing is intentionally not supported.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import yaml from 'js-yaml';
import { computeNonceComponents, checkPrecision, resolveExpiry } from './helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const { ethers } = require('ethers');
const { DutchOrderBuilder } = require('@uniswap/uniswapx-sdk');
// ethers v5 BigNumber (SDK uses .gte() etc — native BigInt not compatible)
const BN = ethers.BigNumber;
const [,, subcommand, ...argv] = process.argv;
// Load defaults from ~/.aurehub/config.yaml
function loadDefaults() {
try {
const configDir = path.join(os.homedir(), '.aurehub');
const raw = fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf8');
const cfg = yaml.load(raw, { schema: yaml.JSON_SCHEMA }) ?? {};
return {
apiUrl: cfg.limit_order?.uniswapx_api || null,
chainId: String(cfg.networks?.ethereum_mainnet?.chain_id || '1'),
limitOrderCfg: cfg.limit_order ?? {},
};
} catch { return { apiUrl: null, chainId: '1', limitOrderCfg: {} }; }
}
function parseArgs(args) {
const result = {};
for (let i = 0; i < args.length; i += 2) {
if (i + 1 >= args.length) {
console.error(`Missing value for flag: ${args[i]}`);
process.exit(1);
}
const key = args[i].replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
result[key] = args[i + 1];
}
return result;
}
async function main() {
const args = parseArgs(argv);
// Apply config.yaml defaults for apiUrl and chainId when not provided via CLI
const defaults = loadDefaults();
if (!args.apiUrl && defaults.apiUrl) args.apiUrl = defaults.apiUrl;
if (!args.chainId && defaults.chainId) args.chainId = defaults.chainId;
args._limitOrderCfg = defaults.limitOrderCfg;
switch (subcommand) {
case 'place': return await place(args);
case 'status': return await status(args);
case 'list': return await list(args);
case 'cancel': return await cancel(args);
default:
console.error('Usage: limit-order.js <place|status|list|cancel> [options]');
process.exit(1);
}
}
main().catch(err => { console.error(err.message); process.exit(1); });
async function place(args) {
const {
tokenIn, tokenOut, amountIn, minAmountOut,
expiry, wallet, chainId, apiUrl,
} = args;
// 1. Validate required args
const required = { tokenIn, tokenOut, amountIn, minAmountOut, wallet, chainId, apiUrl };
for (const [k, v] of Object.entries(required)) {
if (!v) { console.error(`Missing required argument: --${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`); process.exit(1); }
}
// Precision check: both XAUT and USDT have 6 decimals max
if (!checkPrecision(amountIn, 6)) {
console.error('ERROR: amountIn exceeds maximum precision (6 decimal places)');
process.exit(1);
}
if (!checkPrecision(minAmountOut, 6)) {
console.error('ERROR: minAmountOut exceeds maximum precision (6 decimal places)');
process.exit(1);
}
const chainIdNum = parseInt(chainId, 10);
if (Number.isNaN(chainIdNum) || chainIdNum <= 0) {
console.error('ERROR: --chain-id must be a positive integer');
process.exit(1);
}
const limitOrderCfg = args._limitOrderCfg || {};
const expiryLimits = {
defaultSeconds: limitOrderCfg.default_expiry_seconds ?? 86400,
minSeconds: limitOrderCfg.min_expiry_seconds ?? 300,
maxSeconds: limitOrderCfg.max_expiry_seconds ?? 2592000,
};
const rawExpiry = expiry ? parseInt(expiry, 10) : null;
if (rawExpiry !== null && (Number.isNaN(rawExpiry) || rawExpiry <= 0)) {
console.error('ERROR: --expiry must be a positive integer (seconds)');
process.exit(1);
}
const expirySec = resolveExpiry(rawExpiry, expiryLimits);
const deadline = Math.floor(Date.now() / 1000) + expirySec;
// 2. Validate amounts (invariant — does not depend on nonce)
const amountInBN = BN.from(amountIn);
const minAmountOutBN = BN.from(minAmountOut);
if (minAmountOutBN.lte(BN.from(0))) {
console.error('ERROR: --min-amount-out must be greater than 0');
process.exit(1);
}
// 3. Fetch nonce from UniswapX API
// UniswapX API requires Origin header matching app.uniswap.org; may break if Uniswap changes policy
const apiKey = process.env.UNISWAPX_API_KEY || '';
const uniswapHeaders = {
'Origin': 'https://app.uniswap.org',
'User-Agent': 'Mozilla/5.0',
...(apiKey ? { 'x-api-key': apiKey } : {}),
};
const nonceRes = await fetch(`${apiUrl}/nonce?address=${wallet}&chainId=${chainId}`, { headers: uniswapHeaders });
if (!nonceRes.ok) throw new Error(`Nonce fetch failed: ${nonceRes.status} ${await nonceRes.text()}`);
const nonceData = await nonceRes.json();
let nonce = nonceData.nonce;
// 4. Build, sign, and submit order with nonce retry logic (max 5 attempts)
const MAX_NONCE_RETRIES = 5;
for (let attempt = 0; attempt < MAX_NONCE_RETRIES; attempt++) {
// Build order (must rebuild each attempt — nonce changes on retry)
// Fixed-price limit order: decayStart == decayEnd == deadline so the
// DutchOrder never decays — the swapper receives exactly minAmountOut
// (no Dutch-auction price improvement, but also no price deterioration).
const builder = new DutchOrderBuilder(chainIdNum);
const order = builder
.deadline(deadline)
.decayStartTime(deadline)
.decayEndTime(deadline)
.nonce(BN.from(nonce))
.swapper(wallet)
.input({ token: tokenIn, startAmount: amountInBN, endAmount: amountInBN })
.output({
token: tokenOut,
startAmount: minAmountOutBN,
endAmount: minAmountOutBN,
recipient: wallet,
})
.build();
// Sign via EIP-712
const { domain, types, values } = order.permitData();
const bnReplacer = (_, v) => (v && v.type === 'BigNumber' && v.hex) ? v.hex : v;
const eip712Domain = [
{ name: 'name', type: 'string' },
...(domain.version !== undefined ? [{ name: 'version', type: 'string' }] : []),
...(domain.chainId !== undefined ? [{ name: 'chainId', type: 'uint256' }] : []),
...(domain.verifyingContract !== undefined ? [{ name: 'verifyingContract', type: 'address' }] : []),
];
const primaryType = 'PermitWitnessTransferFrom';
const typesWithDomain = { EIP712Domain: eip712Domain, ...types };
const typedDataJson = JSON.stringify({ domain, types: typesWithDomain, primaryType, message: values }, bnReplacer);
let signature;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'limit-order-'));
const tmpFile = path.join(tmpDir, 'typed-data.json');
fs.writeFileSync(tmpFile, typedDataJson);
try {
const signResult = spawnSync(
process.execPath,
[path.join(__dirname, 'swap.js'), 'sign', '--data-file', tmpFile],
{ encoding: 'utf8', timeout: 30_000 }
);
if (signResult.error) {
throw new Error(`Failed to spawn swap.js sign: ${signResult.error.message}`);
}
if (signResult.status !== 0) {
throw new Error(signResult.stderr?.trim() || 'swap.js sign failed');
}
signature = signResult.stdout.trim();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
// Submit to UniswapX API
const encodedOrder = order.serialize();
const orderHash = order.hash();
const submitRes = await fetch(`${apiUrl}/order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...uniswapHeaders },
body: JSON.stringify({ encodedOrder, signature, chainId: chainIdNum }),
});
if (!submitRes.ok) {
const body = await submitRes.text();
if (body.includes('NonceUsed') && attempt < MAX_NONCE_RETRIES - 1) {
nonce = BN.from(nonce).add(BN.from(1)).toString();
continue;
}
throw new Error(`Order submission failed: ${submitRes.status} ${body}`);
}
// 5. Auto-save order to ~/.aurehub/orders/ for later cancellation
const orderData = {
orderHash,
nonce,
deadline: new Date(deadline * 1000).toISOString(),
deadlineUnix: deadline,
tokenIn,
tokenOut,
amountIn,
minAmountOut,
wallet,
createdAt: new Date().toISOString(),
};
const ordersDir = path.join(os.homedir(), '.aurehub', 'orders');
fs.mkdirSync(ordersDir, { recursive: true });
fs.writeFileSync(
path.join(ordersDir, `${orderHash.slice(0, 10)}.json`),
JSON.stringify(orderData, null, 2)
);
// 6. Output JSON for SKILL.md to parse
console.log(JSON.stringify({
address: wallet,
orderHash,
deadline: orderData.deadline,
deadlineUnix: deadline,
nonce,
}));
return;
}
}
async function status(args) {
const { orderHash, apiUrl, chainId } = args;
if (!orderHash || !apiUrl || !chainId) {
console.error('Missing required: --order-hash, --api-url, --chain-id');
process.exit(1);
}
const apiKey = process.env.UNISWAPX_API_KEY || '';
const res = await fetch(
`${apiUrl}/orders?orderHash=${orderHash}&chainId=${chainId}`,
{ headers: {
'Origin': 'https://app.uniswap.org',
'User-Agent': 'Mozilla/5.0',
...(apiKey ? { 'x-api-key': apiKey } : {}),
}}
);
if (!res.ok) throw new Error(`Status fetch failed: ${res.status} ${await res.text()}`);
const data = await res.json();
const orders = data.orders || [];
if (orders.length === 0) {
console.log(JSON.stringify({ status: 'not_found', orderHash }));
return;
}
const o = orders[0];
console.log(JSON.stringify({
status: o.orderStatus,
orderHash: o.orderHash,
deadline: o.deadline ? new Date(o.deadline * 1000).toISOString() : null,
txHash: o.txHash || null,
settledAmounts: o.settledAmounts || null,
}));
}
async function list(args) {
const { wallet, apiUrl, chainId, orderStatus } = args;
if (!wallet || !apiUrl || !chainId) {
console.error('Missing required: --wallet, --api-url, --chain-id');
process.exit(1);
}
const apiKey = process.env.UNISWAPX_API_KEY || '';
const headers = {
'Origin': 'https://app.uniswap.org',
'User-Agent': 'Mozilla/5.0',
...(apiKey ? { 'x-api-key': apiKey } : {}),
};
// The UniswapX API ignores the offerer param when orderStatus is omitted,
// returning unrelated orders. Work around by querying each status separately.
const statuses = orderStatus
? [orderStatus]
: ['open', 'filled', 'expired', 'cancelled'];
const allOrders = [];
const walletLower = wallet.toLowerCase();
for (const s of statuses) {
const url = `${apiUrl}/orders?offerer=${wallet}&chainId=${chainId}&orderStatus=${s}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`List fetch failed (${s}): ${res.status} ${await res.text()}`);
const data = await res.json();
for (const o of (data.orders || [])) {
// Double-check swapper matches our wallet
if ((o.swapper || '').toLowerCase() === walletLower) {
allOrders.push(o);
}
}
}
const orders = allOrders.map(o => ({
orderHash: o.orderHash,
status: o.orderStatus,
inputToken: o.input?.token,
inputAmount: o.input?.startAmount,
outputToken: (o.outputs?.[0])?.token,
outputAmount: (o.outputs?.[0])?.startAmount,
txHash: o.txHash || null,
createdAt: o.createdAt ? new Date(o.createdAt * 1000).toISOString() : null,
}));
console.log(JSON.stringify({ address: wallet, total: orders.length, orders }));
}
async function cancel(args) {
let { nonce, orderHash } = args;
// Resolve nonce from local order file if --order-hash is provided
if (!nonce && orderHash) {
const ordersDir = path.join(os.homedir(), '.aurehub', 'orders');
if (fs.existsSync(ordersDir)) {
const files = fs.readdirSync(ordersDir).filter(f => f.endsWith('.json'));
const prefix = orderHash.toLowerCase();
if (prefix.length < 10) {
console.error('ERROR: --order-hash prefix too short (minimum 10 characters, e.g. "0x9079c4f2")');
process.exit(1);
}
const match = files.find(f => {
try {
const data = JSON.parse(fs.readFileSync(path.join(ordersDir, f), 'utf8'));
return data.orderHash && data.orderHash.toLowerCase().startsWith(prefix);
} catch { return false; }
});
if (match) {
const data = JSON.parse(fs.readFileSync(path.join(ordersDir, match), 'utf8'));
nonce = data.nonce;
}
}
}
if (!nonce) {
console.error('Missing required: --nonce or --order-hash (with matching local order file)');
process.exit(1);
}
const { wordPos, mask } = computeNonceComponents(BigInt(nonce));
console.log(JSON.stringify({
wordPos: wordPos.toString(),
mask: mask.toString(),
permit2: '0x000000000022D473030F116dDEE9F6B43aC78BA3',
}));
}
```
### scripts/package.json
```json
{
"name": "xaut-trade-scripts",
"version": "2.1.1",
"description": "Helper scripts for xaut-trade skill",
"type": "module",
"main": "limit-order.js",
"scripts": {
"test": "vitest run --globals __tests__/helpers.test.js __tests__/swap-cli.test.js __tests__/integration.test.js lib/__tests__/*.test.js"
},
"dependencies": {
"@uniswap/sdk-core": "^6",
"@uniswap/v3-sdk": "3.20.0",
"@uniswap/uniswapx-sdk": "^2.0.0",
"b4a": "^1.6.7",
"bip39-mnemonic": "^2.5.0",
"ethers": "^5.7.2",
"ethers6": "npm:ethers@^6",
"js-yaml": "^4",
"jsbi": "^4",
"sodium-native": "^5.0.8",
"tslib": "^2.8.1"
},
"devDependencies": {
"vitest": "^4.1.0"
}
}
```
### scripts/setup.sh
```bash
#!/usr/bin/env bash
# xaut-trade environment setup
# Usage: bash skills/xaut-trade/scripts/setup.sh
#
# Exit codes:
# 0 — all automated steps complete; check the manual steps summary at the end
# 1 — setup failed (including missing prerequisites); see references/onboarding.md
set -euo pipefail
# ── Colours ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
STEP=0
NPM_DEPS_INSTALLED=false
step() { STEP=$((STEP+1)); echo -e "\n${BLUE}${BOLD}[${STEP}] $1${NC}"; }
ok() { echo -e " ${GREEN}✓ $1${NC}"; }
warn() { echo -e " ${YELLOW}⚠ $1${NC}"; }
manual() {
echo -e "\n ${YELLOW}${BOLD}┌─ Manual action required ────────────────────────────────┐${NC}"
while IFS= read -r line; do
echo -e " ${YELLOW}│${NC} $line"
done <<< "$1"
echo -e " ${YELLOW}${BOLD}└─────────────────────────────────────────────────────────┘${NC}\n"
}
_cleanup() { rm -f "${CAST_ERR_FILE:-}" "${ENV_TMP_FILE:-}"; }
trap '_cleanup; echo -e "\n${RED}❌ Step ${STEP} failed.${NC}\nSee references/onboarding.md for manual instructions, then re-run this script."; exit 1' ERR
trap '_cleanup' EXIT
_ensure_scripts_deps() {
if [ "$NPM_DEPS_INSTALLED" = true ]; then
return 0
fi
echo " Installing npm packages..."
(cd "$SCRIPT_DIR" && npm install --silent)
NPM_DEPS_INSTALLED=true
ok "npm packages installed"
}
# ── Locate skill directory from the script's own path ──────────────────────────
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SKILL_DIR=$(dirname "$SCRIPT_DIR") # skills/xaut-trade/
ACCOUNT_NAME="aurehub-wallet"
echo -e "\n${BOLD}xaut-trade environment setup${NC}"
echo "Skill directory: $SKILL_DIR"
# ── Step 1: Global config directory ───────────────────────────────────────────
step "Create global config directory ~/.aurehub"
mkdir -p ~/.aurehub
ok "~/.aurehub ready"
# ── Step 2: Wallet Mode Selection ─────────────────────────────────────────────
step "Wallet mode selection"
echo ""
echo -e " ${BOLD}=== Wallet Mode ===${NC}"
echo -e " ${BOLD}[1]${NC} WDK (recommended) — seed-phrase based, no external tools needed"
echo -e " ${BOLD}[2]${NC} Foundry — requires Foundry installed, keystore-based"
echo ""
read -rp " Select [1]: " wallet_mode_choice
wallet_mode_choice="${wallet_mode_choice:-1}"
if [ "$wallet_mode_choice" = "1" ]; then
WALLET_MODE="wdk"
elif [ "$wallet_mode_choice" = "2" ]; then
WALLET_MODE="foundry"
else
echo -e " ${YELLOW}Invalid choice. Defaulting to WDK.${NC}"
WALLET_MODE="wdk"
fi
ok "Wallet mode: $WALLET_MODE"
# ── WDK wallet setup ─────────────────────────────────────────────────────────
if [ "$WALLET_MODE" = "wdk" ]; then
# ── Step 3: Check Node.js >= 18 ────────────────────────────────────────────
step "Check Node.js (required for WDK)"
if ! command -v node &>/dev/null; then
echo -e " ${RED}Error: Node.js is required for WDK mode. Install from https://nodejs.org/${NC}"
exit 1
fi
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
echo -e " ${RED}Error: Node.js >= 18 required (found v$NODE_VERSION)${NC}"
exit 1
fi
ok "Node.js $(node -v)"
_ensure_scripts_deps
# ── Step 4: Prompt for wallet password ─────────────────────────────────────
step "WDK wallet password"
WDK_PASSWORD_FILE="$HOME/.aurehub/.wdk_password"
if [ -f "$WDK_PASSWORD_FILE" ] && [ -s "$WDK_PASSWORD_FILE" ]; then
ok "WDK password file already exists, skipping"
else
echo ""
while true; do
read -rs -p " Enter wallet password (min 12 characters): " WDK_PASSWORD
echo ""
if [ ${#WDK_PASSWORD} -lt 12 ]; then
echo -e " ${RED}Error: Password must be at least 12 characters.${NC}"
continue
fi
read -rs -p " Confirm password: " WDK_PASSWORD_CONFIRM
echo ""
if [ "$WDK_PASSWORD" != "$WDK_PASSWORD_CONFIRM" ]; then
echo -e " ${RED}Error: Passwords do not match.${NC}"
continue
fi
break
done
# Write password file
( umask 077; printf '%s' "$WDK_PASSWORD" > "$WDK_PASSWORD_FILE" )
unset WDK_PASSWORD WDK_PASSWORD_CONFIRM
ok "Password saved to $WDK_PASSWORD_FILE (permissions: 600)"
fi
# ── Step 5: Create encrypted wallet ────────────────────────────────────────
step "Create WDK encrypted wallet"
MARKET_DIR="$SCRIPT_DIR"
VAULT_FILE="$HOME/.aurehub/.wdk_vault"
if [ -f "$VAULT_FILE" ]; then
ok "Vault file already exists, reading address..."
WALLET_ADDRESS=$(node "$MARKET_DIR/swap.js" address --config-dir "$HOME/.aurehub" 2>/dev/null \
| node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).address))" 2>/dev/null) || true
if [ -z "$WALLET_ADDRESS" ]; then
echo -e " ${RED}❌ Could not resolve wallet address — verify vault and password file are correct.${NC}"
exit 1
fi
else
RESULT=$(node "$MARKET_DIR/lib/create-wallet.js" --password-file "$WDK_PASSWORD_FILE" --vault-file "$VAULT_FILE")
WALLET_ADDRESS=$(echo "$RESULT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).address))")
fi
ok "Wallet address: $WALLET_ADDRESS"
# ── Security notice: seed phrase backup ──────────────────────────────────────
echo ""
echo -e " ${YELLOW}${BOLD}┌─ IMPORTANT: Back up your seed phrase ─────────────────────┐${NC}"
echo -e " ${YELLOW}│${NC}"
echo -e " ${YELLOW}│${NC} Your wallet is protected by an encrypted vault, but if"
echo -e " ${YELLOW}│${NC} the vault file or password is lost, ${BOLD}your funds are gone${NC}."
echo -e " ${YELLOW}│${NC}"
echo -e " ${YELLOW}│${NC} Export your 12-word seed phrase ${BOLD}now${NC} and store it safely"
echo -e " ${YELLOW}│${NC} (paper, hardware backup — never in cloud or chat)."
echo -e " ${YELLOW}│${NC}"
echo -e " ${YELLOW}│${NC} Run this command in a private terminal:"
echo -e " ${YELLOW}│${NC}"
echo -e " ${YELLOW}│${NC} ${BOLD}node $MARKET_DIR/lib/export-seed.js${NC}"
echo -e " ${YELLOW}│${NC}"
echo -e " ${YELLOW}│${NC} Write down the 12 words and store offline."
echo -e " ${YELLOW}│${NC} ${RED}Never share your seed phrase with anyone.${NC}"
echo -e " ${YELLOW}│${NC}"
echo -e " ${YELLOW}${BOLD}└───────────────────────────────────────────────────────────┘${NC}"
echo ""
fi
# ── Foundry wallet setup ─────────────────────────────────────────────────────
if [ "$WALLET_MODE" = "foundry" ]; then
# ── Step 3: Foundry ──────────────────────────────────────────────────────────
step "Check Foundry (cast)"
if command -v cast &>/dev/null; then
CAST_VERSION_LINE=$(cast --version | head -1)
ok "Foundry already installed: $CAST_VERSION_LINE"
CAST_VERSION=$(echo "$CAST_VERSION_LINE" | awk '{print $3}' | sed 's/-.*$//')
if [ -n "$CAST_VERSION" ] && [ "$(printf '%s\n' "$CAST_VERSION" "1.6.0" | sort -V | head -1)" != "1.6.0" ]; then
warn "Foundry version is below recommended baseline (found: $CAST_VERSION, recommended: >= 1.6.0)."
echo -e " You can upgrade with: ${BOLD}foundryup${NC}"
fi
else
# S1: disclose what is about to run before executing curl|bash
echo -e "\n ${YELLOW}Foundry (cast) is not installed.${NC}"
echo -e " About to download and run the official Foundry installer from foundry.paradigm.xyz"
echo -e " Source: https://github.com/foundry-rs/foundry"
echo
read -rp " Proceed with installation? [Y/n]: " CONFIRM_FOUNDRY
if [[ "${CONFIRM_FOUNDRY:-}" =~ ^[Nn]$ ]]; then
echo -e " Skipped. Install Foundry manually: https://book.getfoundry.sh/getting-started/installation"
exit 1
fi
echo " Downloading Foundry installer (this may take a moment)..."
curl -L https://foundry.paradigm.xyz | bash
# foundryup may not be in PATH yet; add it temporarily for this session
export PATH="$HOME/.foundry/bin:$PATH"
echo " Installing cast, forge, and anvil binaries (~100 MB, please wait)..."
foundryup
manual "Reason: Foundry writes itself to ~/.foundry/bin and appends to ~/.zshrc
(or ~/.bashrc), but the current terminal's PATH is not refreshed automatically.
The script has temporarily added Foundry to this session's PATH so setup can
continue without interruption.
After setup finishes, refresh your shell so 'cast' works in new terminals:
$ source ~/.zshrc # zsh users
$ source ~/.bashrc # bash users
Or open a new terminal window."
fi
# ── Step 4: Keystore password file ─────────────────────────────────────────────
step "Prepare keystore password file"
if [ -f ~/.aurehub/.wallet.password ] && [ -s ~/.aurehub/.wallet.password ]; then
ok "Password file already exists and is non-empty, skipping"
else
if [ ! -f ~/.aurehub/.wallet.password ]; then
( umask 077; touch ~/.aurehub/.wallet.password )
else
warn "Password file exists but is empty: ~/.aurehub/.wallet.password"
fi
echo -e " ${BLUE}Why this is needed:${NC} The Agent signs transactions using your Foundry"
echo -e " keystore. The password is stored in a protected file (chmod 600) so the"
echo -e " Agent can unlock the keystore without the password appearing in shell history."
echo -e " Password will be saved to: ${BOLD}~/.aurehub/.wallet.password${NC}"
echo
read -rsp " Enter your desired keystore password: " WALLET_PASSWORD
echo
if [ -z "$WALLET_PASSWORD" ]; then
echo -e " ${RED}❌ Password cannot be empty.${NC}"; exit 1
fi
( umask 077; printf '%s' "$WALLET_PASSWORD" > ~/.aurehub/.wallet.password )
unset WALLET_PASSWORD
ok "Password saved to ~/.aurehub/.wallet.password (permissions: 600)"
fi
# ── Step 5: Wallet keystore ────────────────────────────────────────────────────
step "Configure wallet keystore"
if cast wallet list 2>/dev/null | grep -qF "$ACCOUNT_NAME"; then
ok "Keystore account '$ACCOUNT_NAME' already exists, skipping"
else
echo -e " No keystore account '${BOLD}$ACCOUNT_NAME${NC}' found."
echo -e " Choose wallet initialization mode:"
echo -e " ${BOLD}1)${NC} Import existing private key into keystore (interactive)"
echo -e " ${BOLD}2)${NC} Create a brand-new keystore wallet (recommended)"
read -rp " Enter 1 or 2: " WALLET_CHOICE
case "${WALLET_CHOICE:-}" in
1)
echo
echo -e " Run this in your terminal (interactive input is hidden):"
echo -e " cast wallet import $ACCOUNT_NAME --interactive"
echo
while ! cast wallet list 2>/dev/null | grep -qF "$ACCOUNT_NAME"; do
read -rp " Press Enter after import is complete, or type 'abort' to exit: " RETRY_INPUT
if [[ "${RETRY_INPUT:-}" == "abort" ]]; then
echo -e " ${RED}Aborted.${NC}"; exit 1
fi
done
;;
2)
mkdir -p ~/.foundry/keystores
WALLET_NEW_HELP=$(cast wallet new --help 2>/dev/null || true)
if echo "$WALLET_NEW_HELP" | grep -q '\[ACCOUNT_NAME\]'; then
if echo "$WALLET_NEW_HELP" | grep -q -- '--password-file'; then
cast wallet new ~/.foundry/keystores "$ACCOUNT_NAME" \
--password-file ~/.aurehub/.wallet.password
elif echo "$WALLET_NEW_HELP" | grep -q -- '--password'; then
echo -e " This Foundry version does not support --password-file for wallet new."
echo -e " Proceeding with interactive password prompt (input hidden)."
cast wallet new ~/.foundry/keystores "$ACCOUNT_NAME" --password
else
echo -e " ${RED}❌ Unsupported 'cast wallet new' password mode in this Foundry version.${NC}"
echo -e " Please upgrade Foundry: ${BOLD}foundryup${NC}"
exit 1
fi
else
echo -e " ${RED}❌ Your Foundry version does not support named account creation for 'cast wallet new'.${NC}"
echo -e " Please upgrade Foundry and re-run setup:"
echo -e " ${BOLD}foundryup${NC}"
exit 1
fi
;;
*)
echo -e " ${RED}Invalid choice, exiting.${NC}"
exit 1
;;
esac
ok "Keystore account '$ACCOUNT_NAME' is ready"
fi
# ── Step 6: Read wallet address ────────────────────────────────────────────────
step "Read wallet address"
# U6: distinguish wrong password vs other errors
WALLET_ADDRESS=""
CAST_ERR_FILE=$(mktemp /tmp/xaut_cast_err.XXXXXX)
if ! WALLET_ADDRESS=$(cast wallet address \
--account "$ACCOUNT_NAME" \
--password-file ~/.aurehub/.wallet.password 2>"$CAST_ERR_FILE"); then
CAST_ERR=$(cat "$CAST_ERR_FILE" 2>/dev/null || true)
rm -f "$CAST_ERR_FILE"
echo -e " ${RED}❌ Could not read wallet address.${NC}"
if echo "$CAST_ERR" | grep -qiE "password|decrypt|mac mismatch|invalid|wrong"; then
echo -e " Likely cause: the password in ~/.aurehub/.wallet.password does not match"
echo -e " the password used when this keystore was created."
echo -e " To fix: delete the password file and re-run this script to enter the correct one."
echo -e " \$ rm ~/.aurehub/.wallet.password && bash \"$0\""
elif echo "$CAST_ERR" | grep -qiE "not found|no such file|keystore"; then
echo -e " Likely cause: keystore file for '$ACCOUNT_NAME' is missing."
echo -e " Run 'cast wallet list' to check available accounts."
else
echo -e " Details: $CAST_ERR"
echo -e " Run 'cast wallet list' to confirm the account exists."
fi
exit 1
fi
rm -f "$CAST_ERR_FILE"
ok "Wallet address: $WALLET_ADDRESS"
fi
# ── Step 7: Generate config files ─────────────────────────────────────────────
step "Generate config files"
# Helper: set a key in .env (update if exists, append if not)
_env_set() {
local key="$1" value="$2" file="$HOME/.aurehub/.env"
if grep -q "^${key}=" "$file" 2>/dev/null; then
sed -i.bak "s|^${key}=.*|${key}=${value}|" "$file" && rm -f "$file.bak"
else
echo "${key}=${value}" >> "$file"
fi
}
if [ -f ~/.aurehub/.env ]; then
ok ".env already exists, updating wallet mode"
_env_set "WALLET_MODE" "$WALLET_MODE"
if [ "$WALLET_MODE" = "wdk" ]; then
_env_set "WDK_PASSWORD_FILE" "$HOME/.aurehub/.wdk_password"
# Remove stale Foundry keys when switching to WDK
sed -i.bak '/^FOUNDRY_ACCOUNT=/d; /^KEYSTORE_PASSWORD_FILE=/d' ~/.aurehub/.env && rm -f ~/.aurehub/.env.bak
else
_env_set "FOUNDRY_ACCOUNT" "$ACCOUNT_NAME"
_env_set "KEYSTORE_PASSWORD_FILE" "$HOME/.aurehub/.wallet.password"
# Remove stale WDK keys when switching to Foundry
sed -i.bak '/^WDK_PASSWORD_FILE=/d' ~/.aurehub/.env && rm -f ~/.aurehub/.env.bak
fi
# Ensure ETH_RPC_URL exists
if ! grep -q "^ETH_RPC_URL=" ~/.aurehub/.env 2>/dev/null; then
_env_set "ETH_RPC_URL" "https://eth.llamarpc.com"
fi
chmod 600 ~/.aurehub/.env
ok "WALLET_MODE=$WALLET_MODE updated in .env"
else
DEFAULT_RPC="https://eth.llamarpc.com"
echo -e " Ethereum node URL (press Enter to use the free public node):"
echo -e " Default: ${BOLD}$DEFAULT_RPC${NC}"
echo -e " Tip: Alchemy or Infura private nodes are more reliable. You can update"
echo -e " this later by editing ETH_RPC_URL in ~/.aurehub/.env"
read -rp " Node URL: " INPUT_RPC
ETH_RPC_URL="${INPUT_RPC:-$DEFAULT_RPC}"
if [ "$WALLET_MODE" = "wdk" ]; then
cat > ~/.aurehub/.env << EOF
WALLET_MODE=$WALLET_MODE
ETH_RPC_URL=$ETH_RPC_URL
ETH_RPC_URL_FALLBACK=https://eth.merkle.io,https://rpc.flashbots.net/fast,https://eth.drpc.org,https://ethereum.publicnode.com
WDK_PASSWORD_FILE=$HOME/.aurehub/.wdk_password
# Required for limit orders only:
# UNISWAPX_API_KEY=your_api_key_here
# Optional — set during setup or first-success prompt if omitted:
# NICKNAME=YourName
EOF
else
cat > ~/.aurehub/.env << EOF
WALLET_MODE=$WALLET_MODE
ETH_RPC_URL=$ETH_RPC_URL
# Fallback RPCs tried in order when primary fails with a network error (429/502/timeout)
# Add a paid Alchemy/Infura node at the front for higher reliability
ETH_RPC_URL_FALLBACK=https://eth.merkle.io,https://rpc.flashbots.net/fast,https://eth.drpc.org,https://ethereum.publicnode.com
FOUNDRY_ACCOUNT=$ACCOUNT_NAME
KEYSTORE_PASSWORD_FILE=$HOME/.aurehub/.wallet.password
# Required for limit orders only:
# UNISWAPX_API_KEY=your_api_key_here
# Optional — set during setup or first-success prompt if omitted:
# NICKNAME=YourName
EOF
fi
chmod 600 ~/.aurehub/.env
ok ".env generated (RPC: $ETH_RPC_URL)"
fi
if [ -f ~/.aurehub/config.yaml ]; then
ok "config.yaml already exists, skipping"
else
cp "$SKILL_DIR/config.example.yaml" ~/.aurehub/config.yaml
ok "config.yaml generated"
fi
# ── Step 8: Limit order dependencies (npm + UniswapX API Key) ─────────────────
step "Limit order dependencies (npm + UniswapX API Key)"
_install_nodejs() {
local suggestion=""
local install_mode=""
if [[ "$OSTYPE" == "darwin"* ]]; then
if command -v brew &>/dev/null; then
suggestion="brew install node"
install_mode="brew"
else
suggestion=$'# Install Homebrew first: https://brew.sh\nbrew install node'
install_mode="manual-brew"
fi
elif command -v apt-get &>/dev/null; then
suggestion="sudo apt install nodejs npm"
install_mode="apt"
elif command -v dnf &>/dev/null; then
suggestion="sudo dnf install nodejs"
install_mode="dnf"
elif command -v yum &>/dev/null; then
suggestion="sudo yum install nodejs"
install_mode="yum"
else
echo -e " ${YELLOW}Could not detect package manager. Install Node.js >= 18 from: https://nodejs.org${NC}"
return 1
fi
echo -e " Node.js >= 18 is required for market and limit orders."
echo -e " Suggested install command:"
echo -e " ${BOLD}$(echo -e "$suggestion")${NC}"
echo
read -rp " Run it now? [Y/n]: " RUN_NODE_INSTALL
if [[ "${RUN_NODE_INSTALL:-}" =~ ^[Nn]$ ]]; then
echo -e " ${YELLOW}Skipped. Market and limit orders will not be available until Node.js >= 18 is installed.${NC}"
return 1
fi
case "$install_mode" in
brew)
brew install node
;;
apt)
sudo apt install nodejs npm
;;
dnf)
sudo dnf install nodejs
;;
yum)
sudo yum install nodejs
;;
manual-brew)
echo -e " ${YELLOW}Homebrew is not installed. Please install Homebrew first, then run: brew install node${NC}"
return 1
;;
*)
echo -e " ${YELLOW}Unsupported install mode. Install Node.js >= 18 from: https://nodejs.org${NC}"
return 1
;;
esac
}
# Check Node.js
NODE_OK=false
if command -v node &>/dev/null; then
NODE_MAJOR=$(node -e 'process.stdout.write(process.version.split(".")[0].slice(1))')
if [ "$NODE_MAJOR" -ge 18 ]; then
ok "Node.js $(node --version)"
NODE_OK=true
else
warn "Node.js version too old: $(node --version) (requires >= 18)"
if _install_nodejs; then
NODE_OK=true
fi
fi
else
warn "Node.js not found"
if _install_nodejs; then
NODE_OK=true
fi
fi
if [ "$NODE_OK" = true ]; then
_ensure_scripts_deps
# Prompt for UniswapX API Key with explicit choices
CURRENT_UNISWAPX_KEY=$(grep '^UNISWAPX_API_KEY=' ~/.aurehub/.env 2>/dev/null | head -1 | cut -d= -f2- || true)
echo
echo -e " ${BOLD}UniswapX API Key${NC} (required for limit orders, not needed for market orders)"
echo -e " Get one free (~5 min): ${BOLD}https://developers.uniswap.org/dashboard${NC}"
echo -e " Sign in with Google/GitHub → Generate Token (Free tier)"
echo
if [ -n "$CURRENT_UNISWAPX_KEY" ]; then
echo -e " Existing key detected."
echo -e " ${BOLD}1)${NC} Keep existing key (recommended)"
echo -e " ${BOLD}2)${NC} Replace with a new key now"
read -rp " Choose 1 or 2 [default: 1]: " UNISWAPX_CHOICE
UNISWAPX_CHOICE="${UNISWAPX_CHOICE:-1}"
else
echo -e " No key currently configured."
echo -e " ${BOLD}1)${NC} Set key now"
echo -e " ${BOLD}2)${NC} Skip for now (market orders still available)"
read -rp " Choose 1 or 2 [default: 2]: " UNISWAPX_CHOICE
UNISWAPX_CHOICE="${UNISWAPX_CHOICE:-2}"
fi
if [ "$UNISWAPX_CHOICE" = "1" ] || { [ "$UNISWAPX_CHOICE" = "2" ] && [ -n "$CURRENT_UNISWAPX_KEY" ]; }; then
if [ "$UNISWAPX_CHOICE" = "1" ] && [ -n "$CURRENT_UNISWAPX_KEY" ]; then
ok "UNISWAPX_API_KEY unchanged"
else
while true; do
read -rp " Enter API Key: " UNISWAPX_KEY
UNISWAPX_KEY=$(printf '%s' "$UNISWAPX_KEY" | xargs)
if [ -z "$UNISWAPX_KEY" ]; then
echo -e " ${YELLOW}Key cannot be empty. Enter a key or press Ctrl+C to abort.${NC}"
continue
fi
if [ ${#UNISWAPX_KEY} -lt 10 ]; then
echo -e " ${YELLOW}Key looks too short. Please verify and try again.${NC}"
continue
fi
ENV_TMP_FILE=$(umask 077; mktemp /tmp/xaut_env.XXXXXX)
grep -v '^UNISWAPX_API_KEY=' ~/.aurehub/.env > "$ENV_TMP_FILE" 2>/dev/null || true
mv "$ENV_TMP_FILE" ~/.aurehub/.env
echo "UNISWAPX_API_KEY=$UNISWAPX_KEY" >> ~/.aurehub/.env
chmod 600 ~/.aurehub/.env
unset UNISWAPX_KEY
ok "UNISWAPX_API_KEY saved to ~/.aurehub/.env"
break
done
fi
else
ok "Skipped (add UNISWAPX_API_KEY to ~/.aurehub/.env later if needed)"
fi
else
warn "Market and limit orders unavailable (Node.js not installed). Re-run setup.sh after installing Node.js >= 18."
fi
# ── Step 9: Activity rankings (optional) ─────────────────────────────────────
step "Activity rankings (optional)"
echo -e " Would you like to join the XAUT trade activity rankings?"
echo -e " This will share your ${BOLD}wallet address${NC} and a ${BOLD}nickname${NC} with https://xaue.com"
echo -e " You can change this anytime by editing ~/.aurehub/.env"
echo
read -rp " Join rankings? [y/N]: " JOIN_RANKINGS
if [[ "${JOIN_RANKINGS:-}" =~ ^[Yy]$ ]]; then
read -rp " Enter your nickname: " RANKINGS_NICKNAME
if [ -n "$RANKINGS_NICKNAME" ]; then
_env_set "RANKINGS_OPT_IN" "true"
_env_set "NICKNAME" "$RANKINGS_NICKNAME"
ok "Rankings enabled (nickname: $RANKINGS_NICKNAME)"
else
_env_set "RANKINGS_OPT_IN" "false"
ok "Rankings skipped (empty nickname)"
fi
else
_env_set "RANKINGS_OPT_IN" "false"
ok "Rankings skipped"
fi
# ── Step 10: Verification ───────────────────────────────────────────────────────
step "Verify environment"
# WALLET_MODE is always set from Step 2. ETH_RPC_URL is set in the fresh-install
# path but NOT in the re-run path — read it from .env if unset.
if [ -z "${ETH_RPC_URL:-}" ]; then
ETH_RPC_URL=$(grep '^ETH_RPC_URL=' ~/.aurehub/.env 2>/dev/null | head -1 | cut -d= -f2-)
fi
if [ "$WALLET_MODE" = "wdk" ]; then
# Verify RPC connectivity using node
if BLOCK=$(node "$SCRIPT_DIR/swap.js" address 2>/dev/null | head -1); then
ok "WDK wallet accessible"
else
warn "Could not verify WDK wallet (market module may not be fully set up yet)"
fi
# RPC check via cast if available, otherwise curl
if command -v cast &>/dev/null; then
if BLOCK=$(cast block-number --rpc-url "$ETH_RPC_URL" 2>/dev/null); then
ok "RPC reachable (latest block #$BLOCK)"
else
echo -e " ${RED}❌ RPC check failed — ETH_RPC_URL is unreachable: $ETH_RPC_URL${NC}"
echo -e " Fix: edit ~/.aurehub/.env and set a valid ETH_RPC_URL, then re-run this script."
echo -e " Free public nodes: https://chainlist.org/chain/1"
exit 1
fi
else
# Fallback: use node to check RPC
if ETH_RPC_URL="$ETH_RPC_URL" node -e "const u=process.env.ETH_RPC_URL;fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jsonrpc:'2.0',method:'eth_blockNumber',params:[],id:1})}).then(r=>r.json()).then(d=>{if(d.result)process.exit(0);else process.exit(1)}).catch(()=>process.exit(1))" 2>/dev/null; then
ok "RPC reachable"
else
echo -e " ${RED}❌ RPC check failed — ETH_RPC_URL is unreachable: $ETH_RPC_URL${NC}"
echo -e " Fix: edit ~/.aurehub/.env and set a valid ETH_RPC_URL, then re-run this script."
echo -e " Free public nodes: https://chainlist.org/chain/1"
exit 1
fi
fi
[ -r "$HOME/.aurehub/.wdk_password" ] \
&& ok "WDK password file readable" \
|| { echo -e " ${RED}❌ WDK password file not readable${NC}"; exit 1; }
[ -f "$HOME/.aurehub/.wdk_vault" ] \
&& ok "WDK vault file exists" \
|| { echo -e " ${RED}❌ WDK vault file not found${NC}"; exit 1; }
else
# Foundry verification
cast --version | head -1 | xargs -I{} echo " ✓ {}"
# U8: make RPC failure a hard stop instead of a warning
if BLOCK=$(cast block-number --rpc-url "$ETH_RPC_URL" 2>/dev/null); then
ok "RPC reachable (latest block #$BLOCK)"
else
echo -e " ${RED}❌ RPC check failed — ETH_RPC_URL is unreachable: $ETH_RPC_URL${NC}"
echo -e " Fix: edit ~/.aurehub/.env and set a valid ETH_RPC_URL, then re-run this script."
echo -e " Free public nodes: https://chainlist.org/chain/1"
exit 1
fi
cast wallet list 2>/dev/null | grep -qF "$ACCOUNT_NAME" \
&& ok "Keystore account exists" \
|| { echo -e " ${RED}❌ Account not found${NC}"; exit 1; }
[ -r ~/.aurehub/.wallet.password ] \
&& ok "Password file readable" \
|| { echo -e " ${RED}❌ Password file not readable${NC}"; exit 1; }
fi
# ── Completion summary ─────────────────────────────────────────────────────────
echo -e "\n${GREEN}${BOLD}━━━ Automated setup complete ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e " Wallet mode: ${BOLD}$WALLET_MODE${NC}"
echo -e " Wallet address: ${BOLD}$WALLET_ADDRESS${NC}"
if [ "$NODE_OK" = true ] && grep -q '^UNISWAPX_API_KEY=.\+' ~/.aurehub/.env 2>/dev/null; then
echo -e " Market orders: ${GREEN}READY${NC}"
echo -e " Limit orders: ${GREEN}READY${NC}"
elif [ "$NODE_OK" = true ]; then
echo -e " Market orders: ${GREEN}READY${NC}"
echo -e " Limit orders: ${YELLOW}NOT READY${NC} (requires UNISWAPX_API_KEY)"
else
echo -e " Market orders: ${YELLOW}NOT READY${NC} (requires Node.js >= 18)"
echo -e " Limit orders: ${YELLOW}NOT READY${NC} (requires Node.js >= 18 and UNISWAPX_API_KEY)"
fi
echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "\n${YELLOW}${BOLD}The following steps require manual action (the script cannot do them for you):${NC}"
echo -e "\n ${BOLD}1. Fund the wallet with ETH (required for gas)${NC}"
echo -e " Reason: on-chain operations consume gas; the script cannot transfer funds."
echo -e " Minimum: ≥ 0.005 ETH"
echo -e " Wallet address: ${BOLD}$WALLET_ADDRESS${NC}"
echo -e "\n ${BOLD}2. Fund the wallet with trading capital (as needed)${NC}"
echo -e " Buy XAUT → deposit USDT to the wallet"
echo -e " Sell XAUT → deposit XAUT to the wallet"
echo -e " Same address: ${BOLD}$WALLET_ADDRESS${NC}"
echo -e "\n ${BOLD}3. Get a UniswapX API Key (limit orders only — skip if you already entered one above)${NC}"
echo -e " Reason: the UniswapX API requires authentication; the script cannot register on your behalf."
echo -e " How to get one (about 5 minutes, free):"
echo -e " a. Visit https://developers.uniswap.org/dashboard"
echo -e " b. Sign in with Google or GitHub"
echo -e " c. Generate a Token (Free tier)"
echo -e " Then add it to your config:"
echo -e " \$ echo 'UNISWAPX_API_KEY=your_key' >> ~/.aurehub/.env"
echo -e "\n${BLUE}Once the steps above are done, send any trade instruction to the Agent to begin.${NC}\n"
# ── Save setup script path for future re-runs ──────────────────────────────────
printf '%s\n' "$SCRIPT_DIR/setup.sh" > ~/.aurehub/.setup_path
```
### scripts/swap.js
```javascript
/**
* swap.js — CLI entry point for market operations.
*
* Usage:
* node swap.js <command> [options]
*
* Commands:
* address — output wallet address
* balance — output ETH, USDT, XAUT balances
* allowance — output ERC-20 allowance (requires --token)
* quote — get a Uniswap V3 quote (requires --side, --amount)
* approve — approve a token spender (requires --token, --amount)
* swap — execute a swap (requires --side, --amount, --min-out)
*/
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { formatUnits, Interface } from 'ethers6';
import { loadConfig, resolveToken, validateContracts } from './lib/config.js';
import { createProvider } from './lib/provider.js';
import { createSigner } from './lib/signer.js';
import { getBalance, getAllowance, approve } from './lib/erc20.js';
import { quote, buildSwap } from './lib/uniswap.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const VALID_COMMANDS = new Set(['quote', 'balance', 'allowance', 'approve', 'swap', 'address', 'sign', 'cancel-nonce']);
// ---------------------------------------------------------------------------
// CLI argument parser — exported for unit-testing without RPC
// ---------------------------------------------------------------------------
/**
* Parse raw argv (everything after "node swap.js") into a structured object.
*
* @param {string[]} argv e.g. ['quote', '--side', 'buy', '--amount', '100']
* @returns {{ command: string, side?: string, amount?: string, minOut?: string, token?: string, configDir?: string }}
*/
export function parseCliArgs(argv) {
const [command, ...rest] = argv;
if (!VALID_COMMANDS.has(command)) {
throw new Error(`Unknown command: "${command}". Valid commands: ${[...VALID_COMMANDS].join(', ')}`);
}
const parsed = { command };
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
const value = rest[i + 1];
switch (flag) {
case '--side':
parsed.side = value;
i++;
break;
case '--amount':
parsed.amount = value;
i++;
break;
case '--min-out':
parsed.minOut = value;
i++;
break;
case '--token':
parsed.token = value;
i++;
break;
case '--config-dir':
parsed.configDir = value;
i++;
break;
case '--data-file':
parsed.dataFile = value;
i++;
break;
case '--word-pos':
parsed.wordPos = value;
i++;
break;
case '--mask':
parsed.mask = value;
i++;
break;
case '--spender':
parsed.spender = value;
i++;
break;
default:
// Ignore unknown flags silently
break;
}
}
if ((command === 'quote' || command === 'swap') && parsed.side) {
const normalized = String(parsed.side).trim().toLowerCase();
if (normalized !== 'buy' && normalized !== 'sell') {
throw new Error(`Invalid --side value "${parsed.side}". Expected "buy" or "sell".`);
}
parsed.side = normalized;
}
return parsed;
}
// ---------------------------------------------------------------------------
// Subcommand implementations
// ---------------------------------------------------------------------------
async function runAddress(cfg, provider) {
const signer = await createSigner(cfg, provider ? provider.getEthersProvider() : null);
console.log(JSON.stringify({ address: signer.address }, null, 2));
}
async function runBalance(cfg, provider) {
const signer = await createSigner(cfg, provider ? provider.getEthersProvider() : null);
const address = signer.address;
const tokens = cfg.yaml?.tokens ?? {};
const usdtToken = resolveToken(cfg, 'USDT');
const xautToken = resolveToken(cfg, 'XAUT');
const [usdtBalance, xautBalance, ethBalanceRaw] = await Promise.all([
getBalance(usdtToken, address, provider),
getBalance(xautToken, address, provider),
provider.getBalance(address),
]);
// ethBalanceRaw is a hex string from the raw JSON-RPC; convert to ETH string
const ethBig = BigInt(ethBalanceRaw);
const ethBalance = formatUnits(ethBig, 18);
console.log(JSON.stringify({ address, ETH: ethBalance, USDT: usdtBalance, XAUT: xautBalance }, null, 2));
}
async function runAllowance(cfg, provider, args) {
if (!args.token) throw new Error('--token is required for allowance');
const signer = await createSigner(cfg, provider ? provider.getEthersProvider() : null);
const address = signer.address;
const token = resolveToken(cfg, args.token);
const contracts = cfg.yaml?.contracts ?? {};
const spender = args.spender || contracts.router;
if (!spender) throw new Error('--spender or contracts.router must be set');
const allowance = await getAllowance(token, address, spender, provider);
console.log(JSON.stringify({ address, token: args.token, allowance, spender }, null, 2));
}
async function runQuote(cfg, provider, args) {
if (!args.side) throw new Error('--side is required for quote');
if (!args.amount) throw new Error('--amount is required for quote');
if (args.side !== 'buy' && args.side !== 'sell') throw new Error('--side must be "buy" or "sell"');
// Resolve pair: buy = USDT→XAUT, sell = XAUT→USDT
const isBuy = args.side === 'buy';
const tokenIn = resolveToken(cfg, isBuy ? 'USDT' : 'XAUT');
const tokenOut = resolveToken(cfg, isBuy ? 'XAUT' : 'USDT');
const fee = _resolveFee(cfg, isBuy ? 'USDT' : 'XAUT', isBuy ? 'XAUT' : 'USDT');
const contracts = cfg.yaml?.contracts ?? {};
if (!contracts.quoter) throw new Error('contracts.quoter not set in config.yaml');
const result = await quote({
tokenIn,
tokenOut,
amountIn: args.amount,
fee,
contracts,
provider,
});
// Convert bigints to strings for JSON output
console.log(JSON.stringify({
side: args.side,
amountIn: args.amount,
amountOut: result.amountOut,
amountOutRaw: result.amountOutRaw.toString(),
sqrtPriceX96: result.sqrtPriceX96.toString(),
gasEstimate: result.gasEstimate.toString(),
}, null, 2));
}
async function runApprove(cfg, provider, args) {
if (!args.token) throw new Error('--token is required for approve');
if (!args.amount) throw new Error('--amount is required for approve');
const signer = await createSigner(cfg, provider ? provider.getEthersProvider() : null);
const token = resolveToken(cfg, args.token);
const contracts = cfg.yaml?.contracts ?? {};
const spender = args.spender || contracts.router;
if (!spender) throw new Error('--spender or contracts.router must be set');
// Check token_rules for requiresResetApprove; skip reset if allowance is already 0
const tokenRules = cfg.yaml?.token_rules ?? {};
const rules = tokenRules[args.token] ?? {};
let requiresResetApprove = rules.requires_reset_approve ?? false;
if (requiresResetApprove && provider) {
const currentAllowance = await getAllowance(token, signer.address, spender, provider);
if (parseFloat(currentAllowance) === 0) requiresResetApprove = false;
}
const result = await approve(token, spender, args.amount, signer, { requiresResetApprove, fallbackProvider: provider });
console.log(JSON.stringify({ address: signer.address, token: args.token, amount: args.amount, spender, txHash: result.hash }, null, 2));
}
async function runSwap(cfg, provider, args) {
if (!args.side) throw new Error('--side is required for swap');
if (!args.amount) throw new Error('--amount is required for swap');
if (!args.minOut) throw new Error('--min-out is required for swap');
if (!args.minOut || Number.isNaN(Number(args.minOut)) || Number(args.minOut) <= 0) throw new Error('--min-out must be a positive number greater than 0');
if (args.side !== 'buy' && args.side !== 'sell') throw new Error('--side must be "buy" or "sell"');
const signer = await createSigner(cfg, provider ? provider.getEthersProvider() : null);
const address = signer.address;
const isBuy = args.side === 'buy';
const tokenIn = resolveToken(cfg, isBuy ? 'USDT' : 'XAUT');
const tokenOut = resolveToken(cfg, isBuy ? 'XAUT' : 'USDT');
const fee = _resolveFee(cfg, isBuy ? 'USDT' : 'XAUT', isBuy ? 'XAUT' : 'USDT');
const contracts = cfg.yaml?.contracts ?? {};
if (!contracts.router) throw new Error('contracts.router not set in config.yaml');
const risk = cfg.yaml?.risk ?? {};
const deadline = Math.floor(Date.now() / 1000) + (risk.deadline_seconds ?? 300);
const tx = buildSwap({
tokenIn,
tokenOut,
amountIn: args.amount,
minAmountOut: args.minOut,
fee,
recipient: address,
deadline,
contracts,
});
const timeoutMs = (risk.deadline_seconds ?? 300) * 1000;
// Send transaction — if this throws, the tx was never broadcast (safe to retry)
const sentTx = await signer.sendTransaction(tx);
// Use fallback-aware receipt wait when available (RPC resilience)
const waitFn = provider?.waitForTransaction
? provider.waitForTransaction(sentTx.hash, 1, timeoutMs)
: sentTx.wait();
// Wait for confirmation — if this throws, the tx WAS broadcast but confirmation
// failed (RPC error, timeout). Output txHash so caller can verify before retrying.
let receipt;
let confirmTimer;
try {
receipt = await Promise.race([
waitFn,
new Promise((_, reject) => {
confirmTimer = setTimeout(() => reject(new Error(
`Transaction not confirmed within ${timeoutMs / 1000}s (txHash: ${sentTx.hash}). It may still be pending — check on Etherscan.`
)), timeoutMs);
}),
]);
} catch (confirmErr) {
clearTimeout(confirmTimer);
console.log(JSON.stringify({
address,
side: args.side,
amountIn: args.amount,
minAmountOut: args.minOut,
txHash: sentTx.hash,
status: 'unconfirmed',
warning: `Transaction was broadcast but confirmation failed: ${confirmErr.message}. Check balance or Etherscan before retrying — the swap may have succeeded.`,
}, null, 2));
process.exit(1);
}
clearTimeout(confirmTimer);
if (!receipt) {
console.log(JSON.stringify({
address,
side: args.side,
amountIn: args.amount,
minAmountOut: args.minOut,
txHash: sentTx.hash,
status: 'unknown',
warning: 'Transaction receipt is null — tx may have been dropped from mempool. Check on Etherscan.',
}, null, 2));
process.exit(1);
}
const failed = receipt.status !== 1;
const result = {
address,
side: args.side,
amountIn: args.amount,
minAmountOut: args.minOut,
txHash: sentTx.hash,
status: failed ? 'failed' : 'success',
gasUsed: receipt.gasUsed.toString(),
};
if (failed) {
result.warning = 'Swap failed. A token approval may still be active — revoke it if you do not intend to retry.';
}
console.log(JSON.stringify(result, null, 2));
if (failed) process.exit(1);
}
async function runSign(cfg, args) {
if (!args.dataFile) throw new Error('--data-file is required for sign');
const raw = readFileSync(args.dataFile, 'utf8');
const { domain, types, message } = JSON.parse(raw);
// ethers v6 derives EIP712Domain automatically — strip it to avoid conflicts
const cleanTypes = { ...types };
delete cleanTypes.EIP712Domain;
const signer = await createSigner(cfg, null);
const signature = await signer.signTypedData(domain, cleanTypes, message);
// Output raw signature string (no JSON) to match cast wallet sign format
process.stdout.write(signature);
}
async function runCancelNonce(cfg, provider, args) {
if (!args.wordPos) throw new Error('--word-pos is required for cancel-nonce');
if (!args.mask) throw new Error('--mask is required for cancel-nonce');
// Validate wordPos and mask as valid uint256 values
let wordPosBig, maskBig;
try { wordPosBig = BigInt(args.wordPos); } catch { throw new Error(`--word-pos is not a valid integer: ${args.wordPos}`); }
try { maskBig = BigInt(args.mask); } catch { throw new Error(`--mask is not a valid integer: ${args.mask}`); }
if (wordPosBig < 0n || wordPosBig >= (1n << 248n)) throw new Error(`--word-pos out of range: ${args.wordPos}`);
if (maskBig <= 0n || maskBig >= (1n << 256n)) throw new Error(`--mask out of range: ${args.mask}`);
const signer = await createSigner(cfg, provider ? provider.getEthersProvider() : null);
const permit2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const iface = new Interface(['function invalidateUnorderedNonces(uint256 wordPos, uint256 mask)']);
const data = iface.encodeFunctionData('invalidateUnorderedNonces', [wordPosBig, maskBig]);
const tx = await signer.sendTransaction({ to: permit2, data });
const receipt = await tx.wait();
if (!receipt) {
console.log(JSON.stringify({
address: signer.address,
txHash: tx.hash,
status: 'unknown',
warning: 'Transaction receipt is null — tx may have been dropped from mempool. Check on Etherscan.',
}, null, 2));
process.exit(1);
}
const failed = receipt.status !== 1;
console.log(JSON.stringify({
address: signer.address,
txHash: tx.hash,
status: failed ? 'failed' : 'success',
}, null, 2));
if (failed) process.exit(1);
}
// ---------------------------------------------------------------------------
// Helper: resolve pool fee from config.yaml pairs
// ---------------------------------------------------------------------------
function _resolveFee(cfg, symbolIn, symbolOut) {
// Note: resolveToken() already restricts symbols to known tokens (USDT, XAUT),
// so this function is only called with whitelisted pairs. The default fallback
// exists as a safety net but should never be reached in normal operation.
const pairs = cfg.yaml?.pairs ?? [];
for (const pair of pairs) {
if (!pair.enabled) continue;
if (
(pair.token_in === symbolIn && pair.token_out === symbolOut) ||
(pair.token_in === symbolOut && pair.token_out === symbolIn)
) {
return pair.fee_tier;
}
}
// Default to 3000 (0.3%) if no matching pair found
return 3000;
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
const isDirectRun =
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isDirectRun) {
const argv = process.argv.slice(2);
let parsed;
try {
parsed = parseCliArgs(argv);
} catch (err) {
console.error(JSON.stringify({ error: err.message }));
process.exit(1);
}
const cfg = loadConfig(parsed.configDir);
validateContracts(cfg);
const COMMANDS_NEEDING_PROVIDER = new Set([
'address',
'balance',
'allowance',
'quote',
'approve',
'swap',
'cancel-nonce',
]);
(async () => {
try {
const provider = COMMANDS_NEEDING_PROVIDER.has(parsed.command)
? createProvider(cfg.env)
: null;
switch (parsed.command) {
case 'address':
await runAddress(cfg, provider);
break;
case 'balance':
await runBalance(cfg, provider);
break;
case 'allowance':
await runAllowance(cfg, provider, parsed);
break;
case 'quote':
await runQuote(cfg, provider, parsed);
break;
case 'approve':
await runApprove(cfg, provider, parsed);
break;
case 'swap':
await runSwap(cfg, provider, parsed);
break;
case 'sign':
await runSign(cfg, parsed);
break;
case 'cancel-nonce':
await runCancelNonce(cfg, provider, parsed);
break;
}
process.exit(0);
} catch (err) {
console.error(JSON.stringify({ error: err.message }));
process.exit(1);
}
})();
}
```