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.
Install command
npx @skill-hub/cli install openclaw-skills-utxo-wallet
Repository
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 repositoryBest 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
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);
});
```