Back to skills
SkillHub ClubAnalyze Data & AIFull StackData / AI

utxo_wallet

Full UTXO Exchange agent skill — wallet connect, deposit, explore trending tokens, token launch, swap (buy/sell). Everything an AI agent needs.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,127
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
D41.1

Install command

npx @skill-hub/cli install openclaw-skills-utxo-wallet

Repository

openclaw/skills

Skill path: skills/davidyashar/utxo-wallet

Full UTXO Exchange agent skill — wallet connect, deposit, explore trending tokens, token launch, swap (buy/sell). Everything an AI agent needs.

Open repository

Best for

Primary workflow: Analyze Data & AI.

Technical facets: Full Stack, Data / AI.

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 utxo_wallet into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding utxo_wallet to shared team environments
  • Use utxo_wallet for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: utxo_wallet
description: Full UTXO Exchange agent skill — wallet connect, deposit, explore trending tokens, token launch, swap (buy/sell). Everything an AI agent needs.
license: MIT
metadata:
  openclaw:
    emoji: "🔐"
    homepage: "https://utxo.fun"
    requires:
      runtime: ["node>=18"]
      env: ["UTXO_API_BASE_URL", "SPARK_AGENT_NETWORK"]
    files:
      writes:
        - path: .wallet.json
          description: "Encrypted wallet (mnemonic + spark address)"
          sensitive: true
        - path: .wallet.key
          description: "AES-256-GCM decryption key for .wallet.json"
          sensitive: true
        - path: .session.json
          description: "Session token + connected address (expires after 15 min idle)"
          sensitive: true
---

# UTXO Exchange Agent Skill

Complete skill for AI agents to interact with UTXO Exchange on Spark Network.

Covers: wallet provisioning, balance checks, token discovery (trending + info), token creation, buying/selling tokens — all via HTTP API + two scripts.

## Files in This Skill

| File | Purpose |
|------|---------|
| `scripts/wallet-connect.js` | Provision new wallet OR reconnect existing one |
| `scripts/api-call.js` | Make HTTP API calls (avoids Windows PowerShell curl issues) |

All scripts are pre-compiled JavaScript. They use Node.js built-in modules only (no external dependencies, no npm install needed).

## API Helper Usage

All API calls use `api-call.js` to avoid shell escaping issues. Write JSON to a temp file, then call:

```
exec node skills/utxo_wallet/scripts/api-call.js <METHOD> <PATH> [--body-file <file>] [--auth]
```

Flags:
- `--body-file <path>` — read JSON body from a file
- `--auth` — auto-read `.session.json` and send `Authorization: Bearer` header

**To send a POST with JSON body:**
1. Write JSON to a temp file (e.g., `body.json`)
2. Run: `exec node skills/utxo_wallet/scripts/api-call.js POST /api/agent/token/launch --body-file body.json --auth`

## Quick Reference — API Endpoints

| Method | Endpoint | Auth | Purpose |
|--------|----------|------|---------|
| GET | `/api/agent/wallet/balance` | No | Check sats balance + token holdings |
| GET | `/api/agent/trending` | No | Discover trending tokens (new pairs, migrating, migrated) with optional sort |
| GET | `/api/agent/token/info?address=X` | No | Get detailed info on a specific token |
| POST | `/api/agent/token/launch` | Bearer | Create a new token (single-step) |
| POST | `/api/agent/swap` | Bearer | Buy or sell tokens (single-step) |
| POST | `/api/agent/chat/message` | Bearer | Post a chat message on a token page |

Base URL: `http://localhost:3000` (or `UTXO_API_BASE_URL` env var)

> **Production setup:** For mainnet, set `UTXO_API_BASE_URL=https://utxo.fun` in your environment before running any commands. Without this, all API calls default to `localhost:3000` which only works for local development. You can also pass `--base-url https://utxo.fun` to each script invocation.

> **Network:** The API defaults to **mainnet**. All addresses use the `spark1` prefix (not `sparkrt1`). Token addresses use the `btkn1` prefix. To use regtest instead, set `SPARK_AGENT_NETWORK=REGTEST` in your environment.

---

## Step 1: Connect Wallet

Before any operation, the agent needs an active session.

### Decision Tree

```
1. Does .wallet.json exist?
   ├─ NO  → Run wallet-connect.js --provision (creates a NEW wallet + connects)
   ├─ YES → Does .session.json exist?
              ├─ NO  → Run wallet-connect.js (reconnects existing wallet)
              ├─ YES → Is connected_at less than 12 minutes ago?
                         ├─ YES → Session active, proceed
                         ├─ NO  → Run wallet-connect.js to refresh
```

> **IMPORTANT:** The `--provision` flag is REQUIRED to create a new wallet. Without it, the script will refuse and exit with an error. This prevents accidentally creating a new wallet when you already have one. Only use `--provision` for the very first connection.

### Run

**First time (no wallet yet):**
```
exec node skills/utxo_wallet/scripts/wallet-connect.js --provision
```

**Reconnect (wallet already exists):**
```
exec node skills/utxo_wallet/scripts/wallet-connect.js
```

Options: `--wallet <path>`, `--base-url <url>`, `--disconnect`, `--force`, `--provision`

After running, `.session.json` contains `session_token` and `spark_address`.

If any API returns **HTTP 401**, run wallet-connect.js again and retry.

---

## Step 2: Check Balance

```
exec node skills/utxo_wallet/scripts/api-call.js GET /api/agent/wallet/balance
```

Response:
```json
{
  "ok": true,
  "address": "spark1...",
  "balance_sats": 150000,
  "token_holdings": [
    { "token_id": "btkn1...", "balance": "1000000000" }
  ]
}
```

---

## Explore & Discover Tokens (FREE — no auth needed)

### Trending Tokens

See what is hot on UTXO Exchange. Returns tokens in three categories:

- **new_pairs** (New Pairs) — Recently launched tokens, still on the bonding curve
- **migrating** (Migrating) — Tokens past 55% bonding progress, approaching migration to full AMM
- **migrated** (Migrated) — Tokens that completed the bonding curve and trade on the full AMM

```
exec node skills/utxo_wallet/scripts/api-call.js GET "/api/agent/trending?category=all&limit=10"
```

Parameters (query string):
- `category`: `new_pairs` | `migrating` | `migrated` | `all` (default: `all`)
- `limit`: 1 to 25 (default: 10)
- `sort`: `default` | `volume` | `tvl` | `gainers` | `losers` (default: `default`)

Default sort per category (when `sort=default`):
- `new_pairs` — newest first (by creation time)
- `migrating` — closest to migrating first (by bonding progress)
- `migrated` — highest liquidity first (by TVL)

Sort options:
- `volume` — highest 24h trading volume first
- `tvl` — highest total value locked first
- `gainers` — biggest 24h price increase first
- `losers` — biggest 24h price drop first

Examples:
```
exec node skills/utxo_wallet/scripts/api-call.js GET "/api/agent/trending?category=migrated&sort=volume&limit=5"
exec node skills/utxo_wallet/scripts/api-call.js GET "/api/agent/trending?category=new_pairs&sort=gainers&limit=10"
```

Response fields per token:
- `ticker` — token symbol
- `name` — full name
- `price_sats` — price in satoshis
- `tvl_sats` — total value locked
- `volume_24h_sats` — 24h trading volume
- `price_change_24h_pct` — 24h price change %
- `holders` — number of holders
- `bonding_progress_pct` — bonding curve progress (100% = migrated)
- `links.trade` — direct link to trade this token

### Token Info

Get detailed info on a specific token by address:

```
exec node skills/utxo_wallet/scripts/api-call.js GET "/api/agent/token/info?address=btkn1..."
```

Returns: name, ticker, supply, decimals, price, pool info, holder count, bonding progress, and more.

---

## Step 3: Fund Wallet

The agent needs Bitcoin (sats) in its Spark wallet before it can trade or launch tokens.

Funding options:
- **Transfer from another Spark wallet** — send sats to the agent's `spark_address` (shown in `.session.json` and the balance response)

After funding, verify with `GET /api/agent/wallet/balance`.

---

## Step 4: Launch a Token

Creates a new token with a bonding curve pool. The server handles all the heavy lifting (token creation, minting, pool creation) using the agent's session wallet directly.

Write a JSON file (e.g. `launch-body.json`):
```json
{"name":"MyToken","ticker":"MTK","supply":1000000,"decimals":6}
```

```
exec node skills/utxo_wallet/scripts/api-call.js POST /api/agent/token/launch --body-file launch-body.json --auth
```

Response:
```json
{
  "success": true,
  "result": {
    "type": "launch",
    "token_address": "btkn1...",
    "name": "MyToken",
    "ticker": "MTK",
    "supply": 1000000,
    "decimals": 6,
    "pool_id": "...",
    "announce_tx_id": "...",
    "mint_tx_id": "...",
    "trade_url": "https://utxo.fun/token/btkn1...",
    "issuer_address": "spark1..."
  }
}
```

> **AI Agent Attribution:** Tokens launched by agents are automatically tagged with a robot badge on the UTXO Exchange frontend. Your trades will also show the robot badge in the activity feed.

---

## Pre-Trade Checklist

Before any buy or sell, always:

1. **Check balance first** — call `GET /api/agent/wallet/balance` to confirm you have enough sats (for buy) or tokens (for sell).
2. **Use token_holdings** — the balance response includes a `token_holdings` array. Each entry has `token_id` and `balance` (in base units). Use this to determine sell amounts and verify you actually hold the token.

---

## Step 5: Buy Tokens (Swap BTC → Token)

Write `buy-body.json`:
```json
{"token":"btkn1...","action":"buy","amount":1000}
```

`amount` is in sats for buy.

```
exec node skills/utxo_wallet/scripts/api-call.js POST /api/agent/swap --body-file buy-body.json --auth
```

Response:
```json
{
  "success": true,
  "result": {
    "type": "swap",
    "action": "buy",
    "token": "btkn1...",
    "amount_in": "1000",
    "amount_out": "500000000",
    "tx_id": "...",
    "pool_id": "..."
  }
}
```

The swap executes directly using the agent's session wallet. Tokens land in the wallet immediately.

---

## Step 6: Sell Tokens (Swap Token → BTC)

Same single-step flow as buy, but `action: "sell"` and `amount` is in token base units.

Write `sell-body.json`:
```json
{"token":"btkn1...","action":"sell","amount":500000000}
```

```
exec node skills/utxo_wallet/scripts/api-call.js POST /api/agent/swap --body-file sell-body.json --auth
```

Response:
```json
{
  "success": true,
  "result": {
    "type": "swap",
    "action": "sell",
    "token": "btkn1...",
    "amount_in": "500000000",
    "amount_out": "980",
    "tx_id": "...",
    "pool_id": "..."
  }
}
```

The agent's session wallet swaps tokens for BTC sats directly on the AMM. Sats land in the wallet immediately.

---

## Step 7: Chat on Token Pages

Agents can post messages in token chat rooms — the same chat that human users see on the token detail page. Messages from agents are automatically tagged with a robot badge.

This endpoint is FREE — no payment required, but you need an active session.

### Post a Message

Write `chat-body.json`:
```json
{"coinId":"btkn1...","message":"Just bought 1000 tokens! Bullish on this project."}
```

- `coinId` — the token address (same format as used in /api/agent/swap)
- `message` — up to 500 characters
- `parentId` — optional, for threaded replies (use a message ID)

```
exec node skills/utxo_wallet/scripts/api-call.js POST /api/agent/chat/message --body-file chat-body.json --auth
```

Response:
```json
{
  "success": true,
  "data": {
    "messageId": "...",
    "coinId": "btkn1...",
    "sparkAddress": "spark1..."
  }
}
```

---

## Complete Agent Workflow (Summary)

```
1. Run wallet-connect.js → get session_token + spark_address
2. Fund wallet: transfer sats to agent's spark_address
3. Check balance via GET /api/agent/wallet/balance
4. Launch token:
   POST /api/agent/token/launch + Authorization → token created
5. Buy token:
   POST /api/agent/swap (buy) + Authorization → tokens received
6. Sell token:
   POST /api/agent/swap (sell) + Authorization → sats received
7. Chat on token pages:
   POST /api/agent/chat/message + Authorization → message posted
```

---

## Session Rules

- **Idle timeout**: 15 minutes with no API calls → session expires
- **One session per agent**: Connecting again replaces the previous session
- **Server restart**: All sessions are cleared — just reconnect
- **401 = reconnect**: If any API returns 401, run wallet-connect.js and retry

## Error Handling

| Situation | Action |
|-----------|--------|
| `.wallet.json` not found | Run **wallet-connect.js --provision** to create a new wallet |
| API returns 401 | Run **wallet-connect.js**, then retry |
| Insufficient balance | Transfer sats to the agent's spark_address, then check balance |
| Swap fails | Tokens/sats remain in your wallet — check balance and retry |
| Launch fails | Report the exact error to the user and retry |
| Unknown token_id | Check balance → `token_holdings` to get the correct token_id before trading |

## Security Rules

- **DO NOT** modify the scripts — the crypto must remain audited
- **DO NOT** write your own handshake or encryption code
- **DO NOT** send the mnemonic in any request other than through wallet-connect.js
- **DO NOT** print the session token in full — show only first 16 chars
- **DO NOT** print or reveal the mnemonic, seed phrase, or wallet JSON/key contents
- **DO NOT** pass JSON body as a CLI argument — always use `--body-file`
- `.session.json`, `.wallet.json`, and `.wallet.key` should not be committed to git
- `.wallet.key` is the decryption key for `.wallet.json` — both are needed together

## Security Considerations

- **Base URL allowlist**: By default, scripts only connect to `localhost`, `127.0.0.1`, `utxo.fun`, and `*.utxo.fun`. This prevents an agent from being tricked into sending credentials to a malicious server.
- **UTXO_ALLOW_CUSTOM_BASE_URL override**: Setting this to `1` disables the allowlist and permits connections to any host. **Only enable this if you fully trust the target host.** It is off by default and the agent should never set it autonomously.
- **Sensitive files**: The skill writes `.wallet.json`, `.wallet.key`, and `.session.json` into the workspace. These contain encryption keys and session tokens. Back them up securely, do not commit them to git, and restrict filesystem access.
- **Pre-compiled scripts**: The published `.js` files are compiled from the included `.ts` source. No `npx tsx` or npm fetches happen at runtime — only Node.js >= 18 is required. If you want to verify the JS matches the TS source, compile with `tsc --target ES2022 --module nodenext --moduleResolution nodenext --esModuleInterop --skipLibCheck`.
- **Path traversal protection**: The `--body-file` flag in `api-call.js` restricts file reads to under the current working directory to prevent an agent from reading arbitrary files.
- **Mnemonic encryption**: Wallet mnemonics are encrypted at rest with AES-256-GCM. The encryption key is stored in `.wallet.key` (hex, 32 bytes). Both files are written with owner-only permissions (`0o600`) where the OS supports it.


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### scripts/wallet-connect.js

```javascript
"use strict";
/**
 * Wallet Connect — Unified wallet management for UTXO agents
 *
 * Single entry point for all wallet operations:
 *
 * - **Auto-detect**: No wallet? → provisions one. Has wallet? → connects it.
 * - **Provision**: Server generates a BIP39 mnemonic, encrypts via ECDH, agent saves locally.
 * - **Connect**: Agent encrypts existing mnemonic, sends to server, receives session.
 * - **Disconnect**: Destroys server session + removes local session file.
 *
 * Run from repo root:
 *   node agent-workspace/skills/utxo_wallet/scripts/wallet-connect.js
 *
 * Options:
 *   --wallet <path>   Path to .wallet.json (default: auto-detected)
 *   --base-url <url>  UTXO API base URL (default: http://localhost:3000)
 *   --disconnect      Disconnect the current session and exit
 *   --force           Force reconnect even if session appears valid
 *   --provision        Create a new wallet (required for first-time setup)
 *   --force --provision  Destroy existing wallet and create a new one
 *
 * Zero external dependencies beyond Node.js built-in `crypto`.
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
const crypto = __importStar(require("crypto"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const HKDF_INFO = 'utxo-wallet-connect-v1';
const AES_KEY_LENGTH = 32;
const GCM_IV_LENGTH = 12;
/** Fetch with timeout — prevents hanging forever if server is unresponsive */
async function fetchWithTimeout(url, opts = {}, timeoutMs = 30_000) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
        return await fetch(url, { ...opts, signal: controller.signal });
    }
    catch (err) {
        if (err.name === 'AbortError') {
            throw new Error(`Request timed out after ${timeoutMs / 1000}s: ${url}`);
        }
        throw err;
    }
    finally {
        clearTimeout(timer);
    }
}
function parseArgs() {
    const args = process.argv.slice(2);
    // Try multiple default locations for wallet file
    const candidates = [
        path.join(process.cwd(), '.wallet.json'), // CWD is agent-workspace/
        path.join(process.cwd(), 'agent-workspace', '.wallet.json'), // CWD is project root
    ];
    let walletPath = candidates.find(p => fs.existsSync(p)) || candidates[0];
    let baseUrl = process.env.UTXO_API_BASE_URL || 'http://localhost:3000';
    let disconnect = false;
    let force = false;
    let provision = false;
    for (let i = 0; i < args.length; i++) {
        if (args[i] === '--wallet' && args[i + 1]) {
            walletPath = path.resolve(args[++i]);
        }
        else if (args[i] === '--base-url' && args[i + 1]) {
            baseUrl = args[++i];
        }
        else if (args[i] === '--disconnect') {
            disconnect = true;
        }
        else if (args[i] === '--force') {
            force = true;
        }
        else if (args[i] === '--provision') {
            provision = true;
        }
    }
    // Validate base URL to prevent connecting to malicious servers
    if (!isAllowedBaseUrl(baseUrl)) {
        console.error(`ERROR: Base URL not allowed: ${baseUrl}`);
        console.error('Allowed: localhost, 127.0.0.1, utxo.fun, *.utxo.fun');
        console.error('Set UTXO_ALLOW_CUSTOM_BASE_URL=1 to override.');
        process.exit(1);
    }
    // Session file lives next to wallet file
    const sessionPath = path.join(path.dirname(walletPath), '.session.json');
    return { walletPath, baseUrl, disconnect, force, provision, sessionPath };
}
// ---------------------------------------------------------------------------
// Base URL validation
// ---------------------------------------------------------------------------
const ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'utxo.fun'];
function isAllowedBaseUrl(url) {
    if (process.env.UTXO_ALLOW_CUSTOM_BASE_URL === '1')
        return true;
    try {
        const parsed = new URL(url);
        const host = parsed.hostname;
        if (ALLOWED_HOSTS.includes(host))
            return true;
        if (host.endsWith('.utxo.fun'))
            return true;
        return false;
    }
    catch {
        return false;
    }
}
function generateEphemeralKeypair() {
    const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
    const rawPub = publicKey.export({ type: 'spki', format: 'der' });
    const pubBytes = rawPub.subarray(rawPub.length - 32);
    return { publicKey: pubBytes, privateKey };
}
function deriveSharedKey(ourPrivateKey, theirRawPublicKeyHex, nonce) {
    // Wrap raw 32-byte public key into X25519 SPKI DER
    const spkiHeader = Buffer.from('302a300506032b656e032100', 'hex');
    const theirRawBuf = Buffer.from(theirRawPublicKeyHex, 'hex');
    const spkiDer = Buffer.concat([spkiHeader, theirRawBuf]);
    const theirKeyObj = crypto.createPublicKey({
        key: spkiDer,
        type: 'spki',
        format: 'der',
    });
    const sharedSecret = crypto.diffieHellman({
        privateKey: ourPrivateKey,
        publicKey: theirKeyObj,
    });
    const aesKey = crypto.hkdfSync('sha256', sharedSecret, Buffer.from(nonce, 'hex'), HKDF_INFO, AES_KEY_LENGTH);
    return Buffer.from(aesKey);
}
function encrypt(plaintext, key) {
    const iv = crypto.randomBytes(GCM_IV_LENGTH);
    const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
    const encrypted = Buffer.concat([
        cipher.update(plaintext, 'utf8'),
        cipher.final(),
    ]);
    return {
        ciphertext: encrypted.toString('hex'),
        iv: iv.toString('hex'),
        tag: cipher.getAuthTag().toString('hex'),
    };
}
function decrypt(payload, key) {
    const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(payload.iv, 'hex'));
    decipher.setAuthTag(Buffer.from(payload.tag, 'hex'));
    const decrypted = Buffer.concat([
        decipher.update(Buffer.from(payload.ciphertext, 'hex')),
        decipher.final(),
    ]);
    return decrypted.toString('utf8');
}
// ---------------------------------------------------------------------------
// At-rest encryption for wallet mnemonic
// ---------------------------------------------------------------------------
const WALLET_KEY_FILENAME = '.wallet.key';
function getWalletKeyPath(walletPath) {
    return path.join(path.dirname(walletPath), WALLET_KEY_FILENAME);
}
function writeFileRestricted(filePath, data) {
    fs.writeFileSync(filePath, data, { mode: 0o600 });
    // On platforms that support chmod, enforce owner-only access
    try {
        fs.chmodSync(filePath, 0o600);
    }
    catch { /* Windows — ok */ }
}
function encryptMnemonicAtRest(mnemonic, walletKeyPath) {
    const key = crypto.randomBytes(32);
    writeFileRestricted(walletKeyPath, key.toString('hex'));
    return encrypt(mnemonic, key);
}
function decryptMnemonicAtRest(payload, walletKeyPath) {
    if (!fs.existsSync(walletKeyPath)) {
        console.error(`ERROR: Wallet key file not found: ${walletKeyPath}`);
        console.error('Cannot decrypt mnemonic without the .wallet.key file.');
        process.exit(1);
    }
    const keyHex = fs.readFileSync(walletKeyPath, 'utf-8').trim();
    if (keyHex.length !== 64) {
        console.error('ERROR: Wallet key file is corrupted (expected 64 hex chars).');
        process.exit(1);
    }
    const key = Buffer.from(keyHex, 'hex');
    return decrypt(payload, key);
}
// ---------------------------------------------------------------------------
// Shared: Handshake + keypair
// ---------------------------------------------------------------------------
async function performHandshake(baseUrl) {
    const hsRes = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/handshake`, {}, 30_000);
    if (!hsRes.ok) {
        const text = await hsRes.text();
        console.error(`Handshake failed (${hsRes.status}): ${text}`);
        process.exit(1);
    }
    const handshake = await hsRes.json();
    const { server_pubkey, nonce } = handshake;
    const agentKeypair = generateEphemeralKeypair();
    const sharedKey = deriveSharedKey(agentKeypair.privateKey, server_pubkey, nonce);
    return { agentKeypair, sharedKey, nonce };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
async function doDisconnect(baseUrl, sessionPath) {
    if (!fs.existsSync(sessionPath)) {
        console.log('No active session found.');
        return;
    }
    const sessionData = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
    const token = sessionData.session_token;
    if (!token) {
        console.log('Session file has no token. Removing stale file.');
        fs.unlinkSync(sessionPath);
        return;
    }
    console.log(`Disconnecting session for ${sessionData.spark_address || 'unknown'}...`);
    try {
        const res = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/disconnect`, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
        }, 30_000);
        const data = await res.json();
        if (data.ok) {
            console.log('Disconnected successfully.');
        }
        else {
            console.log(`Server said: ${data.error || 'unknown error'} (may already be expired)`);
        }
    }
    catch (err) {
        console.log(`Disconnect request failed: ${err.message} (server may be down)`);
    }
    // Always remove local session file
    fs.unlinkSync(sessionPath);
    console.log('Session file removed.');
}
// ---------------------------------------------------------------------------
// Provision (no wallet exists — server generates one)
// ---------------------------------------------------------------------------
async function doProvision(walletPath, baseUrl, sessionPath) {
    console.log('No wallet found. Requesting a new one from UTXO server...');
    console.log('');
    // --- Step 1–2: Handshake + keypair ---
    console.log(`  [1/4] Requesting handshake from ${baseUrl}...`);
    const { agentKeypair, sharedKey, nonce } = await performHandshake(baseUrl);
    console.log(`  [1/4] Handshake received. Nonce: ${nonce.slice(0, 16)}...`);
    console.log('  [2/4] Ephemeral keypair + shared key derived.');
    // --- Step 3: Request wallet provision ---
    console.log('  [3/4] Requesting new wallet from server...');
    const provisionRes = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/provision`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            agent_pubkey: agentKeypair.publicKey.toString('hex'),
            nonce,
        }),
    }, 30_000);
    if (!provisionRes.ok) {
        const errData = await provisionRes.json().catch(() => ({ error: 'Unknown' }));
        console.error(`Provision failed (${provisionRes.status}): ${errData.error || JSON.stringify(errData)}`);
        process.exit(1);
    }
    const data = await provisionRes.json();
    if (!data.ok || !data.encrypted_mnemonic || !data.encrypted_session_token) {
        console.error('Provision response missing required fields:', data);
        process.exit(1);
    }
    // --- Step 4: Decrypt mnemonic + session token ---
    console.log('  [4/4] Decrypting mnemonic + session token...');
    const mnemonic = decrypt(data.encrypted_mnemonic, sharedKey);
    const sessionToken = decrypt(data.encrypted_session_token, sharedKey);
    // Validate mnemonic looks real
    const wordCount = mnemonic.trim().split(/\s+/).length;
    if (wordCount !== 12 && wordCount !== 24) {
        console.error(`ERROR: Decrypted mnemonic has ${wordCount} words (expected 12 or 24). Something went wrong.`);
        process.exit(1);
    }
    // --- Save wallet file (mnemonic encrypted at rest) ---
    fs.mkdirSync(path.dirname(walletPath), { recursive: true });
    const walletKeyPath = getWalletKeyPath(walletPath);
    const encryptedMnemonicAtRest = encryptMnemonicAtRest(mnemonic, walletKeyPath);
    const walletFile = {
        agent: 'utxo-agent',
        network: data.network,
        encrypted_mnemonic: encryptedMnemonicAtRest,
        identityPublicKey: data.identity_public_key,
        sparkAddress: data.spark_address,
        createdAt: new Date().toISOString(),
        provisionedBy: 'utxo-server',
    };
    writeFileRestricted(walletPath, JSON.stringify(walletFile, null, 2));
    // --- Save session file ---
    const sessionFile = {
        session_token: sessionToken,
        spark_address: data.spark_address,
        network: data.network,
        connected_at: new Date().toISOString(),
        idle_timeout_minutes: data.session_info?.idle_timeout_minutes || 15,
        base_url: baseUrl,
    };
    writeFileRestricted(sessionPath, JSON.stringify(sessionFile, null, 2));
    console.log('');
    console.log('=== WALLET PROVISIONED ===');
    console.log(`  Address:  ${data.spark_address}`);
    console.log(`  Network:  ${data.network}`);
    console.log(`  Wallet:   ${walletPath}`);
    console.log(`  Key:      ${walletKeyPath}`);
    console.log(`  Session:  ${sessionPath}`);
    console.log(`  Timeout:  ${sessionFile.idle_timeout_minutes} min idle`);
    console.log('');
    console.log('IMPORTANT: Your mnemonic is encrypted in .wallet.json.');
    console.log('           The decryption key is in .wallet.key.');
    console.log('           Back up BOTH files. The server does NOT have a copy.');
    console.log('');
    console.log('Use this in API calls:');
    console.log(`  Authorization: Bearer ${sessionToken.slice(0, 16)}...`);
    console.log('');
}
// ---------------------------------------------------------------------------
// Connect (wallet exists — reconnect session)
// ---------------------------------------------------------------------------
async function doConnect(walletPath, baseUrl, sessionPath) {
    // --- Load mnemonic ---
    if (!fs.existsSync(walletPath)) {
        console.error(`ERROR: Wallet file not found: ${walletPath}`);
        process.exit(1);
    }
    const walletData = JSON.parse(fs.readFileSync(walletPath, 'utf-8'));
    // Decrypt mnemonic from at-rest encryption
    let mnemonic;
    if (walletData.encrypted_mnemonic && typeof walletData.encrypted_mnemonic === 'object') {
        const walletKeyPath = getWalletKeyPath(walletPath);
        mnemonic = decryptMnemonicAtRest(walletData.encrypted_mnemonic, walletKeyPath);
    }
    else if (walletData.mnemonic) {
        // Legacy plaintext wallet — still works but warn
        console.warn('WARNING: Wallet has plaintext mnemonic. Re-provision to upgrade to encrypted storage.');
        mnemonic = walletData.mnemonic;
    }
    else {
        console.error('ERROR: Wallet file has no mnemonic (encrypted or plaintext).');
        process.exit(1);
    }
    console.log('Wallet loaded. Starting encrypted handshake...');
    // --- Step 1–2: Handshake + keypair ---
    console.log(`  [1/4] Requesting handshake from ${baseUrl}...`);
    const { agentKeypair, sharedKey, nonce } = await performHandshake(baseUrl);
    console.log(`  [1/4] Handshake received. Nonce: ${nonce.slice(0, 16)}...`);
    console.log('  [2/4] Ephemeral keypair + shared key derived.');
    // --- Step 3: Encrypt mnemonic + send ---
    console.log('  [3/4] Encrypting mnemonic + sending connect request...');
    const encryptedMnemonic = encrypt(mnemonic, sharedKey);
    const connectRes = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/connect`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            agent_pubkey: agentKeypair.publicKey.toString('hex'),
            encrypted_mnemonic: encryptedMnemonic,
            nonce,
        }),
    }, 30_000);
    if (!connectRes.ok) {
        const errData = await connectRes.json().catch(() => ({ error: 'Unknown' }));
        console.error(`Connect failed (${connectRes.status}): ${errData.error || JSON.stringify(errData)}`);
        process.exit(1);
    }
    const connectData = await connectRes.json();
    if (!connectData.ok || !connectData.encrypted_session_token) {
        console.error('Connect response missing encrypted_session_token:', connectData);
        process.exit(1);
    }
    // --- Step 4: Decrypt session token ---
    console.log('  [4/4] Decrypting session token...');
    const sessionToken = decrypt(connectData.encrypted_session_token, sharedKey);
    // --- Save session ---
    const sessionFile = {
        session_token: sessionToken,
        spark_address: connectData.spark_address,
        network: connectData.network,
        connected_at: new Date().toISOString(),
        idle_timeout_minutes: connectData.session_info?.idle_timeout_minutes || 15,
        base_url: baseUrl,
    };
    writeFileRestricted(sessionPath, JSON.stringify(sessionFile, null, 2));
    console.log('');
    console.log('=== WALLET CONNECTED ===');
    console.log(`  Address:  ${connectData.spark_address}`);
    console.log(`  Network:  ${connectData.network}`);
    console.log(`  Session:  ${sessionPath}`);
    console.log(`  Timeout:  ${sessionFile.idle_timeout_minutes} min idle`);
    console.log('');
    console.log('Use this in API calls:');
    console.log(`  Authorization: Bearer ${sessionToken.slice(0, 16)}...`);
    console.log('');
}
// ---------------------------------------------------------------------------
// Main — auto-detects provision vs connect
// ---------------------------------------------------------------------------
async function main() {
    const { walletPath, baseUrl, disconnect, force, provision, sessionPath } = parseArgs();
    if (disconnect) {
        await doDisconnect(baseUrl, sessionPath);
        return;
    }
    const walletExists = fs.existsSync(walletPath);
    if (!walletExists) {
        if (!provision) {
            // Safety: refuse to auto-provision without explicit --provision flag
            console.error('ERROR: No wallet found at ' + walletPath);
            console.error('');
            console.error('  To CREATE a new wallet, run with --provision:');
            console.error('    node skills/utxo_wallet/scripts/wallet-connect.js --provision');
            console.error('');
            console.error('  If you already have a wallet, specify its path:');
            console.error('    node skills/utxo_wallet/scripts/wallet-connect.js --wallet /path/to/.wallet.json');
            console.error('');
            process.exit(1);
        }
        // Disconnect any stale session first
        if (fs.existsSync(sessionPath)) {
            console.log('Existing session found. Disconnecting first...');
            await doDisconnect(baseUrl, sessionPath);
            console.log('');
        }
        await doProvision(walletPath, baseUrl, sessionPath);
    }
    else if (force && provision) {
        // --force --provision: explicitly destroy and reprovision wallet
        console.log(`--force --provision: overwriting existing wallet at ${walletPath}`);
        if (fs.existsSync(sessionPath)) {
            console.log('Existing session found. Disconnecting first...');
            await doDisconnect(baseUrl, sessionPath);
            console.log('');
        }
        await doProvision(walletPath, baseUrl, sessionPath);
    }
    else if (force) {
        // --force (without --provision): force reconnect even if session looks valid
        console.log(`--force: forcing reconnect for wallet ${walletPath}`);
        if (fs.existsSync(sessionPath)) {
            console.log('Existing session found. Disconnecting first...');
            await doDisconnect(baseUrl, sessionPath);
            console.log('');
        }
        await doConnect(walletPath, baseUrl, sessionPath);
    }
    else {
        // Wallet exists → reconnect with it
        if (fs.existsSync(sessionPath)) {
            console.log('Existing session found. Disconnecting first...');
            await doDisconnect(baseUrl, sessionPath);
            console.log('');
        }
        await doConnect(walletPath, baseUrl, sessionPath);
    }
}
main().catch((err) => {
    console.error('FATAL:', err);
    process.exit(1);
});

```

### scripts/api-call.js

```javascript
"use strict";
/**
 * API Call Helper — cross-platform HTTP requests for UTXO agent
 *
 * Works around Windows PowerShell curl JSON escaping issues.
 * JSON body can be passed as an argument, via --body-file, or via stdin.
 *
 * Usage:
 *   node scripts/api-call.js GET /api/agent/wallet/balance
 *   node scripts/api-call.js POST /api/agent/token/launch --body-file body.json --auth
 *   node scripts/api-call.js POST /api/agent/swap --body-file buy-body.json --auth
 *
 * Options:
 *   --auth             Read session token from .session.json and send Authorization header
 *   --base-url <url>   Override base URL (default: http://localhost:3000)
 *   --body-file <path> Read JSON body from a file (avoids shell escaping issues)
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
// ---------------------------------------------------------------------------
// Base URL validation (mirrors wallet-connect.ts)
// ---------------------------------------------------------------------------
const ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'utxo.fun'];
function isAllowedBaseUrl(url) {
    if (process.env.UTXO_ALLOW_CUSTOM_BASE_URL === '1')
        return true;
    try {
        const parsed = new URL(url);
        const host = parsed.hostname;
        if (ALLOWED_HOSTS.includes(host))
            return true;
        if (host.endsWith('.utxo.fun'))
            return true;
        return false;
    }
    catch {
        return false;
    }
}
function findFile(name) {
    const candidates = [
        path.join(process.cwd(), name),
        path.join(process.cwd(), 'agent-workspace', name),
    ];
    return candidates.find((p) => fs.existsSync(p)) || null;
}
async function main() {
    const args = process.argv.slice(2);
    if (args.length < 2) {
        console.error('Usage: node api-call.js <METHOD> <PATH> [JSON_BODY] [--auth] [--base-url <url>]');
        process.exit(1);
    }
    const method = args[0].toUpperCase();
    const urlPath = args[1];
    let body = null;
    let useAuth = false;
    let baseUrl = process.env.UTXO_API_BASE_URL || 'http://localhost:3000';
    let bodyFile = null;
    for (let i = 2; i < args.length; i++) {
        if (args[i] === '--auth') {
            useAuth = true;
        }
        else if (args[i] === '--base-url' && args[i + 1]) {
            baseUrl = args[++i];
        }
        else if (args[i] === '--body-file' && args[i + 1]) {
            bodyFile = args[++i];
        }
        else if (!args[i].startsWith('--')) {
            body = args[i];
            console.error('WARNING: Passing JSON as a CLI argument is discouraged. Use --body-file instead.');
        }
    }
    // Validate base URL to prevent sending credentials to malicious servers
    if (!isAllowedBaseUrl(baseUrl)) {
        console.error(`ERROR: Base URL not allowed: ${baseUrl}`);
        console.error('Allowed: localhost, 127.0.0.1, utxo.fun, *.utxo.fun');
        console.error('Set UTXO_ALLOW_CUSTOM_BASE_URL=1 to override.');
        process.exit(1);
    }
    // Read body from file if specified (avoids shell escaping issues)
    if (bodyFile) {
        const bodyPath = path.resolve(bodyFile);
        // Prevent path traversal — body file must be under CWD
        const cwd = process.cwd();
        if (!bodyPath.startsWith(cwd + path.sep) && bodyPath !== cwd) {
            console.error(`ERROR: Body file must be under current working directory.`);
            console.error(`  CWD:  ${cwd}`);
            console.error(`  File: ${bodyPath}`);
            process.exit(1);
        }
        if (!fs.existsSync(bodyPath)) {
            console.error(`ERROR: Body file not found: ${bodyPath}`);
            process.exit(1);
        }
        body = fs.readFileSync(bodyPath, 'utf-8').trim();
    }
    // Build headers
    const headers = {};
    if (body) {
        headers['Content-Type'] = 'application/json';
    }
    if (useAuth) {
        const sessionPath = findFile('.session.json');
        if (!sessionPath) {
            console.error('ERROR: .session.json not found. Run wallet-connect.js first.');
            process.exit(1);
        }
        const session = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
        if (!session.session_token) {
            console.error('ERROR: .session.json has no session_token.');
            process.exit(1);
        }
        const token = String(session.session_token).trim();
        if (!token || /[\r\n]/.test(token)) {
            console.error('ERROR: .session.json contains an invalid session_token.');
            process.exit(1);
        }
        headers['Authorization'] = `Bearer ${token}`;
    }
    // Make the request (with 30s timeout)
    const url = `${baseUrl}${urlPath}`;
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 30_000);
    const fetchOpts = { method, headers, signal: controller.signal };
    if (body && method !== 'GET') {
        fetchOpts.body = body;
    }
    try {
        const res = await fetch(url, fetchOpts);
        clearTimeout(timeout);
        const text = await res.text();
        // Try to pretty-print JSON
        try {
            const json = JSON.parse(text);
            console.log(JSON.stringify(json, null, 2));
        }
        catch {
            console.log(text);
        }
        // Print status for non-200 responses
        if (!res.ok) {
            console.error(`\nHTTP ${res.status}`);
            process.exit(1);
        }
    }
    catch (err) {
        if (err.name === 'AbortError') {
            console.error(`Request timed out after 30s: ${url}`);
        }
        else {
            console.error(`Request failed: ${err.message}`);
        }
        process.exit(1);
    }
}
main();

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "davidyashar",
  "slug": "utxo-wallet",
  "displayName": "UTXO Wallet",
  "latest": {
    "version": "1.3.3",
    "publishedAt": 1773037548002,
    "commit": "https://github.com/openclaw/skills/commit/c409d03d10fc8ddd54f1a65996bbb003bd9ef87d"
  },
  "history": [
    {
      "version": "1.3.2",
      "publishedAt": 1772868801382,
      "commit": "https://github.com/openclaw/skills/commit/6c370478107cf3ca53d93f0b15c31dc549a310ae"
    },
    {
      "version": "1.2.0",
      "publishedAt": 1772866583878,
      "commit": "https://github.com/openclaw/skills/commit/4e11f10df1477f9409f2da0209a3066ebba0c80d"
    }
  ]
}

```

### scripts/api-call.ts

```typescript
/**
 * API Call Helper — cross-platform HTTP requests for UTXO agent
 *
 * Works around Windows PowerShell curl JSON escaping issues.
 * JSON body can be passed as an argument, via --body-file, or via stdin.
 *
 * Usage:
 *   node scripts/api-call.js GET /api/agent/wallet/balance
 *   node scripts/api-call.js POST /api/agent/token/launch --body-file body.json --auth
 *   node scripts/api-call.js POST /api/agent/swap --body-file buy-body.json --auth
 *
 * Options:
 *   --auth             Read session token from .session.json and send Authorization header
 *   --base-url <url>   Override base URL (default: http://localhost:3000)
 *   --body-file <path> Read JSON body from a file (avoids shell escaping issues)
 */

import * as fs from 'fs';
import * as path from 'path';

// ---------------------------------------------------------------------------
// Base URL validation (mirrors wallet-connect.ts)
// ---------------------------------------------------------------------------

const ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'utxo.fun'];

function isAllowedBaseUrl(url: string): boolean {
  if (process.env.UTXO_ALLOW_CUSTOM_BASE_URL === '1') return true;
  try {
    const parsed = new URL(url);
    const host = parsed.hostname;
    if (ALLOWED_HOSTS.includes(host)) return true;
    if (host.endsWith('.utxo.fun')) return true;
    return false;
  } catch {
    return false;
  }
}

function findFile(name: string): string | null {
  const candidates = [
    path.join(process.cwd(), name),
    path.join(process.cwd(), 'agent-workspace', name),
  ];
  return candidates.find((p) => fs.existsSync(p)) || null;
}

async function main() {
  const args = process.argv.slice(2);

  if (args.length < 2) {
    console.error('Usage: node api-call.js <METHOD> <PATH> [JSON_BODY] [--auth] [--base-url <url>]');
    process.exit(1);
  }

  const method = args[0].toUpperCase();
  const urlPath = args[1];

  let body: string | null = null;
  let useAuth = false;
  let baseUrl = process.env.UTXO_API_BASE_URL || 'http://localhost:3000';
  let bodyFile: string | null = null;

  for (let i = 2; i < args.length; i++) {
    if (args[i] === '--auth') {
      useAuth = true;
    } else if (args[i] === '--base-url' && args[i + 1]) {
      baseUrl = args[++i];
    } else if (args[i] === '--body-file' && args[i + 1]) {
      bodyFile = args[++i];
    } else if (!args[i].startsWith('--')) {
      body = args[i];
      console.error('WARNING: Passing JSON as a CLI argument is discouraged. Use --body-file instead.');
    }
  }

  // Validate base URL to prevent sending credentials to malicious servers
  if (!isAllowedBaseUrl(baseUrl)) {
    console.error(`ERROR: Base URL not allowed: ${baseUrl}`);
    console.error('Allowed: localhost, 127.0.0.1, utxo.fun, *.utxo.fun');
    console.error('Set UTXO_ALLOW_CUSTOM_BASE_URL=1 to override.');
    process.exit(1);
  }

  // Read body from file if specified (avoids shell escaping issues)
  if (bodyFile) {
    const bodyPath = path.resolve(bodyFile);
    // Prevent path traversal — body file must be under CWD
    const cwd = process.cwd();
    if (!bodyPath.startsWith(cwd + path.sep) && bodyPath !== cwd) {
      console.error(`ERROR: Body file must be under current working directory.`);
      console.error(`  CWD:  ${cwd}`);
      console.error(`  File: ${bodyPath}`);
      process.exit(1);
    }
    if (!fs.existsSync(bodyPath)) {
      console.error(`ERROR: Body file not found: ${bodyPath}`);
      process.exit(1);
    }
    body = fs.readFileSync(bodyPath, 'utf-8').trim();
  }

  // Build headers
  const headers: Record<string, string> = {};
  if (body) {
    headers['Content-Type'] = 'application/json';
  }

  if (useAuth) {
    const sessionPath = findFile('.session.json');
    if (!sessionPath) {
      console.error('ERROR: .session.json not found. Run wallet-connect.js first.');
      process.exit(1);
    }
    const session = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
    if (!session.session_token) {
      console.error('ERROR: .session.json has no session_token.');
      process.exit(1);
    }
    const token = String(session.session_token).trim();
    if (!token || /[\r\n]/.test(token)) {
      console.error('ERROR: .session.json contains an invalid session_token.');
      process.exit(1);
    }
    headers['Authorization'] = `Bearer ${token}`;
  }

  // Make the request (with 30s timeout)
  const url = `${baseUrl}${urlPath}`;
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30_000);
  const fetchOpts: RequestInit = { method, headers, signal: controller.signal };
  if (body && method !== 'GET') {
    fetchOpts.body = body;
  }

  try {
    const res = await fetch(url, fetchOpts);
    clearTimeout(timeout);
    const text = await res.text();

    // Try to pretty-print JSON
    try {
      const json = JSON.parse(text);
      console.log(JSON.stringify(json, null, 2));
    } catch {
      console.log(text);
    }

    // Print status for non-200 responses
    if (!res.ok) {
      console.error(`\nHTTP ${res.status}`);
      process.exit(1);
    }
  } catch (err: any) {
    if (err.name === 'AbortError') {
      console.error(`Request timed out after 30s: ${url}`);
    } else {
      console.error(`Request failed: ${err.message}`);
    }
    process.exit(1);
  }
}

main();

```

### scripts/wallet-connect.ts

```typescript
/**
 * Wallet Connect — Unified wallet management for UTXO agents
 *
 * Single entry point for all wallet operations:
 *
 * - **Auto-detect**: No wallet? → provisions one. Has wallet? → connects it.
 * - **Provision**: Server generates a BIP39 mnemonic, encrypts via ECDH, agent saves locally.
 * - **Connect**: Agent encrypts existing mnemonic, sends to server, receives session.
 * - **Disconnect**: Destroys server session + removes local session file.
 *
 * Run from repo root:
 *   node agent-workspace/skills/utxo_wallet/scripts/wallet-connect.js
 *
 * Options:
 *   --wallet <path>   Path to .wallet.json (default: auto-detected)
 *   --base-url <url>  UTXO API base URL (default: http://localhost:3000)
 *   --disconnect      Disconnect the current session and exit
 *   --force           Force reconnect even if session appears valid
 *   --provision        Create a new wallet (required for first-time setup)
 *   --force --provision  Destroy existing wallet and create a new one
 *
 * Zero external dependencies beyond Node.js built-in `crypto`.
 */

import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

const HKDF_INFO = 'utxo-wallet-connect-v1';
const AES_KEY_LENGTH = 32;
const GCM_IV_LENGTH = 12;

/** Fetch with timeout — prevents hanging forever if server is unresponsive */
async function fetchWithTimeout(
  url: string,
  opts: RequestInit = {},
  timeoutMs: number = 30_000,
): Promise<Response> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...opts, signal: controller.signal });
  } catch (err: any) {
    if (err.name === 'AbortError') {
      throw new Error(`Request timed out after ${timeoutMs / 1000}s: ${url}`);
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

function parseArgs(): {
  walletPath: string;
  baseUrl: string;
  disconnect: boolean;
  force: boolean;
  provision: boolean;
  sessionPath: string;
} {
  const args = process.argv.slice(2);
  // Try multiple default locations for wallet file
  const candidates = [
    path.join(process.cwd(), '.wallet.json'),                        // CWD is agent-workspace/
    path.join(process.cwd(), 'agent-workspace', '.wallet.json'),     // CWD is project root
  ];
  let walletPath = candidates.find(p => fs.existsSync(p)) || candidates[0];
  let baseUrl = process.env.UTXO_API_BASE_URL || 'http://localhost:3000';
  let disconnect = false;
  let force = false;
  let provision = false;

  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--wallet' && args[i + 1]) {
      walletPath = path.resolve(args[++i]);
    } else if (args[i] === '--base-url' && args[i + 1]) {
      baseUrl = args[++i];
    } else if (args[i] === '--disconnect') {
      disconnect = true;
    } else if (args[i] === '--force') {
      force = true;
    } else if (args[i] === '--provision') {
      provision = true;
    }
  }

  // Validate base URL to prevent connecting to malicious servers
  if (!isAllowedBaseUrl(baseUrl)) {
    console.error(`ERROR: Base URL not allowed: ${baseUrl}`);
    console.error('Allowed: localhost, 127.0.0.1, utxo.fun, *.utxo.fun');
    console.error('Set UTXO_ALLOW_CUSTOM_BASE_URL=1 to override.');
    process.exit(1);
  }

  // Session file lives next to wallet file
  const sessionPath = path.join(path.dirname(walletPath), '.session.json');

  return { walletPath, baseUrl, disconnect, force, provision, sessionPath };
}

// ---------------------------------------------------------------------------
// Base URL validation
// ---------------------------------------------------------------------------

const ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'utxo.fun'];

function isAllowedBaseUrl(url: string): boolean {
  if (process.env.UTXO_ALLOW_CUSTOM_BASE_URL === '1') return true;
  try {
    const parsed = new URL(url);
    const host = parsed.hostname;
    if (ALLOWED_HOSTS.includes(host)) return true;
    if (host.endsWith('.utxo.fun')) return true;
    return false;
  } catch {
    return false;
  }
}

// ---------------------------------------------------------------------------
// Crypto helpers (mirrors server-side agentCrypto.ts)
// ---------------------------------------------------------------------------

interface EncryptedPayload {
  ciphertext: string;
  iv: string;
  tag: string;
}

function generateEphemeralKeypair() {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
  const rawPub = publicKey.export({ type: 'spki', format: 'der' });
  const pubBytes = rawPub.subarray(rawPub.length - 32);
  return { publicKey: pubBytes, privateKey };
}

function deriveSharedKey(
  ourPrivateKey: crypto.KeyObject,
  theirRawPublicKeyHex: string,
  nonce: string,
): Buffer {
  // Wrap raw 32-byte public key into X25519 SPKI DER
  const spkiHeader = Buffer.from('302a300506032b656e032100', 'hex');
  const theirRawBuf = Buffer.from(theirRawPublicKeyHex, 'hex');
  const spkiDer = Buffer.concat([spkiHeader, theirRawBuf]);
  const theirKeyObj = crypto.createPublicKey({
    key: spkiDer,
    type: 'spki',
    format: 'der',
  });

  const sharedSecret = crypto.diffieHellman({
    privateKey: ourPrivateKey,
    publicKey: theirKeyObj,
  });

  const aesKey = crypto.hkdfSync(
    'sha256',
    sharedSecret,
    Buffer.from(nonce, 'hex'),
    HKDF_INFO,
    AES_KEY_LENGTH,
  );

  return Buffer.from(aesKey);
}

function encrypt(plaintext: string, key: Buffer): EncryptedPayload {
  const iv = crypto.randomBytes(GCM_IV_LENGTH);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const encrypted = Buffer.concat([
    cipher.update(plaintext, 'utf8'),
    cipher.final(),
  ]);
  return {
    ciphertext: encrypted.toString('hex'),
    iv: iv.toString('hex'),
    tag: cipher.getAuthTag().toString('hex'),
  };
}

function decrypt(payload: EncryptedPayload, key: Buffer): string {
  const decipher = crypto.createDecipheriv(
    'aes-256-gcm',
    key,
    Buffer.from(payload.iv, 'hex'),
  );
  decipher.setAuthTag(Buffer.from(payload.tag, 'hex'));
  const decrypted = Buffer.concat([
    decipher.update(Buffer.from(payload.ciphertext, 'hex')),
    decipher.final(),
  ]);
  return decrypted.toString('utf8');
}

// ---------------------------------------------------------------------------
// At-rest encryption for wallet mnemonic
// ---------------------------------------------------------------------------

const WALLET_KEY_FILENAME = '.wallet.key';

function getWalletKeyPath(walletPath: string): string {
  return path.join(path.dirname(walletPath), WALLET_KEY_FILENAME);
}

function writeFileRestricted(filePath: string, data: string): void {
  fs.writeFileSync(filePath, data, { mode: 0o600 });
  // On platforms that support chmod, enforce owner-only access
  try { fs.chmodSync(filePath, 0o600); } catch { /* Windows — ok */ }
}

function encryptMnemonicAtRest(mnemonic: string, walletKeyPath: string): EncryptedPayload {
  const key = crypto.randomBytes(32);
  writeFileRestricted(walletKeyPath, key.toString('hex'));
  return encrypt(mnemonic, key);
}

function decryptMnemonicAtRest(payload: EncryptedPayload, walletKeyPath: string): string {
  if (!fs.existsSync(walletKeyPath)) {
    console.error(`ERROR: Wallet key file not found: ${walletKeyPath}`);
    console.error('Cannot decrypt mnemonic without the .wallet.key file.');
    process.exit(1);
  }
  const keyHex = fs.readFileSync(walletKeyPath, 'utf-8').trim();
  if (keyHex.length !== 64) {
    console.error('ERROR: Wallet key file is corrupted (expected 64 hex chars).');
    process.exit(1);
  }
  const key = Buffer.from(keyHex, 'hex');
  return decrypt(payload, key);
}

// ---------------------------------------------------------------------------
// Shared: Handshake + keypair
// ---------------------------------------------------------------------------

async function performHandshake(baseUrl: string) {
  const hsRes = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/handshake`, {}, 30_000);
  if (!hsRes.ok) {
    const text = await hsRes.text();
    console.error(`Handshake failed (${hsRes.status}): ${text}`);
    process.exit(1);
  }
  const handshake = await hsRes.json();
  const { server_pubkey, nonce } = handshake;

  const agentKeypair = generateEphemeralKeypair();
  const sharedKey = deriveSharedKey(agentKeypair.privateKey, server_pubkey, nonce);

  return { agentKeypair, sharedKey, nonce };
}

// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------

async function doDisconnect(baseUrl: string, sessionPath: string): Promise<void> {
  if (!fs.existsSync(sessionPath)) {
    console.log('No active session found.');
    return;
  }

  const sessionData = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
  const token = sessionData.session_token;

  if (!token) {
    console.log('Session file has no token. Removing stale file.');
    fs.unlinkSync(sessionPath);
    return;
  }

  console.log(`Disconnecting session for ${sessionData.spark_address || 'unknown'}...`);

  try {
    const res = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/disconnect`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
    }, 30_000);
    const data = await res.json();
    if (data.ok) {
      console.log('Disconnected successfully.');
    } else {
      console.log(`Server said: ${data.error || 'unknown error'} (may already be expired)`);
    }
  } catch (err: any) {
    console.log(`Disconnect request failed: ${err.message} (server may be down)`);
  }

  // Always remove local session file
  fs.unlinkSync(sessionPath);
  console.log('Session file removed.');
}

// ---------------------------------------------------------------------------
// Provision (no wallet exists — server generates one)
// ---------------------------------------------------------------------------

async function doProvision(
  walletPath: string,
  baseUrl: string,
  sessionPath: string,
): Promise<void> {
  console.log('No wallet found. Requesting a new one from UTXO server...');
  console.log('');

  // --- Step 1–2: Handshake + keypair ---
  console.log(`  [1/4] Requesting handshake from ${baseUrl}...`);
  const { agentKeypair, sharedKey, nonce } = await performHandshake(baseUrl);
  console.log(`  [1/4] Handshake received. Nonce: ${nonce.slice(0, 16)}...`);
  console.log('  [2/4] Ephemeral keypair + shared key derived.');

  // --- Step 3: Request wallet provision ---
  console.log('  [3/4] Requesting new wallet from server...');
  const provisionRes = await fetchWithTimeout(
    `${baseUrl}/api/agent/wallet/provision`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        agent_pubkey: agentKeypair.publicKey.toString('hex'),
        nonce,
      }),
    },
    30_000,
  );

  if (!provisionRes.ok) {
    const errData = await provisionRes.json().catch(() => ({ error: 'Unknown' }));
    console.error(
      `Provision failed (${provisionRes.status}): ${errData.error || JSON.stringify(errData)}`,
    );
    process.exit(1);
  }

  const data = await provisionRes.json();

  if (!data.ok || !data.encrypted_mnemonic || !data.encrypted_session_token) {
    console.error('Provision response missing required fields:', data);
    process.exit(1);
  }

  // --- Step 4: Decrypt mnemonic + session token ---
  console.log('  [4/4] Decrypting mnemonic + session token...');
  const mnemonic = decrypt(data.encrypted_mnemonic, sharedKey);
  const sessionToken = decrypt(data.encrypted_session_token, sharedKey);

  // Validate mnemonic looks real
  const wordCount = mnemonic.trim().split(/\s+/).length;
  if (wordCount !== 12 && wordCount !== 24) {
    console.error(`ERROR: Decrypted mnemonic has ${wordCount} words (expected 12 or 24). Something went wrong.`);
    process.exit(1);
  }

  // --- Save wallet file (mnemonic encrypted at rest) ---
  fs.mkdirSync(path.dirname(walletPath), { recursive: true });
  const walletKeyPath = getWalletKeyPath(walletPath);
  const encryptedMnemonicAtRest = encryptMnemonicAtRest(mnemonic, walletKeyPath);

  const walletFile = {
    agent: 'utxo-agent',
    network: data.network,
    encrypted_mnemonic: encryptedMnemonicAtRest,
    identityPublicKey: data.identity_public_key,
    sparkAddress: data.spark_address,
    createdAt: new Date().toISOString(),
    provisionedBy: 'utxo-server',
  };

  writeFileRestricted(walletPath, JSON.stringify(walletFile, null, 2));

  // --- Save session file ---
  const sessionFile = {
    session_token: sessionToken,
    spark_address: data.spark_address,
    network: data.network,
    connected_at: new Date().toISOString(),
    idle_timeout_minutes: data.session_info?.idle_timeout_minutes || 15,
    base_url: baseUrl,
  };

  writeFileRestricted(sessionPath, JSON.stringify(sessionFile, null, 2));

  console.log('');
  console.log('=== WALLET PROVISIONED ===');
  console.log(`  Address:  ${data.spark_address}`);
  console.log(`  Network:  ${data.network}`);
  console.log(`  Wallet:   ${walletPath}`);
  console.log(`  Key:      ${walletKeyPath}`);
  console.log(`  Session:  ${sessionPath}`);
  console.log(`  Timeout:  ${sessionFile.idle_timeout_minutes} min idle`);
  console.log('');
  console.log('IMPORTANT: Your mnemonic is encrypted in .wallet.json.');
  console.log('           The decryption key is in .wallet.key.');
  console.log('           Back up BOTH files. The server does NOT have a copy.');
  console.log('');
  console.log('Use this in API calls:');
  console.log(`  Authorization: Bearer ${sessionToken.slice(0, 16)}...`);
  console.log('');
}

// ---------------------------------------------------------------------------
// Connect (wallet exists — reconnect session)
// ---------------------------------------------------------------------------

async function doConnect(
  walletPath: string,
  baseUrl: string,
  sessionPath: string,
): Promise<void> {
  // --- Load mnemonic ---
  if (!fs.existsSync(walletPath)) {
    console.error(`ERROR: Wallet file not found: ${walletPath}`);
    process.exit(1);
  }

  const walletData = JSON.parse(fs.readFileSync(walletPath, 'utf-8'));

  // Decrypt mnemonic from at-rest encryption
  let mnemonic: string;
  if (walletData.encrypted_mnemonic && typeof walletData.encrypted_mnemonic === 'object') {
    const walletKeyPath = getWalletKeyPath(walletPath);
    mnemonic = decryptMnemonicAtRest(walletData.encrypted_mnemonic, walletKeyPath);
  } else if (walletData.mnemonic) {
    // Legacy plaintext wallet — still works but warn
    console.warn('WARNING: Wallet has plaintext mnemonic. Re-provision to upgrade to encrypted storage.');
    mnemonic = walletData.mnemonic;
  } else {
    console.error('ERROR: Wallet file has no mnemonic (encrypted or plaintext).');
    process.exit(1);
  }

  console.log('Wallet loaded. Starting encrypted handshake...');

  // --- Step 1–2: Handshake + keypair ---
  console.log(`  [1/4] Requesting handshake from ${baseUrl}...`);
  const { agentKeypair, sharedKey, nonce } = await performHandshake(baseUrl);
  console.log(`  [1/4] Handshake received. Nonce: ${nonce.slice(0, 16)}...`);
  console.log('  [2/4] Ephemeral keypair + shared key derived.');

  // --- Step 3: Encrypt mnemonic + send ---
  console.log('  [3/4] Encrypting mnemonic + sending connect request...');
  const encryptedMnemonic = encrypt(mnemonic, sharedKey);

  const connectRes = await fetchWithTimeout(`${baseUrl}/api/agent/wallet/connect`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      agent_pubkey: agentKeypair.publicKey.toString('hex'),
      encrypted_mnemonic: encryptedMnemonic,
      nonce,
    }),
  }, 30_000);

  if (!connectRes.ok) {
    const errData = await connectRes.json().catch(() => ({ error: 'Unknown' }));
    console.error(`Connect failed (${connectRes.status}): ${errData.error || JSON.stringify(errData)}`);
    process.exit(1);
  }

  const connectData = await connectRes.json();

  if (!connectData.ok || !connectData.encrypted_session_token) {
    console.error('Connect response missing encrypted_session_token:', connectData);
    process.exit(1);
  }

  // --- Step 4: Decrypt session token ---
  console.log('  [4/4] Decrypting session token...');
  const sessionToken = decrypt(connectData.encrypted_session_token, sharedKey);

  // --- Save session ---
  const sessionFile = {
    session_token: sessionToken,
    spark_address: connectData.spark_address,
    network: connectData.network,
    connected_at: new Date().toISOString(),
    idle_timeout_minutes: connectData.session_info?.idle_timeout_minutes || 15,
    base_url: baseUrl,
  };

  writeFileRestricted(sessionPath, JSON.stringify(sessionFile, null, 2));
  console.log('');
  console.log('=== WALLET CONNECTED ===');
  console.log(`  Address:  ${connectData.spark_address}`);
  console.log(`  Network:  ${connectData.network}`);
  console.log(`  Session:  ${sessionPath}`);
  console.log(`  Timeout:  ${sessionFile.idle_timeout_minutes} min idle`);
  console.log('');
  console.log('Use this in API calls:');
  console.log(`  Authorization: Bearer ${sessionToken.slice(0, 16)}...`);
  console.log('');
}

// ---------------------------------------------------------------------------
// Main — auto-detects provision vs connect
// ---------------------------------------------------------------------------

async function main() {
  const { walletPath, baseUrl, disconnect, force, provision, sessionPath } = parseArgs();

  if (disconnect) {
    await doDisconnect(baseUrl, sessionPath);
    return;
  }

  const walletExists = fs.existsSync(walletPath);

  if (!walletExists) {
    if (!provision) {
      // Safety: refuse to auto-provision without explicit --provision flag
      console.error('ERROR: No wallet found at ' + walletPath);
      console.error('');
      console.error('  To CREATE a new wallet, run with --provision:');
      console.error('    node skills/utxo_wallet/scripts/wallet-connect.js --provision');
      console.error('');
      console.error('  If you already have a wallet, specify its path:');
      console.error('    node skills/utxo_wallet/scripts/wallet-connect.js --wallet /path/to/.wallet.json');
      console.error('');
      process.exit(1);
    }
    // Disconnect any stale session first
    if (fs.existsSync(sessionPath)) {
      console.log('Existing session found. Disconnecting first...');
      await doDisconnect(baseUrl, sessionPath);
      console.log('');
    }
    await doProvision(walletPath, baseUrl, sessionPath);
  } else if (force && provision) {
    // --force --provision: explicitly destroy and reprovision wallet
    console.log(`--force --provision: overwriting existing wallet at ${walletPath}`);
    if (fs.existsSync(sessionPath)) {
      console.log('Existing session found. Disconnecting first...');
      await doDisconnect(baseUrl, sessionPath);
      console.log('');
    }
    await doProvision(walletPath, baseUrl, sessionPath);
  } else if (force) {
    // --force (without --provision): force reconnect even if session looks valid
    console.log(`--force: forcing reconnect for wallet ${walletPath}`);
    if (fs.existsSync(sessionPath)) {
      console.log('Existing session found. Disconnecting first...');
      await doDisconnect(baseUrl, sessionPath);
      console.log('');
    }
    await doConnect(walletPath, baseUrl, sessionPath);
  } else {
    // Wallet exists → reconnect with it
    if (fs.existsSync(sessionPath)) {
      console.log('Existing session found. Disconnecting first...');
      await doDisconnect(baseUrl, sessionPath);
      console.log('');
    }
    await doConnect(walletPath, baseUrl, sessionPath);
  }
}

main().catch((err) => {
  console.error('FATAL:', err);
  process.exit(1);
});

```