diff --git a/README.md b/README.md index 3088db0..6e02276 100644 --- a/README.md +++ b/README.md @@ -146,34 +146,34 @@ paytaca pay https://api.example.com/v1/complete --method POST --body '{"prompt": # → Handles 402 → pays → returns response ``` -### AI Agent Integration +### AI Agent Integration (MCP + Paytaca AI) -Paytaca CLI provides AI agent skills for wallet operations. Install from GitHub (supports 45+ AI frameworks): +Configure an AI harness in one step. This installs the Paytaca MCP server and, for opencode, also the Paytaca AI provider (model catalogue + API key) so the Paytaca AI models are usable immediately: ```bash -# Install specific skill -npx skills add paytaca/paytaca-cli --skill -a -g +paytaca ai configure opencode # claude | opencode | cursor | codex | pi | generic +``` + +The command creates a wallet-bound API key against the Paytaca AI backend, writes the provider (base URL, models, key) and MCP server into the harness config, then checks your AI credits and offers to buy a plan when none are active. -# Install all skills -npx skills add paytaca/paytaca-cli --all +Re-running is idempotent: an existing provider API key is reused. Then run the server over stdio: -# Examples -npx skills add paytaca/paytaca-cli --skill paytaca-x402 -a opencode -g -npx skills add paytaca/paytaca-cli --skill paytaca-wallet -a claude-code -g -npx skills add paytaca/paytaca-cli --skill paytaca-send -a openclaw -g +```bash +paytaca mcp ``` -**Available Skills:** +**Read-only wallets:** if the active wallet cannot sign (no seed phrase in the keychain), `ai configure` asks for an API key instead of creating one. Generate it from your full wallet and hand it over: + +```bash +paytaca ai api-key create # full wallet: prints sk-pytc-… once +paytaca ai configure opencode --api-key sk-pytc-… # read-only wallet +``` -| Skill | Purpose | Approval Needed | -|-------|---------|------------------| -| `paytaca-x402` | HTTP 402 payment handling | Yes (before payment) | -| `paytaca-wallet` | Balance, addresses, history, token info | No (read-only) | -| `paytaca-send` | Send BCH and CashTokens | Yes (before sending) | +Read-only wallets report credits from the shared wallet hash but can't buy plans — fund credits from the full wallet. -**Supported frameworks:** OpenCode, Claude Code, OpenClaw, Cursor, Cline, Windsurf, Roo, Gemini CLI, Codex, GitHub Copilot, Goose, Trae, Kiro CLI, and 30+ more. +**MCP tools:** wallet reads (`get_balance`, `get_transactions`, `get_receiving_address`, `get_tokens`, `send`) plus Paytaca AI (`get_models`, `get_plans`, `get_credits`, `buy_plan`, `auto_refill`, `get_help`). -See [skills/README.md](skills/README.md) for detailed skill documentation. +Spending tools (`send`, `buy_plan`, `auto_refill`) require host-level approval. By default MCP operates on your **main wallet** and can spend real funds. ## Network @@ -224,6 +224,10 @@ src/ x402.ts x402 header parsing, payment requirement selection types/ x402.ts x402 payment types (PaymentRequired, PaymentPayload, etc.) + ai/ + client.ts Paytaca AI backend client (config, wallet status, chat) + oauth.ts BCH OAuth challenge -> access token -> API key + configure.ts Harness setup (MCP + Paytaca AI provider + credits offer) ``` ## Key Dependencies diff --git a/package-lock.json b/package-lock.json index 6834795..cbd84bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "paytaca-cli", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paytaca-cli", - "version": "0.6.1", + "version": "0.7.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", "@cashlab/cauldron": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.2.0", "bip39": "^3.1.0", "chalk": "^5.4.1", @@ -18,7 +19,8 @@ "js-sha256": "^0.9.0", "nostr-tools": "^2.23.3", "qrcode-terminal": "^0.12.0", - "watchtower-cash-js": "^0.3.2" + "watchtower-cash-js": "^0.3.2", + "zod": "^4.6.5" }, "bin": { "paytaca": "bin/paytaca.js" @@ -107,6 +109,18 @@ "tslib": "^2.4.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -114,6 +128,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@napi-rs/keyring": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", @@ -937,6 +991,52 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -965,6 +1065,81 @@ "@noble/hashes": "^1.2.0" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -996,6 +1171,28 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1003,6 +1200,81 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1013,6 +1285,53 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1020,6 +1339,24 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1030,6 +1367,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1040,6 +1407,90 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1065,6 +1516,27 @@ "dev": true, "license": "MIT" }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -1092,6 +1564,24 @@ } } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1107,27 +1597,211 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", - "license": "MIT" + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.1.tgz", + "integrity": "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", @@ -1384,6 +2058,65 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -1394,6 +2127,12 @@ "node": ">=10" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1413,6 +2152,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/nostr-tools": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.7.tgz", @@ -1454,6 +2222,27 @@ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1465,6 +2254,55 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1492,6 +2330,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -1521,6 +2368,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/qrcode-terminal": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", @@ -1529,6 +2393,59 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -1563,6 +2480,172 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1602,6 +2685,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -1653,6 +2745,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -1671,6 +2772,37 @@ "license": "0BSD", "optional": true }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1692,6 +2824,24 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", @@ -1873,6 +3023,21 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1889,6 +3054,30 @@ "engines": { "node": ">=8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 4c11ab8..f0fa0af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paytaca-cli", - "version": "0.6.2", + "version": "0.7.0", "description": "Command-line interface for the Paytaca Bitcoin Cash wallet", "type": "module", "main": "dist/index.js", @@ -11,7 +11,6 @@ "files": [ "bin/", "dist/", - "skills/", "LICENSE", "README.md" ], @@ -42,6 +41,7 @@ "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", "@cashlab/cauldron": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.2.0", "bip39": "^3.1.0", "chalk": "^5.4.1", @@ -49,7 +49,8 @@ "js-sha256": "^0.9.0", "nostr-tools": "^2.23.3", "qrcode-terminal": "^0.12.0", - "watchtower-cash-js": "^0.3.2" + "watchtower-cash-js": "^0.3.2", + "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/skills/README.md b/skills/README.md deleted file mode 100644 index 4df6681..0000000 --- a/skills/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Paytaca CLI Skills - -This directory contains AI agent skills for the Paytaca CLI. - -## Available Skills - -### paytaca-x402 - -Handles x402 HTTP payment requests using Paytaca BCH wallet. Use when encountering HTTP 402 responses or when calling APIs that may require payment via the x402 protocol. - -```bash -npx skills add paytaca/paytaca-cli --skill paytaca-x402 -a -g -``` - -### paytaca-wallet - -Query wallet information including BCH balance, CashToken holdings, addresses, and transaction history. Use for read-only wallet operations. - -```bash -npx skills add paytaca/paytaca-cli --skill paytaca-wallet -a -g -``` - -### paytaca-send - -Send BCH and CashTokens from the wallet. **Requires user approval** before any sending operation. - -```bash -npx skills add paytaca/paytaca-cli --skill paytaca-send -a -g -``` - -## Installation - -Install specific skill: -```bash -npx skills add paytaca/paytaca-cli --skill -a -g -``` - -Install all skills: -```bash -npx skills add paytaca/paytaca-cli --all -``` - -List available skills: -```bash -npx skills add paytaca/paytaca-cli --list -``` - -## Skill Overview - -| Skill | Purpose | Approval Needed | -|-------|---------|-----------------| -| paytaca-x402 | HTTP 402 payment handling | Yes (before payment) | -| paytaca-wallet | Balance, addresses, history, token info | No (read-only) | -| paytaca-send | Send BCH and tokens | Yes (before sending) | - -## Adding New Skills - -To add a new skill: - -1. Create a new directory: `skills//` -2. Add a `SKILL.md` file with YAML frontmatter: - ```yaml - --- - name: - description: What this skill does and when to use it - --- - ``` -3. Write the skill instructions below the frontmatter \ No newline at end of file diff --git a/skills/paytaca-send/SKILL.md b/skills/paytaca-send/SKILL.md deleted file mode 100644 index 0d6b4c1..0000000 --- a/skills/paytaca-send/SKILL.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -name: paytaca-send -description: Send BCH and CashTokens from the wallet. REQUIRES USER APPROVAL before any sending operation. ---- - -# Paytaca Send - -This skill handles sending BCH and CashTokens. **All sending operations require explicit user approval before execution.** - -## ⚠️ User Approval Required - -**CRITICAL**: The agent MUST NOT execute any `paytaca send` or `paytaca token send` command without first: -1. Informing the user of the exact amount and recipient -2. Waiting for explicit user confirmation (e.g., "yes", "send it", "confirm") -3. Only then executing the send command - -These commands spend real funds from the user's wallet. - ---- - -## Send BCH - -```bash -# Basic send (BCH amount) -paytaca send
- -# Send in satoshis -paytaca send
50000 --unit sats - -# Testnet (chipnet) -paytaca send
0.001 --chipnet -``` - -### Example Workflow -``` -Agent: I'll send 0.001 BCH to bitcoincash:qpm5... -Confirm? (This will spend real funds) - -User: yes - -Agent: paytaca send bitcoincash:qpm5... 0.001 - → Transaction sent: txid... -``` - ---- - -## Send Fungible Tokens - -```bash -# Send tokens (amount in base units) -paytaca token send
--token - -# Example: Send 1000 units of a token -paytaca token send bitcoincash:zpm5... 1000 --token abc123... -``` - -**Note**: Token amounts are in base units. If a token has 8 decimals, sending `1` token requires `100000000` base units. - -### Token Address Warning -Tokens should be sent to token-aware (z-prefix) addresses. The CLI will warn if the recipient address is not token-aware. - ---- - -## Send NFTs - -```bash -# Send NFT (auto-detect UTXO) -paytaca token send-nft
--token --commitment - -# Example: Send NFT with empty commitment -paytaca token send-nft bitcoincash:zpm5... --token abc123... --commitment "" - -# With capability (minting or mutable) -paytaca token send-nft
--token --commitment --capability minting -``` - -### Find NFT details -```bash -# List NFTs for a token category -paytaca token info -``` - ---- - -## Network - -All commands default to **mainnet**. Use `--chipnet` for testnet: - -```bash -paytaca send
0.01 --chipnet -paytaca token send
100 --token --chipnet -paytaca token send-nft
--token --commitment "" --chipnet -``` - ---- - -## Decision Flow - -When user requests to send funds: - -1. **Validate inputs**: Check address format and amount -2. **Inform user**: State the exact amount, recipient, and network -3. **Wait for approval**: Ask "Send X [BCH/tokens] to
? Confirm?" -4. **Execute**: Only after explicit approval - -### Example: Sending BCH -``` -User: Send 0.01 BCH to bitcoincash:qpm5qz6j5p5s5s5s5s5s5s5s5s5s5s5s5s5 - -Agent: Checking wallet balance... -paytaca balance - -Agent: Balance: 0.5 BCH -Sending 0.01 BCH to bitcoincash:qpm5qz6j5p5s5s5s5s5s5s5s5s5s5s5s5s -Confirm? (yes/no) - -User: yes - -Agent: paytaca send bitcoincash:qpm5qz6j5p5s5s5s5s5s5s5s5s5s5s5s5s 0.01 - → Transaction sent! - txid: abc123... -``` - -### Example: Sending Tokens -``` -User: Send 1000 tokens to bitcoincash:zpm5... - -Agent: Let me check your token balance first... -paytaca token info - -Agent: You have 5000 tokens. -Sending 1000 base units to bitcoincash:zpm5... -Confirm? (yes/no) - -User: yes - -Agent: paytaca token send bitcoincash:zpm5... 1000 --token - → Transaction sent! -``` - ---- - -## Error Handling - -| Error | Cause | Solution | -|-------|-------|----------| -| `Insufficient balance` | Not enough BCH | Check `paytaca balance` first | -| `Insufficient BCH for fees` | Can't afford tx fee | Need more BCH for miner fee | -| `Invalid address` | Bad address format | Use CashAddr format | -| `Token not found` | Unknown category | Check token category ID | - ---- - -## Security Notes - -- Seed phrase is stored in OS keychain (never transmitted) -- Transactions are signed locally -- Always verify recipient address before confirming -- Test with small amounts on mainnet, or use `--chipnet` for testing \ No newline at end of file diff --git a/skills/paytaca-wallet/SKILL.md b/skills/paytaca-wallet/SKILL.md deleted file mode 100644 index 195f76c..0000000 --- a/skills/paytaca-wallet/SKILL.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -name: paytaca-wallet -description: Query wallet information including BCH balance, CashToken holdings, addresses, and transaction history. Use for read-only wallet operations. ---- - -# Paytaca Wallet Info - -This skill provides read-only access to wallet information via the Paytaca CLI. - -## Commands - -### Check Balance - -```bash -# BCH balance -paytaca balance - -# Balance in satoshis -paytaca balance --sats - -# CashToken balances only (positive balances) -paytaca balance --tokens - -# BCH and CashToken balances -paytaca balance --all - -# Balance for a specific token -paytaca balance --token - -# Testnet (chipnet) -paytaca balance --chipnet -``` - -### View Transaction History - -```bash -# Recent transactions -paytaca history - -# Filter by type -paytaca history --type incoming -paytaca history --type outgoing - -# Filter by token -paytaca history --token - -# Pagination -paytaca history --page 2 - -# Satoshis display -paytaca history --sats -``` - -### Get Receiving Address - -```bash -# BCH address with QR code -paytaca receive - -# Address at specific index -paytaca receive --index 5 - -# Address only (no QR) -paytaca receive --no-qr - -# Token-aware address (z-prefix) -paytaca receive --token - -# With payment amount (BIP21 URI) -paytaca receive --amount 0.5 - -# Token payment URI -paytaca receive --token --amount 100 -``` - -The displayed address (and its token-aware variant) is auto-subscribed with Watchtower, so incoming BCH and CashTokens are monitored even at indices beyond the initial wallet scan. - -### Derive Addresses - -```bash -# Address at index 0 -paytaca address derive - -# Address at index 5 -paytaca address derive 5 - -# Token address -paytaca address derive --token - -# List first 5 addresses -paytaca address list - -# List 20 addresses -paytaca address list -n 20 - -# Token addresses -paytaca address list --token -``` - -### Wallet Info - -```bash -# Wallet hash, address, balance -paytaca wallet info - -# Testnet -paytaca wallet info --chipnet -``` - -### Token Information - -```bash -# List all fungible tokens -paytaca token list - -# Token details (name, symbol, decimals, balance, NFTs) -paytaca token info -``` - -## JSON Output - -For programmatic parsing, use `--json` where available: - -```bash -paytaca balance --json # Not yet supported -paytaca history --json # Not yet supported -``` - -## Network - -All commands default to **mainnet**. Use `--chipnet` for testnet: - -```bash -paytaca balance --chipnet -paytaca history --chipnet -paytaca receive --chipnet -paytaca token list --chipnet -``` - -## Common Workflows - -### Check wallet status -```bash -paytaca wallet info -paytaca balance -``` - -### Get deposit address -```bash -paytaca receive -``` - -### Check token holdings -```bash -paytaca balance --tokens -paytaca token list -paytaca token info -``` - -### View recent activity -```bash -paytaca history -paytaca history --type incoming -``` \ No newline at end of file diff --git a/skills/paytaca-x402/SKILL.md b/skills/paytaca-x402/SKILL.md deleted file mode 100644 index 21d1bbd..0000000 --- a/skills/paytaca-x402/SKILL.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: paytaca-x402 -description: Handles x402 HTTP payment requests using Paytaca BCH wallet. Use when encountering HTTP 402 responses or when calling APIs that may require payment via the x402 protocol. ---- - -# Paytaca x402 Payment Handler - -This skill teaches the agent how to handle x402-based HTTP payments using the Paytaca CLI wallet. - -## Overview - -Some APIs use the x402 protocol for HTTP payments. When you call these APIs: -- Server returns **HTTP 402 PAYMENT REQUIRED** -- You must pay with BCH to access the resource -- After payment, the server returns the actual response - -## Commands - -### Check if a URL requires payment (recommended first step) - -```bash -paytaca check --json -# Returns: acceptsX402, acceptsBch, estimatedCostSats, etc. -``` - -Example output: -```json -{ - "url": "https://api.example.com/v1/complete", - "acceptsX402": true, - "acceptsBch": true, - "paymentRequired": true, - "estimatedCostSats": "100", - "costInBch": "0.00000100" -} -``` - -### Preview payment without executing (dry-run) - -```bash -paytaca pay --dry-run --json -# Shows what would happen without actually paying -``` - -### Make a paid request - -```bash -# Basic (will prompt for confirmation) -paytaca pay - -# With JSON output (preferred for AI agents) -paytaca pay --json - -# Skip confirmation prompt (only use after user has already approved via paytaca check) -paytaca pay --confirmed - -# POST request -paytaca pay --method POST --body '{"prompt":"hello"}' - -# Custom headers -paytaca pay -H "Authorization: Bearer token123" -``` - -### Check wallet balance - -```bash -paytaca balance -``` - -## Decision Flow - -When preparing to call an unfamiliar API that might require payment: - -1. **First check**: `paytaca check --json` - - If `paymentRequired: false` → proceed normally - - If `paymentRequired: true` and `acceptsBch: true` → inform user of cost and **seek explicit approval** before paying - - If `paymentRequired: true` but `acceptsBch: false` → inform user - -2. **When encountering HTTP 402**: - - Parse the `PAYMENT-REQUIRED` headers - - **Seek explicit user approval** before spending any BCH - - Once approved, use `paytaca pay --json` to handle payment - - The command handles: parse headers → build BCH tx → broadcast → retry with signature - -3. **For known paid APIs**: - - **Always seek user approval first**, then use `paytaca pay ` - it handles 402 automatically - -## ⚠️ User Approval Required Before Any Payment - -**CRITICAL**: The agent MUST NOT execute `paytaca pay` without explicit user approval. Since `paytaca pay` spends real BCH from the user's wallet, always: - -1. Run `paytaca check --json` first to determine the cost -2. Inform the user of the cost (e.g., "This API costs ~1000 sats") -3. Wait for explicit user confirmation (e.g., "yes", "go ahead", "pay") -4. Only then execute the payment using `paytaca pay --confirmed --json` - -The `--confirmed` flag skips the interactive confirmation prompt since the user has already approved the transaction. Do NOT use `--confirmed` without first obtaining explicit user approval. - -Do NOT assume the user wants to pay - even if the cost seems small. - -## AI Agent Workflow - -``` -Task: Call a paid API -Agent: paytaca check https://api.example.com/v1/complete --json - → {"acceptsBch": true, "estimatedCostSats": "100"} - -Agent: Informs user "This API costs 100 sats (0.00000100 BCH). Approve to proceed?" -User: "yes" - -Agent: paytaca pay https://api.example.com/v1/complete --method POST --body '{"prompt":"hello"}' --confirmed --json - → Handles 402 → pays 100 sats → returns response with txid -``` - -## Key Options - -| Option | Description | -|--------|-------------| -| `--json` | Machine-readable output (recommended for AI) | -| `--dry-run` | Preview payment without executing | -| `--confirmed` | Skip confirmation prompt (prior approval obtained) | -| `--chipnet` | Use chipnet (testnet) instead of mainnet | -| `--max-amount` | Override max payment amount in sats | - -## Notes - -- Payment is per-request (no batching) -- Each request = separate BCH transaction -- Only BCH payments are supported (no stablecoins) -- Uses local wallet from OS keychain (credentials never leave the machine) diff --git a/src/ai/autoRefill.test.ts b/src/ai/autoRefill.test.ts new file mode 100644 index 0000000..683ea51 --- /dev/null +++ b/src/ai/autoRefill.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import type { AutoRefillState } from './autoRefill.js' +import { autoRefillCanBuy, remainingBudget } from './autoRefill.js' + +function state(overrides: Partial = {}): AutoRefillState { + return { + enabled: true, + model: 'z-ai/glm-5.3-flash', + minutes: 15, + maxMinutes: 60, + spentMinutes: 0, + paymentMethod: 'bch', + ...overrides, + } +} + +describe('remainingBudget', () => { + it('is maxMinutes minus spent', () => { + expect(remainingBudget(state({ spentMinutes: 30 }))).toBe(30) + }) + + it('never goes negative', () => { + expect(remainingBudget(state({ spentMinutes: 90 }))).toBe(0) + }) + + it('is null without state or maxMinutes', () => { + expect(remainingBudget(null)).toBeNull() + expect(remainingBudget({ enabled: true })).toBeNull() + }) +}) + +describe('autoRefillCanBuy', () => { + it('allows a purchase within budget', () => { + expect(autoRefillCanBuy(state(), 'z-ai/glm-5.3-flash', 15)).toEqual({ + canBuy: true, + }) + }) + + it('refuses when disabled', () => { + const result = autoRefillCanBuy(state({ enabled: false }), 'z-ai/glm-5.3-flash', 15) + expect(result.canBuy).toBe(false) + expect(result.reason).toMatch(/not enabled/i) + }) + + it('refuses another model', () => { + const result = autoRefillCanBuy(state(), 'deepseek/deepseek-v4-pro', 15) + expect(result.canBuy).toBe(false) + expect(result.reason).toMatch(/armed for/i) + }) + + it('refuses another plan duration', () => { + expect(autoRefillCanBuy(state(), 'z-ai/glm-5.3-flash', 30).canBuy).toBe(false) + }) + + it('refuses when the budget is exhausted', () => { + const result = autoRefillCanBuy( + state({ spentMinutes: 60 }), + 'z-ai/glm-5.3-flash', + 15 + ) + expect(result.canBuy).toBe(false) + expect(result.reason).toMatch(/budget exhausted/i) + }) + + it('refuses null state', () => { + expect(autoRefillCanBuy(null, 'z-ai/glm-5.3-flash', 15).canBuy).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/ai/autoRefill.ts b/src/ai/autoRefill.ts new file mode 100644 index 0000000..e8bf4c4 --- /dev/null +++ b/src/ai/autoRefill.ts @@ -0,0 +1,101 @@ +import fs from 'fs' +import { PAYTACA_DIR, AUTO_REFILL_FILE } from './config.js' + +export type PaymentMethod = 'bch' | 'lift' + +export interface AutoRefillState { + enabled: boolean + model?: string + minutes?: number + maxMinutes?: number + spentMinutes?: number + paymentMethod?: PaymentMethod + startedAt?: string + lastRefillAt?: string +} + +export function readAutoRefillState(): AutoRefillState | null { + try { + const raw = fs.readFileSync(AUTO_REFILL_FILE, 'utf8') + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return null + return parsed as AutoRefillState + } catch { + return null + } +} + +export function writeAutoRefillState(state: AutoRefillState): void { + fs.mkdirSync(PAYTACA_DIR, { recursive: true, mode: 0o700 }) + fs.writeFileSync(AUTO_REFILL_FILE, JSON.stringify(state, null, 2), { + mode: 0o600, + }) +} + +export interface ArmAutoRefillOptions { + model?: string + minutes?: number + maxMinutes?: number + paymentMethod?: PaymentMethod +} + +export function armAutoRefill(opts: ArmAutoRefillOptions): AutoRefillState { + const existing = readAutoRefillState() + const state: AutoRefillState = { + enabled: true, + model: opts.model ?? existing?.model, + minutes: opts.minutes ?? existing?.minutes, + maxMinutes: opts.maxMinutes ?? existing?.maxMinutes, + spentMinutes: existing?.spentMinutes ?? 0, + paymentMethod: opts.paymentMethod ?? existing?.paymentMethod ?? 'bch', + startedAt: new Date().toISOString(), + lastRefillAt: existing?.lastRefillAt, + } + writeAutoRefillState(state) + return state +} + +export function disarmAutoRefill(): AutoRefillState { + const existing = readAutoRefillState() + const state: AutoRefillState = { + ...(existing || {}), + enabled: false, + } + writeAutoRefillState(state) + return state +} + +export function remainingBudget(state: AutoRefillState | null): number | null { + if (!state || state.maxMinutes === undefined) return null + return Math.max(0, state.maxMinutes - (state.spentMinutes ?? 0)) +} + +export function autoRefillCanBuy( + state: AutoRefillState | null, + model: string, + minutes: number +): { canBuy: boolean; reason?: string } { + if (!state || !state.enabled) { + return { canBuy: false, reason: 'Auto-refill is not enabled.' } + } + if (state.model && state.model !== model) { + return { + canBuy: false, + reason: `Auto-refill is armed for ${state.model}, not ${model}.`, + } + } + if (state.minutes && state.minutes !== minutes) { + return { + canBuy: false, + reason: `Auto-refill is armed for ${state.minutes}-minute plans, not ${minutes}.`, + } + } + const budget = remainingBudget(state) + if (budget !== null && budget < minutes) { + return { + canBuy: false, + reason: `Auto-refill budget exhausted (${budget} min left).`, + } + } + return { canBuy: true } +} \ No newline at end of file diff --git a/src/ai/client.ts b/src/ai/client.ts new file mode 100644 index 0000000..1c40f9f --- /dev/null +++ b/src/ai/client.ts @@ -0,0 +1,198 @@ +import { resolveBackendUrl, apiUrl } from './config.js' + +export interface PriceTier { + minutes: number + price_usd?: number + price_php?: number + price_sats: number +} + +export interface AiModelConfig { + id: string + display_name: string + tier?: string + price_tiers: PriceTier[] +} + +export interface AiConfig { + models: AiModelConfig[] + default_model?: string + default_duration_minutes?: number + context_retention_hours?: number + lift_payment_discount_percent?: number + proxy_url?: string + image_models?: unknown[] +} + +export interface WalletSession { + model_id?: string + ai_model?: string + display_name?: string + model_active?: boolean + session_active?: boolean + time_credits_seconds?: number + time_used_seconds?: number + time_remaining_seconds?: number + token_limit?: number +} + +export interface WalletStatus extends WalletSession { + sessions?: WalletSession[] +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool' + content: string +} + +export interface ChatResult { + ok: boolean + status: number + statusText: string + headers: Record + data: any +} + +export class AiApiError extends Error { + status?: number + body?: unknown + + constructor(message: string, status?: number, body?: unknown) { + super(message) + this.name = 'AiApiError' + this.status = status + this.body = body + } +} + +export interface RequestOptions { + backendUrl?: string + headers?: Record + timeoutMs?: number + signal?: AbortSignal +} + +async function doFetch( + url: string, + init: RequestInit, + timeoutMs?: number +): Promise { + const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined + return fetch(url, { ...init, signal }) +} + +async function parseBody(response: Response): Promise { + const text = await response.text() + if (!text) return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +function headersToObject(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { + out[key] = value + }) + return out +} + +export async function getConfig(opts: RequestOptions = {}): Promise { + const base = resolveBackendUrl(opts.backendUrl) + let response: Response + try { + response = await doFetch( + apiUrl(base, '/v1/config'), + { method: 'GET', headers: opts.headers }, + opts.timeoutMs ?? 15000 + ) + } catch (err: any) { + throw new AiApiError(`Backend unreachable: ${err?.message || err}`) + } + + const body = await parseBody(response) + if (!response.ok) { + throw new AiApiError( + `GET /v1/config failed (${response.status} ${response.statusText})`, + response.status, + body + ) + } + return body as AiConfig +} + +export async function getWalletStatus( + walletHash: string, + opts: RequestOptions & { modelId?: string } = {} +): Promise { + const base = resolveBackendUrl(opts.backendUrl) + const url = new URL(apiUrl(base, '/v1/wallet/status')) + if (opts.modelId) url.searchParams.set('model_id', opts.modelId) + + let response: Response + try { + response = await doFetch( + url.toString(), + { + method: 'GET', + headers: { 'X-Wallet-Hash': walletHash, ...(opts.headers || {}) }, + }, + opts.timeoutMs ?? 15000 + ) + } catch (err: any) { + throw new AiApiError(`Backend unreachable: ${err?.message || err}`) + } + + const body = await parseBody(response) + if (!response.ok) { + throw new AiApiError( + `GET /v1/wallet/status failed (${response.status} ${response.statusText})`, + response.status, + body + ) + } + return body as WalletStatus +} + +export interface ChatRequestOptions extends RequestOptions { + walletHash: string + body: { + model?: string + messages: ChatMessage[] + stream?: boolean + temperature?: number + max_tokens?: number + [key: string]: unknown + } +} + +export async function chatCompletions( + opts: ChatRequestOptions +): Promise { + const base = resolveBackendUrl(opts.backendUrl) + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Wallet-Hash': opts.walletHash, + ...(opts.headers || {}), + } + + const response = await doFetch( + apiUrl(base, '/v1/chat/completions'), + { + method: 'POST', + headers, + body: JSON.stringify({ stream: false, ...opts.body }), + }, + opts.timeoutMs ?? 240000 + ) + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + data: await parseBody(response), + } +} \ No newline at end of file diff --git a/src/ai/config.ts b/src/ai/config.ts new file mode 100644 index 0000000..3775163 --- /dev/null +++ b/src/ai/config.ts @@ -0,0 +1,21 @@ +import os from 'os' +import path from 'path' + +export const DEFAULT_BACKEND_URL = 'https://api.paytaca.ai' + +export const LIFT_TOKEN_ID = + '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc' + +export const PAYTACA_DIR = path.join(os.homedir(), '.paytaca') +export const AUTO_REFILL_FILE = path.join(PAYTACA_DIR, 'auto-refill.json') + +export function resolveBackendUrl(explicit?: string): string { + const url = explicit || process.env.PAYTACA_BACKEND_URL || DEFAULT_BACKEND_URL + return url.replace(/\/+$/, '') +} + +export function apiUrl(baseUrl: string, apiPath: string): string { + const normalized = baseUrl.replace(/\/+$/, '') + const suffix = apiPath.startsWith('/') ? apiPath : `/${apiPath}` + return `${normalized}${suffix}` +} \ No newline at end of file diff --git a/src/ai/configure.test.ts b/src/ai/configure.test.ts new file mode 100644 index 0000000..f370de8 --- /dev/null +++ b/src/ai/configure.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import type { AiConfig } from './client.js' +import { + buildOpencodeProvider, + deepMerge, + extractExistingApiKey, + isValidApiKey, + OPENCODE_PROVIDER_NPM, + parseJsonConfig, +} from './configure.js' + +const CONFIG: AiConfig = { + models: [ + { id: 'deepseek/deepseek-v4-flash', display_name: 'DeepSeek V4 Flash', price_tiers: [] }, + { id: 'z-ai/glm-5.3-flash', display_name: 'GLM 5.3 Flash', price_tiers: [] }, + ], + default_model: 'deepseek/deepseek-v4-flash', +} + +describe('configure', () => { + it('deep merges nested config without dropping sibling keys', () => { + const existing = { + provider: { other: { name: 'Other' }, 'paytaca-ai': { name: 'old' } }, + mcp: { other: { type: 'local' } }, + theme: 'dark', + } + const merged = deepMerge(existing, { + provider: { 'paytaca-ai': { name: 'new' } }, + mcp: { paytaca: { type: 'local' } }, + }) + expect(merged).toEqual({ + provider: { + other: { name: 'Other' }, + 'paytaca-ai': { name: 'new' }, + }, + mcp: { other: { type: 'local' }, paytaca: { type: 'local' } }, + theme: 'dark', + }) + }) + + it('builds the opencode provider with a model per backend model', () => { + const provider = buildOpencodeProvider(CONFIG, 'https://api.paytaca.ai', 'sk-pytc-test') + expect(provider.npm).toBe(OPENCODE_PROVIDER_NPM) + expect(provider.name).toBe('Paytaca AI') + expect(provider.options).toEqual({ + baseURL: 'https://api.paytaca.ai/v1', + apiKey: 'sk-pytc-test', + }) + expect(Object.keys(provider.models as Record)).toEqual([ + 'deepseek/deepseek-v4-flash', + 'z-ai/glm-5.3-flash', + ]) + }) + + it('reads an existing provider api key for idempotent re-runs', () => { + expect( + extractExistingApiKey({ + provider: { 'paytaca-ai': { options: { apiKey: 'sk-pytc-existing' } } }, + }) + ).toBe('sk-pytc-existing') + expect(extractExistingApiKey({ provider: {} })).toBeNull() + expect(extractExistingApiKey({})).toBeNull() + expect( + extractExistingApiKey({ provider: { 'paytaca-ai': { options: {} } } }) + ).toBeNull() + }) + + it('validates API keys supplied for read-only wallets', () => { + expect(isValidApiKey('sk-pytc-0123456789abcdef0123456789abcdef0123456789abcdef')).toBe(true) + expect(isValidApiKey(' sk-pytc-0123456789abcdef ')).toBe(true) + expect(isValidApiKey('sk-pytc-')).toBe(false) + expect(isValidApiKey('sk-other-0123456789abcdef')).toBe(false) + expect(isValidApiKey('')).toBe(false) + }) + + it('parses opencode JSONC configs with comments and trailing commas', () => { + const raw = `{ + // the schema, with a URL that must not be treated as a comment + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "opencode-agent-memory", + "@tarquinen/opencode-dcp@latest", + ], + /* block comment */ + "lsp": true, + }` + expect(parseJsonConfig(raw)).toEqual({ + $schema: 'https://opencode.ai/config.json', + plugin: ['opencode-agent-memory', '@tarquinen/opencode-dcp@latest'], + lsp: true, + }) + expect(parseJsonConfig('')).toEqual({}) + expect(parseJsonConfig('{ "a": 1 }')).toEqual({ a: 1 }) + }) +}) diff --git a/src/ai/configure.ts b/src/ai/configure.ts new file mode 100644 index 0000000..5276669 --- /dev/null +++ b/src/ai/configure.ts @@ -0,0 +1,359 @@ +/** + * Harness configuration: writes the MCP server entry and, for opencode, also + * the Paytaca AI provider (models + API key) so AI models are usable right + * after `paytaca ai configure`. + */ + +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { CLIENTS, buildTemplate, type ClientTemplate, type McpClient } from '../commands/mcp.js' +import { WalletNotConfiguredError } from '../core/context.js' +import { loadWalletRef } from '../wallet/index.js' +import { getConfig, getWalletStatus, type AiConfig } from './client.js' +import { resolveBackendUrl } from './config.js' +import { hasActiveCredits, summarizeAllCredits, type CreditsSummary } from './credits.js' +import { selectModel, selectTier } from './models.js' +import { provisionApiKey } from './oauth.js' + +export const OPENCODE_PROVIDER_ID = 'paytaca-ai' +export const OPENCODE_PROVIDER_NPM = '@ai-sdk/openai-compatible' +export const DEFAULT_MODEL_CONTEXT = 128000 +export const DEFAULT_MODEL_OUTPUT = 8192 + +export interface PlanSuggestion { + modelId: string + displayName: string + minutes: number + priceSats: number + priceUsd?: number +} + +export interface ConfigureResult { + harness: McpClient + path: string | null + format: 'json' | 'toml' + mcpInstalled: boolean + providerInstalled: boolean + providerError?: string + canSign: boolean + apiKeyReused: boolean + apiKeyProvided: boolean + apiKeyPrefix?: string + models: { id: string; name: string }[] + creditsActive: boolean + credits: CreditsSummary[] + planSuggestion?: PlanSuggestion +} + +export interface ConfigureOptions { + harness: McpClient + chipnet?: boolean + backendUrl?: string + path?: string + apiKey?: string + requestApiKey?: () => Promise +} + +export function isValidApiKey(key: string): boolean { + return /^sk-pytc-[A-Za-z0-9]{8,}$/.test(key.trim()) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function deepMerge( + target: Record, + patch: Record +): Record { + const out: Record = { ...target } + for (const [key, value] of Object.entries(patch)) { + out[key] = isPlainObject(value) && isPlainObject(target[key]) + ? deepMerge(target[key] as Record, value) + : value + } + return out +} + +export function buildOpencodeProvider( + config: AiConfig, + backendUrl: string, + apiKey: string +): Record { + const models: Record = {} + for (const model of config.models || []) { + if (!model?.id) continue + models[model.id] = { + name: model.display_name || model.id, + limit: { context: DEFAULT_MODEL_CONTEXT, output: DEFAULT_MODEL_OUTPUT }, + } + } + return { + npm: OPENCODE_PROVIDER_NPM, + name: 'Paytaca AI', + options: { baseURL: `${backendUrl}/v1`, apiKey }, + models, + } +} + +export function extractExistingApiKey( + config: Record, + providerId: string = OPENCODE_PROVIDER_ID +): string | null { + const provider = config.provider + if (!isPlainObject(provider)) return null + const entry = provider[providerId] + if (!isPlainObject(entry)) return null + const options = entry.options + if (!isPlainObject(options)) return null + const apiKey = options.apiKey + return typeof apiKey === 'string' && apiKey.length > 0 ? apiKey : null +} + +function stripJsonComments(input: string): string { + let out = '' + let inString = false + let inLineComment = false + let inBlockComment = false + for (let i = 0; i < input.length; i++) { + const char = input[i] + const next = input[i + 1] + if (inLineComment) { + if (char === '\n') { + inLineComment = false + out += char + } + continue + } + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false + i++ + } + continue + } + if (inString) { + out += char + if (char === '\\') { + out += next ?? '' + i++ + } else if (char === '"') { + inString = false + } + continue + } + if (char === '"') { + inString = true + out += char + continue + } + if (char === '/' && next === '/') { + inLineComment = true + i++ + continue + } + if (char === '/' && next === '*') { + inBlockComment = true + i++ + continue + } + out += char + } + return out +} + +function stripTrailingCommas(input: string): string { + let out = '' + let inString = false + for (let i = 0; i < input.length; i++) { + const char = input[i] + if (inString) { + out += char + if (char === '\\') { + out += input[i + 1] ?? '' + i++ + } else if (char === '"') { + inString = false + } + continue + } + if (char === '"') { + inString = true + out += char + continue + } + if (char === ',') { + let j = i + 1 + while (j < input.length && /\s/.test(input[j])) j++ + if (input[j] === '}' || input[j] === ']') continue + } + out += char + } + return out +} + +export function parseJsonConfig(raw: string): Record { + if (raw.trim() === '') return {} + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + parsed = JSON.parse(stripTrailingCommas(stripJsonComments(raw))) + } + return isPlainObject(parsed) ? parsed : {} +} + +function readJson(path: string): Record { + if (!existsSync(path)) return {} + try { + return parseJsonConfig(readFileSync(path, 'utf-8')) + } catch { + throw new Error(`Existing config at ${path} could not be parsed; not overwriting.`) + } +} + +function writeJsonFile(path: string, value: Record): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) + chmodSync(path, 0o600) +} + +function appendToml(path: string, toml: string): void { + const existing = existsSync(path) ? readFileSync(path, 'utf-8') : '' + if (existing.includes('[mcp_servers.paytaca]')) return + mkdirSync(dirname(path), { recursive: true }) + const separator = existing && !existing.endsWith('\n') ? '\n\n' : existing ? '\n' : '' + writeFileSync(path, existing + separator + toml, 'utf-8') + chmodSync(path, 0o600) +} + +function buildPlanSuggestion(config: AiConfig): PlanSuggestion | undefined { + const models = config.models || [] + const model = selectModel(models, config.default_model || '') || models[0] + if (!model?.id) return undefined + const wanted = config.default_duration_minutes + const tier = (wanted !== undefined ? selectTier(model, wanted) : null) || model.price_tiers?.[0] + if (!tier) return undefined + return { + modelId: model.id, + displayName: model.display_name || model.id, + minutes: tier.minutes, + priceSats: Number(tier.price_sats) || 0, + priceUsd: tier.price_usd, + } +} + +export async function configureHarness(options: ConfigureOptions): Promise { + const { harness } = options + if (!CLIENTS.includes(harness)) { + throw new Error(`Unknown harness "${harness}". Use one of: ${CLIENTS.join(', ')}.`) + } + + const isChipnet = Boolean(options.chipnet) + const backendUrl = resolveBackendUrl(options.backendUrl) + const wallet = loadWalletRef() + if (!wallet) throw new WalletNotConfiguredError() + const template: ClientTemplate = buildTemplate(harness, isChipnet) + const target = options.path || template.path + + const result: ConfigureResult = { + harness, + path: target, + format: template.format, + mcpInstalled: false, + providerInstalled: false, + canSign: wallet.canSign, + apiKeyReused: false, + apiKeyProvided: false, + models: [], + creditsActive: false, + credits: [], + } + + let config: AiConfig | null = null + let configError: string | undefined + try { + config = await getConfig({ backendUrl }) + } catch (err: any) { + configError = err?.message || String(err) + } + + if (harness === 'opencode') { + if (!target) throw new Error('No known config path for opencode. Use --path.') + const existing = readJson(target) + const mcpEntry = (template.json as any)?.mcp?.paytaca + + let apiKey = extractExistingApiKey(existing) + if (apiKey) { + result.apiKeyReused = true + } else if (options.apiKey) { + if (isValidApiKey(options.apiKey)) { + apiKey = options.apiKey.trim() + result.apiKeyProvided = true + } else { + result.providerError = 'Invalid API key: expected a Paytaca key like sk-pytc-…' + } + } else if (wallet.canSign && wallet.mnemonic) { + try { + const created = await provisionApiKey({ + mnemonic: wallet.mnemonic, + walletHash: wallet.walletHash, + backendUrl, + name: 'paytaca-cli', + }) + apiKey = created.key + result.apiKeyPrefix = created.keyPrefix + } catch (err: any) { + result.providerError = err?.message || String(err) + } + } else if (options.requestApiKey) { + const entered = await options.requestApiKey() + if (entered && isValidApiKey(entered)) { + apiKey = entered.trim() + result.apiKeyProvided = true + } else { + result.providerError = + 'No API key provided. A full wallet is required to create one automatically.' + } + } else { + result.providerError = + 'This wallet is read-only. Pass --api-key with a key created from your full wallet.' + } + + let merged = deepMerge(existing, { mcp: { paytaca: mcpEntry } }) + if (config && apiKey) { + const provider = buildOpencodeProvider(config, backendUrl, apiKey) + merged = deepMerge(merged, { provider: { [OPENCODE_PROVIDER_ID]: provider } }) + result.models = (config.models || []) + .filter((m) => m?.id) + .map((m) => ({ id: m.id, name: m.display_name || m.id })) + result.providerInstalled = true + } else if (!result.providerError) { + result.providerError = configError || 'Unable to load the AI configuration.' + } + + writeJsonFile(target, merged) + result.mcpInstalled = true + } else { + if (!target) throw new Error(`No known config file for harness "${harness}". Use --path.`) + if (template.format === 'toml') { + appendToml(target, template.toml as string) + } else { + const existing = readJson(target) + writeJsonFile(target, deepMerge(existing, template.json as Record)) + } + result.mcpInstalled = true + } + + if (config) result.planSuggestion = buildPlanSuggestion(config) + + try { + const status = await getWalletStatus(wallet.walletHash, { backendUrl }) + result.credits = summarizeAllCredits(status) + result.creditsActive = hasActiveCredits(status) + } catch { + result.creditsActive = false + } + + return result +} diff --git a/src/ai/credits.test.ts b/src/ai/credits.test.ts new file mode 100644 index 0000000..471ca95 --- /dev/null +++ b/src/ai/credits.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import type { WalletStatus } from './client.js' +import { + getSessions, + findSession, + hasActiveCredits, + summarizeCredits, + summarizeAllCredits, +} from './credits.js' + +function status(sessions: WalletStatus[]): WalletStatus { + return { sessions } as unknown as WalletStatus +} + +const active: WalletStatus = { + model_id: 'z-ai/glm-5.3-flash', + display_name: 'GLM 5.3 Flash', + model_active: true, + time_remaining_seconds: 900, + time_credits_seconds: 1800, + time_used_seconds: 900, + token_limit: 50000, +} as unknown as WalletStatus + +const expired: WalletStatus = { + model_id: 'deepseek/deepseek-v4-pro', + display_name: 'DeepSeek V4 Pro', + model_active: false, + time_remaining_seconds: 0, +} as unknown as WalletStatus + +describe('getSessions', () => { + it('returns the sessions array when present', () => { + expect(getSessions(status([active, expired]))).toHaveLength(2) + }) + + it('wraps a bare status object', () => { + expect(getSessions(active)).toEqual([active]) + }) +}) + +describe('findSession', () => { + it('finds a matching model', () => { + expect(findSession(status([active, expired]), 'deepseek-v4-pro')).toBe(expired) + }) + + it('falls back to an active session', () => { + expect(findSession(status([expired, active]))).toBe(active) + }) + + it('returns null for no sessions', () => { + expect(findSession(status([]))).toBeNull() + }) +}) + +describe('hasActiveCredits', () => { + it('is true when a session has remaining time', () => { + expect(hasActiveCredits(status([active]))).toBe(true) + }) + + it('is false when all sessions are expired', () => { + expect(hasActiveCredits(status([expired]))).toBe(false) + }) + + it('scopes the check to the requested model', () => { + expect(hasActiveCredits(status([active]), 'deepseek-v4-pro')).toBe(false) + expect(hasActiveCredits(status([active]), 'glm-5.3-flash')).toBe(true) + }) + + it('is false for null status', () => { + expect(hasActiveCredits(null)).toBe(false) + }) +}) + +describe('summarizeCredits', () => { + it('summarizes the matching session', () => { + const summary = summarizeCredits(status([active]), 'glm-5.3-flash') + expect(summary).toMatchObject({ + modelId: 'z-ai/glm-5.3-flash', + displayName: 'GLM 5.3 Flash', + active: true, + timeRemainingSeconds: 900, + tokenLimit: 50000, + }) + }) +}) + +describe('summarizeAllCredits', () => { + it('summarizes every session with its own active state', () => { + const summaries = summarizeAllCredits(status([active, expired])) + expect(summaries).toHaveLength(2) + expect(summaries[0]).toMatchObject({ + modelId: 'z-ai/glm-5.3-flash', + active: true, + timeRemainingSeconds: 900, + }) + expect(summaries[1]).toMatchObject({ + modelId: 'deepseek/deepseek-v4-pro', + active: false, + timeRemainingSeconds: 0, + }) + }) + + it('returns an empty array when there are no sessions', () => { + expect(summarizeAllCredits(status([]))).toEqual([]) + }) +}) diff --git a/src/ai/credits.ts b/src/ai/credits.ts new file mode 100644 index 0000000..3b9b42f --- /dev/null +++ b/src/ai/credits.ts @@ -0,0 +1,109 @@ +import { getWalletStatus, type WalletStatus, type WalletSession } from './client.js' + +export interface CreditsSummary { + modelId: string | null + displayName: string | null + active: boolean + timeRemainingSeconds: number + timeCreditsSeconds: number + timeUsedSeconds: number + tokenLimit: number | null +} + +export function getSessions(status: WalletStatus): WalletSession[] { + if (Array.isArray(status.sessions)) return status.sessions + return [status] +} + +function sessionMatches(session: WalletSession, modelId: string): boolean { + const q = modelId.toLowerCase() + const id = String(session.model_id || session.ai_model || '').toLowerCase() + const name = String(session.display_name || '').toLowerCase() + return id === q || id.includes(q) || name.includes(q) +} + +export function findSession( + status: WalletStatus, + modelId?: string +): WalletSession | null { + const sessions = getSessions(status).filter(Boolean) + if (sessions.length === 0) return null + + if (modelId) { + const match = sessions.find((s) => sessionMatches(s, modelId)) + if (match) return match + } + + return ( + sessions.find( + (s) => + s.model_active === true || + s.session_active === true || + (s.time_remaining_seconds ?? 0) > 0 + ) || + sessions[0] || + null + ) +} + +function sessionActive(session: WalletSession): boolean { + return ( + session.model_active === true || + session.session_active === true || + (session.time_remaining_seconds ?? 0) > 0 + ) +} + +export function hasActiveCredits( + status: WalletStatus | null, + modelId?: string +): boolean { + if (!status) return false + const sessions = getSessions(status).filter(Boolean) + const candidates = modelId + ? sessions.filter((s) => sessionMatches(s, modelId)) + : sessions + return candidates.some(sessionActive) +} + +export function formatRemaining(seconds: number): string { + if (!isFinite(seconds) || seconds <= 0) return '0s' + const total = Math.floor(seconds) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + const parts: string[] = [] + if (h > 0) parts.push(`${h}h`) + if (m > 0) parts.push(`${m}m`) + if (s > 0 && h === 0) parts.push(`${s}s`) + return parts.join(' ') || '0s' +} + +function summarizeSession(session: WalletSession): CreditsSummary { + return { + modelId: session.model_id || session.ai_model || null, + displayName: session.display_name || null, + active: sessionActive(session), + timeRemainingSeconds: session.time_remaining_seconds ?? 0, + timeCreditsSeconds: session.time_credits_seconds ?? 0, + timeUsedSeconds: session.time_used_seconds ?? 0, + tokenLimit: session.token_limit ?? null, + } +} + +export function summarizeCredits( + status: WalletStatus, + modelId?: string +): CreditsSummary { + const session = findSession(status, modelId) + return { + ...summarizeSession(session ?? ({} as WalletSession)), + active: hasActiveCredits(status, modelId), + } +} + +export function summarizeAllCredits(status: WalletStatus): CreditsSummary[] { + return getSessions(status).filter(Boolean).map(summarizeSession) +} + +export { getWalletStatus } \ No newline at end of file diff --git a/src/ai/models.test.ts b/src/ai/models.test.ts new file mode 100644 index 0000000..157234a --- /dev/null +++ b/src/ai/models.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import type { AiConfig, AiModelConfig } from './client.js' +import { selectModel, selectTier, listPlans, listModels } from './models.js' + +const flash: AiModelConfig = { + id: 'z-ai/glm-5.3-flash', + display_name: 'GLM 5.3 Flash', + tier: 'budget', + price_tiers: [ + { minutes: 15, price_sats: 4000 }, + { minutes: 30, price_sats: 8000 }, + { minutes: 60, price_sats: 15000 }, + ], +} + +const pro: AiModelConfig = { + id: 'deepseek/deepseek-v4-pro', + display_name: 'DeepSeek V4 Pro', + tier: 'premium', + price_tiers: [ + { minutes: 15, price_sats: 9000 }, + { minutes: 30, price_sats: 17000 }, + ], +} + +const config: AiConfig = { models: [flash, pro], default_model: flash.id } + +describe('selectModel', () => { + it('matches exact id', () => { + expect(selectModel(config.models, 'deepseek/deepseek-v4-pro')).toBe(pro) + }) + + it('matches display name case-insensitively', () => { + expect(selectModel(config.models, 'glm 5.3 flash')).toBe(flash) + }) + + it('matches substrings', () => { + expect(selectModel(config.models, 'pro')).toBe(pro) + }) + + it('returns null for unknown models', () => { + expect(selectModel(config.models, 'nope')).toBeNull() + }) +}) + +describe('selectTier', () => { + it('returns the exact tier', () => { + expect(selectTier(flash, 30)?.price_sats).toBe(8000) + }) + + it('rounds up to the next available tier', () => { + expect(selectTier(flash, 20)?.minutes).toBe(30) + }) + + it('falls back to the longest tier when none is long enough', () => { + expect(selectTier(pro, 60)?.minutes).toBe(30) + }) +}) + +describe('listModels', () => { + it('returns the models array', () => { + expect(listModels(config)).toHaveLength(2) + }) + + it('tolerates a missing models array', () => { + expect(listModels({} as AiConfig)).toEqual([]) + }) +}) + +describe('listPlans', () => { + it('groups plans by tier in canonical order', () => { + const groups = listPlans(config) + expect(groups.map((g) => g.tier)).toEqual(['budget', 'premium']) + expect(groups[0].models[0].id).toBe(flash.id) + expect(groups[1].models[0].id).toBe(pro.id) + }) + + it('filters to a single model', () => { + const groups = listPlans(config, 'pro') + expect(groups).toHaveLength(1) + expect(groups[0].tier).toBe('premium') + expect(groups[0].models).toHaveLength(1) + }) + + it('returns nothing for an unknown model filter', () => { + expect(listPlans(config, 'unknown-model')).toEqual([]) + }) +}) \ No newline at end of file diff --git a/src/ai/models.ts b/src/ai/models.ts new file mode 100644 index 0000000..18ce5bd --- /dev/null +++ b/src/ai/models.ts @@ -0,0 +1,95 @@ +import type { AiConfig, AiModelConfig, PriceTier } from './client.js' + +const TIER_ORDER = ['budget', 'premium', 'frontier', 'cheap', 'other'] + +export interface PlanView { + id: string + displayName: string + tier: string + plans: PriceTier[] +} + +export interface PlanGroup { + tier: string + models: PlanView[] +} + +export function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes} min` + const hours = minutes / 60 + return Number.isInteger(hours) ? `${hours} hr` : `${minutes} min` +} + +export function formatPriceUsd(usd: number | undefined): string { + if (usd === undefined || usd === null || !isFinite(usd)) return 'n/a' + return `$${usd.toFixed(2)}` +} + +export function selectModel( + models: AiModelConfig[], + query: string +): AiModelConfig | null { + if (!query) return null + const q = query.toLowerCase() + const exact = models.find( + (m) => + String(m.id).toLowerCase() === q || + String(m.display_name).toLowerCase() === q + ) + if (exact) return exact + return ( + models.find((m) => { + const id = String(m.id).toLowerCase() + const name = String(m.display_name).toLowerCase() + return id.includes(q) || name.includes(q) + }) || null + ) +} + +export function selectTier( + model: AiModelConfig, + minutes: number +): PriceTier | null { + const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [] + const exact = tiers.find((t) => Number(t.minutes) === Number(minutes)) + if (exact) return exact + const sorted = [...tiers].sort((a, b) => a.minutes - b.minutes) + return sorted.find((t) => t.minutes >= minutes) || sorted[sorted.length - 1] || null +} + +export function listModels(config: AiConfig): AiModelConfig[] { + return Array.isArray(config.models) ? config.models : [] +} + +export function listPlans( + config: AiConfig, + modelQuery?: string +): PlanGroup[] { + let models = listModels(config) + if (modelQuery) { + const model = selectModel(models, modelQuery) + models = model ? [model] : [] + } + + const groups = new Map() + for (const model of models) { + const tier = String(model.tier || 'other').toLowerCase() + const view: PlanView = { + id: model.id, + displayName: model.display_name, + tier, + plans: Array.isArray(model.price_tiers) ? model.price_tiers : [], + } + const list = groups.get(tier) || [] + list.push(view) + groups.set(tier, list) + } + + const orderedTiers = [...groups.keys()].sort((a, b) => { + const ai = TIER_ORDER.indexOf(a) + const bi = TIER_ORDER.indexOf(b) + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) + }) + + return orderedTiers.map((tier) => ({ tier, models: groups.get(tier)! })) +} \ No newline at end of file diff --git a/src/ai/oauth.test.ts b/src/ai/oauth.test.ts new file mode 100644 index 0000000..9017027 --- /dev/null +++ b/src/ai/oauth.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { hexToBin, secp256k1, sha256 } from '@bitauth/libauth' +import { pubkeyToAddress } from '../utils/crypto.js' +import { + buildAuthMessage, + createAuthMaterial, + signAuthMessage, +} from './oauth.js' + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +describe('oauth', () => { + it('builds the bitcoincash-oauth challenge message', () => { + expect(buildAuthMessage('abc123', 1700000000)).toBe( + 'bitcoincash-oauth|oauth|abc123|1700000000' + ) + expect(buildAuthMessage('abc123', 1700000000, 'custom')).toBe( + 'bitcoincash-oauth|custom|abc123|1700000000' + ) + }) + + it('signs sha256(message) as raw DER (no Bitcoin magic prefix)', () => { + const privateKey = new Uint8Array(32).fill(7) + const message = buildAuthMessage('abc123', 1700000000) + const signature = signAuthMessage(message, privateKey) + + expect(signature).toMatch(/^[0-9a-f]+$/) + const digest = sha256.hash(new TextEncoder().encode(message)) + const publicKey = secp256k1.derivePublicKeyCompressed(privateKey) + expect(typeof publicKey).not.toBe('string') + expect( + secp256k1.verifySignatureDER( + hexToBin(signature), + publicKey as Uint8Array, + digest + ) + ).toBe(true) + }) + + it('binds the auth user id to the wallet hash', () => { + const material = createAuthMaterial(MNEMONIC, 'wallet-hash-xyz', 1700000000) + expect(material.userId).toBe('wallet-hash-xyz') + expect(material.timestamp).toBe(1700000000) + expect(material.domain).toBe('oauth') + expect(material.address).toBe(pubkeyToAddress(material.publicKey, false)) + + const digest = sha256.hash( + new TextEncoder().encode(buildAuthMessage(material.userId, material.timestamp)) + ) + expect( + secp256k1.verifySignatureDER( + hexToBin(material.signature), + hexToBin(material.publicKey), + digest + ) + ).toBe(true) + }) + + it('derives a stable address from the same mnemonic', () => { + const first = createAuthMaterial(MNEMONIC, 'wallet-hash-xyz', 1700000000) + const second = createAuthMaterial(MNEMONIC, 'wallet-hash-xyz', 1700000001) + expect(second.address).toBe(first.address) + expect(second.publicKey).toBe(first.publicKey) + }) +}) diff --git a/src/ai/oauth.ts b/src/ai/oauth.ts new file mode 100644 index 0000000..84e2be2 --- /dev/null +++ b/src/ai/oauth.ts @@ -0,0 +1,213 @@ +/** + * Bitcoin Cash OAuth (bitcoincash-oauth) client for the Paytaca AI backend. + * + * Flow: sign a challenge with a wallet-derived key, register the key's + * address, exchange the signature for an access token, then mint an API key + * bound to the wallet hash. The API key is an identity only — AI usage is + * still metered against the wallet's credits. + */ + +import { binToHex, secp256k1, sha256 } from '@bitauth/libauth' +import { LibauthHDWallet } from '../wallet/keys.js' +import { BCH_DERIVATION_PATH } from '../utils/network.js' +import { pubkeyToAddress } from '../utils/crypto.js' +import { resolveBackendUrl, apiUrl } from './config.js' +import { AiApiError } from './client.js' + +export const OAUTH_DOMAIN = 'oauth' +export const OAUTH_AUTH_PATH = '2/0' + +export interface WalletAuthMaterial { + userId: string + publicKey: string + address: string + timestamp: number + domain: string + signature: string +} + +export interface CreatedApiKey { + id: string + key: string + keyPrefix: string + name: string +} + +interface JsonResponse { + ok: boolean + status: number + statusText: string + body: any +} + +export function buildAuthMessage( + userId: string, + timestamp: number, + domain: string = OAUTH_DOMAIN +): string { + return `bitcoincash-oauth|${domain}|${userId}|${timestamp}` +} + +/** Raw DER ECDSA over sha256(message) — no Bitcoin Signed Message prefix. */ +export function signAuthMessage(message: string, privateKey: Uint8Array): string { + const digest = sha256.hash(new TextEncoder().encode(message)) + const signature = secp256k1.signMessageHashDER(privateKey, digest) + return typeof signature === 'string' ? signature : binToHex(signature) +} + +export function createAuthMaterial( + mnemonic: string, + walletHash: string, + timestamp?: number +): WalletAuthMaterial { + const hdWallet = new LibauthHDWallet(mnemonic, BCH_DERIVATION_PATH, 'mainnet') + const node = hdWallet.getNodeAt(OAUTH_AUTH_PATH) + const publicKey = hdWallet.getPubkeyAt(OAUTH_AUTH_PATH) + const ts = timestamp ?? Math.floor(Date.now() / 1000) + const signature = signAuthMessage(buildAuthMessage(walletHash, ts), node.privateKey) + return { + userId: walletHash, + publicKey, + address: pubkeyToAddress(publicKey, false), + timestamp: ts, + domain: OAUTH_DOMAIN, + signature, + } +} + +async function postJson( + url: string, + body: unknown, + token?: string, + timeoutMs = 15000 +): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (err: any) { + throw new AiApiError(`Backend unreachable: ${err?.message || err}`) + } + + const text = await response.text() + let parsed: any = null + if (text) { + try { + parsed = JSON.parse(text) + } catch { + parsed = text + } + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: parsed, + } +} + +function errorMessage(response: JsonResponse): string { + const body = response.body + if (body && typeof body === 'object') { + return body.error || body.detail || body.message || JSON.stringify(body) + } + if (typeof body === 'string' && body) return body + return response.statusText +} + +export async function registerOAuthUser( + material: WalletAuthMaterial, + opts: { backendUrl?: string } = {} +): Promise { + const url = apiUrl(resolveBackendUrl(opts.backendUrl), '/v1/auth/register') + const response = await postJson(url, { + bitcoincash_address: material.address, + user_id: material.userId, + timestamp: material.timestamp, + domain: material.domain, + public_key: material.publicKey, + signature: material.signature, + }) + + if (!response.ok) { + const message = errorMessage(response) + if (response.status === 409 || /already|exists/i.test(message)) return + throw new AiApiError( + `OAuth register failed (${response.status} ${response.statusText}): ${message}`, + response.status, + response.body + ) + } +} + +export async function createAccessToken( + material: WalletAuthMaterial, + opts: { backendUrl?: string } = {} +): Promise { + const url = apiUrl(resolveBackendUrl(opts.backendUrl), '/v1/auth/token') + const response = await postJson(url, { + user_id: material.userId, + timestamp: material.timestamp, + domain: material.domain, + public_key: material.publicKey, + signature: material.signature, + scopes: ['read'], + }) + + const token = response.body?.access_token + if (!response.ok || typeof token !== 'string' || !token) { + throw new AiApiError( + `OAuth token request failed (${response.status} ${response.statusText}): ${errorMessage(response)}`, + response.status, + response.body + ) + } + return token +} + +export async function createApiKey( + accessToken: string, + name: string, + opts: { backendUrl?: string } = {} +): Promise { + const url = apiUrl(resolveBackendUrl(opts.backendUrl), '/v1/api-keys') + const response = await postJson(url, { name }, accessToken) + + const rawKey = response.body?.key + if (!response.ok || typeof rawKey !== 'string' || !rawKey) { + throw new AiApiError( + `API key creation failed (${response.status} ${response.statusText}): ${errorMessage(response)}`, + response.status, + response.body + ) + } + + return { + id: String(response.body?.id ?? ''), + key: rawKey, + keyPrefix: String(response.body?.key_prefix ?? rawKey.slice(0, 12)), + name, + } +} + +export async function provisionApiKey(options: { + mnemonic: string + walletHash: string + backendUrl?: string + name?: string + timestamp?: number +}): Promise { + const { mnemonic, walletHash, backendUrl, name = 'paytaca-cli', timestamp } = options + const material = createAuthMaterial(mnemonic, walletHash, timestamp) + await registerOAuthUser(material, { backendUrl }) + const accessToken = await createAccessToken(material, { backendUrl }) + return createApiKey(accessToken, name, { backendUrl }) +} diff --git a/src/ai/purchase.ts b/src/ai/purchase.ts new file mode 100644 index 0000000..f28155c --- /dev/null +++ b/src/ai/purchase.ts @@ -0,0 +1,620 @@ +import { ExchangeLab } from '@cashlab/cauldron' +import { PayoutAmountRuleType } from '@cashlab/common' +import { + cashAddressToLockingBytecode, + binToHex, +} from '@cashlab/common/libauth.js' +import { requireWallet, type WalletContext } from '../core/context.js' +import { isValidBchAddress } from '../core/wallet.js' +import { LibauthHDWallet } from '../wallet/keys.js' +import { X402Payer } from '../wallet/x402.js' +import { + parsePaymentRequiredJson, + selectBchPaymentRequirements, +} from '../utils/x402.js' +import { BCH_DERIVATION_PATH } from '../utils/network.js' +import { + fetchPoolsForToken, + fetchCauldronFee, +} from '../wallet/cauldron/api.js' +import { apiPoolToMicroPool, microPoolToPoolV0 } from '../wallet/cauldron/pools.js' +import { + attemptTrade, + watchtowerUtxosToSpendableCoins, + type WatchtowerUtxo, +} from '../wallet/cauldron/transact.js' +import { computePlatformFee } from '../wallet/cauldron/swap.js' +import { getBchUsdPrice } from '../utils/prices.js' +import type { PaymentRequirements } from '../types/x402.js' +import { + getConfig, + getWalletStatus, + type AiConfig, + type AiModelConfig, + type PriceTier, +} from './client.js' +import { LIFT_TOKEN_ID, resolveBackendUrl, apiUrl } from './config.js' +import { selectModel, selectTier, formatDuration } from './models.js' +import { findSession, hasActiveCredits } from './credits.js' + +export type PaymentMethod = 'bch' | 'lift' + +export interface BuyPlanOptions { + model: string + minutes: number + paymentMethod?: PaymentMethod + isChipnet?: boolean + confirmed?: boolean + backendUrl?: string + skipProbe?: boolean + skipActiveCreditGuard?: boolean +} + +export interface BuyPlanResult { + success: boolean + paid: boolean + model?: string + displayName?: string + minutes?: number + priceSats?: number + paymentMethod?: PaymentMethod + txid?: string + recipientAddress?: string + status?: number + sessionActivated?: boolean + timeRemainingSeconds?: number + data?: unknown + error?: string +} + +const PLAN_PROBE_SAMPLES = 3 +const PLAN_PROBE_SPACING_MS = 500 +const PLAN_PROBE_PRICE_DRIFT_TOLERANCE = 0.05 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function probePlanStability( + baseUrl: string, + modelId: string, + minutes: number +): Promise { + let okSamples = 0 + const prices: number[] = [] + let lastError = '' + + for (let i = 0; i < PLAN_PROBE_SAMPLES; i++) { + try { + const config = await getConfig({ backendUrl: baseUrl, timeoutMs: 6000 }) + const model = selectModel(config.models || [], modelId) + if (!model) { + lastError = `model ${modelId} not in plan config` + } else { + const tier = selectTier(model, minutes) + if (!tier) { + lastError = `no ${minutes}-min plan for ${modelId}` + } else { + prices.push(Number(tier.price_sats) || 0) + okSamples++ + } + } + } catch (err: any) { + lastError = err?.message || String(err) + } + if (i < PLAN_PROBE_SAMPLES - 1) await sleep(PLAN_PROBE_SPACING_MS) + } + + if (okSamples === PLAN_PROBE_SAMPLES && prices.length > 0) { + const min = Math.min(...prices) + const max = Math.max(...prices) + const drift = min > 0 ? (max - min) / min : max === min ? 0 : Infinity + if (drift <= PLAN_PROBE_PRICE_DRIFT_TOLERANCE) return + throw new Error( + `Backend plan price was unstable before purchase (${prices.join(', ')} sats). No payment was broadcast — please retry in a few seconds.` + ) + } + throw new Error( + `Backend plan endpoint was unreachable before purchase (reachable ${okSamples}/${PLAN_PROBE_SAMPLES}${ + lastError ? `, last error: ${lastError}` : '' + }). No payment was broadcast — please retry in a few seconds.` + ) +} + +interface BuildPlanRequest { + url: string + headers: Record + body: string +} + +function buildPlanRequest( + baseUrl: string, + walletHash: string, + model: AiModelConfig, + tier: PriceTier, + paymentMethod: PaymentMethod +): BuildPlanRequest { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Wallet-Hash': walletHash, + 'X-Model-Id': model.id, + 'X-Duration-Minutes': String(tier.minutes), + 'X-Plan-Probe': '1', + } + if (paymentMethod === 'lift') headers['X-Payment-Method'] = 'lift' + + return { + url: `${apiUrl(baseUrl, '/v1/chat/completions')}?wallet_hash=${encodeURIComponent( + walletHash + )}`, + headers, + body: JSON.stringify({ + model: model.id, + messages: [{ role: 'user', content: 'ok' }], + stream: false, + max_tokens: 1, + }), + } +} + +async function payWithLift( + ctx: WalletContext, + hdWallet: LibauthHDWallet, + requirements: PaymentRequirements, + changeAddress: string, + isChipnet: boolean +): Promise<{ txid: string; vout: number }> { + const tokenId = LIFT_TOKEN_ID + const amountSats = BigInt(requirements.amount) + + const [apiPools, allUtxos, tokenUtxos] = await Promise.all([ + fetchPoolsForToken(tokenId), + ctx.bch.getUtxos(), + ctx.bch.getUtxos({ category: tokenId }), + ]) + if (!apiPools || apiPools.length === 0) { + throw new Error('No active Cauldron pools for the payment token.') + } + const pools = apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0) + + const tokenBalance = (tokenUtxos || []).reduce( + (sum: bigint, u: WatchtowerUtxo) => sum + BigInt(u.amount || 0), + 0n + ) + if (tokenBalance <= 0n) { + throw new Error( + 'No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or pay with BCH.' + ) + } + + const bchUtxos = allUtxos.filter((utxo: WatchtowerUtxo) => !utxo.is_cashtoken) + const spendableCoins = watchtowerUtxosToSpendableCoins({ + utxos: [...bchUtxos, ...(tokenUtxos || [])], + wallet: hdWallet, + }) + if (spendableCoins.length === 0) { + throw new Error('No spendable UTXOs available.') + } + + const payToDecoded = cashAddressToLockingBytecode(requirements.payTo) + if (!payToDecoded || typeof payToDecoded === 'string' || !payToDecoded.bytecode) { + throw new Error(`Invalid payment address: ${requirements.payTo}`) + } + const changeDecoded = cashAddressToLockingBytecode(changeAddress) + if (!changeDecoded || typeof changeDecoded === 'string' || !changeDecoded.bytecode) { + throw new Error(`Invalid change address: ${changeAddress}`) + } + + const feeConfig = await fetchCauldronFee(isChipnet).catch(() => null) + const bchUsdPrice = feeConfig + ? await getBchUsdPrice(isChipnet).catch(() => null) + : null + + const exlab = new ExchangeLab() + let trade = null + let tradeTx = null + let lastError: Error | null = null + + for (const buffer of [2000n, 20000n, 100000n]) { + try { + trade = attemptTrade({ + pools, + isBuyingToken: false, + supply: undefined, + demand: amountSats + buffer, + }) + const payoutRules: any[] = [ + { + type: PayoutAmountRuleType.FIXED, + locking_bytecode: payToDecoded.bytecode, + amount: amountSats, + }, + ] + + const platformFee = feeConfig + ? computePlatformFee({ + tradeResult: trade, + isBuyingToken: false, + feeConfig, + bchUsdPrice, + }) + : null + if (platformFee) { + const feeDecoded = cashAddressToLockingBytecode(platformFee.to) + if (feeDecoded && typeof feeDecoded !== 'string' && feeDecoded.bytecode) { + payoutRules.push({ + type: PayoutAmountRuleType.FIXED, + locking_bytecode: feeDecoded.bytecode, + amount: platformFee.amount, + }) + } + } + + payoutRules.push({ + type: PayoutAmountRuleType.CHANGE, + locking_bytecode: changeDecoded.bytecode, + allow_mixing_native_and_token: false, + allow_mixing_native_and_token_when_bch_change_is_dust: false, + add_change_to_txfee_when_bch_change_is_dust: true, + }) + + tradeTx = exlab.createTradeTx( + trade.entries, + spendableCoins, + payoutRules, + null, + 1n + ) + exlab.verifyTradeTx(tradeTx) + break + } catch (err: any) { + lastError = err + } + } + + if (!tradeTx) { + const supply = (trade as any)?.summary?.supply + if (supply && tokenBalance < supply) { + throw new Error( + `Insufficient LIFT balance: this payment needs ${supply} base units but the wallet has ${tokenBalance}.` + ) + } + throw new Error( + `Could not fund the payment by selling LIFT: ${ + lastError?.message || 'unknown error' + }` + ) + } + + const tx = (tradeTx as any).libauth_generated_transaction + const payToHex = binToHex(payToDecoded.bytecode) + const vout = tx.outputs.findIndex( + (o: any) => binToHex(o.lockingBytecode) === payToHex + ) + if (vout === -1) throw new Error('Payment output missing from built transaction.') + + const txHex = binToHex((tradeTx as any).txbin) + const broadcastResponse = await (ctx.bch.watchtower as any).BCH._api.post( + 'broadcast/', + { transaction: txHex } + ) + const data = broadcastResponse.data + if (data?.result) { + data[data.success ? 'txid' : 'error'] = data.result + delete data.result + } + if (!data?.success || !data?.txid) { + throw new Error(data?.error || 'Broadcast failed') + } + return { txid: data.txid, vout } +} + +function resolveModelTier( + config: AiConfig, + modelQuery: string, + minutes: number +): { model: AiModelConfig; tier: PriceTier } { + const model = selectModel(config.models || [], modelQuery) + if (!model) { + throw new Error(`Unknown model "${modelQuery}". Run \`paytaca ai models\` to list available models.`) + } + const tier = selectTier(model, minutes) + if (!tier) { + throw new Error( + `No ${minutes}-minute plan available for ${model.display_name || model.id}.` + ) + } + return { model, tier } +} + +export async function buyPlan(opts: BuyPlanOptions): Promise { + const paymentMethod: PaymentMethod = + opts.paymentMethod === 'lift' ? 'lift' : 'bch' + const isChipnet = Boolean(opts.isChipnet) + const baseUrl = resolveBackendUrl(opts.backendUrl) + + const modelQuery = String(opts.model || '').trim() + const minutes = Number(opts.minutes) + if (!modelQuery) { + return { success: false, paid: false, error: 'Missing model.' } + } + if (!Number.isFinite(minutes) || minutes <= 0) { + return { success: false, paid: false, error: 'Missing or invalid minutes.' } + } + + let ctx: WalletContext + let model: AiModelConfig + let tier: PriceTier + try { + ctx = requireWallet(isChipnet) + const config = await getConfig({ backendUrl: baseUrl }) + const resolved = resolveModelTier(config, modelQuery, minutes) + model = resolved.model + tier = resolved.tier + } catch (err: any) { + return { success: false, paid: false, error: err?.message || String(err) } + } + + const priceSats = Number(tier.price_sats) || 0 + + if (!opts.skipActiveCreditGuard) { + try { + const status = await getWalletStatus(ctx.walletHash, { + backendUrl: baseUrl, + }) + if (hasActiveCredits(status, model.id)) { + const session = findSession(status, model.id) + const remaining = formatDuration(session?.time_remaining_seconds ?? 0) + return { + success: false, + paid: false, + model: model.id, + displayName: model.display_name, + error: `${model.display_name || model.id} still has ${remaining} of active credits. A new plan cannot be purchased until they are used up or expire.`, + } + } + } catch (err: any) { + return { + success: false, + paid: false, + error: `Could not check existing credits before purchase: ${err?.message || err}`, + } + } + } + + if (paymentMethod === 'bch') { + try { + const balance = await ctx.bch.getBalance() + const availableSats = Math.round(balance.spendable * 1e8) + if (priceSats > 0 && availableSats < priceSats) { + return { + success: false, + paid: false, + model: model.id, + displayName: model.display_name, + priceSats, + error: `Insufficient balance: ${(availableSats / 1e8).toFixed( + 8 + )} BCH available but the ${minutes}-minute plan costs ${( + priceSats / 1e8 + ).toFixed(8)} BCH.`, + } + } + } catch { + // Balance check is best-effort; the payment flow will surface real errors. + } + } + + if (!opts.skipProbe && process.env.PAYTACA_PLAN_PROBE !== '0') { + try { + await probePlanStability(baseUrl, model.id, tier.minutes) + } catch (err: any) { + return { success: false, paid: false, error: err?.message || String(err) } + } + } + + const hdWallet = new LibauthHDWallet( + ctx.mnemonic, + BCH_DERIVATION_PATH, + isChipnet ? 'chipnet' : 'mainnet' + ) + const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 }) + const { url, headers, body } = buildPlanRequest( + baseUrl, + ctx.walletHash, + model, + tier, + paymentMethod + ) + + let firstResponse: Response + try { + firstResponse = await fetch(url, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(240000), + }) + } catch (err: any) { + return { success: false, paid: false, error: `Request failed: ${err?.message || err}` } + } + + const firstText = await firstResponse.text() + let firstData: any + try { + firstData = JSON.parse(firstText) + } catch { + firstData = firstText + } + + if (firstResponse.status !== 402) { + return { + success: true, + paid: false, + model: model.id, + displayName: model.display_name, + minutes: tier.minutes, + priceSats, + paymentMethod, + status: firstResponse.status, + data: firstData, + error: + firstResponse.ok + ? 'No purchase made: the model already has active credits, so the request was served without payment.' + : `Unexpected response (${firstResponse.status}).`, + } + } + + const paymentRequired = parsePaymentRequiredJson(firstData) + if (!paymentRequired) { + return { + success: false, + paid: false, + status: 402, + error: 'Could not parse PaymentRequired from 402 response body.', + } + } + const requirements = selectBchPaymentRequirements( + paymentRequired, + isChipnet ? 'chipnet' : 'mainnet' + ) + if (!requirements) { + return { + success: false, + paid: false, + status: 402, + error: 'Server does not accept BCH payment for this plan.', + } + } + + if (!opts.confirmed) { + return { + success: false, + paid: false, + status: 402, + model: model.id, + displayName: model.display_name, + minutes: tier.minutes, + priceSats, + paymentMethod, + recipientAddress: requirements.payTo, + error: 'Payment not confirmed.', + } + } + + const changeAddress = ctx.bch.getAddressSetAt(0).change + let txid: string + let vout = 0 + try { + if (paymentMethod === 'lift') { + const result = await payWithLift( + ctx, + hdWallet, + requirements, + changeAddress, + isChipnet + ) + txid = result.txid + vout = result.vout + } else { + if (!isValidBchAddress(requirements.payTo, isChipnet)) { + return { + success: false, + paid: false, + status: 402, + error: 'Server returned an invalid BCH payment address.', + } + } + const amountBch = Number(requirements.amount) / 1e8 + const sendResult = await ctx.bch.sendBch( + amountBch, + requirements.payTo, + changeAddress + ) + if (!sendResult.success || !sendResult.txid) { + return { + success: false, + paid: false, + status: 402, + error: sendResult.error || 'Transaction broadcast failed.', + } + } + txid = sendResult.txid + } + } catch (err: any) { + return { success: false, paid: false, status: 402, error: err?.message || String(err) } + } + + const paymentPayload = await x402Payer.createPaymentPayload( + requirements, + paymentRequired.resource.url, + txid, + vout, + requirements.amount + ) + const retryHeaders = { + ...headers, + 'PAYMENT-SIGNATURE': JSON.stringify(paymentPayload), + } + + let retryResponse: Response + try { + retryResponse = await fetch(url, { + method: 'POST', + headers: retryHeaders, + body, + signal: AbortSignal.timeout(240000), + }) + } catch (err: any) { + return { + success: true, + paid: true, + model: model.id, + displayName: model.display_name, + minutes: tier.minutes, + priceSats, + paymentMethod, + txid, + recipientAddress: requirements.payTo, + error: `Payment was broadcast but the response timed out: ${err?.message || err}. Check credits with \`paytaca ai credits\`.`, + } + } + + const retryText = await retryResponse.text() + let retryData: any + try { + retryData = JSON.parse(retryText) + } catch { + retryData = retryText + } + + let sessionActivated = false + let timeRemainingSeconds: number | undefined + try { + const status = await getWalletStatus(ctx.walletHash, { backendUrl: baseUrl }) + const session = findSession(status, model.id) + sessionActivated = hasActiveCredits(status, model.id) + timeRemainingSeconds = session?.time_remaining_seconds + } catch { + // Non-critical. + } + + return { + success: retryResponse.ok, + paid: true, + model: model.id, + displayName: model.display_name, + minutes: tier.minutes, + priceSats, + paymentMethod, + txid, + recipientAddress: requirements.payTo, + status: retryResponse.status, + sessionActivated, + timeRemainingSeconds, + data: retryData, + error: retryResponse.ok + ? undefined + : `Payment broadcast (txid ${txid}) but the backend returned ${retryResponse.status}.`, + } +} \ No newline at end of file diff --git a/src/commands/address.ts b/src/commands/address.ts index 9a9bc46..330884f 100644 --- a/src/commands/address.ts +++ b/src/commands/address.ts @@ -10,7 +10,7 @@ import { Command } from 'commander' import chalk from 'chalk' -import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { WalletNotConfiguredError, resolveNetwork, requireWallet } from '../core/context.js' export function registerAddressCommands(program: Command): void { const address = program @@ -24,9 +24,11 @@ export function registerAddressCommands(program: Command): void { .argument('[index]', 'Address index (default: 0)', '0') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') .option('--token', 'Show token-aware (z-prefix) addresses for CashTokens') + .option('--json', 'Output as JSON') .action(async (_index, opts) => { const isChipnet = Boolean(opts.chipnet) const isToken = Boolean(opts.token) + const asJson = Boolean(opts.json) const index = parseInt(_index, 10) if (isNaN(index) || index < 0) { @@ -34,45 +36,61 @@ export function registerAddressCommands(program: Command): void { process.exit(1) } - const data = loadMnemonic() - if (!data) { + try { + const ctx = requireWallet(isChipnet) + const network = resolveNetwork(isChipnet).network + const addressSet = isToken + ? ctx.bch.getTokenAddressSetAt(index) + : ctx.bch.getAddressSetAt(index) + + let subscribed = false + try { + subscribed = Boolean(await ctx.bch.getNewAddressSet(index)) + } catch { + // Non-critical + } + + if (asJson) { + console.log( + JSON.stringify( + { + network, + index, + token: isToken, + receiving: addressSet.receiving, + change: addressSet.change, + subscribed, + }, + null, + 2 + ) + ) + return + } + + const label = isToken ? 'Token address' : 'Address' + console.log(chalk.bold(`\n ${label} at index ${index} (${network})\n`)) + console.log(` Receiving: ${addressSet.receiving}`) + console.log(chalk.dim(` Change: ${addressSet.change}`)) + if (isToken) console.log(chalk.dim(' Type: token-aware (z-prefix)')) console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') + subscribed + ? chalk.dim(' Watching: subscribed with Watchtower') + : chalk.yellow(' Watching: not subscribed (address may not be monitored)') ) + console.log() + } catch (err: any) { + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) + process.exit(1) + } + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } + console.log(chalk.red(`\n Error: ${err.message || err}\n`)) process.exit(1) } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - const network = isChipnet ? 'chipnet' : 'mainnet' - - const addressSet = isToken - ? bchWallet.getTokenAddressSetAt(index) - : bchWallet.getAddressSetAt(index) - - // ── Subscribe the address with Watchtower ─────────────────────── - // The backend derives the token-aware variant automatically, so this - // covers both BCH and CashToken payments to the derived addresses. - let subscribed = false - try { - subscribed = Boolean(await bchWallet.getNewAddressSet(index)) - } catch { - // Non-critical: deriving the address should still succeed - } - - const label = isToken ? 'Token address' : 'Address' - console.log(chalk.bold(`\n ${label} at index ${index} (${network})\n`)) - console.log(` Receiving: ${addressSet.receiving}`) - console.log(chalk.dim(` Change: ${addressSet.change}`)) - if (isToken) { - console.log(chalk.dim(' Type: token-aware (z-prefix)')) - } - console.log( - subscribed - ? chalk.dim(' Watching: subscribed with Watchtower') - : chalk.yellow(' Watching: not subscribed (address may not be monitored)') - ) - console.log() }) // ── address list ─────────────────────────────────────────────────────── @@ -82,9 +100,11 @@ export function registerAddressCommands(program: Command): void { .option('-n, --count ', 'Number of addresses to derive', '5') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') .option('--token', 'Show token-aware (z-prefix) addresses for CashTokens') + .option('--json', 'Output as JSON') .action((_opts) => { const isChipnet = Boolean(_opts.chipnet) const isToken = Boolean(_opts.token) + const asJson = Boolean(_opts.json) const count = parseInt(_opts.count, 10) if (isNaN(count) || count < 1) { @@ -92,34 +112,48 @@ export function registerAddressCommands(program: Command): void { process.exit(1) } - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) + try { + const ctx = requireWallet(isChipnet) + const network = resolveNetwork(isChipnet).network + + const addresses = [] + for (let i = 0; i < count; i++) { + const addressSet = isToken + ? ctx.bch.getTokenAddressSetAt(i) + : ctx.bch.getAddressSetAt(i) + addresses.push({ + index: i, + receiving: addressSet.receiving, + change: addressSet.change, + }) + } + + if (asJson) { + console.log(JSON.stringify({ network, token: isToken, addresses }, null, 2)) + return + } + + const typeLabel = isToken ? 'Token Addresses' : 'Addresses' + console.log(chalk.bold(`\n ${typeLabel} (${network})\n`)) + console.log(chalk.dim(` ${'Index'.padEnd(8)}${'Receiving Address'}`)) + console.log(chalk.dim(` ${'─'.repeat(70)}`)) + + for (const a of addresses) { + console.log(` ${String(a.index).padEnd(8)}${a.receiving}`) + } + + console.log() + } catch (err: any) { + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) + process.exit(1) + } + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } + console.log(chalk.red(`\n Error: ${err.message || err}\n`)) process.exit(1) } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - const network = isChipnet ? 'chipnet' : 'mainnet' - - const typeLabel = isToken ? 'Token Addresses' : 'Addresses' - console.log(chalk.bold(`\n ${typeLabel} (${network})\n`)) - console.log( - chalk.dim( - ` ${'Index'.padEnd(8)}${'Receiving Address'}` - ) - ) - console.log(chalk.dim(` ${'─'.repeat(70)}`)) - - for (let i = 0; i < count; i++) { - const addressSet = isToken - ? bchWallet.getTokenAddressSetAt(i) - : bchWallet.getAddressSetAt(i) - console.log(` ${String(i).padEnd(8)}${addressSet.receiving}`) - } - - console.log() }) -} +} \ No newline at end of file diff --git a/src/commands/ai.ts b/src/commands/ai.ts new file mode 100644 index 0000000..6d5b724 --- /dev/null +++ b/src/commands/ai.ts @@ -0,0 +1,723 @@ +/** + * CLI commands: ai + * + * Paytaca AI integration: model catalogue, plan pricing, credits, plan + * purchase via x402 (BCH or LIFT), non-streaming chat, and auto-refill. + */ + +import { Command } from 'commander' +import chalk from 'chalk' +import readline from 'readline' +import { WalletNotConfiguredError } from '../core/context.js' +import { loadWalletRef } from '../wallet/index.js' +import { getBalanceView, getTokenBalances } from '../core/wallet.js' +import { formatSats, bchToSats } from '../utils/format.js' +import { formatUsd } from '../utils/prices.js' +import { + getConfig, + getWalletStatus, + type AiConfig, + type AiModelConfig, + type PriceTier, +} from '../ai/client.js' +import { resolveBackendUrl, LIFT_TOKEN_ID } from '../ai/config.js' +import { + listPlans, + selectModel, + selectTier, + formatDuration, + formatPriceUsd, +} from '../ai/models.js' +import { + summarizeCredits, + summarizeAllCredits, + getSessions, + formatRemaining, +} from '../ai/credits.js' +import { buyPlan, type PaymentMethod } from '../ai/purchase.js' +import { provisionApiKey } from '../ai/oauth.js' +import { configureHarness, type ConfigureResult } from '../ai/configure.js' +import { CLIENTS, type McpClient } from './mcp.js' +import { + armAutoRefill, + disarmAutoRefill, + readAutoRefillState, + remainingBudget, +} from '../ai/autoRefill.js' + +function outputJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)) +} + +async function loadConfigOrExit(opts: { backend?: string; json?: boolean }): Promise { + try { + return await getConfig({ backendUrl: opts.backend }) + } catch (err: any) { + const message = err?.message || String(err) + if (opts.json) outputJson({ error: message }) + else console.log(chalk.red(`\nError: ${message}\n`)) + process.exitCode = 1 + return null + } +} + +async function promptConfirmation(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + return new Promise((resolve) => { + rl.question(chalk.bold(`\n ${message} (y/N): `), (answer) => { + rl.close() + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') + }) + }) +} + +function promptSecret(message: string): Promise { + if (!process.stdin.isTTY) { + return Promise.reject(new Error('A terminal is required to enter the API key.')) + } + process.stdout.write(chalk.bold(`\n ${message}: `)) + const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => void } + stdin.setRawMode?.(true) + stdin.resume() + stdin.setEncoding('utf8') + + return new Promise((resolve) => { + let value = '' + const finish = (result: string) => { + stdin.removeListener('data', onData) + stdin.removeListener('end', onEnd) + stdin.setRawMode?.(false) + stdin.pause() + process.stdout.write('\n') + resolve(result) + } + const onData = (chunk: string) => { + for (const char of chunk) { + if (char === '\r' || char === '\n') { + finish(value) + return + } + if (char === '\u0003') { + finish('') + process.exit(130) + } + if (char === '\u007f' || char === '\b') { + value = value.slice(0, -1) + continue + } + value += char + } + } + const onEnd = () => finish(value) + stdin.on('data', onData) + stdin.on('end', onEnd) + }) +} + +function printPlanGroups(config: AiConfig, modelQuery?: string): void { + const groups = listPlans(config, modelQuery) + if (groups.length === 0) { + console.log(chalk.dim('\n No plans found.\n')) + return + } + for (const group of groups) { + console.log(chalk.bold(`\n ${group.tier.toUpperCase()}\n`)) + for (const model of group.models) { + console.log(` ${chalk.bold(model.displayName)} ${chalk.dim(`(${model.id})`)}`) + for (const tier of model.plans) { + const price = formatPriceUsd(tier.price_usd) + const sats = `${formatSats(Number(tier.price_sats) || 0)} sats` + console.log( + ` ${formatDuration(tier.minutes).padEnd(8)} ${price.padEnd(8)} ${chalk.dim(sats)}` + ) + } + console.log() + } + } +} + +export function registerAiCommands(program: Command): void { + const ai = program.command('ai').description('Paytaca AI: models, plans, credits, purchase') + + // ── ai configure ──────────────────────────────────────────────────── + ai.command('configure') + .description('Install the Paytaca MCP + AI provider into an AI harness') + .argument('[harness]', `Target harness: ${CLIENTS.join(', ')}`, 'opencode') + .option('--backend ', 'Override backend URL') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--path ', 'Override the target config file path') + .option('--api-key ', 'Use an existing Paytaca API key (required for read-only wallets)') + .option('-y, --yes', 'Buy a plan without prompting when no credits are active') + .option('--json', 'Output as JSON') + .action(async (harnessArg: string | undefined, opts) => { + const harness = String(harnessArg || 'opencode').toLowerCase() as McpClient + const json = Boolean(opts.json) + const interactive = + !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) + + const requestApiKey = async (): Promise => { + console.log(chalk.yellow('\n This wallet is read-only; an API key is required.')) + console.log( + chalk.dim(' Create one from your full wallet: paytaca ai api-key create') + ) + const entered = await promptSecret('Paste your Paytaca API key') + return entered.trim() || null + } + + let result: ConfigureResult + try { + result = await configureHarness({ + harness, + chipnet: Boolean(opts.chipnet), + backendUrl: opts.backend, + path: opts.path, + apiKey: opts.apiKey, + requestApiKey: interactive ? requestApiKey : undefined, + }) + } catch (err: any) { + if (json) outputJson({ error: err.message }) + else console.log(chalk.red(`\nError: ${err.message}\n`)) + process.exitCode = 1 + return + } + + let purchased: Awaited> | null = null + + if (!json && result.canSign && !result.creditsActive && result.planSuggestion) { + const plan = result.planSuggestion + const proceed = + Boolean(opts.yes) || + (await promptConfirmation( + `No active AI credits. Buy a ${plan.minutes}-minute plan for ${plan.displayName} (${formatPriceUsd(plan.priceUsd)})?` + )) + if (proceed) { + purchased = await buyPlan({ + model: plan.modelId, + minutes: plan.minutes, + paymentMethod: 'bch', + isChipnet: Boolean(opts.chipnet), + backendUrl: opts.backend, + confirmed: true, + }) + } + } + + if (json) { + outputJson({ ...result, purchased }) + if (!result.mcpInstalled || result.providerError) process.exitCode = 1 + return + } + + console.log(chalk.bold(`\n Paytaca AI — ${result.harness}\n`)) + if (result.path) console.log(` Config: ${result.path}`) + console.log( + ` MCP: ${result.mcpInstalled ? chalk.green('configured') : chalk.red('failed')}` + ) + if (result.harness === 'opencode') { + if (result.providerInstalled) { + const how = result.apiKeyReused + ? 'existing API key' + : result.apiKeyProvided + ? 'provided API key' + : 'new API key' + const suffix = result.apiKeyPrefix ? `, ${result.apiKeyPrefix}…` : '' + console.log( + ` Provider: ${chalk.green('configured')} ${chalk.dim(`(${how}${suffix})`)}` + ) + if (result.models.length > 0) { + console.log( + chalk.dim( + ` Models: ${result.models.length} available — use \`paytaca-ai/\`` + ) + ) + } + } else { + const detail = result.providerError ? chalk.dim(` (${result.providerError})`) : '' + console.log(` Provider: ${chalk.yellow('not configured')}${detail}`) + } + } + + if (result.creditsActive) { + console.log(chalk.green('\n AI credits active — ready to use.')) + } else if (purchased?.paid) { + console.log( + chalk.green( + `\n Plan purchased: ${purchased.displayName || purchased.model} — ${purchased.minutes} minutes.` + ) + ) + if (purchased.txid) console.log(chalk.dim(` txid: ${purchased.txid}`)) + } else if (purchased && !purchased.success) { + console.log(chalk.red(`\n Purchase failed: ${purchased.error || 'unknown error'}`)) + } else if (!result.canSign) { + console.log(chalk.yellow('\n No active AI credits.')) + if (result.planSuggestion) { + console.log( + chalk.dim( + ` Fund from your full wallet: paytaca ai purchase --model ${result.planSuggestion.modelId} --minutes ${result.planSuggestion.minutes}` + ) + ) + } + } else if (result.planSuggestion) { + console.log(chalk.yellow('\n No active AI credits.')) + console.log( + chalk.dim( + ` Buy a plan: paytaca ai purchase --model ${result.planSuggestion.modelId} --minutes ${result.planSuggestion.minutes}` + ) + ) + } + + console.log(chalk.dim('\n Restart your AI harness to load the new configuration.\n')) + if (!result.mcpInstalled) process.exitCode = 1 + }) + + // ── ai api-key ────────────────────────────────────────────────────── + const apiKey = ai.command('api-key').description('Manage Paytaca AI API keys') + + apiKey + .command('create') + .description('Create an API key bound to your wallet (requires a full wallet)') + .option('--name ', 'Label for the key', 'paytaca-cli') + .option('--backend ', 'Override backend URL') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const wallet = loadWalletRef() + if (!wallet) { + const message = + 'No wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.' + if (opts.json) outputJson({ error: message }) + else console.log(chalk.red(`\nError: ${message}\n`)) + process.exitCode = 1 + return + } + if (!wallet.canSign || !wallet.mnemonic) { + const message = 'This wallet is read-only. Create an API key from your full wallet.' + if (opts.json) outputJson({ error: message }) + else console.log(chalk.red(`\nError: ${message}\n`)) + process.exitCode = 1 + return + } + + try { + const created = await provisionApiKey({ + mnemonic: wallet.mnemonic, + walletHash: wallet.walletHash, + backendUrl: opts.backend, + name: opts.name, + }) + if (opts.json) { + outputJson(created) + return + } + console.log(chalk.bold('\n Paytaca AI API key created\n')) + console.log(` Name: ${created.name}`) + console.log(` Prefix: ${created.keyPrefix}`) + console.log(` API key: ${chalk.bold(created.key)}`) + console.log(chalk.yellow('\n Copy this key now — it will not be shown again.')) + console.log( + chalk.dim( + ' Then configure a harness: paytaca ai configure opencode --api-key \n' + ) + ) + } catch (err: any) { + if (opts.json) outputJson({ error: err.message }) + else console.log(chalk.red(`\nError: ${err.message}\n`)) + process.exitCode = 1 + } + }) + + // ── ai models ─────────────────────────────────────────────────────── + ai.command('models') + .description('List available AI models') + .option('--backend ', 'Override backend URL') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const config = await loadConfigOrExit(opts) + if (!config) return + const models = (config.models || []).map((m: AiModelConfig) => ({ + id: m.id, + displayName: m.display_name, + tier: m.tier || 'other', + plans: m.price_tiers || [], + })) + if (opts.json) { + outputJson({ defaultModel: config.default_model, models }) + return + } + console.log(chalk.bold('\n AI Models\n')) + for (const m of models) { + console.log(` ${chalk.bold(m.displayName)} ${chalk.dim(`(${m.id})`)}`) + if (m.plans.length > 0) { + const cheapest = m.plans.reduce((a: PriceTier, b: PriceTier) => + a.price_usd !== undefined && (b.price_usd === undefined || a.price_usd <= b.price_usd) ? a : b + ) + console.log( + chalk.dim(` from ${formatPriceUsd(cheapest.price_usd)} · ${m.plans.length} plans`) + ) + } + } + console.log() + }) + + // ── ai plans ──────────────────────────────────────────────────────── + ai.command('plans') + .description('Show plan pricing for AI models') + .argument('[model]', 'Filter to a single model id or display name') + .option('--backend ', 'Override backend URL') + .option('--json', 'Output as JSON') + .action(async (model: string | undefined, opts) => { + const config = await loadConfigOrExit(opts) + if (!config) return + const groups = listPlans(config, model) + if (opts.json) { + outputJson({ + discountPercent: config.lift_payment_discount_percent ?? 0, + groups, + }) + return + } + console.log(chalk.bold(`\n AI Plans${model ? ` — ${model}` : ''}\n`)) + printPlanGroups(config, model) + const discount = config.lift_payment_discount_percent + if (discount) { + console.log(chalk.green(` Pay with LIFT for a ${discount}% discount.\n`)) + } + }) + + // ── ai credits ────────────────────────────────────────────────────── + ai.command('credits') + .description('Show remaining AI time credits') + .option('--model ', 'Filter to a single model') + .option('--backend ', 'Override backend URL') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const wallet = loadWalletRef() + if (!wallet) { + const err = new WalletNotConfiguredError() + if (opts.json) outputJson({ error: err.message }) + else console.log(chalk.red(`\n${err.message}\n`)) + process.exitCode = 1 + return + } + const walletHash = wallet.walletHash + try { + const status = await getWalletStatus(walletHash, { + backendUrl: opts.backend, + modelId: opts.model, + }) + if (opts.json) { + if (opts.model) { + outputJson({ walletHash, ...summarizeCredits(status, opts.model) }) + } else { + outputJson({ walletHash, sessions: summarizeAllCredits(status) }) + } + return + } + const summaries = opts.model + ? [summarizeCredits(status, opts.model)] + : summarizeAllCredits(status) + console.log(chalk.bold('\n AI Credits\n')) + if (summaries.length === 0) { + console.log(chalk.dim(' No sessions found.\n')) + return + } + for (const summary of summaries) { + console.log( + ` ${chalk.bold(summary.displayName || summary.modelId || 'unknown')}` + ) + console.log(` Model: ${summary.modelId || '(unknown)'}`) + console.log(` Remaining: ${formatRemaining(summary.timeRemainingSeconds)}`) + console.log(` Used: ${formatRemaining(summary.timeUsedSeconds)}`) + console.log(` Total: ${formatRemaining(summary.timeCreditsSeconds)}`) + console.log( + ` Status: ${summary.active ? chalk.green('active') : chalk.dim('inactive')}` + ) + console.log() + } + } catch (err: any) { + if (opts.json) outputJson({ error: err.message }) + else console.log(chalk.red(`\nError: ${err.message}\n`)) + process.exitCode = 1 + } + }) + + // ── ai usage ──────────────────────────────────────────────────────── + ai.command('usage') + .description('Show per-session AI usage') + .option('--backend ', 'Override backend URL') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const wallet = loadWalletRef() + if (!wallet) { + const err = new WalletNotConfiguredError() + if (opts.json) outputJson({ error: err.message }) + else console.log(chalk.red(`\n${err.message}\n`)) + process.exitCode = 1 + return + } + const walletHash = wallet.walletHash + try { + const status = await getWalletStatus(walletHash, { backendUrl: opts.backend }) + const sessions = getSessions(status).map((s) => ({ + model: s.model_id || s.ai_model || null, + displayName: s.display_name || null, + active: s.model_active === true || s.session_active === true, + remainingSeconds: s.time_remaining_seconds ?? 0, + usedSeconds: s.time_used_seconds ?? 0, + creditsSeconds: s.time_credits_seconds ?? 0, + })) + if (opts.json) { + outputJson({ walletHash, sessions }) + return + } + console.log(chalk.bold('\n AI Usage\n')) + if (sessions.length === 0) { + console.log(chalk.dim(' No sessions found.\n')) + return + } + for (const s of sessions) { + console.log(` ${chalk.bold(s.displayName || s.model || 'unknown')}`) + console.log( + chalk.dim( + ` used ${formatRemaining(s.usedSeconds)} · remaining ${formatRemaining( + s.remainingSeconds + )} of ${formatRemaining(s.creditsSeconds)}` + ) + ) + console.log() + } + } catch (err: any) { + if (opts.json) outputJson({ error: err.message }) + else console.log(chalk.red(`\nError: ${err.message}\n`)) + process.exitCode = 1 + } + }) + + // ── ai balance ────────────────────────────────────────────────────── + ai.command('balance') + .description('Show wallet balance available for AI plan purchases (BCH + LIFT)') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const isChipnet = Boolean(opts.chipnet) + try { + const balance = await getBalanceView(isChipnet) + const tokens = await getTokenBalances(isChipnet) + const lift = tokens.tokens.find((t) => t.category === LIFT_TOKEN_ID) + if (opts.json) { + outputJson({ + network: balance.network, + bch: { + balance: balance.balanceBch, + spendable: balance.spendableBch, + sats: balance.spendableSats, + usd: balance.usd, + }, + lift: lift + ? { + category: lift.category, + symbol: lift.symbol, + displayBalance: lift.displayBalance, + rawBalance: lift.rawBalance, + } + : null, + }) + return + } + console.log(chalk.bold(`\n Funds for AI Plans (${balance.network})\n`)) + console.log(` BCH: ${balance.spendableBch} BCH ${chalk.dim(`(${formatSats(balance.spendableSats)} sats)`)}`) + if (balance.usd !== null) { + console.log(chalk.dim(` ≈ ${formatUsd(balance.usd)}`)) + } + if (lift) { + console.log(` LIFT: ${lift.displayBalance} ${lift.symbol || 'LIFT'}`) + } else { + console.log(chalk.dim(' LIFT: none')) + } + console.log() + } catch (err: any) { + if (opts.json) outputJson({ error: err.message }) + else console.log(chalk.red(`\nError: ${err.message}\n`)) + process.exitCode = 1 + } + }) + + // ── ai purchase ───────────────────────────────────────────────────── + ai.command('purchase') + .description('Purchase an AI plan (x402 payment with BCH or LIFT)') + .requiredOption('--model ', 'Model id or display name') + .requiredOption('--minutes ', 'Plan duration in minutes') + .option('--lift', 'Pay with LIFT tokens (discount) instead of BCH') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--backend ', 'Override backend URL') + .option('-y, --yes', 'Skip confirmation prompt') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const paymentMethod: PaymentMethod = opts.lift ? 'lift' : 'bch' + const minutes = Number(opts.minutes) + const json = Boolean(opts.json) + + if (!Number.isFinite(minutes) || minutes <= 0) { + if (json) outputJson({ error: 'Minutes must be a positive number.' }) + else console.log(chalk.red('\nError: Minutes must be a positive number.\n')) + process.exitCode = 1 + return + } + + const config = await loadConfigOrExit(opts) + if (!config) return + const model = selectModel(config.models || [], opts.model) + if (!model) { + const err = `Unknown model "${opts.model}".` + if (json) outputJson({ error: err }) + else console.log(chalk.red(`\nError: ${err}\n`)) + process.exitCode = 1 + return + } + const tier = selectTier(model, minutes) + if (!tier) { + const err = `No ${minutes}-minute plan for ${model.display_name || model.id}.` + if (json) outputJson({ error: err }) + else console.log(chalk.red(`\nError: ${err}\n`)) + process.exitCode = 1 + return + } + + let discountPercent = 0 + if (paymentMethod === 'lift') { + discountPercent = config.lift_payment_discount_percent ?? 0 + } + const rawSats = Number(tier.price_sats) || 0 + const discountSats = Math.round(rawSats * (discountPercent / 100)) + const effectiveSats = rawSats - discountSats + + const confirmed = + Boolean(opts.yes) || + (json + ? false + : await promptConfirmation( + `Buy ${tier.minutes}-minute plan for ${model.display_name || model.id} for ${ + (effectiveSats / 1e8).toFixed(8) + } BCH${paymentMethod === 'lift' ? ' (paid with LIFT)' : ''}?` + )) + + if (json && !opts.yes) { + outputJson({ + error: 'Payment not confirmed. Re-run with --yes to execute.', + model: model.id, + displayName: model.display_name, + minutes: tier.minutes, + priceSats: effectiveSats, + paymentMethod, + }) + process.exitCode = 1 + return + } + + if (!confirmed) { + if (json) outputJson({ error: 'Payment rejected by user.' }) + else console.log(chalk.dim('\n Purchase cancelled.\n')) + process.exitCode = 1 + return + } + + const result = await buyPlan({ + model: model.id, + minutes: tier.minutes, + paymentMethod, + isChipnet: Boolean(opts.chipnet), + backendUrl: opts.backend, + confirmed: true, + }) + + if (json) { + outputJson(result) + if (!result.success) process.exitCode = 1 + return + } + + if (!result.success) { + console.log(chalk.red(`\n Error: ${result.error || 'Purchase failed.'}\n`)) + process.exitCode = 1 + return + } + if (!result.paid) { + console.log(chalk.yellow(`\n ${result.error || 'No purchase made.'}\n`)) + return + } + console.log(chalk.green(`\n Plan purchased: ${result.displayName || result.model} — ${result.minutes} minutes.`)) + if (result.paymentMethod === 'lift') { + console.log(chalk.dim(` Paid with LIFT${discountPercent ? ` (${discountPercent}% discount)` : ''}.`)) + } + if (result.txid) console.log(` txid: ${result.txid}`) + if (result.timeRemainingSeconds !== undefined) { + console.log(chalk.dim(` Credits remaining: ${formatRemaining(result.timeRemainingSeconds)}`)) + } + console.log() + }) + + // ── ai auto-refill ────────────────────────────────────────────────── + ai.command('auto-refill') + .description('Arm, disarm, or inspect automatic plan refills') + .option('--enable', 'Arm auto-refill') + .option('--disable', 'Disarm auto-refill') + .option('--status', 'Show auto-refill status (default)') + .option('--model ', 'Model id or display name to auto-refill') + .option('--minutes ', 'Plan size in minutes per refill') + .option('--max-minutes ', 'Maximum total minutes to auto-buy') + .option('--lift', 'Pay refills with LIFT tokens') + .option('--json', 'Output as JSON') + .action((opts) => { + if (opts.enable) { + const minutes = opts.minutes !== undefined ? Number(opts.minutes) : undefined + const maxMinutes = + opts.maxMinutes !== undefined ? Number(opts.maxMinutes) : undefined + const badMinutes = minutes !== undefined && (!Number.isFinite(minutes) || minutes <= 0) + const badMaxMinutes = + maxMinutes !== undefined && (!Number.isFinite(maxMinutes) || maxMinutes <= 0) + if (badMinutes || badMaxMinutes) { + const message = '--minutes and --max-minutes must be positive numbers.' + if (opts.json) outputJson({ error: message }) + else console.log(chalk.red(`\nError: ${message}\n`)) + process.exitCode = 1 + return + } + const state = armAutoRefill({ + model: opts.model, + minutes, + maxMinutes, + paymentMethod: opts.lift ? 'lift' : 'bch', + }) + if (opts.json) outputJson(state) + else console.log(chalk.green(`\n Auto-refill armed${state.model ? ` for ${state.model}` : ''}.\n`)) + return + } + if (opts.disable) { + const state = disarmAutoRefill() + if (opts.json) outputJson(state) + else console.log(chalk.dim('\n Auto-refill disarmed.\n')) + return + } + const state = readAutoRefillState() + if (opts.json) { + outputJson({ state, remainingMinutes: remainingBudget(state) }) + return + } + console.log(chalk.bold('\n Auto-Refill\n')) + if (!state) { + console.log(chalk.dim(' Not configured.\n')) + return + } + console.log(` Enabled: ${state.enabled ? chalk.green('yes') : chalk.dim('no')}`) + console.log(` Model: ${state.model || '(any)'}`) + console.log(` Plan: ${state.minutes ? formatDuration(state.minutes) : '(any)'}`) + console.log(` Payment: ${state.paymentMethod || 'bch'}`) + const budget = remainingBudget(state) + console.log(` Budget: ${budget === null ? '(unlimited)' : `${budget} min remaining`}`) + console.log() + }) +} \ No newline at end of file diff --git a/src/commands/balance.ts b/src/commands/balance.ts index b30d9bd..d266266 100644 --- a/src/commands/balance.ts +++ b/src/commands/balance.ts @@ -13,123 +13,54 @@ import { Command } from 'commander' import chalk from 'chalk' -import { loadWallet, loadMnemonic } from '../wallet/index.js' -import type { BchWallet } from '../wallet/bch.js' +import { WalletNotConfiguredError } from '../core/context.js' import { - getBchUsdPrice, - formatUsd, - fetchAssetPrices, - getUsdPerToken, - tokenAmountToUsd, -} from '../utils/prices.js' - -/** Convert BCH to satoshis (1 BCH = 100,000,000 sats) */ -function bchToSats(bch: number): number { - return Math.round(bch * 1e8) -} - -/** Format a number with thousands separators */ -function formatSats(sats: number): string { - return sats.toLocaleString('en-US') -} - -/** Format a token amount with decimals */ -function formatTokenAmount(rawAmount: number, decimals: number): string { - if (decimals === 0) return rawAmount.toLocaleString('en-US') - const scaled = rawAmount / Math.pow(10, decimals) - return scaled.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: decimals, - }) -} - -/** Print the BCH balance (BCH + USD, or sats with --sats). */ -async function showBchBalance( - bchWallet: BchWallet, - isChipnet: boolean, - showSatsOnly: boolean -): Promise { - const result = await bchWallet.getBalance() - - const balanceSats = bchToSats(result.balance) - const spendableSats = bchToSats(result.spendable) - + getBalanceView, + getTokenBalances, + getTokenDetail, + type BalanceView, + type TokenBalanceView, +} from '../core/wallet.js' +import { formatSats } from '../utils/format.js' +import { formatUsd } from '../utils/prices.js' + +function printBchBalance(balance: BalanceView, showSatsOnly: boolean): void { if (showSatsOnly) { - console.log(` Balance: ${formatSats(balanceSats)} sats`) - if (result.spendable !== result.balance) { - console.log(chalk.dim(` Spendable: ${formatSats(spendableSats)} sats`)) + console.log(` Balance: ${formatSats(balance.balanceSats)} sats`) + if (balance.spendableBch !== balance.balanceBch) { + console.log(chalk.dim(` Spendable: ${formatSats(balance.spendableSats)} sats`)) } return } - let usdPerBch: number | null = null - try { - usdPerBch = await getBchUsdPrice(isChipnet) - } catch { - // USD price unavailable — show BCH only - } - - console.log(` Balance: ${result.balance} BCH`) - if (usdPerBch !== null) { - console.log( - chalk.dim(` ≈ ${formatUsd(result.balance * usdPerBch)}`) - ) + console.log(` Balance: ${balance.balanceBch} BCH`) + if (balance.usd !== null) { + console.log(chalk.dim(` ≈ ${formatUsd(balance.usd)}`)) } } -/** Print CashToken balances, excluding tokens with a zero balance. */ -async function showTokenBalances( - bchWallet: BchWallet, - isChipnet: boolean -): Promise { - const tokens = (await bchWallet.getFungibleTokens()).filter((t) => t.balance > 0) - +function printTokenBalances(tokens: TokenBalanceView[]): void { if (tokens.length === 0) { console.log(chalk.dim(' No CashTokens with a positive balance.\n')) return } - // Fetch USD prices for all held tokens (batched by the util) - const prices = new Map() - try { - const priceData = await fetchAssetPrices( - tokens.map((t) => `ct/${t.category}`), - ['USD'], - isChipnet - ) - for (const p of priceData) { - if (String(p.currency || '').toLowerCase() !== 'usd') continue - const catMatch = String(p.asset || '').match(/^ct\/([a-fA-F0-9]+)$/) - if (!catMatch) continue - const raw = parseFloat(p.price_value) - if (!isFinite(raw) || raw === 0) continue - prices.set(catMatch[1], 1 / raw) - } - } catch { - // Pricing unavailable — proceed without USD values - } - let totalUsd = 0 let pricedCount = 0 console.log(chalk.dim(' Tokens:')) for (const t of tokens) { - const amount = formatTokenAmount(t.balance, t.decimals) const symbol = t.symbol ? ` ${t.symbol}` : '' const name = t.name !== 'Unknown Token' ? t.name : '' - const usdPerToken = prices.get(t.category) - console.log(` ${chalk.bold(amount + symbol)}`) - if (name) { - console.log(chalk.dim(` ${name}`)) - } + console.log(` ${chalk.bold(t.displayBalance + symbol)}`) + if (name) console.log(chalk.dim(` ${name}`)) console.log(chalk.dim(` ${t.category}`)) - if (usdPerToken !== undefined) { - const usdValue = tokenAmountToUsd(t.balance, t.decimals, usdPerToken) - totalUsd += usdValue + if (t.usd !== null) { + totalUsd += t.usd pricedCount += 1 - console.log(chalk.green(` ≈ ${formatUsd(usdValue)}`)) + console.log(chalk.green(` ≈ ${formatUsd(t.usd)}`)) } console.log() } @@ -142,6 +73,25 @@ async function showTokenBalances( } } +function handleError(err: any): void { + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } + const status = err?.response?.status + if (status === 404) { + console.log( + chalk.yellow(' Wallet not yet registered with Watchtower on this network.') + ) + console.log( + chalk.dim(' Run `paytaca wallet create` or `paytaca wallet import` to register.') + ) + return + } + console.log(chalk.red(` Error fetching balance: ${err.message || err}`)) + process.exit(1) +} + export function registerBalanceCommand(program: Command): void { program .command('balance') @@ -151,11 +101,13 @@ export function registerBalanceCommand(program: Command): void { .option('--tokens', 'Show CashToken balances only (excludes BCH)') .option('--all', 'Show BCH and CashToken balances') .option('--sats', 'Display BCH balance in satoshis only') + .option('--json', 'Output as JSON') .action(async (opts) => { const isChipnet = Boolean(opts.chipnet) const showSatsOnly = Boolean(opts.sats) const showTokens = Boolean(opts.tokens) const showAll = Boolean(opts.all) + const asJson = Boolean(opts.json) const network = isChipnet ? 'chipnet' : 'mainnet' const tokenId: string = opts.token || '' @@ -176,102 +128,69 @@ export function registerBalanceCommand(program: Command): void { process.exit(1) } - // ── Validate wallet ────────────────────────────────────────────── - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red( - '\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n' - ) - ) - process.exit(1) - } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - try { if (tokenId) { - // ── Specific token balance ────────────────────────────────── - let tokenName = '' - let tokenSymbol = '' - let decimals = 0 - - try { - const info = await bchWallet.getTokenInfo(tokenId) - if (info) { - tokenName = info.name !== 'Unknown Token' ? info.name : '' - tokenSymbol = info.symbol || '' - decimals = info.decimals || 0 - } - } catch { - // Token info unavailable — proceed without metadata + const detail = await getTokenDetail(tokenId, isChipnet) + if (asJson) { + console.log(JSON.stringify(detail, null, 2)) + return } - - const label = tokenSymbol || tokenName || 'Token' + const label = detail.symbol || (detail.name !== 'Unknown Token' ? detail.name : '') || 'Token' console.log(chalk.bold(`\n ${label} Balance (${network})\n`)) console.log(chalk.dim(` Category: ${tokenId}`)) - if (tokenName) { - console.log(chalk.dim(` Name: ${tokenName}`)) + if (detail.name !== 'Unknown Token') { + console.log(chalk.dim(` Name: ${detail.name}`)) } - - const result = await bchWallet.getTokenBalance(tokenId) - const displayBalance = decimals > 0 - ? (result.balance / 10 ** decimals) - : result.balance - const unit = tokenSymbol || 'tokens' - - let usdPerToken: number | undefined - try { - const p = await getUsdPerToken(tokenId, isChipnet) - if (p !== null) usdPerToken = p - } catch { - // Pricing unavailable — show token only + const unit = detail.symbol || 'tokens' + console.log(` Balance: ${detail.displayBalance} ${unit}`) + if (detail.usd !== null) { + console.log(chalk.dim(` ≈ ${formatUsd(detail.usd)}`)) } + console.log() + return + } - console.log(` Balance: ${displayBalance} ${unit}`) - if (usdPerToken !== undefined) { - const usdValue = tokenAmountToUsd(result.balance, decimals, usdPerToken) + if (showTokens || showAll) { + const tokenResult = await getTokenBalances(isChipnet) + const balance = showAll ? await getBalanceView(isChipnet) : null + + if (asJson) { console.log( - chalk.dim(` ≈ ${formatUsd(usdValue)}`) + JSON.stringify( + showAll ? { balance, tokens: tokenResult.tokens } : tokenResult.tokens, + null, + 2 + ) ) + return } - } else if (showTokens || showAll) { - // ── Token balances, optionally with BCH ───────────────────── + const title = showAll ? 'Balances' : 'Token Balances' console.log(chalk.bold(`\n ${title} (${network})\n`)) - - if (showAll) { - await showBchBalance(bchWallet, isChipnet, showSatsOnly) + if (balance) { + printBchBalance(balance, showSatsOnly) console.log() } - await showTokenBalances(bchWallet, isChipnet) - } else { - // ── BCH balance ───────────────────────────────────────────── - console.log(chalk.bold(`\n Balance (${network})\n`)) - await showBchBalance(bchWallet, isChipnet, showSatsOnly) + printTokenBalances(tokenResult.tokens) + console.log() + return } + + const balance = await getBalanceView(isChipnet) + if (asJson) { + console.log(JSON.stringify(balance, null, 2)) + return + } + + console.log(chalk.bold(`\n Balance (${network})\n`)) + printBchBalance(balance, showSatsOnly) + console.log() } catch (err: any) { - const status = err?.response?.status - if (status === 404) { - console.log( - chalk.yellow( - ' Wallet not yet registered with Watchtower on this network.' - ) - ) - console.log( - chalk.dim( - ' Run `paytaca wallet create` or `paytaca wallet import` to register.' - ) - ) - } else { - console.log( - chalk.red(` Error fetching balance: ${err.message || err}`) - ) + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) process.exit(1) } + handleError(err) } - - console.log() }) } \ No newline at end of file diff --git a/src/commands/history.ts b/src/commands/history.ts index 0a936ac..5284593 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -10,34 +10,11 @@ import { Command } from 'commander' import chalk from 'chalk' -import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { WalletNotConfiguredError } from '../core/context.js' +import { getHistoryView } from '../core/wallet.js' +import { bchToSats, formatDate, shortTxid } from '../utils/format.js' import { formatUsd } from '../utils/prices.js' -/** Convert BCH to satoshis (1 BCH = 100,000,000 sats) */ -function bchToSats(bch: number): number { - return Math.round(bch * 1e8) -} - -/** Format a date string to a concise local representation */ -function formatDate(isoDate: string): string { - const d = new Date(isoDate) - if (isNaN(d.getTime())) return isoDate - return d.toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) -} - -/** Truncate a txid for display */ -function shortTxid(txid: string): string { - if (txid.length <= 20) return txid - return txid.slice(0, 10) + '...' + txid.slice(-10) -} - export function registerHistoryCommand(program: Command): void { program .command('history') @@ -51,9 +28,11 @@ export function registerHistoryCommand(program: Command): void { ) .option('--token ', 'Filter by CashToken category ID (64-character hex)') .option('--sats', 'Display amounts in satoshis') + .option('--json', 'Output as JSON') .action(async (opts) => { const isChipnet = Boolean(opts.chipnet) const showSats = Boolean(opts.sats) + const asJson = Boolean(opts.json) const network = isChipnet ? 'chipnet' : 'mainnet' const page = parseInt(opts.page, 10) const recordType: string = opts.type @@ -78,62 +57,26 @@ export function registerHistoryCommand(program: Command): void { process.exit(1) } - // ── Validate wallet ────────────────────────────────────────────── - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red( - '\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n' - ) - ) - process.exit(1) - } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) + try { + const view = await getHistoryView({ page, recordType, tokenId }, isChipnet) - // Resolve token label for header - let headerLabel = 'Transaction History' - if (tokenId) { - try { - const info = await bchWallet.getTokenInfo(tokenId) - if (info?.symbol) { - headerLabel = `${info.symbol} Transaction History` - } else if (info?.name && info.name !== 'Unknown Token') { - headerLabel = `${info.name} Transaction History` - } else { - headerLabel = `Token Transaction History` - } - } catch { - headerLabel = 'Token Transaction History' + if (asJson) { + console.log(JSON.stringify(view, null, 2)) + return } - } - console.log(chalk.bold(`\n ${headerLabel} (${network})\n`)) + console.log(chalk.bold(`\n ${view.headerLabel} (${network})\n`)) + if (tokenId) console.log(chalk.dim(` Category: ${tokenId}\n`)) - if (tokenId) { - console.log(chalk.dim(` Category: ${tokenId}\n`)) - } - - try { - const result = await bchWallet.getHistory({ page, recordType, tokenId }) - - if (!result.history || result.history.length === 0) { + if (view.records.length === 0) { console.log(chalk.dim(' No transactions found.\n')) return } - const explorer = isChipnet - ? 'https://chipnet.chaingraph.cash/tx/' - : 'https://bchexplorer.info/tx/' - - for (const tx of result.history) { + for (const tx of view.records) { const isIncoming = tx.record_type === 'incoming' - const arrow = isIncoming - ? chalk.green(' IN') - : chalk.red(' OUT') + const arrow = isIncoming ? chalk.green(' IN') : chalk.red(' OUT') - // When filtering by token, the amount is the token amount (not BCH) const amount = tokenId ? `${tx.amount}` : showSats @@ -141,18 +84,22 @@ export function registerHistoryCommand(program: Command): void { : `${tx.amount} BCH` let usdSuffix = '' - if (!tokenId && typeof tx.usd_price === 'number' && tx.usd_price > 0 && tx.amount != null) { + if ( + !tokenId && + typeof tx.usd_price === 'number' && + tx.usd_price > 0 && + tx.amount != null + ) { const usdValue = tx.amount * tx.usd_price - if (usdValue > 0) { - usdSuffix = chalk.dim(` | ≈ ${formatUsd(usdValue)}`) - } + if (usdValue > 0) usdSuffix = chalk.dim(` | ≈ ${formatUsd(usdValue)}`) } - const amountColored = (isIncoming - ? chalk.green(`+${amount}`) - : chalk.red(`-${amount}`)) + usdSuffix - + const amountColored = + (isIncoming ? chalk.green(`+${amount}`) : chalk.red(`-${amount}`)) + usdSuffix const date = formatDate(tx.tx_timestamp || tx.date_created) + const explorer = isChipnet + ? 'https://chipnet.bchexplorer.info/tx/' + : 'https://bchexplorer.info/tx/' console.log(` ${arrow} ${amountColored}`) console.log(chalk.dim(` ${date}`)) @@ -161,18 +108,25 @@ export function registerHistoryCommand(program: Command): void { console.log() } - // ── Pagination info ──────────────────────────────────────────── - const pageNum = parseInt(result.page, 10) || page const tokenFlag = tokenId ? ` --token ${tokenId}` : '' console.log( chalk.dim( - ` Page ${pageNum} of ${result.num_pages}` + - (result.has_next - ? ` — next: paytaca history --page ${pageNum + 1}${tokenFlag}${isChipnet ? ' --chipnet' : ''}` + ` Page ${view.page} of ${view.numPages}` + + (view.hasNext + ? ` — next: paytaca history --page ${view.page + 1}${tokenFlag}${isChipnet ? ' --chipnet' : ''}` : '') ) ) + console.log() } catch (err: any) { + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) + process.exit(1) + } + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } const status = err?.response?.status if (status === 404) { console.log( @@ -186,13 +140,10 @@ export function registerHistoryCommand(program: Command): void { ) ) } else { - console.log( - chalk.red(` Error fetching history: ${err.message || err}`) - ) + console.log(chalk.red(` Error fetching history: ${err.message || err}`)) process.exit(1) } + console.log() } - - console.log() }) -} +} \ No newline at end of file diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts new file mode 100644 index 0000000..b24440e --- /dev/null +++ b/src/commands/mcp.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { CLIENTS, buildTemplate } from './mcp.js' + +describe('mcp config templates', () => { + it('exposes every supported client', () => { + expect(CLIENTS).toEqual(['claude', 'opencode', 'cursor', 'codex', 'pi', 'generic']) + }) + + it('opencode uses the local mcp block with an array command', () => { + const { json } = buildTemplate('opencode', false) + expect(json).toEqual({ + mcp: { + paytaca: { + type: 'local', + command: ['paytaca', 'mcp'], + enabled: true, + }, + }, + }) + }) + + it('claude and cursor use mcpServers with command/args', () => { + for (const client of ['claude', 'cursor', 'generic'] as const) { + const { json } = buildTemplate(client, false) + expect(json).toEqual({ + mcpServers: { paytaca: { command: 'paytaca', args: ['mcp'] } }, + }) + } + }) + + it('pi writes the standard mcpServers block to the adapter path', () => { + const template = buildTemplate('pi', false) + expect(template.format).toBe('json') + expect(template.path).toBe(join(homedir(), '.pi', 'agent', 'mcp.json')) + expect(template.json).toEqual({ + mcpServers: { paytaca: { command: 'paytaca', args: ['mcp'] } }, + }) + expect(template.instructions).toMatch(/pi-mcp-adapter/) + }) + + it('codex emits a TOML table', () => { + const template = buildTemplate('codex', false) + expect(template.format).toBe('toml') + expect(template.toml).toContain('[mcp_servers.paytaca]') + expect(template.toml).toContain('command = "paytaca"') + }) + + it('adds --chipnet when requested', () => { + const template = buildTemplate('opencode', true) + expect(template.json).toMatchObject({ + mcp: { paytaca: { command: ['paytaca', 'mcp', '--chipnet'] } }, + }) + }) +}) \ No newline at end of file diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..57c61f2 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,130 @@ +import { join } from 'node:path' +import { homedir } from 'node:os' +import { Command } from 'commander' +import { runMcpServer } from '../mcp/server.js' + +export type McpClient = 'claude' | 'opencode' | 'cursor' | 'codex' | 'pi' | 'generic' + +export const CLIENTS: McpClient[] = ['claude', 'opencode', 'cursor', 'codex', 'pi', 'generic'] + +export interface ClientTemplate { + client: McpClient + format: 'json' | 'toml' + path: string | null + json?: Record + toml?: string + snippet: string + instructions: string +} + +function serverArgs(chipnet: boolean): string[] { + return chipnet ? ['mcp', '--chipnet'] : ['mcp'] +} + +function claudeConfigPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') + } + return join(homedir(), '.config', 'Claude', 'claude_desktop_config.json') +} + +export function buildTemplate(client: McpClient, chipnet: boolean): ClientTemplate { + const args = serverArgs(chipnet) + + switch (client) { + case 'opencode': { + const json = { + mcp: { + paytaca: { + type: 'local', + command: ['paytaca', ...args], + enabled: true, + }, + }, + } + return { + client, + format: 'json', + path: join(homedir(), '.config', 'opencode', 'opencode.json'), + json, + snippet: JSON.stringify(json, null, 2), + instructions: + 'Add this to your project opencode.json or ~/.config/opencode/opencode.json, then restart opencode.', + } + } + case 'cursor': { + const json = { + mcpServers: { paytaca: { command: 'paytaca', args } }, + } + return { + client, + format: 'json', + path: join(homedir(), '.cursor', 'mcp.json'), + json, + snippet: JSON.stringify(json, null, 2), + instructions: 'Add this to ~/.cursor/mcp.json, then restart Cursor.', + } + } + case 'codex': { + const toml = `[mcp_servers.paytaca]\ncommand = "paytaca"\nargs = ${JSON.stringify(args)}\n` + return { + client, + format: 'toml', + path: join(homedir(), '.codex', 'config.toml'), + toml, + snippet: toml, + instructions: 'Append this to ~/.codex/config.toml, then restart Codex.', + } + } + case 'pi': { + const json = { + mcpServers: { paytaca: { command: 'paytaca', args } }, + } + return { + client, + format: 'json', + path: join(homedir(), '.pi', 'agent', 'mcp.json'), + json, + snippet: JSON.stringify(json, null, 2), + instructions: + 'Requires the pi-mcp-adapter extension: run `pi install npm:pi-mcp-adapter` and restart Pi. Pi reads standard mcpServers config from ~/.pi/agent/mcp.json (or ~/.config/mcp/mcp.json).', + } + } + case 'claude': { + const json = { + mcpServers: { paytaca: { command: 'paytaca', args } }, + } + return { + client, + format: 'json', + path: claudeConfigPath(), + json, + snippet: JSON.stringify(json, null, 2), + instructions: `Add this to ${claudeConfigPath()}, then restart Claude. For Claude Code you can instead run: claude mcp add paytaca -- paytaca ${args.join(' ')}`, + } + } + default: { + const json = { + mcpServers: { paytaca: { command: 'paytaca', args } }, + } + return { + client, + format: 'json', + path: null, + json, + snippet: JSON.stringify(json, null, 2), + instructions: 'Add this to your MCP client configuration.', + } + } + } +} + +export function registerMcpCommand(program: Command): void { + program + .command('mcp') + .description('Run the Paytaca MCP server (stdio) for AI agents') + .option('--chipnet', 'Default tools to chipnet (testnet) instead of mainnet') + .action(async (opts) => { + await runMcpServer({ defaultChipnet: Boolean(opts.chipnet) }) + }) +} \ No newline at end of file diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 879bb3c..936cacb 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -173,7 +173,7 @@ async function runPayHuman( if (result.payment?.required && result.payment.txid) { const explorer = isChipnet - ? 'https://chipnet.chaingraph.cash/tx/' + ? 'https://chipnet.bchexplorer.info/tx/' : 'https://bchexplorer.info/tx/' console.log(chalk.dim(` Payment txid: ${explorer}${result.payment.txid}`)) if (result.payment.recipientAddress) { diff --git a/src/commands/receive.ts b/src/commands/receive.ts index fe84d44..773f61b 100644 --- a/src/commands/receive.ts +++ b/src/commands/receive.ts @@ -2,57 +2,16 @@ * CLI command: receive * * Displays a receiving address and its QR code for accepting BCH or CashToken payments. - * The QR code encodes the address in CashAddr format, rendered directly - * in the terminal using Unicode block characters. * * Use --token to display a token-aware (z-prefix) address for receiving CashTokens. - * Optionally pass a category ID to generate a PayPro payment URI: - * --token → bitcoincash:?c= - * - * Use --amount to embed a requested amount into the URI: - * BCH: bitcoincash:
?amount= (BIP21) - * Token: bitcoincash:?c=&f= (PayPro) + * Use --amount to embed a requested amount into the URI. */ import { Command } from 'commander' import chalk from 'chalk' import qrcode from 'qrcode-terminal' -import { loadWallet, loadMnemonic } from '../wallet/index.js' -import type { FungibleToken } from '../wallet/bch.js' - -/** - * Build a BIP21 payment URI for BCH. - * Format: bitcoincash:
?amount= - */ -function buildBchPaymentUri(address: string, amount?: number): string { - const bare = address.replace(/^(bitcoincash|bchtest):/, '') - let uri = `bitcoincash:${bare}` - if (amount !== undefined && amount > 0) { - uri += `?amount=${amount}` - } - return uri -} - -/** - * Build a PayPro payment URI for CashToken receiving. - * - * Format: bitcoincash:?c=[&f=] - * This follows the convention used by paytaca-app (see receive.vue addressAmountFormat). - * - * The `f` parameter is the token amount in base units (scaled by 10^decimals). - */ -function buildTokenPaymentUri( - address: string, - category: string, - baseUnitAmount?: number -): string { - const bare = address.replace(/^(bitcoincash|bchtest):/, '') - let uri = `bitcoincash:${bare}?c=${category}` - if (baseUnitAmount !== undefined && baseUnitAmount > 0) { - uri += `&f=${Math.round(baseUnitAmount)}` - } - return uri -} +import { WalletNotConfiguredError } from '../core/context.js' +import { getReceiveAddressView } from '../core/wallet.js' export function registerReceiveCommand(program: Command): void { program @@ -63,9 +22,11 @@ export function registerReceiveCommand(program: Command): void { .option('--token [category]', 'Show token-aware (z-prefix) address; optionally specify a category ID for a PayPro URI') .option('--amount ', 'Request a specific amount (BCH for plain, token units when --token is set)') .option('--no-qr', 'Hide QR code, show address only') + .option('--json', 'Output as JSON') .action(async (opts) => { const isChipnet = Boolean(opts.chipnet) const showQr = opts.qr !== false + const asJson = Boolean(opts.json) const tokenOpt: boolean | string = opts.token const isToken = Boolean(tokenOpt) const category = typeof tokenOpt === 'string' ? tokenOpt : '' @@ -74,16 +35,12 @@ export function registerReceiveCommand(program: Command): void { const rawAmount = opts.amount ? parseFloat(opts.amount) : undefined if (isNaN(index) || index < 0) { - console.log( - chalk.red('\nError: Index must be a non-negative integer.\n') - ) + console.log(chalk.red('\nError: Index must be a non-negative integer.\n')) process.exit(1) } if (rawAmount !== undefined && (isNaN(rawAmount) || rawAmount <= 0)) { - console.log( - chalk.red('\nError: Amount must be a positive number.\n') - ) + console.log(chalk.red('\nError: Amount must be a positive number.\n')) process.exit(1) } @@ -104,109 +61,61 @@ export function registerReceiveCommand(program: Command): void { process.exit(1) } - // ── Validate wallet ────────────────────────────────────────────── - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red( - '\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n' - ) - ) - process.exit(1) - } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - - const address = isToken - ? bchWallet.getTokenAddressSetAt(index).receiving - : bchWallet.getAddressSetAt(index).receiving - - // ── Subscribe the address with Watchtower ─────────────────────── - // The backend derives the token-aware variant automatically, so this - // covers both BCH and CashToken payments to the displayed address. - let subscribed = false try { - subscribed = Boolean(await bchWallet.getNewAddressSet(index)) - } catch { - // Non-critical: displaying the address should still succeed - } + const view = await getReceiveAddressView( + { index, token: isToken, category, amount: rawAmount }, + isChipnet + ) - // ── Resolve token metadata (if category specified) ───────────── - let tokenInfo: FungibleToken | null = null - let tokenName = '' - if (category) { - try { - tokenInfo = await bchWallet.getTokenInfo(category) - if (tokenInfo?.symbol) { - tokenName = tokenInfo.symbol - } else if (tokenInfo?.name && tokenInfo.name !== 'Unknown Token') { - tokenName = tokenInfo.name - } - } catch { - // Token info unavailable — proceed without metadata + if (asJson) { + console.log(JSON.stringify(view, null, 2)) + return } - } - - // ── Build payment URI ──────────────────────────────────────────── - let paymentUri = '' - - if (category) { - // PayPro: token amount scaled to base units via decimals - const decimals = tokenInfo?.decimals ?? 0 - const baseUnitAmount = rawAmount !== undefined - ? rawAmount * 10 ** decimals - : undefined - paymentUri = buildTokenPaymentUri(address, category, baseUnitAmount) - } else if (rawAmount !== undefined) { - // BIP21: plain BCH amount - paymentUri = buildBchPaymentUri(address, rawAmount) - } - - const qrContent = paymentUri || address - // ── Output ─────────────────────────────────────────────────────── - const label = category - ? `Receive ${tokenName || 'CashTokens'}` - : isToken - ? 'Receive CashTokens' - : 'Receive BCH' - console.log(chalk.bold(`\n ${label} (${network})\n`)) - console.log(` Address: ${address}`) - console.log(chalk.dim(` Index: ${index}`)) - console.log( - subscribed - ? chalk.dim(' Watching: subscribed with Watchtower') - : chalk.yellow(' Watching: not subscribed (address may not be monitored)') - ) - if (isToken) { - console.log(chalk.dim(' Type: token-aware (z-prefix)')) - } - if (category) { - console.log(chalk.dim(` Category: ${category}`)) - } - if (rawAmount !== undefined) { - const unit = category - ? (tokenName || 'tokens') - : 'BCH' - console.log(` Amount: ${rawAmount} ${unit}`) - } - if (paymentUri) { - console.log(` URI: ${paymentUri}`) - } + const label = category + ? `Receive ${view.tokenName || 'CashTokens'}` + : isToken + ? 'Receive CashTokens' + : 'Receive BCH' + console.log(chalk.bold(`\n ${label} (${network})\n`)) + console.log(` Address: ${view.address}`) + console.log(chalk.dim(` Index: ${view.index}`)) + console.log( + view.subscribed + ? chalk.dim(' Watching: subscribed with Watchtower') + : chalk.yellow(' Watching: not subscribed (address may not be monitored)') + ) + if (isToken) console.log(chalk.dim(' Type: token-aware (z-prefix)')) + if (category) console.log(chalk.dim(` Category: ${category}`)) + if (rawAmount !== undefined) { + const unit = category ? (view.tokenName || 'tokens') : 'BCH' + console.log(` Amount: ${rawAmount} ${unit}`) + } + if (view.paymentUri) console.log(` URI: ${view.paymentUri}`) + + if (showQr) { + console.log() + qrcode.generate(view.qrContent, { small: true }, (qr: string) => { + const indented = qr + .split('\n') + .map((line) => ' ' + line) + .join('\n') + console.log(indented) + }) + } - if (showQr) { console.log() - qrcode.generate(qrContent, { small: true }, (qr: string) => { - // Indent QR code for alignment with the rest of the output - const indented = qr - .split('\n') - .map((line) => ' ' + line) - .join('\n') - console.log(indented) - }) + } catch (err: any) { + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) + process.exit(1) + } + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } + console.log(chalk.red(`\n Error: ${err.message || err}\n`)) + process.exit(1) } - - console.log() }) -} +} \ No newline at end of file diff --git a/src/commands/send.ts b/src/commands/send.ts index 24e0a9b..1f290ed 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -3,22 +3,13 @@ * * Sends BCH from the wallet to an external address. * currency defaults to "bch"; also accepts "sats" / "satoshis" or "usd". - * - * The transaction flow is identical to paytaca-app: - * 1. Load mnemonic + walletHash from keychain - * 2. Create BchWallet with watchtower-cash-js - * 3. Call watchtower.BCH.send() which handles: - * - UTXO fetching from Watchtower API - * - Transaction building with libauth - * - Signing with HD-derived private keys - * - Broadcasting to the BCH network - * 4. Return txid */ import { Command } from 'commander' import chalk from 'chalk' -import { Address } from 'watchtower-cash-js' -import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { WalletNotConfiguredError } from '../core/context.js' +import { isValidBchAddress, sendBch } from '../core/wallet.js' +import { explorerTxUrl } from '../utils/format.js' import { getBchUsdPrice, formatUsd } from '../utils/prices.js' export function registerSendCommand(program: Command): void { @@ -29,21 +20,13 @@ export function registerSendCommand(program: Command): void { .argument('', 'Amount to send') .argument('[currency]', 'Currency: bch (default), sats / satoshis, or usd', 'bch') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') .action(async (address: string, amountStr: string, currency: string, opts) => { const isChipnet = Boolean(opts.chipnet) + const asJson = Boolean(opts.json) const unit = currency.toLowerCase() const network = isChipnet ? 'chipnet' : 'mainnet' - // ── Validate wallet ────────────────────────────────────────────── - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) - process.exit(1) - } - - // ── Parse amount ───────────────────────────────────────────────── let amountBch = parseFloat(amountStr) if (isNaN(amountBch) || amountBch <= 0) { console.log(chalk.red('\nError: Amount must be a positive number.\n')) @@ -65,61 +48,70 @@ export function registerSendCommand(program: Command): void { process.exit(1) } - // ── Validate recipient address ─────────────────────────────────── - const addressValidator = new Address(address) - if ( - !addressValidator.isValidBCHAddress(isChipnet) && - !(addressValidator as any).isP2SH?.() && - !(addressValidator as any).isTokenAddress?.() - ) { + if (!isValidBchAddress(address, isChipnet)) { console.log(chalk.red('\nError: Invalid BCH address.\n')) process.exit(1) } - // ── Build and send ─────────────────────────────────────────────── - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - - // Use the first receiving address as change address (same as paytaca-app default) - const changeAddressSet = bchWallet.getAddressSetAt(0) - const changeAddress = changeAddressSet.change + try { + if (!asJson) { + const bchFormatted = amountBch.toFixed(8).replace(/\.?0+$/, '') + console.log( + `\n Sending ${chalk.bold(bchFormatted + ' BCH')}` + + (usdPrice !== null ? chalk.dim(` (≈ ${formatUsd(amountBch * usdPrice)})`) : '') + + ` on ${chalk.cyan(network)}` + ) + if (usdPrice !== null) { + console.log(chalk.dim(` Rate: 1 BCH = ${formatUsd(usdPrice)}`)) + } + console.log(chalk.dim(` To: ${address}`)) + console.log() + } - const bchFormatted = amountBch.toFixed(8).replace(/\.?0+$/, '') - console.log(`\n Sending ${chalk.bold(bchFormatted + ' BCH')}${usdPrice !== null ? chalk.dim(` (≈ ${formatUsd(amountBch * usdPrice)})`) : ''} on ${chalk.cyan(network)}`) - if (usdPrice !== null) { - console.log(chalk.dim(` Rate: 1 BCH = ${formatUsd(usdPrice)}`)) - } - console.log(chalk.dim(` To: ${address}`)) - console.log(chalk.dim(` Change: ${changeAddress}`)) - console.log() + const outcome = await sendBch({ address, amountBch }, isChipnet) - try { - const result = await bchWallet.sendBch(amountBch, address, changeAddress) + if (asJson) { + console.log( + JSON.stringify( + { ...outcome, address, amountBch }, + null, + 2 + ) + ) + if (!outcome.success) process.exit(1) + return + } - if (result.success) { + if (outcome.success) { console.log(chalk.green(' Transaction sent successfully!\n')) - if (result.txid) { - console.log(` txid: ${result.txid}`) - const explorer = isChipnet - ? 'https://chipnet.chaingraph.cash/tx/' - : 'https://bchexplorer.info/tx/' - console.log(chalk.dim(` ${explorer}${result.txid}`)) + if (outcome.txid) { + console.log(` txid: ${outcome.txid}`) + console.log(chalk.dim(` ${outcome.explorerUrl}`)) } } else { - console.log(chalk.red(` Transaction failed: ${result.error || 'Unknown error'}`)) - if (result.lackingSats) { + console.log(chalk.red(` Transaction failed: ${outcome.error || 'Unknown error'}`)) + if (outcome.lackingSats) { console.log( chalk.yellow( - ` Insufficient balance. Short by ${result.lackingSats} satoshis.` + ` Insufficient balance. Short by ${outcome.lackingSats} satoshis.` ) ) } + process.exit(1) } + + console.log() } catch (err: any) { + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) + process.exit(1) + } + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } console.log(chalk.red(`\n Error: ${err.message || err}\n`)) process.exit(1) } - - console.log() }) -} +} \ No newline at end of file diff --git a/src/commands/swap.ts b/src/commands/swap.ts index 020aebc..e54d334 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -145,7 +145,7 @@ export function registerSwapCommand(program: Command): void { if (result.txid) { console.log(` txid: ${result.txid}`) const explorer = isChipnet - ? 'https://chipnet.chaingraph.cash/tx/' + ? 'https://chipnet.bchexplorer.info/tx/' : 'https://bchexplorer.info/tx/' console.log(chalk.dim(` ${explorer}${result.txid}`)) } diff --git a/src/commands/token.ts b/src/commands/token.ts index 952febb..c8db57e 100644 --- a/src/commands/token.ts +++ b/src/commands/token.ts @@ -1,42 +1,33 @@ /** - * CLI commands: token list | info | send | send-nft + * CLI commands: token list | info | price | send | send-nft * * CashTokens support for the Paytaca CLI. - * - * CashTokens are the native token protocol on Bitcoin Cash, supporting - * both fungible tokens (FTs) and non-fungible tokens (NFTs) at the - * consensus layer. Tokens are received at token-aware addresses - * (z-prefix CashAddr format). - * - * Token sends are delegated to watchtower-cash-js BCH.send() with - * the `token` parameter — identical to how paytaca-app handles it. */ import { Command } from 'commander' import chalk from 'chalk' -import { Address } from 'watchtower-cash-js' -import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { WalletNotConfiguredError, requireWallet } from '../core/context.js' import { - fetchAssetPrices, - getUsdPerToken, - tokenAmountToUsd, - formatUsd, -} from '../utils/prices.js' - -/** Truncate a hex string for display */ -function shortHex(hex: string, len: number = 8): string { - if (hex.length <= len * 2 + 3) return hex - return hex.slice(0, len) + '...' + hex.slice(-len) -} - -/** Format a token amount with decimals */ -function formatTokenAmount(rawAmount: number, decimals: number): string { - if (decimals === 0) return rawAmount.toLocaleString('en-US') - const scaled = rawAmount / Math.pow(10, decimals) - return scaled.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: decimals, - }) + getTokenBalances, + getTokenDetail, + isValidBchAddress, + isTokenAddress, + sendToken, +} from '../core/wallet.js' +import { explorerTxUrl, formatTokenAmount, shortHex } from '../utils/format.js' +import { formatUsd } from '../utils/prices.js' + +function reportError(err: any, asJson: boolean, contextLabel: string): never { + if (asJson) { + console.log(JSON.stringify({ error: err.message || String(err) }, null, 2)) + process.exit(1) + } + if (err instanceof WalletNotConfiguredError) { + console.log(chalk.red(`\n${err.message}\n`)) + process.exit(1) + } + console.log(chalk.red(` Error ${contextLabel}: ${err.message || err}`)) + process.exit(1) } export function registerTokenCommands(program: Command): void { @@ -50,99 +41,69 @@ export function registerTokenCommands(program: Command): void { .command('list') .description('List fungible CashTokens in the wallet') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') .action(async (opts) => { const isChipnet = Boolean(opts.chipnet) + const asJson = Boolean(opts.json) const network = isChipnet ? 'chipnet' : 'mainnet' - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) - process.exit(1) - } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - - console.log(chalk.bold(`\n CashTokens (${network})\n`)) - try { - const tokens = await bchWallet.getFungibleTokens() + const result = await getTokenBalances(isChipnet) - if (tokens.length === 0) { - console.log(chalk.dim(' No tokens found.\n')) + if (asJson) { + console.log(JSON.stringify(result.tokens, null, 2)) return } - // Fetch USD prices for all held tokens (batched by the util) - let prices = new Map() - try { - const priceData = await fetchAssetPrices( - tokens.map((t) => `ct/${t.category}`), - ['USD'], - isChipnet - ) - prices = new Map() - for (const p of priceData) { - if (String(p.currency || '').toLowerCase() !== 'usd') continue - const catMatch = String(p.asset || '').match(/^ct\/([a-fA-F0-9]+)$/) - if (!catMatch) continue - const raw = parseFloat(p.price_value) - if (!isFinite(raw) || raw === 0) continue - prices.set(catMatch[1], 1 / raw) - } - } catch { - // Pricing unavailable — proceed without USD values + console.log(chalk.bold(`\n CashTokens (${network})\n`)) + + if (result.tokens.length === 0) { + console.log(chalk.dim(' No tokens found.\n')) + return } let totalUsd = 0 let pricedCount = 0 - for (const t of tokens) { - const amount = formatTokenAmount(t.balance, t.decimals) + for (const t of result.tokens) { const symbol = t.symbol ? ` ${t.symbol}` : '' const name = t.name !== 'Unknown Token' ? t.name : '' - const usdPerToken = prices.get(t.category) - console.log(` ${chalk.bold(amount + symbol)}`) - if (name) { - console.log(chalk.dim(` ${name}`)) - } + console.log(` ${chalk.bold(t.displayBalance + symbol)}`) + if (name) console.log(chalk.dim(` ${name}`)) console.log(chalk.dim(` ${t.category}`)) - if (usdPerToken !== undefined) { - const usdValue = tokenAmountToUsd(t.balance, t.decimals, usdPerToken) - totalUsd += usdValue + if (t.usd !== null) { + totalUsd += t.usd pricedCount += 1 - console.log(chalk.green(` ≈ ${formatUsd(usdValue)}`)) + console.log(chalk.green(` ≈ ${formatUsd(t.usd)}`)) } console.log() } if (pricedCount > 0) { - console.log(chalk.dim(` ${tokens.length} token${tokens.length !== 1 ? 's' : ''} total`)) + console.log(chalk.dim(` ${result.tokens.length} token${result.tokens.length !== 1 ? 's' : ''} total`)) console.log(chalk.dim(` ${pricedCount} priced`)) console.log(chalk.bold(` Total: ${formatUsd(totalUsd)}`)) console.log() } else { - console.log(chalk.dim(` ${tokens.length} token${tokens.length !== 1 ? 's' : ''} total`)) + console.log(chalk.dim(` ${result.tokens.length} token${result.tokens.length !== 1 ? 's' : ''} total`)) } + console.log() } catch (err: any) { + if (err instanceof WalletNotConfiguredError) reportError(err, asJson, '') const status = err?.response?.status - if (status === 404) { + if (status === 404 && !asJson) { console.log( chalk.yellow(' Wallet not yet registered with Watchtower on this network.') ) console.log( chalk.dim(' Run `paytaca wallet create` or `paytaca wallet import` to register.') ) - } else { - console.log(chalk.red(` Error fetching tokens: ${err.message || err}`)) - process.exit(1) + console.log() + return } + reportError(err, asJson, 'fetching tokens') } - - console.log() }) // ── token info ───────────────────────────────────────────────────── @@ -152,98 +113,59 @@ export function registerTokenCommands(program: Command): void { .description('Show details for a specific CashToken') .argument('', 'Token category ID (64-character hex)') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') .action(async (category: string, opts) => { const isChipnet = Boolean(opts.chipnet) + const asJson = Boolean(opts.json) const network = isChipnet ? 'chipnet' : 'mainnet' - // Validate category format if (!/^[a-fA-F0-9]{64}$/.test(category)) { console.log(chalk.red('\nError: Category must be a 64-character hex string.\n')) process.exit(1) } - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) - process.exit(1) - } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - - console.log(chalk.bold(`\n Token Info (${network})\n`)) - try { - const tokenInfo = await bchWallet.getTokenInfo(category) + const detail = await getTokenDetail(category, isChipnet) - if (!tokenInfo) { - console.log(chalk.yellow(' Token not found.\n')) + if (asJson) { + console.log(JSON.stringify(detail, null, 2)) return } - console.log(` Name: ${tokenInfo.name}`) - if (tokenInfo.symbol) { - console.log(` Symbol: ${tokenInfo.symbol}`) - } - console.log(` Decimals: ${tokenInfo.decimals}`) - console.log(` Category: ${tokenInfo.category}`) + console.log(chalk.bold(`\n Token Info (${network})\n`)) + console.log(` Name: ${detail.name}`) + if (detail.symbol) console.log(` Symbol: ${detail.symbol}`) + console.log(` Decimals: ${detail.decimals}`) + console.log(` Category: ${detail.category}`) - // Fetch token USD price (watchtower.cash asset-prices) - let usdPerToken: number | undefined - try { - const p = await getUsdPerToken(category, isChipnet) - if (p !== null) usdPerToken = p - } catch { - // Pricing unavailable — omit USD lines + const symbol = detail.symbol ? ` ${detail.symbol}` : '' + console.log(` Balance: ${detail.displayBalance}${symbol}`) + if (detail.usd !== null) { + console.log(chalk.green(` Value: ≈ ${formatUsd(detail.usd)}`)) } - - // Fetch wallet-specific balance - try { - const balResult = await bchWallet.getTokenBalance(category) - const amount = formatTokenAmount(balResult.balance, tokenInfo.decimals) - const symbol = tokenInfo.symbol ? ` ${tokenInfo.symbol}` : '' - console.log(` Balance: ${amount}${symbol}`) - if (usdPerToken !== undefined) { - const usdValue = tokenAmountToUsd(balResult.balance, tokenInfo.decimals, usdPerToken) - console.log(chalk.green(` Value: ≈ ${formatUsd(usdValue)}`)) - } - } catch { - // Balance may not be available if wallet doesn't hold this token - console.log(chalk.dim(' Balance: 0')) - } - - if (usdPerToken !== undefined) { + if (detail.usdPerToken !== null) { console.log( chalk.dim( - ` Price: ${formatUsd(usdPerToken)} per ${tokenInfo.symbol || 'token'}` + ` Price: ${formatUsd(detail.usdPerToken)} per ${detail.symbol || 'token'}` ) ) } - // Show NFTs for this category - try { - const nfts = await bchWallet.getNftUtxos(category) - if (nfts.length > 0) { - console.log(`\n ${chalk.bold('NFTs')} (${nfts.length})\n`) - for (const nft of nfts) { - const cap = nft.capability === 'none' ? '' : ` [${nft.capability}]` - const commitment = nft.commitment ? shortHex(nft.commitment) : '(empty)' - console.log(` ${chalk.cyan(commitment)}${cap}`) - console.log(chalk.dim(` ${nft.txid}:${nft.vout}`)) - console.log() - } + if (detail.nfts.length > 0) { + console.log(`\n ${chalk.bold('NFTs')} (${detail.nfts.length})\n`) + for (const nft of detail.nfts) { + const cap = nft.capability === 'none' ? '' : ` [${nft.capability}]` + const commitment = nft.commitment ? shortHex(nft.commitment) : '(empty)' + console.log(` ${chalk.cyan(commitment)}${cap}`) + console.log(chalk.dim(` ${nft.txid}:${nft.vout}`)) + console.log() } - } catch { - // NFT fetch may fail; non-critical } + + console.log() } catch (err: any) { - console.log(chalk.red(` Error: ${err.message || err}`)) - process.exit(1) + reportError(err, asJson, '') } - - console.log() }) // ── token price ──────────────────────────────────────────────────── @@ -254,17 +176,17 @@ export function registerTokenCommands(program: Command): void { .argument('', 'Token category ID (64-character hex)') .argument('[amount]', 'Token amount in display units (defaults to wallet balance)') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') .action(async (category: string, amountStr: string | undefined, opts) => { const isChipnet = Boolean(opts.chipnet) + const asJson = Boolean(opts.json) const network = isChipnet ? 'chipnet' : 'mainnet' - // Validate category format if (!/^[a-fA-F0-9]{64}$/.test(category)) { console.log(chalk.red('\nError: Category must be a 64-character hex string.\n')) process.exit(1) } - // Validate amount if provided let requestedAmount: number | null = null if (amountStr !== undefined) { requestedAmount = Number(amountStr) @@ -274,79 +196,58 @@ export function registerTokenCommands(program: Command): void { } } - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) - process.exit(1) - } - - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - - console.log(chalk.bold(`\n Token Price (${network})\n`)) - - // Resolve token metadata (symbol/decimals) — optional, non-fatal - let symbol = '' - let decimals = 0 - let name = '' try { - const info = await bchWallet.getTokenInfo(category) - if (info) { - symbol = info.symbol || '' - name = info.name !== 'Unknown Token' ? info.name : '' - decimals = info.decimals || 0 - } - } catch { - // Token metadata unavailable — proceed without it - } + const detail = await getTokenDetail(category, isChipnet) + const displayAmount = + requestedAmount !== null + ? requestedAmount + : detail.rawBalance / Math.pow(10, detail.decimals) - // Resolve the display amount to price - let displayAmount: number - if (requestedAmount !== null) { - displayAmount = requestedAmount - } else { - try { - const balResult = await bchWallet.getTokenBalance(category) - displayAmount = balResult.balance / Math.pow(10, decimals) - } catch { - console.log(chalk.yellow(' Wallet balance unavailable; specify an to price.\n')) - process.exit(1) + if (asJson) { + console.log( + JSON.stringify( + { + network, + category, + symbol: detail.symbol, + name: detail.name, + decimals: detail.decimals, + amount: displayAmount, + usdPerToken: detail.usdPerToken, + usd: + detail.usdPerToken !== null + ? displayAmount * detail.usdPerToken + : null, + }, + null, + 2 + ) + ) + return } - } - - // Fetch USD price - let usdPerToken: number | null = null - try { - usdPerToken = await getUsdPerToken(category, isChipnet) - } catch { - // leave null - } - const label = symbol || name || shortHex(category) - console.log(` Token: ${label}`) - if (name) console.log(chalk.dim(` Name: ${name}`)) - console.log(chalk.dim(` Category: ${category}`)) + const label = detail.symbol || (detail.name !== 'Unknown Token' ? detail.name : '') || shortHex(category) + console.log(chalk.bold(`\n Token Price (${network})\n`)) + console.log(` Token: ${label}`) + if (detail.name !== 'Unknown Token') console.log(chalk.dim(` Name: ${detail.name}`)) + console.log(chalk.dim(` Category: ${category}`)) - if (usdPerToken === null) { - console.log(chalk.yellow('\n No market price available for this token.\n')) - return - } + if (detail.usdPerToken === null) { + console.log(chalk.yellow('\n No market price available for this token.\n')) + return + } - const usdValue = displayAmount * usdPerToken - console.log( - chalk.green( - ` Price: ${formatUsd(usdPerToken)} per ${symbol || 'token'}` + const usdValue = displayAmount * detail.usdPerToken + console.log( + chalk.green(` Price: ${formatUsd(detail.usdPerToken)} per ${detail.symbol || 'token'}`) ) - ) - console.log( - chalk.bold( - ` Value: ${formatUsd(usdValue)} for ${displayAmount} ${symbol || 'token(s)'}` + console.log( + chalk.bold(` Value: ${formatUsd(usdValue)} for ${displayAmount} ${detail.symbol || 'token(s)'}`) ) - ) - - console.log() + console.log() + } catch (err: any) { + reportError(err, asJson, '') + } }) // ── token send ───────────────────────────────────────────────────── @@ -358,34 +259,25 @@ export function registerTokenCommands(program: Command): void { .argument('', 'Token amount to send (in base units, before decimal scaling)') .requiredOption('--token ', 'Token category ID (64-character hex)') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') .action(async (address: string, amountStr: string, opts) => { const isChipnet = Boolean(opts.chipnet) + const asJson = Boolean(opts.json) const category: string = opts.token const network = isChipnet ? 'chipnet' : 'mainnet' - // Validate wallet - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) - process.exit(1) - } - - // Validate category if (!/^[a-fA-F0-9]{64}$/.test(category)) { console.log(chalk.red('\nError: Category must be a 64-character hex string.\n')) process.exit(1) } - // Parse amount as bigint let tokenAmount: bigint try { tokenAmount = BigInt(amountStr) } catch { console.log(chalk.red('\nError: Amount must be a valid integer.\n')) process.exit(1) - return // unreachable, but helps TS narrowing + return } if (tokenAmount <= 0n) { @@ -393,19 +285,12 @@ export function registerTokenCommands(program: Command): void { process.exit(1) } - // Validate recipient address - const addressValidator = new Address(address) - if ( - !addressValidator.isValidBCHAddress(isChipnet) && - !(addressValidator as any).isP2SH?.() && - !(addressValidator as any).isTokenAddress?.() - ) { + if (!isValidBchAddress(address, isChipnet)) { console.log(chalk.red('\nError: Invalid BCH address.\n')) process.exit(1) } - // Warn if not a token address - if (!(addressValidator as any).isTokenAddress?.()) { + if (!isTokenAddress(address) && !asJson) { console.log( chalk.yellow('\n Warning: Address is not a token-aware (z-prefix) address.') ) @@ -414,59 +299,64 @@ export function registerTokenCommands(program: Command): void { ) } - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) - - // Token change goes to our token address - const tokenChangeAddress = bchWallet.getTokenAddressSetAt(0).change - - // Fetch token info for display - let tokenLabel = shortHex(category) try { - const info = await bchWallet.getTokenInfo(category) - if (info?.symbol) tokenLabel = info.symbol - else if (info?.name && info.name !== 'Unknown Token') tokenLabel = info.name - } catch { - // Non-critical; use category hex - } + let tokenLabel = shortHex(category) + try { + const detail = await getTokenDetail(category, isChipnet) + if (detail.symbol) tokenLabel = detail.symbol + else if (detail.name !== 'Unknown Token') tokenLabel = detail.name + } catch { + // non-critical + } - console.log(`\n Sending ${chalk.bold(amountStr + ' ' + tokenLabel)} on ${chalk.cyan(network)}`) - console.log(chalk.dim(` Category: ${category}`)) - console.log(chalk.dim(` To: ${address}`)) - console.log(chalk.dim(` Change: ${tokenChangeAddress}`)) - console.log() + if (!asJson) { + console.log(`\n Sending ${chalk.bold(amountStr + ' ' + tokenLabel)} on ${chalk.cyan(network)}`) + console.log(chalk.dim(` Category: ${category}`)) + console.log(chalk.dim(` To: ${address}`)) + console.log() + } - try { - const result = await bchWallet.sendToken( - category, - tokenAmount, - address, - tokenChangeAddress + const outcome = await sendToken( + { category, amount: tokenAmount, address }, + isChipnet ) - if (result.success) { + if (asJson) { + console.log( + JSON.stringify( + { + ...outcome, + address, + category, + amount: amountStr, + }, + null, + 2 + ) + ) + if (!outcome.success) process.exit(1) + return + } + + if (outcome.success) { console.log(chalk.green(' Transaction sent successfully!\n')) - if (result.txid) { - console.log(` txid: ${result.txid}`) - const explorer = isChipnet - ? 'https://chipnet.chaingraph.cash/tx/' - : 'https://bchexplorer.info/tx/' - console.log(chalk.dim(` ${explorer}${result.txid}`)) + if (outcome.txid) { + console.log(` txid: ${outcome.txid}`) + console.log(chalk.dim(` ${outcome.explorerUrl}`)) } } else { - console.log(chalk.red(` Transaction failed: ${result.error || 'Unknown error'}`)) - if (result.lackingSats) { + console.log(chalk.red(` Transaction failed: ${outcome.error || 'Unknown error'}`)) + if (outcome.lackingSats) { console.log( - chalk.yellow(` Insufficient BCH for transaction fees. Short by ${result.lackingSats} satoshis.`) + chalk.yellow(` Insufficient BCH for transaction fees. Short by ${outcome.lackingSats} satoshis.`) ) } + process.exit(1) } + console.log() } catch (err: any) { - console.log(chalk.red(`\n Error: ${err.message || err}\n`)) - process.exit(1) + reportError(err, asJson, '') } - - console.log() }) // ── token send-nft ───────────────────────────────────────────────── @@ -481,62 +371,54 @@ export function registerTokenCommands(program: Command): void { .option('--txid ', 'UTXO txid containing the NFT (auto-detected if omitted)') .option('--vout ', 'UTXO output index (auto-detected if omitted)') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .option('--json', 'Output as JSON') .action(async (address: string, opts) => { const isChipnet = Boolean(opts.chipnet) + const asJson = Boolean(opts.json) const category: string = opts.token const commitment: string = opts.commitment const capability: string = opts.capability || 'none' const network = isChipnet ? 'chipnet' : 'mainnet' - // Validate wallet - const data = loadMnemonic() - if (!data) { - console.log( - chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') - ) - process.exit(1) - } - - // Validate category if (!/^[a-fA-F0-9]{64}$/.test(category)) { console.log(chalk.red('\nError: Category must be a 64-character hex string.\n')) process.exit(1) } - // Validate capability if (!['none', 'minting', 'mutable'].includes(capability)) { console.log(chalk.red('\nError: Capability must be "none", "minting", or "mutable".\n')) process.exit(1) } - // Validate address - const addressValidator = new Address(address) - if ( - !addressValidator.isValidBCHAddress(isChipnet) && - !(addressValidator as any).isP2SH?.() && - !(addressValidator as any).isTokenAddress?.() - ) { + if (!isValidBchAddress(address, isChipnet)) { console.log(chalk.red('\nError: Invalid BCH address.\n')) process.exit(1) } - const w = loadWallet()! - const bchWallet = w.forNetwork(isChipnet) + try { + const ctx = requireWallet(isChipnet) - // Resolve UTXO — either from flags or auto-detect - let txid: string = opts.txid || '' - let vout: number = opts.vout !== undefined ? parseInt(opts.vout, 10) : -1 + let txid: string = opts.txid || '' + let vout: number = opts.vout !== undefined ? parseInt(opts.vout, 10) : -1 - if (!txid || vout < 0) { - // Auto-detect: find the NFT UTXO matching category + commitment + capability - console.log(chalk.dim('\n Searching for NFT UTXO...')) - try { - const nfts = await bchWallet.getNftUtxos(category) + if (!txid || vout < 0) { + if (!asJson) console.log(chalk.dim('\n Searching for NFT UTXO...')) + const nfts = await ctx.bch.getNftUtxos(category) const match = nfts.find( (n) => n.commitment === commitment && n.capability === capability ) if (!match) { + if (asJson) { + console.log( + JSON.stringify( + { error: `No NFT found matching category ${category}` }, + null, + 2 + ) + ) + process.exit(1) + } console.log( chalk.red(`\n Error: No NFT found matching category ${shortHex(category)} ` + `with commitment "${commitment}" and capability "${capability}".\n`) @@ -547,25 +429,21 @@ export function registerTokenCommands(program: Command): void { txid = match.txid vout = match.vout - } catch (err: any) { - console.log(chalk.red(`\n Error searching for NFTs: ${err.message || err}\n`)) - process.exit(1) } - } - // Change address for leftover BCH - const changeAddress = bchWallet.getTokenAddressSetAt(0).change + const changeAddress = ctx.bch.getTokenAddressSetAt(0).change - console.log(`\n Sending NFT on ${chalk.cyan(network)}`) - console.log(chalk.dim(` Category: ${category}`)) - console.log(chalk.dim(` Commitment: ${commitment || '(empty)'}`)) - console.log(chalk.dim(` Capability: ${capability}`)) - console.log(chalk.dim(` UTXO: ${shortHex(txid)}:${vout}`)) - console.log(chalk.dim(` To: ${address}`)) - console.log() + if (!asJson) { + console.log(`\n Sending NFT on ${chalk.cyan(network)}`) + console.log(chalk.dim(` Category: ${category}`)) + console.log(chalk.dim(` Commitment: ${commitment || '(empty)'}`)) + console.log(chalk.dim(` Capability: ${capability}`)) + console.log(chalk.dim(` UTXO: ${shortHex(txid)}:${vout}`)) + console.log(chalk.dim(` To: ${address}`)) + console.log() + } - try { - const result = await bchWallet.sendNft( + const result = await ctx.bch.sendNft( category, commitment, capability, @@ -575,14 +453,29 @@ export function registerTokenCommands(program: Command): void { changeAddress ) + const jsonResult = { + network, + success: result.success, + txid: result.txid, + explorerUrl: result.txid ? explorerTxUrl(result.txid, isChipnet) : undefined, + error: result.error, + category, + commitment, + capability, + address, + } + + if (asJson) { + console.log(JSON.stringify(jsonResult, null, 2)) + if (!result.success) process.exit(1) + return + } + if (result.success) { console.log(chalk.green(' Transaction sent successfully!\n')) if (result.txid) { console.log(` txid: ${result.txid}`) - const explorer = isChipnet - ? 'https://chipnet.chaingraph.cash/tx/' - : 'https://bchexplorer.info/tx/' - console.log(chalk.dim(` ${explorer}${result.txid}`)) + console.log(chalk.dim(` ${explorerTxUrl(result.txid, isChipnet)}`)) } } else { console.log(chalk.red(` Transaction failed: ${result.error || 'Unknown error'}`)) @@ -591,12 +484,11 @@ export function registerTokenCommands(program: Command): void { chalk.yellow(` Insufficient BCH for transaction fees. Short by ${result.lackingSats} satoshis.`) ) } + process.exit(1) } + console.log() } catch (err: any) { - console.log(chalk.red(`\n Error: ${err.message || err}\n`)) - process.exit(1) + reportError(err, asJson, '') } - - console.log() }) -} +} \ No newline at end of file diff --git a/src/core/context.ts b/src/core/context.ts new file mode 100644 index 0000000..c60c05e --- /dev/null +++ b/src/core/context.ts @@ -0,0 +1,65 @@ +/** + * Wallet context resolution shared by the CLI and the MCP server. + * + * Fuses keychain access and network selection into a single + * {@link WalletContext}, and throws typed errors instead of + * calling process.exit(), so it is safe to embed. + */ + +import { loadMnemonic, Wallet } from '../wallet/index.js' +import type { BchWallet } from '../wallet/bch.js' + +export type Network = 'mainnet' | 'chipnet' + +export interface WalletContext { + mnemonic: string + walletHash: string + network: Network + isChipnet: boolean + wallet: Wallet + bch: BchWallet +} + +export class WalletNotConfiguredError extends Error { + constructor() { + super( + 'No wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.' + ) + this.name = 'WalletNotConfiguredError' + } +} + +export function resolveNetwork(isChipnet: boolean = false): { + network: Network + isChipnet: boolean +} { + return { network: isChipnet ? 'chipnet' : 'mainnet', isChipnet } +} + +/** Resolve the active wallet for the given network, or throw. */ +export function requireWallet(isChipnet: boolean = false): WalletContext { + const data = loadMnemonic() + if (!data) throw new WalletNotConfiguredError() + + const wallet = new Wallet(data.mnemonic) + const { network } = resolveNetwork(isChipnet) + + return { + mnemonic: data.mnemonic, + walletHash: data.walletHash, + network, + isChipnet, + wallet, + bch: wallet.forNetwork(isChipnet), + } +} + +/** Resolve the active wallet, or return null when none is configured. */ +export function tryWallet(isChipnet: boolean = false): WalletContext | null { + try { + return requireWallet(isChipnet) + } catch (err) { + if (err instanceof WalletNotConfiguredError) return null + throw err + } +} diff --git a/src/core/wallet.ts b/src/core/wallet.ts new file mode 100644 index 0000000..7a98ac9 --- /dev/null +++ b/src/core/wallet.ts @@ -0,0 +1,463 @@ +/** + * Core wallet operations returning typed, render-agnostic results. + * + * Both the CLI commands and the MCP tools call these functions. + * No chalk, no process.exit(), no console output. + */ + +import { Address } from 'watchtower-cash-js' +import { requireWallet, resolveNetwork, type Network } from './context.js' +import type { FungibleToken, NftUtxo, SendResult } from '../wallet/bch.js' +import { bchToSats, explorerTxUrl, formatTokenAmount } from '../utils/format.js' +import { + fetchAssetPrices, + getBchUsdPrice, + getUsdPerToken, + tokenAmountToUsd, +} from '../utils/prices.js' + +export interface BalanceView { + network: Network + walletHash: string + balanceBch: number + balanceSats: number + spendableBch: number + spendableSats: number + usdPerBch: number | null + usd: number | null +} + +export interface TokenBalanceView { + category: string + name: string + symbol: string + decimals: number + rawBalance: number + displayBalance: string + usdPerToken: number | null + usd: number | null +} + +export interface TokenListResult { + network: Network + walletHash: string + tokens: TokenBalanceView[] +} + +export interface TokenDetailResult { + network: Network + walletHash: string + category: string + name: string + symbol: string + decimals: number + rawBalance: number + displayBalance: string + usdPerToken: number | null + usd: number | null + nfts: NftUtxo[] +} + +export interface HistoryRecord { + record_type: 'outgoing' | 'incoming' + txid: string + amount: number + tx_fee: number + senders: any[][] + recipients: any[][] + date_created: string + tx_timestamp: string + usd_price: number + market_prices: Record + attributes: any +} + +export interface HistoryView { + network: Network + tokenId: string + headerLabel: string + records: HistoryRecord[] + page: number + numPages: number + hasNext: boolean +} + +export interface ReceiveAddressView { + network: Network + address: string + index: number + token: boolean + category: string + tokenName: string + decimals: number + amount: number | undefined + paymentUri: string + qrContent: string + subscribed: boolean +} + +export interface WalletInfoView { + network: Network + walletHash: string + address: string + balance: BalanceView +} + +export interface SendOutcome { + network: Network + success: boolean + txid?: string + explorerUrl?: string + error?: string + lackingSats?: string +} + +async function fetchTokenPriceMap( + categories: string[], + isChipnet: boolean +): Promise> { + const prices = new Map() + if (categories.length === 0) return prices + try { + const priceData = await fetchAssetPrices( + categories.map((c) => `ct/${c}`), + ['USD'], + isChipnet + ) + for (const p of priceData) { + if (String(p.currency || '').toLowerCase() !== 'usd') continue + const catMatch = String(p.asset || '').match(/^ct\/([a-fA-F0-9]+)$/) + if (!catMatch) continue + const raw = parseFloat(p.price_value) + if (!isFinite(raw) || raw === 0) continue + prices.set(catMatch[1], 1 / raw) + } + } catch { + // Pricing unavailable — proceed without USD values + } + return prices +} + +export async function getBalanceView( + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + const result = await ctx.bch.getBalance() + + let usdPerBch: number | null = null + try { + usdPerBch = await getBchUsdPrice(isChipnet) + } catch { + // USD price unavailable + } + + return { + network: ctx.network, + walletHash: ctx.walletHash, + balanceBch: result.balance, + balanceSats: bchToSats(result.balance), + spendableBch: result.spendable, + spendableSats: bchToSats(result.spendable), + usdPerBch, + usd: usdPerBch !== null ? result.balance * usdPerBch : null, + } +} + +export async function getTokenBalances( + isChipnet: boolean = false, + opts: { includeZero?: boolean } = {} +): Promise { + const ctx = requireWallet(isChipnet) + const all = await ctx.bch.getFungibleTokens() + const tokens = opts.includeZero ? all : all.filter((t) => t.balance > 0) + + const priceMap = await fetchTokenPriceMap( + tokens.map((t) => t.category), + isChipnet + ) + + return { + network: ctx.network, + walletHash: ctx.walletHash, + tokens: tokens.map((t) => { + const usdPerToken = priceMap.get(t.category) ?? null + return { + category: t.category, + name: t.name, + symbol: t.symbol, + decimals: t.decimals, + rawBalance: t.balance, + displayBalance: formatTokenAmount(t.balance, t.decimals), + usdPerToken, + usd: + usdPerToken !== null + ? tokenAmountToUsd(t.balance, t.decimals, usdPerToken) + : null, + } + }), + } +} + +export async function getTokenDetail( + category: string, + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + + let info: FungibleToken | null = null + try { + info = await ctx.bch.getTokenInfo(category) + } catch { + // metadata unavailable + } + + let usdPerToken: number | null = null + try { + usdPerToken = await getUsdPerToken(category, isChipnet) + } catch { + // pricing unavailable + } + + let rawBalance = 0 + try { + const bal = await ctx.bch.getTokenBalance(category) + rawBalance = bal.balance + } catch { + // balance unavailable + } + + let nfts: NftUtxo[] = [] + try { + nfts = await ctx.bch.getNftUtxos(category) + } catch { + // NFT fetch non-critical + } + + const decimals = info?.decimals ?? 0 + + return { + network: ctx.network, + walletHash: ctx.walletHash, + category, + name: info?.name ?? 'Unknown Token', + symbol: info?.symbol ?? '', + decimals, + rawBalance, + displayBalance: formatTokenAmount(rawBalance, decimals), + usdPerToken, + usd: + usdPerToken !== null + ? tokenAmountToUsd(rawBalance, decimals, usdPerToken) + : null, + nfts, + } +} + +export async function getHistoryView( + opts: { + page?: number + recordType?: string + tokenId?: string + } = {}, + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + const tokenId = opts.tokenId ?? '' + const page = opts.page ?? 1 + + let headerLabel = 'Transaction History' + if (tokenId) { + headerLabel = 'Token Transaction History' + try { + const info = await ctx.bch.getTokenInfo(tokenId) + if (info?.symbol) headerLabel = `${info.symbol} Transaction History` + else if (info?.name && info.name !== 'Unknown Token') + headerLabel = `${info.name} Transaction History` + } catch { + // keep generic label + } + } + + const result = await ctx.bch.getHistory({ + page, + recordType: opts.recordType, + tokenId, + }) + + return { + network: ctx.network, + tokenId, + headerLabel, + records: result.history ?? [], + page: parseInt(result.page, 10) || page, + numPages: result.num_pages, + hasNext: result.has_next, + } +} + +export function buildBchPaymentUri(address: string, amount?: number): string { + const bare = address.replace(/^(bitcoincash|bchtest):/, '') + let uri = `bitcoincash:${bare}` + if (amount !== undefined && amount > 0) uri += `?amount=${amount}` + return uri +} + +export function buildTokenPaymentUri( + address: string, + category: string, + baseUnitAmount?: number +): string { + const bare = address.replace(/^(bitcoincash|bchtest):/, '') + let uri = `bitcoincash:${bare}?c=${category}` + if (baseUnitAmount !== undefined && baseUnitAmount > 0) { + uri += `&f=${Math.round(baseUnitAmount)}` + } + return uri +} + +export async function getReceiveAddressView( + opts: { + index?: number + token?: boolean + category?: string + amount?: number + subscribe?: boolean + } = {}, + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + const index = opts.index ?? 0 + const isToken = Boolean(opts.token) + const category = opts.category ?? '' + + const address = isToken + ? ctx.bch.getTokenAddressSetAt(index).receiving + : ctx.bch.getAddressSetAt(index).receiving + + let subscribed = false + if (opts.subscribe !== false) { + try { + subscribed = Boolean(await ctx.bch.getNewAddressSet(index)) + } catch { + // non-critical + } + } + + let tokenName = '' + let decimals = 0 + if (category) { + try { + const info = await ctx.bch.getTokenInfo(category) + if (info?.symbol) tokenName = info.symbol + else if (info?.name && info.name !== 'Unknown Token') tokenName = info.name + decimals = info?.decimals ?? 0 + } catch { + // token metadata unavailable + } + } + + let paymentUri = '' + if (category) { + const baseUnitAmount = + opts.amount !== undefined ? opts.amount * 10 ** decimals : undefined + paymentUri = buildTokenPaymentUri(address, category, baseUnitAmount) + } else if (opts.amount !== undefined) { + paymentUri = buildBchPaymentUri(address, opts.amount) + } + + return { + network: ctx.network, + address, + index, + token: isToken, + category, + tokenName, + decimals, + amount: opts.amount, + paymentUri, + qrContent: paymentUri || address, + subscribed, + } +} + +export async function getWalletInfoView( + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + const address = ctx.bch.getAddressSetAt(0).receiving + const balance = await getBalanceView(isChipnet) + return { + network: ctx.network, + walletHash: ctx.walletHash, + address, + balance, + } +} + +export function isValidBchAddress(address: string, isChipnet: boolean): boolean { + const validator = new Address(address) + return Boolean( + validator.isValidBCHAddress(isChipnet) || + (validator as any).isP2SH?.() || + (validator as any).isTokenAddress?.() + ) +} + +export function isTokenAddress(address: string): boolean { + return Boolean((new Address(address) as any).isTokenAddress?.()) +} + +export async function sendBch( + opts: { address: string; amountBch: number; changeAddress?: string }, + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + const changeAddress = + opts.changeAddress ?? ctx.bch.getAddressSetAt(0).change + + const result = await ctx.bch.sendBch( + opts.amountBch, + opts.address, + changeAddress + ) + + return toSendOutcome(result, ctx.network, isChipnet) +} + +export async function sendToken( + opts: { category: string; amount: bigint; address: string; changeAddress?: string }, + isChipnet: boolean = false +): Promise { + const ctx = requireWallet(isChipnet) + const changeAddress = + opts.changeAddress ?? ctx.bch.getTokenAddressSetAt(0).change + + const result = await ctx.bch.sendToken( + opts.category, + opts.amount, + opts.address, + changeAddress + ) + + return toSendOutcome(result, ctx.network, isChipnet) +} + +function toSendOutcome( + result: SendResult, + network: Network, + isChipnet: boolean +): SendOutcome { + const outcome: SendOutcome = { + network, + success: result.success, + error: result.error, + lackingSats: result.lackingSats?.toString(), + } + if (result.txid) { + outcome.txid = result.txid + outcome.explorerUrl = explorerTxUrl(result.txid, isChipnet) + } + return outcome +} + +export { resolveNetwork } diff --git a/src/index.ts b/src/index.ts index d15dcad..1aea0c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ import { registerPayCommand } from './commands/pay.js' import { registerCheckCommand } from './commands/check.js' import { registerChatCommands } from './commands/chat.js' import { registerSwapCommand } from './commands/swap.js' +import { registerAiCommands } from './commands/ai.js' +import { registerMcpCommand } from './commands/mcp.js' const packageJson = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8') @@ -42,5 +44,7 @@ registerPayCommand(program) registerCheckCommand(program) registerChatCommands(program) registerSwapCommand(program) +registerAiCommands(program) +registerMcpCommand(program) program.parse() diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts new file mode 100644 index 0000000..1e090eb --- /dev/null +++ b/src/mcp/server.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { createServer } from './server.js' + +let server: McpServer | undefined +let client: Client | undefined + +async function connect(): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + server = createServer() + client = new Client({ name: 'paytaca-test', version: '1.0.0' }) + await server.connect(serverTransport) + await client.connect(clientTransport) + return client +} + +afterEach(async () => { + await client?.close() + await server?.close() + client = undefined + server = undefined +}) + +describe('paytaca MCP server', () => { + it('advertises the full tool set', async () => { + const c = await connect() + const { tools } = await c.listTools() + const names = tools.map((t) => t.name).sort() + expect(names).toEqual( + [ + 'auto_refill', + 'buy_plan', + 'get_balance', + 'get_credits', + 'get_help', + 'get_models', + 'get_plans', + 'get_receiving_address', + 'get_tokens', + 'get_transactions', + 'send', + ].sort() + ) + }) + + it('answers get_help without a wallet', async () => { + const c = await connect() + const result = await c.callTool({ name: 'get_help', arguments: {} }) + const content = result.content as { type: string; text: string }[] + expect(content[0].type).toBe('text') + expect(content[0].text).toMatch(/get_balance/) + expect(content[0].text).toMatch(/buy_plan/) + }) + + it('marks spending tools as destructive', async () => { + const c = await connect() + const { tools } = await c.listTools() + const send = tools.find((t) => t.name === 'send') + const buy = tools.find((t) => t.name === 'buy_plan') + expect(send?.annotations?.destructiveHint).toBe(true) + expect(buy?.annotations?.destructiveHint).toBe(true) + }) +}) \ No newline at end of file diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..5f975f8 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { registerTools } from './tools.js' + +function readVersion(): string { + try { + const pkg = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf-8') + ) + return pkg.version || '0.0.0' + } catch { + return '0.0.0' + } +} + +export function createServer(opts: { defaultChipnet?: boolean } = {}): McpServer { + const server = new McpServer( + { name: 'paytaca', version: readVersion() }, + { capabilities: { tools: {} } } + ) + registerTools(server, opts) + return server +} + +export async function runMcpServer(opts: { defaultChipnet?: boolean } = {}): Promise { + const server = createServer(opts) + const transport = new StdioServerTransport() + await server.connect(transport) +} \ No newline at end of file diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts new file mode 100644 index 0000000..7925132 --- /dev/null +++ b/src/mcp/tools.ts @@ -0,0 +1,423 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + getBalanceView, + getHistoryView, + getReceiveAddressView, + getTokenBalances, + getTokenDetail, + sendBch, + sendToken, + isValidBchAddress, +} from '../core/wallet.js' +import { WalletNotConfiguredError } from '../core/context.js' +import { loadWalletRef } from '../wallet/index.js' +import { getConfig } from '../ai/client.js' +import { listModels, listPlans } from '../ai/models.js' +import { + getWalletStatus, + summarizeCredits, + summarizeAllCredits, +} from '../ai/credits.js' +import { buyPlan } from '../ai/purchase.js' +import { + armAutoRefill, + disarmAutoRefill, + readAutoRefillState, + remainingBudget, +} from '../ai/autoRefill.js' + +type ToolResult = { + content: { type: 'text'; text: string }[] + isError?: boolean +} + +function text(value: string): ToolResult { + return { content: [{ type: 'text', text: value }] } +} + +function json(value: unknown): ToolResult { + return text(JSON.stringify(value, null, 2)) +} + +function fail(err: unknown): ToolResult { + const message = err instanceof Error ? err.message : String(err) + return { content: [{ type: 'text', text: message }], isError: true } +} + +const HELP = `Paytaca MCP tools + +Wallet (read): + get_balance BCH balance (mainnet/chipnet) + get_transactions Paginated transaction history + get_receiving_address Derive a receiving address (+ BIP21/PayPro URI) + get_tokens List CashToken balances or inspect one category + +Wallet (spend): + send Send BCH or a CashToken to an address + +Paytaca AI: + get_models List AI models + get_plans Plan pricing grouped by tier + get_credits Remaining AI time credits + buy_plan Buy an AI plan with BCH or LIFT (spends funds) + auto_refill Arm/disarm/inspect automatic plan refills + +Notes: + - All tools accept an optional "chipnet" flag (default mainnet). + - send and buy_plan spend real funds from the active wallet; the MCP host + is responsible for asking the user for approval before invoking them. + - Wallet is resolved from the OS keychain (run "paytaca wallet create" first).` + +export function registerTools( + server: McpServer, + opts: { defaultChipnet?: boolean } = {} +): void { + const cn = (value?: boolean): boolean => + value ?? Boolean(opts.defaultChipnet) + + server.registerTool( + 'get_help', + { + title: 'Paytaca help', + description: 'List the Paytaca MCP tools and usage notes.', + inputSchema: {}, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async () => text(HELP) + ) + + server.registerTool( + 'get_balance', + { + title: 'Get BCH balance', + description: + 'Return the BCH balance (in BCH and sats) of the active wallet.', + inputSchema: { chipnet: z.boolean().optional() }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ chipnet }) => { + try { + return json(await getBalanceView(cn(chipnet))) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'get_transactions', + { + title: 'Get transaction history', + description: 'Return paginated BCH/CashToken transaction history.', + inputSchema: { + chipnet: z.boolean().optional(), + type: z.enum(['all', 'incoming', 'outgoing']).optional(), + page: z.number().int().positive().optional(), + token_category: z.string().optional(), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ chipnet, type, page, token_category }) => { + try { + return json( + await getHistoryView( + { + page: page ?? 1, + recordType: type ?? 'all', + tokenId: token_category || '', + }, + cn(chipnet) + ) + ) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'get_receiving_address', + { + title: 'Get receiving address', + description: + 'Derive a receiving address. With token_category, returns a token-aware (z-prefix) address and a PayPro URI. With amount, embeds the requested amount.', + inputSchema: { + chipnet: z.boolean().optional(), + index: z.number().int().nonnegative().optional(), + token: z.boolean().optional(), + token_category: z.string().optional(), + amount: z.number().positive().optional(), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ chipnet, index, token, token_category, amount }) => { + try { + return json( + await getReceiveAddressView( + { + index: index ?? 0, + token: Boolean(token) || Boolean(token_category), + category: token_category, + amount, + }, + cn(chipnet) + ) + ) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'get_tokens', + { + title: 'Get CashToken balances', + description: + 'List CashToken holdings, or return details for one token when token_category is given.', + inputSchema: { + chipnet: z.boolean().optional(), + token_category: z.string().optional(), + include_zero: z.boolean().optional(), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ chipnet, token_category, include_zero }) => { + try { + if (token_category) { + return json(await getTokenDetail(token_category, cn(chipnet))) + } + return json( + await getTokenBalances(cn(chipnet), { + includeZero: Boolean(include_zero), + }) + ) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'send', + { + title: 'Send funds', + description: + 'Send BCH, or a CashToken when token_category is given. Token amounts are in base units. SPENDS REAL FUNDS.', + inputSchema: { + address: z.string(), + amount: z.string(), + unit: z.enum(['bch', 'sats']).optional(), + token_category: z.string().optional(), + chipnet: z.boolean().optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ address, amount, unit, token_category, chipnet }) => { + const isChipnet = cn(chipnet) + try { + if (!isValidBchAddress(address, isChipnet)) { + return fail(new Error('Invalid BCH address.')) + } + + if (token_category) { + let tokenAmount: bigint + try { + tokenAmount = BigInt(amount) + } catch { + return fail(new Error('Token amount must be an integer.')) + } + if (tokenAmount <= 0n) { + return fail(new Error('Token amount must be positive.')) + } + return json( + await sendToken( + { category: token_category, amount: tokenAmount, address }, + isChipnet + ) + ) + } + + const parsed = Number(amount) + if (!isFinite(parsed) || parsed <= 0) { + return fail(new Error('Amount must be a positive number.')) + } + const amountBch = unit === 'sats' ? parsed / 1e8 : parsed + return json(await sendBch({ address, amountBch }, isChipnet)) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'get_models', + { + title: 'List AI models', + description: 'List the Paytaca AI models available for purchase.', + inputSchema: { backend: z.string().optional() }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ backend }) => { + try { + const config = await getConfig({ backendUrl: backend }) + return json(listModels(config)) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'get_plans', + { + title: 'Get AI plan pricing', + description: + 'Return plan pricing grouped by tier. Pass model to filter a single model.', + inputSchema: { + model: z.string().optional(), + backend: z.string().optional(), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ model, backend }) => { + try { + const config = await getConfig({ backendUrl: backend }) + return json(listPlans(config, model)) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'get_credits', + { + title: 'Get AI time credits', + description: + 'Return remaining Paytaca AI time credits for all models, or for one model when "model" is given.', + inputSchema: { + model: z.string().optional(), + chipnet: z.boolean().optional(), + backend: z.string().optional(), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ model, chipnet, backend }) => { + try { + const wallet = loadWalletRef() + if (!wallet) throw new WalletNotConfiguredError() + const status = await getWalletStatus(wallet.walletHash, { + modelId: model, + backendUrl: backend, + }) + if (!model) { + return json({ sessions: summarizeAllCredits(status) }) + } + return json(summarizeCredits(status, model)) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'buy_plan', + { + title: 'Buy an AI plan', + description: + 'Purchase an AI plan for a model and duration, paying with BCH or LIFT via x402. SPENDS REAL FUNDS. The MCP host must obtain user approval before calling this.', + inputSchema: { + model: z.string(), + minutes: z.number().int().positive(), + payment_method: z.enum(['bch', 'lift']).optional(), + chipnet: z.boolean().optional(), + backend: z.string().optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ model, minutes, payment_method, chipnet, backend }) => { + try { + return json( + await buyPlan({ + model, + minutes, + paymentMethod: payment_method ?? 'bch', + isChipnet: cn(chipnet), + backendUrl: backend, + confirmed: true, + }) + ) + } catch (err) { + return fail(err) + } + } + ) + + server.registerTool( + 'auto_refill', + { + title: 'Manage auto-refill', + description: + 'Arm, disarm, or inspect automatic plan refills. Arming buys a plan silently when credits run out, up to max_minutes.', + inputSchema: { + enabled: z.boolean().optional(), + model: z.string().optional(), + minutes: z.number().int().positive().optional(), + max_minutes: z.number().int().positive().optional(), + payment_method: z.enum(['bch', 'lift']).optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ enabled, model, minutes, max_minutes, payment_method }) => { + try { + if (enabled === false) { + return json(disarmAutoRefill()) + } + + if (enabled === true) { + if (!model || !minutes || !max_minutes) { + return fail( + new Error( + 'Arming auto-refill requires model, minutes, and max_minutes.' + ) + ) + } + return json( + armAutoRefill({ + model, + minutes, + maxMinutes: max_minutes, + paymentMethod: payment_method ?? 'bch', + }) + ) + } + + const state = readAutoRefillState() + return json({ + armed: Boolean(state?.enabled), + remainingMinutes: remainingBudget(state), + state, + }) + } catch (err) { + return fail(err) + } + } + ) +} \ No newline at end of file diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..3379ef3 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,65 @@ +/** + * Shared display/formatting helpers. + * + * Pure functions only — no chalk, no I/O — so both the CLI renderers + * and the core layer can use them. + */ + +/** Convert BCH to satoshis (1 BCH = 100,000,000 sats). */ +export function bchToSats(bch: number): number { + return Math.round(bch * 1e8) +} + +/** Convert satoshis to BCH. */ +export function satsToBch(sats: number): number { + return sats / 1e8 +} + +/** Format a number with thousands separators. */ +export function formatSats(sats: number): string { + return sats.toLocaleString('en-US') +} + +/** Format a token amount (base units) using its decimals. */ +export function formatTokenAmount(rawAmount: number, decimals: number): string { + if (decimals === 0) return rawAmount.toLocaleString('en-US') + const scaled = rawAmount / Math.pow(10, decimals) + return scaled.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }) +} + +/** Format an ISO date string to a concise local representation. */ +export function formatDate(isoDate: string): string { + const d = new Date(isoDate) + if (isNaN(d.getTime())) return isoDate + return d.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +/** Truncate a txid for display. */ +export function shortTxid(txid: string): string { + if (txid.length <= 20) return txid + return txid.slice(0, 10) + '...' + txid.slice(-10) +} + +/** Truncate a hex string for display. */ +export function shortHex(hex: string, len: number = 8): string { + if (hex.length <= len * 2 + 3) return hex + return hex.slice(0, len) + '...' + hex.slice(-len) +} + +/** Block explorer transaction URL for the given network. */ +export function explorerTxUrl(txid: string, isChipnet: boolean): string { + const base = isChipnet + ? 'https://chipnet.bchexplorer.info/tx/' + : 'https://bchexplorer.info/tx/' + return `${base}${txid}` +} diff --git a/src/utils/x402.test.ts b/src/utils/x402.test.ts index aa2f050..506c52a 100644 --- a/src/utils/x402.test.ts +++ b/src/utils/x402.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest' +import { createHash } from 'node:crypto' +import { hexToBin, secp256k1 } from '@bitauth/libauth' import { parsePaymentRequiredJson, selectBchPaymentRequirements, @@ -7,6 +9,7 @@ import { parsePaymentResponse, isBchNetwork, isChipnetNetwork, + signMessageBCH, BCH_MAINNET_NETWORK, BCH_CHIPNET_NETWORK, BCH_ASSET_ID, @@ -247,6 +250,37 @@ describe('parsePaymentResponse', () => { }) }) +describe('signMessageBCH', () => { + it('signs the Bitcoin Signed Message digest in the correct key order', () => { + const privateKey = new Uint8Array(32).fill(7) + const privateKeyHex = Buffer.from(privateKey).toString('hex') + const message = 'x402 payment' + + const signature = signMessageBCH(message, privateKeyHex) + + const prefix = '\x18Bitcoin Signed Message:\n' + const messageBytes = Buffer.from(message, 'utf8') + const prefixed = Buffer.concat([ + Buffer.from(prefix, 'utf8'), + Buffer.from([messageBytes.length]), + messageBytes, + ]) + const digest = createHash('sha256') + .update(createHash('sha256').update(prefixed).digest()) + .digest() + const publicKey = secp256k1.derivePublicKeyCompressed(privateKey) + expect(typeof publicKey).not.toBe('string') + expect( + secp256k1.verifySignatureDER( + Buffer.from(signature, 'base64'), + publicKey as Uint8Array, + digest + ) + ).toBe(true) + expect(hexToBin(privateKeyHex)).toEqual(privateKey) + }) +}) + describe('network helpers', () => { describe('isBchNetwork', () => { it('should return true for mainnet', () => { diff --git a/src/utils/x402.ts b/src/utils/x402.ts index 68b1267..7fd0f9c 100644 --- a/src/utils/x402.ts +++ b/src/utils/x402.ts @@ -135,7 +135,7 @@ export function signMessageBCH( const prefixedMessage = Buffer.concat([prefixBytes, lengthByte, messageBytes]) const hash = crypto.createHash('sha256').update(crypto.createHash('sha256').update(prefixedMessage).digest()).digest() const privateKey = Buffer.from(privateKeyHex, 'hex') - const signature = secp256k1.signMessageHashDER(hash, privateKey) + const signature = secp256k1.signMessageHashDER(privateKey, hash) return Buffer.from(signature).toString('base64') } diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 77671d9..4f8986e 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -100,6 +100,31 @@ export function loadMnemonic(): { return { mnemonic, walletHash } } +export interface WalletRef { + walletHash: string + mnemonic?: string + canSign: boolean +} + +/** + * Load a reference to the active wallet without requiring a signing mnemonic. + * + * Full wallets have both a wallet hash and a mnemonic; read-only wallets only + * have the wallet hash. Callers that must sign should reject `canSign === false`, + * while read-only flows can still use the hash. + */ +export function loadWalletRef(): WalletRef | null { + const walletHash = getActiveWallet() + if (!walletHash) return null + + const mnemonic = keychainGetMnemonic(walletHash) + return { + walletHash, + mnemonic: mnemonic ?? undefined, + canSign: Boolean(mnemonic), + } +} + /** * High-level wallet class that provides access to BCH sub-wallets. *