@paytaca/opencode-plugin 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.';\n }\n return parts.join('\\n');\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n');\n}\n\n// Plan pricing grouped by tier, optionally filtered to one model\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n const groups = { budget: [], premium: [], frontier: [], other: [] };\n for (const m of models) {\n const key = String(m.tier || '').toLowerCase();\n const groupKey = (key === 'budget' || key === 'premium' || key === 'frontier') ? key : 'other';\n groups[groupKey].push(m);\n }\n const lines = ['Paytaca AI \u2014 Model Pricing'];\n const order = [\n { key: 'budget', label: 'Budget' },\n { key: 'premium', label: 'Premium' },\n { key: 'frontier', label: 'Frontier' },\n { key: 'other', label: 'Other' },\n ];\n let any = false;\n for (const g of order) {\n const group = groups[g.key];\n if (group.length === 0) continue;\n any = true;\n lines.push('');\n lines.push(g.label);\n for (const m of group) {\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('- ' + (m.display_name || m.id) + ': no pricing configured');\n continue;\n }\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n lines.push((m.display_name || m.id) + ':');\n for (const t of sorted) {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? t.price_usd.toFixed(4) : '?.??';\n lines.push(' ' + (t.minutes || 0) + ' minutes \u2014 USD ' + usd + ' (' + bch + ' BCH)');\n }\n }\n }\n if (!any) {\n lines.push('No models available.');\n }\n return lines.join('\\n');\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow).\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n fs.writeFileSync(configFile, JSON.stringify({\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n }), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes.'];\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS BCH FROM THE WALLET \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session.',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
1
+ export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\n// LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).\n// The same token id is used by the payment wrapper's payWithLift().\nconst LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.\n// The assistant fetches this when the user asks \"how do I...\", \"what is...\",\n// or anything about funding, paying, or how the service works.\nconst FAQ = [\n ['What is Paytaca AI?', 'Paytaca AI is a service that gives you affordable, convenient access to AI inference through your Paytaca wallet. Instead of per-token metering or a monthly subscription, you buy short time-based plans in advance \u2014 15, 30, or 60 minutes of a model \u2014 and use them up as you chat.'],\n ['What makes Paytaca AI different from other AI services?', 'We keep AI access affordable and simple by billing in pre-purchased blocks of time rather than per token. Pick a model, buy a 15/30/60-minute plan with your wallet, and the time is yours to use. No surprises per message, no recurring billing.'],\n ['Did this plugin create a wallet for me?', 'Yes. Installing the Paytaca AI opencode plugin automatically created a Paytaca wallet on this device (a Bitcoin Cash wallet). Any BCH or LIFT tokens you send to its address are available to this plugin to spend.'],\n ['How do I fund my wallet?', 'Get your wallet\\'s receiving address (ask \"what\\'s my address?\" or call get_receiving_address), then send BCH or LIFT tokens to it from any Bitcoin Cash wallet or exchange. Funding must happen BEFORE you can buy a plan. Check your balance anytime with get_balance.'],\n ['How do I buy a plan?', 'Three steps: (1) make sure your wallet has funds \u2014 get_receiving_address to deposit, get_balance to confirm; (2) see pricing with get_plans; (3) ask to buy, e.g. \"Buy a DeepSeek V4 Flash plan for 30 minutes.\" opencode will ask you to approve the payment.'],\n ['Can I pay with LIFT tokens?', 'Yes. Plans can be paid in BCH or LIFT tokens. To pay with tokens, add \"pay with LIFT\" to your buy request, e.g. \"Buy a 15-minute GLM plan and pay with LIFT.\" Paying with LIFT applies a discount \u2014 see the current rate below (the backend sets it, so it can change anytime).'],\n ['Why can\\'t I buy again while I still have credits?', 'Plans are time blocks, not balances that stack. While a model still has active time, buying another plan for it would charge nothing extra. Use up or wait out the remaining credits, then buy again. Check remaining time with get_credits.'],\n ['What is Bitcoin Cash (BCH)?', 'Bitcoin Cash is peer-to-peer electronic cash. Transactions are confirmed in seconds to minutes with extremely low fees (fractions of a cent), which makes it practical for small, everyday payments like buying a plan. It is the currency Paytaca AI payments run on.'],\n ['What are CashTokens and LIFT?', 'CashTokens are fungible and non-fungible tokens issued on the Bitcoin Cash blockchain. LIFT is a Paytaca CashToken you can use to pay for AI plans, and it can be bought/sold for BCH on the Cauldron DEX.'],\n ['What is Paytaca?', 'Paytaca is a Bitcoin Cash wallet and payments ecosystem, and Paytaca AI is its AI inference service. The Paytaca wallet app holds your BCH and CashTokens, and Paytaca AI lets you spend them on AI usage.'],\n].map(([q, a]) => '- **' + q + '**\\n ' + a).join('\\n');\n\n// Appended to Paytaca AI info tool outputs (plans, credits, models): asks the\n// user what they want to do next and steers them toward buying a plan, with\n// concrete example prompts they can reuse to place the order.\nasync function nextSteps() {\n const percent = await getLiftDiscountPercent();\n const liftLine = percent > 0\n ? '- Pay with LIFT tokens instead of BCH: just add \"pay with LIFT\" to your request, e.g. \"Buy a 30-minute GLM plan and pay with LIFT.\" \u2014 you get **' + percent + '% off**.'\n : '- Pay with LIFT tokens instead of BCH: just add \"pay with LIFT\" to your request, e.g. \"Buy a 30-minute GLM plan and pay with LIFT.\"';\n return [\n '',\n 'What would you like to do next?',\n '- Buy a plan: pick a model and duration from the list above, then say something like: \"Buy a DeepSeek V4 Flash plan for 15 minutes.\"',\n liftLine,\n '- Check your credits: \"How much time do I have left?\"',\n '- See prices for another model: \"Show me the plans for <model>.\"',\n ].join('\\n');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.' + await nextSteps();\n }\n return parts.join('\\n') + await nextSteps();\n}\n\n// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.\nasync function getHelp() {\n let liftLine = '';\n const percent = await getLiftDiscountPercent();\n if (percent > 0) {\n liftLine = '\\n\\n\uD83D\uDCA1 **Current LIFT discount: ' + percent + '%** off plans paid with LIFT tokens.';\n }\n return 'Paytaca AI and your wallet \u2014 frequently asked questions:\\n\\n' + FAQ + liftLine + await nextSteps();\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n') + await nextSteps();\n}\n\n// Plan pricing as a compact matrix: models as rows, durations as columns,\n// with USD and BCH price in each cell. Durations are always 15/30/60 min.\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n if (models.length === 0) {\n return 'No models available.' + await nextSteps();\n }\n const durationColumns = [15, 30, 60];\n const lines = ['| Model | 15 min | 30 min | 60 min |', '|---|---|---|---|'];\n for (const m of models) {\n const name = m.display_name || m.id;\n const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';\n const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;\n const label = name + (tier && !tierInName ? ' (' + tier + ')' : '');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n const byMinutes = {};\n for (const t of tiers) {\n byMinutes[Number(t.minutes)] = t;\n }\n const cells = durationColumns.map((mins) => {\n const t = byMinutes[mins];\n if (!t) return '-';\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';\n return usd + ' / ' + bch + ' BCH';\n });\n lines.push('| ' + label + ' | ' + cells.join(' | ') + ' |');\n }\n return lines.join('\\n') + await nextSteps();\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:s*([d.]+)s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// LIFT token balance in base units (2 decimals), null when the CLI output\n// cannot be parsed.\nasync function getLiftBalanceUnits() {\n const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);\n const match = out.match(/Balance:s*([d.]+)s*LIFT/i);\n if (!match) return null;\n return BigInt(Math.round(parseFloat(match[1]) * 100));\n}\n\n// LIFT payment discount percent advertised by the backend (/v1/config), cached\n// for ~5 minutes. Returns 0 when unset/unavailable so callers can fall back to\n// no-discount messaging.\nlet liftDiscountCache = { at: 0, percent: 0 };\nasync function getLiftDiscountPercent() {\n const now = Date.now();\n if (liftDiscountCache.at && now - liftDiscountCache.at < 300000) {\n return liftDiscountCache.percent;\n }\n let percent = 0;\n try {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n percent = Number(data.lift_payment_discount_percent) || 0;\n } catch (e) {\n log('Failed to fetch LIFT discount config: ' + e.message);\n }\n liftDiscountCache = { at: now, percent };\n return percent;\n}\n\n// Format a satoshi amount as a BCH string with up to 8 decimals.\nfunction formatBch(sats) {\n return (Number(sats) / 100000000).toFixed(8);\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Explicit restriction: a plan cannot be purchased while the model still has\n // active credits. The backend serves requests without a 402 once credits are\n // active, so buying again would silently charge nothing and the user would\n // think they stacked time. Block the purchase and tell them to use up the\n // remaining credits (or wait for them to expire) before buying again.\n let status;\n try {\n status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n } catch (e) {\n throw new Error('Could not check existing credits before purchase: ' + e.message + '. Try again, or check credits with get_credits.');\n }\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const existing = sessions.find((s) => s.ai_model === model.id && s.time_remaining_seconds > 0);\n if (existing) {\n const remaining = formatDuration(existing.time_remaining_seconds);\n throw new Error((model.display_name || model.id) + ' still has ' + remaining + ' of active credits. A new plan cannot be purchased until the remaining credits are used up or expire. Check credits with get_credits and buy again once they run out.');\n }\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH \u2014 the\n // LIFT path funds the plan by selling tokens, so no BCH balance is required.\n if (paymentMethod === 'bch') {\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n } else {\n // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting\n // for approval and then failing inside the wrapper.\n const liftBalanceUnits = await getLiftBalanceUnits();\n if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method \"bch\".');\n }\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n if (paymentMethod === 'lift') {\n extraHeaders['X-Payment-Method'] = 'lift';\n }\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n const config = {\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n };\n if (paymentMethod === 'lift') {\n config.paymentMethod = 'lift';\n }\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const paid = result.payment && result.payment.required;\n if (!paid) {\n // No payment was required \u2014 the model already had active credits, so the\n // server served the request for free. Report this instead of claiming a\n // purchase so the user knows nothing was charged.\n return 'No purchase made: ' + (model.display_name || model.id) + ' already has active credits, so the request was served without payment. Check your credits with get_credits.';\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];\n if (paymentMethod === 'lift') {\n const discountPercent = await getLiftDiscountPercent();\n if (discountPercent > 0) {\n const discountSats = Math.round(priceSats * (discountPercent / 100));\n lines.push('**LIFT discount applied: ' + discountPercent + '% \u2014 you saved ' + formatBch(discountSats) + ' BCH.**');\n } else {\n lines.push('Paid with LIFT tokens.');\n }\n }\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_help',\n description: 'Get a Q&A guide about the Paytaca wallet, funding it, buying plans, paying with BCH or LIFT, what makes Paytaca AI unique, Bitcoin Cash, CashTokens/LIFT, and Paytaca itself. Use when the user asks how the wallet or buying works, how to fund or top up, what Paytaca AI is, what makes it different, or anything about Bitcoin Cash, CashTokens, or LIFT.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. IMPORTANT RESTRICTION: a plan CANNOT be purchased while the model still has active credits \u2014 the backend serves requests without payment once credits are active, so buying again charges nothing and does not stack time. Before calling, check the model\\'s credits with get_credits; if the model still has time remaining, do NOT buy \u2014 tell the user to use up or wait out the remaining credits first. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT \u2014 LIFT phrasing: when the user says \"pay with LIFT\", \"pay with LIFT tokens\", \"use LIFT\", or mentions paying a plan with their LIFT token balance, set payment_method to \"lift\". LIFT is the Paytaca token users hold to pay for AI plans; \"pay with LIFT\" is NOT asking to buy a plan called LIFT. Default to \"bch\" unless the user explicitly mentions LIFT/tokens. Paying with LIFT gets a discount (rate set server-side; see get_help or get_plans for the current percent).',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says \"pay with LIFT\" or \"pay with LIFT tokens\" \u2014 this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_help': text = await getHelp(); break;\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
2
2
  //# sourceMappingURL=mcp.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,q7uBAgmB9B,CAAC"}
1
+ {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,qhkCAguB9B,CAAC"}
@@ -31,6 +31,10 @@ const CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.o
31
31
  const PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';
32
32
  const DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';
33
33
 
34
+ // LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).
35
+ // The same token id is used by the payment wrapper's payWithLift().
36
+ const LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';
37
+
34
38
  const PROTOCOL_VERSION = '2025-06-18';
35
39
 
36
40
  // Logging setup
@@ -138,6 +142,40 @@ function formatDuration(totalSeconds) {
138
142
  return minutes + ':' + String(secs).padStart(2, '0');
139
143
  }
140
144
 
145
+ // Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.
146
+ // The assistant fetches this when the user asks "how do I...", "what is...",
147
+ // or anything about funding, paying, or how the service works.
148
+ const FAQ = [
149
+ ['What is Paytaca AI?', 'Paytaca AI is a service that gives you affordable, convenient access to AI inference through your Paytaca wallet. Instead of per-token metering or a monthly subscription, you buy short time-based plans in advance — 15, 30, or 60 minutes of a model — and use them up as you chat.'],
150
+ ['What makes Paytaca AI different from other AI services?', 'We keep AI access affordable and simple by billing in pre-purchased blocks of time rather than per token. Pick a model, buy a 15/30/60-minute plan with your wallet, and the time is yours to use. No surprises per message, no recurring billing.'],
151
+ ['Did this plugin create a wallet for me?', 'Yes. Installing the Paytaca AI opencode plugin automatically created a Paytaca wallet on this device (a Bitcoin Cash wallet). Any BCH or LIFT tokens you send to its address are available to this plugin to spend.'],
152
+ ['How do I fund my wallet?', 'Get your wallet\\'s receiving address (ask "what\\'s my address?" or call get_receiving_address), then send BCH or LIFT tokens to it from any Bitcoin Cash wallet or exchange. Funding must happen BEFORE you can buy a plan. Check your balance anytime with get_balance.'],
153
+ ['How do I buy a plan?', 'Three steps: (1) make sure your wallet has funds — get_receiving_address to deposit, get_balance to confirm; (2) see pricing with get_plans; (3) ask to buy, e.g. "Buy a DeepSeek V4 Flash plan for 30 minutes." opencode will ask you to approve the payment.'],
154
+ ['Can I pay with LIFT tokens?', 'Yes. Plans can be paid in BCH or LIFT tokens. To pay with tokens, add "pay with LIFT" to your buy request, e.g. "Buy a 15-minute GLM plan and pay with LIFT." Paying with LIFT applies a discount — see the current rate below (the backend sets it, so it can change anytime).'],
155
+ ['Why can\\'t I buy again while I still have credits?', 'Plans are time blocks, not balances that stack. While a model still has active time, buying another plan for it would charge nothing extra. Use up or wait out the remaining credits, then buy again. Check remaining time with get_credits.'],
156
+ ['What is Bitcoin Cash (BCH)?', 'Bitcoin Cash is peer-to-peer electronic cash. Transactions are confirmed in seconds to minutes with extremely low fees (fractions of a cent), which makes it practical for small, everyday payments like buying a plan. It is the currency Paytaca AI payments run on.'],
157
+ ['What are CashTokens and LIFT?', 'CashTokens are fungible and non-fungible tokens issued on the Bitcoin Cash blockchain. LIFT is a Paytaca CashToken you can use to pay for AI plans, and it can be bought/sold for BCH on the Cauldron DEX.'],
158
+ ['What is Paytaca?', 'Paytaca is a Bitcoin Cash wallet and payments ecosystem, and Paytaca AI is its AI inference service. The Paytaca wallet app holds your BCH and CashTokens, and Paytaca AI lets you spend them on AI usage.'],
159
+ ].map(([q, a]) => '- **' + q + '**\\n ' + a).join('\\n');
160
+
161
+ // Appended to Paytaca AI info tool outputs (plans, credits, models): asks the
162
+ // user what they want to do next and steers them toward buying a plan, with
163
+ // concrete example prompts they can reuse to place the order.
164
+ async function nextSteps() {
165
+ const percent = await getLiftDiscountPercent();
166
+ const liftLine = percent > 0
167
+ ? '- Pay with LIFT tokens instead of BCH: just add "pay with LIFT" to your request, e.g. "Buy a 30-minute GLM plan and pay with LIFT." — you get **' + percent + '% off**.'
168
+ : '- Pay with LIFT tokens instead of BCH: just add "pay with LIFT" to your request, e.g. "Buy a 30-minute GLM plan and pay with LIFT."';
169
+ return [
170
+ '',
171
+ 'What would you like to do next?',
172
+ '- Buy a plan: pick a model and duration from the list above, then say something like: "Buy a DeepSeek V4 Flash plan for 15 minutes."',
173
+ liftLine,
174
+ '- Check your credits: "How much time do I have left?"',
175
+ '- See prices for another model: "Show me the plans for <model>."',
176
+ ].join('\\n');
177
+ }
178
+
141
179
  // Remaining time credits per model session
142
180
  async function getCredits() {
143
181
  const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });
@@ -162,9 +200,19 @@ async function getCredits() {
162
200
  }
163
201
  }
164
202
  if (parts.length === 0) {
165
- return 'No active time credits.';
203
+ return 'No active time credits.' + await nextSteps();
204
+ }
205
+ return parts.join('\\n') + await nextSteps();
206
+ }
207
+
208
+ // Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.
209
+ async function getHelp() {
210
+ let liftLine = '';
211
+ const percent = await getLiftDiscountPercent();
212
+ if (percent > 0) {
213
+ liftLine = '\\n\\n💡 **Current LIFT discount: ' + percent + '%** off plans paid with LIFT tokens.';
166
214
  }
167
- return parts.join('\\n');
215
+ return 'Paytaca AI and your wallet — frequently asked questions:\\n\\n' + FAQ + liftLine + await nextSteps();
168
216
  }
169
217
 
170
218
  // Wallet BCH balance via the paytaca CLI
@@ -188,10 +236,11 @@ async function getModels() {
188
236
  if (m.tier) line += ' [' + m.tier + ']';
189
237
  lines.push('- ' + line);
190
238
  }
191
- return lines.join('\\n');
239
+ return lines.join('\\n') + await nextSteps();
192
240
  }
193
241
 
194
- // Plan pricing grouped by tier, optionally filtered to one model
242
+ // Plan pricing as a compact matrix: models as rows, durations as columns,
243
+ // with USD and BCH price in each cell. Durations are always 15/30/60 min.
195
244
  async function getPlans(filterModel) {
196
245
  const data = await getJson(BACKEND_URL + '/v1/config', {});
197
246
  let models = Array.isArray(data.models) ? data.models : [];
@@ -203,47 +252,32 @@ async function getPlans(filterModel) {
203
252
  return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;
204
253
  });
205
254
  }
206
- const groups = { budget: [], premium: [], frontier: [], other: [] };
255
+ if (models.length === 0) {
256
+ return 'No models available.' + await nextSteps();
257
+ }
258
+ const durationColumns = [15, 30, 60];
259
+ const lines = ['| Model | 15 min | 30 min | 60 min |', '|---|---|---|---|'];
207
260
  for (const m of models) {
208
- const key = String(m.tier || '').toLowerCase();
209
- const groupKey = (key === 'budget' || key === 'premium' || key === 'frontier') ? key : 'other';
210
- groups[groupKey].push(m);
211
- }
212
- const lines = ['Paytaca AI Model Pricing'];
213
- const order = [
214
- { key: 'budget', label: 'Budget' },
215
- { key: 'premium', label: 'Premium' },
216
- { key: 'frontier', label: 'Frontier' },
217
- { key: 'other', label: 'Other' },
218
- ];
219
- let any = false;
220
- for (const g of order) {
221
- const group = groups[g.key];
222
- if (group.length === 0) continue;
223
- any = true;
224
- lines.push('');
225
- lines.push(g.label);
226
- for (const m of group) {
227
- lines.push('');
228
- const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];
229
- if (tiers.length === 0) {
230
- lines.push('- ' + (m.display_name || m.id) + ': no pricing configured');
231
- continue;
232
- }
233
- const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));
234
- lines.push((m.display_name || m.id) + ':');
235
- for (const t of sorted) {
236
- const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;
237
- const bch = (sats / 100000000).toFixed(8);
238
- const usd = typeof t.price_usd === 'number' ? t.price_usd.toFixed(4) : '?.??';
239
- lines.push(' ' + (t.minutes || 0) + ' minutes — USD ' + usd + ' (' + bch + ' BCH)');
240
- }
261
+ const name = m.display_name || m.id;
262
+ const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';
263
+ const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;
264
+ const label = name + (tier && !tierInName ? ' (' + tier + ')' : '');
265
+ const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];
266
+ const byMinutes = {};
267
+ for (const t of tiers) {
268
+ byMinutes[Number(t.minutes)] = t;
241
269
  }
270
+ const cells = durationColumns.map((mins) => {
271
+ const t = byMinutes[mins];
272
+ if (!t) return '-';
273
+ const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;
274
+ const bch = (sats / 100000000).toFixed(8);
275
+ const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';
276
+ return usd + ' / ' + bch + ' BCH';
277
+ });
278
+ lines.push('| ' + label + ' | ' + cells.join(' | ') + ' |');
242
279
  }
243
- if (!any) {
244
- lines.push('No models available.');
245
- }
246
- return lines.join('\\n');
280
+ return lines.join('\\n') + await nextSteps();
247
281
  }
248
282
 
249
283
  // Resolve a model (by id or display name) and its price tier by minutes.
@@ -275,11 +309,45 @@ async function resolvePlan(modelFilter, minutes) {
275
309
  // Wallet balance in sats (mirrors the proxy's pre-payment check)
276
310
  async function getBalanceSats() {
277
311
  const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);
278
- const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);
312
+ const match = out.match(/Balance:\s*([\d.]+)\s*BCH/i);
279
313
  if (!match) return null;
280
314
  return Math.floor(parseFloat(match[1]) * 100000000);
281
315
  }
282
316
 
317
+ // LIFT token balance in base units (2 decimals), null when the CLI output
318
+ // cannot be parsed.
319
+ async function getLiftBalanceUnits() {
320
+ const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);
321
+ const match = out.match(/Balance:\s*([\d.]+)\s*LIFT/i);
322
+ if (!match) return null;
323
+ return BigInt(Math.round(parseFloat(match[1]) * 100));
324
+ }
325
+
326
+ // LIFT payment discount percent advertised by the backend (/v1/config), cached
327
+ // for ~5 minutes. Returns 0 when unset/unavailable so callers can fall back to
328
+ // no-discount messaging.
329
+ let liftDiscountCache = { at: 0, percent: 0 };
330
+ async function getLiftDiscountPercent() {
331
+ const now = Date.now();
332
+ if (liftDiscountCache.at && now - liftDiscountCache.at < 300000) {
333
+ return liftDiscountCache.percent;
334
+ }
335
+ let percent = 0;
336
+ try {
337
+ const data = await getJson(BACKEND_URL + '/v1/config', {});
338
+ percent = Number(data.lift_payment_discount_percent) || 0;
339
+ } catch (e) {
340
+ log('Failed to fetch LIFT discount config: ' + e.message);
341
+ }
342
+ liftDiscountCache = { at: now, percent };
343
+ return percent;
344
+ }
345
+
346
+ // Format a satoshi amount as a BCH string with up to 8 decimals.
347
+ function formatBch(sats) {
348
+ return (Number(sats) / 100000000).toFixed(8);
349
+ }
350
+
283
351
  // Purchase time credits for a specific model and plan duration. Reuses the
284
352
  // same payment wrapper the proxy runs on a 402 (x402 payment + retry with the
285
353
  // PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks —
@@ -287,6 +355,7 @@ async function getBalanceSats() {
287
355
  async function buyPlan(args) {
288
356
  const modelFilter = String(args.model || '').trim();
289
357
  const minutes = Number(args.minutes);
358
+ const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';
290
359
  if (!modelFilter) {
291
360
  throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');
292
361
  }
@@ -297,13 +366,41 @@ async function buyPlan(args) {
297
366
  const { model, tier } = await resolvePlan(modelFilter, minutes);
298
367
  const priceSats = Number(tier.price_sats) || 0;
299
368
 
369
+ // Explicit restriction: a plan cannot be purchased while the model still has
370
+ // active credits. The backend serves requests without a 402 once credits are
371
+ // active, so buying again would silently charge nothing and the user would
372
+ // think they stacked time. Block the purchase and tell them to use up the
373
+ // remaining credits (or wait for them to expire) before buying again.
374
+ let status;
375
+ try {
376
+ status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });
377
+ } catch (e) {
378
+ throw new Error('Could not check existing credits before purchase: ' + e.message + '. Try again, or check credits with get_credits.');
379
+ }
380
+ const sessions = Array.isArray(status.sessions) ? status.sessions : [];
381
+ const existing = sessions.find((s) => s.ai_model === model.id && s.time_remaining_seconds > 0);
382
+ if (existing) {
383
+ const remaining = formatDuration(existing.time_remaining_seconds);
384
+ throw new Error((model.display_name || model.id) + ' still has ' + remaining + ' of active credits. A new plan cannot be purchased until the remaining credits are used up or expire. Check credits with get_credits and buy again once they run out.');
385
+ }
386
+
300
387
  // Fail fast on insufficient balance instead of sending a txn that cannot
301
- // fund the plan (mirrors the proxy's 402 flow).
302
- const balanceSats = await getBalanceSats();
303
- if (balanceSats !== null && balanceSats < priceSats) {
304
- const addr = await getReceivingAddress({});
305
- const shortfall = (priceSats - balanceSats) / 100000000;
306
- throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);
388
+ // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH — the
389
+ // LIFT path funds the plan by selling tokens, so no BCH balance is required.
390
+ if (paymentMethod === 'bch') {
391
+ const balanceSats = await getBalanceSats();
392
+ if (balanceSats !== null && balanceSats < priceSats) {
393
+ const addr = await getReceivingAddress({});
394
+ const shortfall = (priceSats - balanceSats) / 100000000;
395
+ throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);
396
+ }
397
+ } else {
398
+ // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting
399
+ // for approval and then failing inside the wrapper.
400
+ const liftBalanceUnits = await getLiftBalanceUnits();
401
+ if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {
402
+ throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method "bch".');
403
+ }
307
404
  }
308
405
 
309
406
  const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');
@@ -324,25 +421,32 @@ async function buyPlan(args) {
324
421
  'X-Model-Id': model.id,
325
422
  'X-Duration-Minutes': String(tier.minutes),
326
423
  };
424
+ if (paymentMethod === 'lift') {
425
+ extraHeaders['X-Payment-Method'] = 'lift';
426
+ }
327
427
 
328
428
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));
329
429
  const bodyFile = path.join(tmpDir, 'body.json');
330
430
  const configFile = path.join(tmpDir, 'config.json');
331
431
  try {
332
432
  fs.writeFileSync(bodyFile, body, 'utf8');
333
- fs.writeFileSync(configFile, JSON.stringify({
433
+ const config = {
334
434
  url: url,
335
435
  method: 'POST',
336
436
  headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),
337
437
  bodyFile: bodyFile,
338
438
  confirmed: true,
339
- }), 'utf8');
439
+ };
440
+ if (paymentMethod === 'lift') {
441
+ config.paymentMethod = 'lift';
442
+ }
443
+ fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');
340
444
  } catch (e) {
341
445
  try { fs.rmdirSync(tmpDir); } catch (e2) {}
342
446
  throw new Error('Failed to write payment files: ' + e.message);
343
447
  }
344
448
 
345
- log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min');
449
+ log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');
346
450
  let stdout;
347
451
  try {
348
452
  stdout = await runCommand('node', [wrapper, configFile], 250000);
@@ -366,7 +470,24 @@ async function buyPlan(args) {
366
470
  throw new Error(result.error || 'Payment failed (status ' + result.status + ').');
367
471
  }
368
472
 
369
- const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes.'];
473
+ const paid = result.payment && result.payment.required;
474
+ if (!paid) {
475
+ // No payment was required — the model already had active credits, so the
476
+ // server served the request for free. Report this instead of claiming a
477
+ // purchase so the user knows nothing was charged.
478
+ return 'No purchase made: ' + (model.display_name || model.id) + ' already has active credits, so the request was served without payment. Check your credits with get_credits.';
479
+ }
480
+
481
+ const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];
482
+ if (paymentMethod === 'lift') {
483
+ const discountPercent = await getLiftDiscountPercent();
484
+ if (discountPercent > 0) {
485
+ const discountSats = Math.round(priceSats * (discountPercent / 100));
486
+ lines.push('**LIFT discount applied: ' + discountPercent + '% — you saved ' + formatBch(discountSats) + ' BCH.**');
487
+ } else {
488
+ lines.push('Paid with LIFT tokens.');
489
+ }
490
+ }
370
491
  if (result.payment && result.payment.txid) {
371
492
  lines.push('Transaction: ' + result.payment.txid);
372
493
  }
@@ -443,6 +564,11 @@ async function sendFunds(args) {
443
564
 
444
565
  // Tool schemas (concise descriptions so MCP tool context stays small)
445
566
  const TOOLS = [
567
+ {
568
+ name: 'get_help',
569
+ description: 'Get a Q&A guide about the Paytaca wallet, funding it, buying plans, paying with BCH or LIFT, what makes Paytaca AI unique, Bitcoin Cash, CashTokens/LIFT, and Paytaca itself. Use when the user asks how the wallet or buying works, how to fund or top up, what Paytaca AI is, what makes it different, or anything about Bitcoin Cash, CashTokens, or LIFT.',
570
+ inputSchema: { type: 'object', properties: {} },
571
+ },
446
572
  {
447
573
  name: 'get_credits',
448
574
  description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',
@@ -470,13 +596,14 @@ const TOOLS = [
470
596
  },
471
597
  {
472
598
  name: 'buy_plan',
473
- description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS BCH FROM THE WALLET — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session.',
599
+ description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. IMPORTANT RESTRICTION: a plan CANNOT be purchased while the model still has active credits — the backend serves requests without payment once credits are active, so buying again charges nothing and does not stack time. Before calling, check the model\\'s credits with get_credits; if the model still has time remaining, do NOT buy — tell the user to use up or wait out the remaining credits first. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT — LIFT phrasing: when the user says "pay with LIFT", "pay with LIFT tokens", "use LIFT", or mentions paying a plan with their LIFT token balance, set payment_method to "lift". LIFT is the Paytaca token users hold to pay for AI plans; "pay with LIFT" is NOT asking to buy a plan called LIFT. Default to "bch" unless the user explicitly mentions LIFT/tokens. Paying with LIFT gets a discount (rate set server-side; see get_help or get_plans for the current percent).',
474
600
  inputSchema: {
475
601
  type: 'object',
476
602
  required: ['model', 'minutes'],
477
603
  properties: {
478
604
  model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },
479
605
  minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },
606
+ payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says "pay with LIFT" or "pay with LIFT tokens" — this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },
480
607
  },
481
608
  },
482
609
  },
@@ -558,6 +685,7 @@ function handleMessage(msg) {
558
685
  let text;
559
686
  try {
560
687
  switch (name) {
688
+ case 'get_help': text = await getHelp(); break;
561
689
  case 'get_credits': text = await getCredits(); break;
562
690
  case 'get_balance': text = await getBalance(); break;
563
691
  case 'get_models': text = await getModels(); break;
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kEAAkE;;;AAErD,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgmBjC,CAAC"}
1
+ {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kEAAkE;;;AAErD,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAguBjC,CAAC"}