@paytaca/opencode-plugin 0.3.0 → 0.3.1

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.
@@ -64,7 +64,7 @@ let requestCounter = 0;
64
64
  // Utility: run shell command and return output
65
65
  function runCommand(cmd, args = []) {
66
66
  return new Promise((resolve, reject) => {
67
- const child = spawn(cmd, args, { shell: false });
67
+ const child = spawn(cmd, args, { shell: process.platform === 'win32' });
68
68
  let stdout = '';
69
69
  let stderr = '';
70
70
 
@@ -115,7 +115,7 @@ const LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0
115
115
  async function getLiftBalance() {
116
116
  try {
117
117
  const output = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID]);
118
- const match = output.match(/Balance:\\s*([\\d.]+)\\s*LIFT/i);
118
+ const match = output.match(/Balance:\s*([\d.]+)\s*LIFT/i);
119
119
  if (match) return Math.round(parseFloat(match[1]) * 100);
120
120
  return null;
121
121
  } catch (err) {
@@ -124,6 +124,23 @@ async function getLiftBalance() {
124
124
  }
125
125
  }
126
126
 
127
+ // Short-lived cache of wallet + LIFT balances so we don't shell out to the CLI
128
+ // on every forwarded prompt. The backend concierge reads these headers to give
129
+ // free balance answers when the wallet has no paid capacity.
130
+ const BALANCE_CACHE_TTL = 15000;
131
+ let balanceCache = { at: 0, sats: null, lift: null };
132
+
133
+ async function getCachedBalances() {
134
+ const now = Date.now();
135
+ if (balanceCache.at && now - balanceCache.at < BALANCE_CACHE_TTL) {
136
+ return { sats: balanceCache.sats, lift: balanceCache.lift };
137
+ }
138
+ const sats = await getWalletBalance();
139
+ const lift = await getLiftBalance();
140
+ balanceCache = { at: now, sats, lift };
141
+ return { sats, lift };
142
+ }
143
+
127
144
  // LIFT payment discount percent advertised by the backend (/v1/config), cached
128
145
  // briefly (30s) so a server-side rate change is picked up quickly. Returns 0
129
146
  // when unset/unavailable so callers can fall back to no-discount messaging.
@@ -381,7 +398,8 @@ async function streamLowBalanceNotice(res, modelName, otherModels) {
381
398
  }
382
399
 
383
400
  // Forward request to Django and return response (buffered, for non-streaming)
384
- function forwardToDjango(req, body, callback) {
401
+ async function forwardToDjango(req, body, callback) {
402
+ const balances = await getCachedBalances();
385
403
  const options = {
386
404
  hostname: DJANGO_HOST,
387
405
  port: DJANGO_PORT,
@@ -390,6 +408,8 @@ function forwardToDjango(req, body, callback) {
390
408
  headers: {
391
409
  'Content-Type': req.headers['content-type'] || 'application/json',
392
410
  'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',
411
+ 'X-Wallet-Balance-Sats': balances.sats !== null ? String(balances.sats) : '',
412
+ 'X-Lift-Balance-Units': balances.lift !== null ? String(balances.lift) : '',
393
413
  'Content-Length': Buffer.byteLength(body),
394
414
  },
395
415
  };
@@ -433,7 +453,8 @@ function forwardToDjango(req, body, callback) {
433
453
  }
434
454
 
435
455
  // Forward streaming request to Django
436
- function forwardStreaming(req, res, body, callback) {
456
+ async function forwardStreaming(req, res, body, callback) {
457
+ const balances = await getCachedBalances();
437
458
  const options = {
438
459
  hostname: DJANGO_HOST,
439
460
  port: DJANGO_PORT,
@@ -442,6 +463,8 @@ function forwardStreaming(req, res, body, callback) {
442
463
  headers: {
443
464
  'Content-Type': req.headers['content-type'] || 'application/json',
444
465
  'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',
466
+ 'X-Wallet-Balance-Sats': balances.sats !== null ? String(balances.sats) : '',
467
+ 'X-Lift-Balance-Units': balances.lift !== null ? String(balances.lift) : '',
445
468
  'Content-Length': Buffer.byteLength(body),
446
469
  },
447
470
  };
@@ -866,6 +889,184 @@ function runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, paymentMethod,
866
889
  });
867
890
  }
868
891
 
892
+ // ---------------------------------------------------------------------------
893
+ // Auto-refill: when armed via the MCP auto_refill tool, the proxy silently buys
894
+ // a plan of the configured size on 402 (credits exhausted) and retries instead
895
+ // of showing the interactive tier prompt. It stops when the cumulative budget
896
+ // (maxMinutes) is reached, the requested model mismatches, funds are short, or
897
+ // 24h pass without a refill (so a stale armed state cannot keep spending in a
898
+ // later session).
899
+ const AUTO_REFILL_FILE = path.join(LOG_DIR, 'auto-refill.json');
900
+
901
+ function getAutoRefillState() {
902
+ try {
903
+ const s = JSON.parse(fs.readFileSync(AUTO_REFILL_FILE, 'utf8'));
904
+ if (!s || s.enabled !== true) {
905
+ return null;
906
+ }
907
+ return {
908
+ enabled: true,
909
+ minutes: Number(s.minutes) || 0,
910
+ maxMinutes: Number(s.maxMinutes) || 0,
911
+ spentMinutes: Number(s.spentMinutes) || 0,
912
+ model: s.model ? String(s.model) : '',
913
+ paymentMethod: s.paymentMethod === 'lift' ? 'lift' : 'bch',
914
+ startedAt: s.startedAt || new Date().toISOString(),
915
+ lastRefillAt: s.lastRefillAt || null,
916
+ };
917
+ } catch (err) {
918
+ log('Failed to read auto-refill state: ' + err.message);
919
+ return null;
920
+ }
921
+ }
922
+
923
+ function saveAutoRefillState(s) {
924
+ try {
925
+ fs.writeFileSync(AUTO_REFILL_FILE, JSON.stringify(s, null, 2), 'utf8');
926
+ } catch (err) {
927
+ log('Failed to write auto-refill state: ' + err.message);
928
+ }
929
+ }
930
+
931
+ // Can the proxy buy another plan right now? Handles budget, model scope and a
932
+ // staleness rule — when it returns false the caller falls through to the normal
933
+ // interactive tier prompt so the user can buy manually.
934
+ function autoRefillCanBuy(s, modelId) {
935
+ if (!s || !s.enabled) return false;
936
+ if (s.minutes <= 0 || s.maxMinutes < s.minutes) {
937
+ log('Auto-refill disarmed: bad config (minutes=' + s.minutes + ' maxMinutes=' + s.maxMinutes + ')');
938
+ s.enabled = false;
939
+ saveAutoRefillState(s);
940
+ return false;
941
+ }
942
+ if (s.spentMinutes >= s.maxMinutes) {
943
+ return false;
944
+ }
945
+ if (s.model && (!modelId || s.model !== modelId)) {
946
+ return false;
947
+ }
948
+ const now = Date.now();
949
+ const anchorMs = new Date(s.lastRefillAt || s.startedAt || now).getTime();
950
+ if (isNaN(anchorMs) || now - anchorMs > 24 * 3600 * 1000) {
951
+ log('Auto-refill disarmed: stale (no refill for 24h)');
952
+ s.enabled = false;
953
+ saveAutoRefillState(s);
954
+ return false;
955
+ }
956
+ return true;
957
+ }
958
+
959
+ // Buy the configured plan automatically and retry the request. Mirrors the
960
+ // interactive payment path (keepalive + paytaca pay + SSE delivery) but never
961
+ // asks the user to pick a tier. On failure it disarms auto-refill and streams
962
+ // the payment-error prompt so the user can retry by hand.
963
+ async function autoRefillAndRetry(res, walletHash, pendingPayload, refill) {
964
+ log('AUTO-REFILL: buying ' + refill.minutes + '-min plan for wallet ' + walletHash?.substring(0, 16) + '...');
965
+ pendingPayload.step = 'processing';
966
+ const extraHeaders = {};
967
+ if (pendingPayload.modelId) {
968
+ extraHeaders['X-Model-Id'] = pendingPayload.modelId;
969
+ }
970
+ extraHeaders['X-Duration-Minutes'] = String(refill.minutes);
971
+ if (refill.paymentMethod === 'lift') {
972
+ extraHeaders['X-Payment-Method'] = 'lift';
973
+ }
974
+
975
+ if (!res.headersSent) {
976
+ try {
977
+ res.writeHead(200, {
978
+ 'Content-Type': 'text/event-stream',
979
+ 'Cache-Control': 'no-cache',
980
+ 'Connection': 'keep-alive',
981
+ 'X-Payment-Processing': 'true',
982
+ });
983
+ } catch (e) {
984
+ log('autoRefillAndRetry writeHead failed: ' + e.message);
985
+ }
986
+ }
987
+ const keepalive = setInterval(() => {
988
+ if (res.destroyed || res.writableEnded) { clearInterval(keepalive); return; }
989
+ res.write(': keepalive\\n\\n');
990
+ }, 2000);
991
+
992
+ runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, refill.paymentMethod || 'bch', async (err, responseJson) => {
993
+ clearInterval(keepalive);
994
+ pendingPayments.delete(walletHash);
995
+
996
+ const disarmAndFail = async (msg) => {
997
+ refill.enabled = false;
998
+ saveAutoRefillState(refill);
999
+ log('AUTO-REFILL: failed (' + msg + ') — disarmed');
1000
+ if (res.headersSent && !res.destroyed && !res.writableEnded) {
1001
+ await streamPaymentFailureAndRetry(res, walletHash, pendingPayload, '\\n\\n❌ Auto-refill payment failed: ' + msg + '\\n\\n');
1002
+ } else if (!res.headersSent) {
1003
+ try {
1004
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1005
+ res.end(JSON.stringify({ error: 'Payment failed', message: msg }));
1006
+ } catch (e) { log('Failed to write auto-refill error JSON: ' + e.message); }
1007
+ } else {
1008
+ log('Cannot send auto-refill failure — response already ended or destroyed');
1009
+ }
1010
+ };
1011
+
1012
+ if (err) {
1013
+ return await disarmAndFail(err.message);
1014
+ }
1015
+ if (!responseJson.success) {
1016
+ const msg = responseJson.timeout
1017
+ ? 'response timed out after payment — check credits with \\'credits\\' and retry'
1018
+ : (responseJson.error || 'Unknown payment error');
1019
+ return await disarmAndFail(msg);
1020
+ }
1021
+
1022
+ // Payment succeeded — count it toward the budget.
1023
+ const spent = Math.min(refill.spentMinutes + refill.minutes, refill.maxMinutes);
1024
+ const exhausted = spent >= refill.maxMinutes;
1025
+ refill.spentMinutes = spent;
1026
+ refill.lastRefillAt = new Date().toISOString();
1027
+ if (exhausted) {
1028
+ refill.enabled = false;
1029
+ }
1030
+ saveAutoRefillState(refill);
1031
+
1032
+ const chatCompletion = responseJson?.data || responseJson;
1033
+ let wasStreaming = false;
1034
+ try {
1035
+ wasStreaming = JSON.parse(pendingPayload.body).stream === true;
1036
+ } catch (e) {}
1037
+
1038
+ log('AUTO-REFILL: payment ok, spent=' + spent + ' min, exhausted=' + exhausted);
1039
+
1040
+ if (res.destroyed || res.writableEnded) {
1041
+ log('Auto-refill payment succeeded but response connection is gone — cannot deliver chat response');
1042
+ return;
1043
+ }
1044
+
1045
+ let note = '⚡ Auto-refill active: bought a ' + refill.minutes + '-minute plan for ' + (pendingPayload.displayName || pendingPayload.modelId || 'this model') + ' (' + spent + '/' + refill.maxMinutes + ' min budget used)';
1046
+ if (exhausted) {
1047
+ note += ' — **budget reached, auto-refill is now OFF**. Reply to buy more manually if you need to keep going.';
1048
+ }
1049
+ note += '. Generating your response...\\n\\n';
1050
+
1051
+ if (wasStreaming) {
1052
+ try {
1053
+ jsonToSse(res, chatCompletion, { prependContent: PROXY_MARKER + '\\n' + note });
1054
+ } catch (e) {
1055
+ log('auto-refill jsonToSse threw: ' + e.message);
1056
+ }
1057
+ } else {
1058
+ try {
1059
+ if (!res.headersSent) {
1060
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1061
+ }
1062
+ res.end(JSON.stringify(chatCompletion));
1063
+ } catch (e) {
1064
+ log('auto-refill non-stream send failed: ' + e.message);
1065
+ }
1066
+ }
1067
+ });
1068
+ }
1069
+
869
1070
  // Extract the last user message content from a chat payload
870
1071
  function getLastUserMessageContent(body) {
871
1072
  try {
@@ -1635,6 +1836,42 @@ const server = http.createServer(async (req, res) => {
1635
1836
  });
1636
1837
 
1637
1838
  if (tiers && tiers.length > 0) {
1839
+ // AUTO-REFILL: when armed, buy the configured plan and retry the
1840
+ // request without the interactive tier prompt. Falls through to the
1841
+ // prompt when not armed, model mismatched, budget exhausted, plan
1842
+ // no longer offered, or the wallet cannot fund the refill.
1843
+ const refill = getAutoRefillState();
1844
+ if (refill && autoRefillCanBuy(refill, modelId || requestModel)) {
1845
+ const refillTier = (tiers || []).find((t) => Number(t.minutes) === Number(refill.minutes));
1846
+ if (refillTier && refillTier.price_sats) {
1847
+ if (refill.paymentMethod !== 'lift') {
1848
+ const currentBalanceSats = await getWalletBalance();
1849
+ if (currentBalanceSats === null || currentBalanceSats >= Number(refillTier.price_sats)) {
1850
+ const pp = pendingPayments.get(walletHash);
1851
+ await autoRefillAndRetry(res, walletHash, pp, refill);
1852
+ return;
1853
+ }
1854
+ log('AUTO-REFILL: insufficient balance (' + (currentBalanceSats === null ? 'n/a' : currentBalanceSats) + ' sats < ' + refillTier.price_sats + ') — disarming');
1855
+ refill.enabled = false;
1856
+ saveAutoRefillState(refill);
1857
+ } else {
1858
+ const liftUnits = await getLiftBalance();
1859
+ if (liftUnits !== null && liftUnits > 0) {
1860
+ const pp = pendingPayments.get(walletHash);
1861
+ await autoRefillAndRetry(res, walletHash, pp, refill);
1862
+ return;
1863
+ }
1864
+ log('AUTO-REFILL: no LIFT tokens — disarming');
1865
+ refill.enabled = false;
1866
+ saveAutoRefillState(refill);
1867
+ }
1868
+ } else {
1869
+ log('AUTO-REFILL: configured ' + refill.minutes + '-min plan not offered — disarming');
1870
+ refill.enabled = false;
1871
+ saveAutoRefillState(refill);
1872
+ }
1873
+ }
1874
+
1638
1875
  // New flow: show tier selection prompt. Also tell the user about
1639
1876
  // other models that still have paid credits, so they can switch
1640
1877
  // instead of buying a plan for the currently selected model.
@@ -1728,9 +1965,9 @@ const server = http.createServer(async (req, res) => {
1728
1965
  };
1729
1966
 
1730
1967
  if (isStreaming) {
1731
- forwardStreaming(req, res, body, handleResponse);
1968
+ await forwardStreaming(req, res, body, handleResponse);
1732
1969
  } else {
1733
- forwardToDjango(req, body, handleResponse);
1970
+ await forwardToDjango(req, body, handleResponse);
1734
1971
  }
1735
1972
 
1736
1973
  } catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAquDnC,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAk9DnC,CAAC"}
@@ -1,2 +1,2 @@
1
- export declare const WRAPPER_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca Pay Wrapper \u2014 handles large request bodies by reading from a file.\n * Imports paytaca-cli modules directly (avoids CLI argument size limits).\n */\n\nimport { readFileSync } from 'fs';\nimport { execSync } from 'child_process';\nimport { fileURLToPath } from 'url';\nimport { dirname, join } from 'path';\n\n// How long to wait for the server to respond before treating the payment as timed out.\n// Default 240s so heavy non-streaming generations (large context / long output) can\n// complete; override with PAYTACA_PAY_TIMEOUT_MS.\nconst PAY_TIMEOUT_MS = Number(process.env.PAYTACA_PAY_TIMEOUT_MS || 240000);\n\n// Find paytaca-cli installation\nfunction findPaytacaCliPath() {\n const possiblePaths = [];\n \n // Try to get global npm root\n try {\n const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();\n possiblePaths.push(\n join(globalPath, 'paytaca-cli'),\n join(globalPath, 'opencode-plugin', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Common global locations\n possiblePaths.push(\n '/usr/lib/node_modules/paytaca-cli',\n '/usr/local/lib/node_modules/paytaca-cli',\n '/opt/homebrew/lib/node_modules/paytaca-cli',\n );\n \n // Try current file's node_modules (for bundled installs)\n try {\n const currentFile = fileURLToPath(import.meta.url);\n const currentDir = dirname(currentFile);\n possiblePaths.push(\n join(currentDir, '..', 'node_modules', 'paytaca-cli'),\n join(currentDir, '..', '..', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Find first valid path\n for (const basePath of possiblePaths) {\n try {\n const walletPath = join(basePath, 'dist', 'wallet', 'index.js');\n readFileSync(walletPath);\n return basePath;\n } catch {}\n }\n \n throw new Error('paytaca-cli not found. Try reinstalling opencode-plugin: npm install @paytaca/opencode-plugin');\n}\n\n// Load paytaca-cli modules\nlet loadMnemonic, loadWallet, LibauthHDWallet, X402Payer, parsePaymentRequiredJson, selectBchPaymentRequirements, BCH_DERIVATION_PATH;\n\ntry {\n const basePath = findPaytacaCliPath();\n \n ({ loadMnemonic, loadWallet } = await import(join(basePath, 'dist', 'wallet', 'index.js')));\n ({ LibauthHDWallet } = await import(join(basePath, 'dist', 'wallet', 'keys.js')));\n ({ X402Payer } = await import(join(basePath, 'dist', 'wallet', 'x402.js')));\n ({ parsePaymentRequiredJson, selectBchPaymentRequirements } = await import(join(basePath, 'dist', 'utils', 'x402.js')));\n ({ BCH_DERIVATION_PATH } = await import(join(basePath, 'dist', 'utils', 'network.js')));\n} catch (err) {\n console.log(JSON.stringify({ success: false, error: 'Failed to load paytaca-cli: ' + err.message }));\n process.exit(1);\n}\n\n// Cauldron payment support (opt-in via config.paymentMethod === 'lift').\n// The LIFT token is sold in a single swap transaction whose output pays the\n// x402 payTo address directly. Uses the same machinery as paytaca-cli's\n// \"paytaca swap\" command, imported via absolute paths because the wrapper runs\n// outside any node_modules tree.\nconst LIFT_TOKEN_ID = process.env.PAYTACA_PAYMENT_TOKEN_ID || '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\nlet cauldronLoaded = false;\nlet fetchPoolsForToken, apiPoolToMicroPool, microPoolToPoolV0, attemptTrade, watchtowerUtxosToSpendableCoins, ExchangeLab, PayoutAmountRuleType, cashAddressToLockingBytecode, binToHex;\ntry {\n const basePath = findPaytacaCliPath();\n const cauldronDir = join(basePath, 'dist', 'wallet', 'cauldron');\n const cashlabDir = join(basePath, 'node_modules', '@cashlab');\n ({ fetchPoolsForToken } = await import(join(cauldronDir, 'api.js')));\n ({ apiPoolToMicroPool, microPoolToPoolV0 } = await import(join(cauldronDir, 'pools.js')));\n ({ attemptTrade, watchtowerUtxosToSpendableCoins } = await import(join(cauldronDir, 'transact.js')));\n ({ default: ExchangeLab } = await import(join(cashlabDir, 'cauldron', 'out', 'exchange-lab.js')));\n ({ PayoutAmountRuleType } = await import(join(cashlabDir, 'common', 'out', 'constants.js')));\n ({ cashAddressToLockingBytecode, binToHex } = await import(join(cashlabDir, 'common', 'out', 'libauth.js')));\n cauldronLoaded = true;\n} catch (err) {\n // Cauldron modules are only needed for LIFT payments; BCH payments still work.\n cauldronLoaded = false;\n}\n\nasync function main() {\n const configPath = process.argv[2];\n if (!configPath) {\n console.log(JSON.stringify({ success: false, error: 'Usage: node paytaca-pay-wrapper.mjs <config.json>' }));\n process.exit(1);\n }\n\n const config = JSON.parse(readFileSync(configPath, 'utf8'));\n const { url, method, headers, bodyFile, chipnet, confirmed, paymentMethod } = config;\n\n const body = readFileSync(bodyFile, 'utf8');\n\n const data = loadMnemonic();\n if (!data) {\n console.log(JSON.stringify({ success: false, error: 'No wallet found. Run paytaca wallet create first.' }));\n process.exit(1);\n }\n\n const wallet = loadWallet();\n const isChipnet = Boolean(chipnet);\n const bchWallet = wallet.forNetwork(isChipnet);\n const hdWallet = new LibauthHDWallet(data.mnemonic, BCH_DERIVATION_PATH, isChipnet ? 'chipnet' : 'mainnet');\n const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 });\n\n try {\n const result = await executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod);\n console.log(JSON.stringify(result, null, 2));\n } catch (err) {\n console.log(JSON.stringify({ success: false, error: err.message || String(err) }, null, 2));\n process.exit(1);\n }\n}\n\n// Sell LIFT tokens via Cauldron in a single swap transaction that pays the\n// x402 payTo address directly. Returns { txid, vout } for the payment payload.\nasync function payWithLift(bchWallet, hdWallet, requirements, changeAddress) {\n if (!cauldronLoaded) {\n throw new Error('Cauldron payment modules unavailable. Update paytaca-cli to 0.5.0+ to pay with LIFT.');\n }\n const tokenId = LIFT_TOKEN_ID;\n const amountSats = BigInt(requirements.amount);\n\n const [apiPools, allUtxos, tokenUtxos] = await Promise.all([\n fetchPoolsForToken(tokenId),\n bchWallet.getUtxos(),\n bchWallet.getUtxos({ category: tokenId }),\n ]);\n if (!apiPools || apiPools.length === 0) {\n throw new Error('No active Cauldron pools for the payment token.');\n }\n const pools = apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0);\n\n const tokenBalance = (tokenUtxos || []).reduce((sum, u) => sum + BigInt(u.amount || 0), 0n);\n if (tokenBalance <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or pay with BCH.');\n }\n\n const bchUtxos = allUtxos.filter((utxo) => !utxo.is_cashtoken);\n const spendableCoins = watchtowerUtxosToSpendableCoins({\n utxos: [...bchUtxos, ...(tokenUtxos || [])],\n wallet: hdWallet,\n });\n if (spendableCoins.length === 0) {\n throw new Error('No spendable UTXOs available.');\n }\n\n const payToDecoded = cashAddressToLockingBytecode(requirements.payTo);\n if (!payToDecoded || typeof payToDecoded === 'string' || !payToDecoded.bytecode) {\n throw new Error('Invalid payment address: ' + requirements.payTo);\n }\n const changeDecoded = cashAddressToLockingBytecode(changeAddress);\n if (!changeDecoded || typeof changeDecoded === 'string' || !changeDecoded.bytecode) {\n throw new Error('Invalid change address: ' + changeAddress);\n }\n\n const exlab = new ExchangeLab();\n const payoutRules = [\n { type: PayoutAmountRuleType.FIXED, locking_bytecode: payToDecoded.bytecode, amount: amountSats },\n { 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 },\n ];\n\n // Back-compute the token supply for a demand target slightly above the plan\n // cost so the received BCH covers the fixed payout plus fees (excess becomes\n // change). Retry with a bigger buffer if the first target leaves no change.\n let trade = null;\n let tradeTx = null;\n let lastError = null;\n for (const buffer of [2000n, 20000n, 100000n]) {\n try {\n trade = attemptTrade({ pools, isBuyingToken: false, supply: undefined, demand: amountSats + buffer });\n tradeTx = exlab.createTradeTx(trade.entries, spendableCoins, payoutRules, null, 1n);\n exlab.verifyTradeTx(tradeTx);\n break;\n } catch (e) {\n lastError = e;\n }\n }\n if (!tradeTx) {\n const supply = trade?.summary?.supply;\n if (supply && tokenBalance < supply) {\n throw new Error('Insufficient LIFT balance: this payment needs ' + supply + ' base units but the wallet has ' + tokenBalance + '.');\n }\n throw new Error('Could not fund the payment by selling LIFT: ' + (lastError?.message || 'unknown error'));\n }\n\n const tx = tradeTx.libauth_generated_transaction;\n const payToHex = binToHex(payToDecoded.bytecode);\n const vout = tx.outputs.findIndex((o) => binToHex(o.lockingBytecode) === payToHex);\n if (vout === -1) {\n throw new Error('Payment output missing from built transaction.');\n }\n\n const txHex = binToHex(tradeTx.txbin);\n const broadcastResponse = await bchWallet.watchtower.BCH._api.post('broadcast/', { transaction: txHex });\n const data = broadcastResponse.data;\n if (data?.result) {\n data[data.success ? 'txid' : 'error'] = data.result;\n delete data.result;\n }\n if (!data?.success || !data?.txid) {\n throw new Error(data?.error || 'Broadcast failed');\n }\n return { txid: data.txid, vout };\n}\n\nasync function executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod) {\n // Tell the backend the payment method so it can apply the LIFT discount and\n // record how the plan was paid. Set once here \u2014 the same headers object is\n // reused for the 402 fetch and the PAYMENT-SIGNATURE retry.\n if (paymentMethod === 'lift') {\n headers['X-Payment-Method'] = 'lift';\n }\n\n const response = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n\n const responseHeaders = {};\n response.headers.forEach((value, key) => { responseHeaders[key] = value; });\n const responseText = await response.text();\n let responseData;\n try { responseData = JSON.parse(responseText); } catch { responseData = responseText; }\n\n if (response.status === 402) {\n const paymentRequired = parsePaymentRequiredJson(responseData);\n if (!paymentRequired) {\n return { success: false, status: 402, error: 'Could not parse PaymentRequired from 402 response body' };\n }\n const requirements = selectBchPaymentRequirements(paymentRequired, isChipnet ? 'chipnet' : 'mainnet');\n if (!requirements) {\n return {\n success: false, status: 402, error: 'Server does not accept BCH payment',\n data: { acceptedSchemes: paymentRequired.accepts.map(a => ({ scheme: a.scheme, network: a.network })) },\n };\n }\n\n const payerAddress = x402Payer.getPayerAddress();\n const address = requirements.payTo;\n const amountBch = Number(requirements.amount) / 1e8;\n const changeAddressSet = bchWallet.getAddressSetAt(0);\n const changeAddress = changeAddressSet.change;\n\n if (!confirmed) {\n return {\n success: false, status: 402, error: 'Payment not confirmed.',\n payment: { required: true, amount: requirements.amount, payTo: address },\n };\n }\n\n let txid, vout = 0;\n if (paymentMethod === 'lift') {\n // Sell LIFT via Cauldron; the swap transaction pays the plan directly.\n const liftPayment = await payWithLift(bchWallet, hdWallet, requirements, changeAddress);\n txid = liftPayment.txid;\n vout = liftPayment.vout;\n } else {\n const sendResult = await bchWallet.sendBch(amountBch, address, changeAddress);\n if (!sendResult.success) {\n return { success: false, status: 402, payment: { required: true, error: sendResult.error }, error: sendResult.error };\n }\n txid = sendResult.txid;\n }\n\n const paymentPayload = await x402Payer.createPaymentPayload(requirements, paymentRequired.resource.url, txid, vout, requirements.amount);\n headers['PAYMENT-SIGNATURE'] = JSON.stringify(paymentPayload);\n\n let retryResponse;\n try {\n retryResponse = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n } catch (e) {\n if (e.name === 'AbortError') {\n return { success: false, timeout: true, error: 'Response timed out from server.' };\n }\n throw e;\n }\n const retryResponseHeaders = {};\n retryResponse.headers.forEach((value, key) => { retryResponseHeaders[key] = value; });\n const retryResponseText = await retryResponse.text();\n let retryResponseData;\n try { retryResponseData = JSON.parse(retryResponseText); } catch { retryResponseData = retryResponseText; }\n\n return {\n success: retryResponse.ok,\n status: retryResponse.status,\n statusText: retryResponse.statusText,\n headers: retryResponseHeaders,\n data: retryResponseData,\n payment: { required: true, txid, recipientAddress: address, method: paymentMethod === 'lift' ? 'lift' : 'bch' },\n };\n }\n\n return {\n success: response.ok,\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n data: responseData,\n payment: { required: false },\n };\n}\n\nmain();\n";
1
+ export declare const WRAPPER_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca Pay Wrapper \u2014 handles large request bodies by reading from a file.\n * Imports paytaca-cli modules directly (avoids CLI argument size limits).\n */\n\nimport { readFileSync, realpathSync } from 'fs';\nimport { execSync } from 'child_process';\nimport { fileURLToPath } from 'url';\nimport { dirname, join } from 'path';\n\n// How long to wait for the server to respond before treating the payment as timed out.\n// Default 240s so heavy non-streaming generations (large context / long output) can\n// complete; override with PAYTACA_PAY_TIMEOUT_MS.\nconst PAY_TIMEOUT_MS = Number(process.env.PAYTACA_PAY_TIMEOUT_MS || 240000);\n\n// Pre-payment stability probe. Before broadcasting the payment we sample the\n// plan/config endpoint several times ~500ms apart to catch a \"flapping\"\n// backend (reachable one moment, unreachable the next, e.g. right after a\n// purchase). If any sample fails while others succeed, or the plan price\n// changes between samples, we abort BEFORE any money moves. Disable with\n// PAYTACA_PLAN_PROBE=0.\nconst PLAN_PROBE_ENABLED = process.env.PAYTACA_PLAN_PROBE !== '0';\nconst PLAN_PROBE_SAMPLES = 3;\nconst PLAN_PROBE_SPACING_MS = 500;\nconst PLAN_PROBE_TIMEOUT_MS = 6000;\n\nfunction planProbeSleep(ms) {\n return new Promise(function (resolve) { setTimeout(resolve, ms); });\n}\n\n// Probe /v1/config (models + price tiers) repeatedly. Throws when the endpoint\n// is flapping or the plan price changes between samples. Returns undefined on\n// a stable backend.\nasync function probePlanStability(url, headers) {\n const modelId = headers['X-Model-Id'];\n const minutes = headers['X-Duration-Minutes'];\n if (!modelId || !minutes) return;\n let origin = null;\n try { origin = new URL(url).origin; } catch (e) { return; }\n if (!origin) return;\n\n let okSamples = 0;\n let totalSamples = 0;\n const priceSamples = [];\n let lastError = '';\n\n for (let i = 0; i < PLAN_PROBE_SAMPLES; i++) {\n totalSamples++;\n try {\n const controller = new AbortController();\n const timer = setTimeout(function () { controller.abort(); }, PLAN_PROBE_TIMEOUT_MS);\n let res;\n try {\n res = await fetch(origin + '/v1/config', { signal: controller.signal });\n } finally {\n clearTimeout(timer);\n }\n if (!res.ok) { lastError = 'HTTP ' + res.status; continue; }\n const data = await res.json();\n const models = Array.isArray(data.models) ? data.models : [];\n const model = models.find(function (m) {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n const q = String(modelId).toLowerCase();\n return id.indexOf(q) !== -1 || name.indexOf(q) !== -1;\n });\n if (!model) { lastError = 'model ' + modelId + ' not in plan config'; continue; }\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const tier = tiers.find(function (t) { return Number(t.minutes) === Number(minutes); });\n if (!tier) { lastError = 'no ' + minutes + '-min plan for ' + modelId + ' in plan config'; continue; }\n priceSamples.push(Number(tier.price_sats) || 0);\n okSamples++;\n } catch (e) {\n lastError = (e && e.name === 'AbortError') ? 'timeout' : (e && e.message) || String(e);\n }\n if (i < PLAN_PROBE_SAMPLES - 1) await planProbeSleep(PLAN_PROBE_SPACING_MS);\n }\n\n // Stable = every sample succeeded AND every sample quoted the same price.\n if (okSamples === totalSamples && priceSamples.length > 0) {\n const first = priceSamples[0];\n const allEqual = priceSamples.every(function (p) { return p === first; });\n if (allEqual) return;\n }\n throw new Error('Backend plan endpoint looked unstable before purchase (reachable ' + okSamples + '/' + totalSamples + (lastError ? ', last error: ' + lastError : '') + '). No payment was broadcast \u2014 please retry in a few seconds.');\n}\n\n// Find paytaca-cli installation\nfunction findPaytacaCliPath() {\n const possiblePaths = [];\n\n // Resolve the paytaca command itself when it's on PATH. This covers local\n // installs (e.g. the plugin's own node_modules) and per-node asdf globals\n // that `npm root -g` may not reflect when multiple node versions exist.\n try {\n const whichCmd = process.platform === 'win32' ? 'where' : 'which';\n const bin = execSync(`${whichCmd} paytaca`, { encoding: 'utf8' }).trim();\n if (bin) {\n let dir = dirname(realpathSync(bin));\n for (let i = 0; i < 6; i++) {\n const parent = dirname(dir);\n if (parent === dir) break;\n possiblePaths.push(dir);\n dir = parent;\n }\n }\n } catch {}\n\n // Try to get global npm root\n try {\n const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();\n possiblePaths.push(\n join(globalPath, 'paytaca-cli'),\n join(globalPath, 'opencode-plugin', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Common global locations\n possiblePaths.push(\n '/usr/lib/node_modules/paytaca-cli',\n '/usr/local/lib/node_modules/paytaca-cli',\n '/opt/homebrew/lib/node_modules/paytaca-cli',\n );\n \n // Windows global npm location\n if (process.platform === 'win32' && process.env.APPDATA) {\n possiblePaths.push(join(process.env.APPDATA, 'npm', 'node_modules', 'paytaca-cli'));\n }\n \n // Try current file's node_modules (for bundled installs)\n try {\n const currentFile = fileURLToPath(import.meta.url);\n const currentDir = dirname(currentFile);\n possiblePaths.push(\n join(currentDir, '..', 'node_modules', 'paytaca-cli'),\n join(currentDir, '..', '..', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Find first valid path\n for (const basePath of possiblePaths) {\n try {\n const walletPath = join(basePath, 'dist', 'wallet', 'index.js');\n readFileSync(walletPath);\n return basePath;\n } catch {}\n }\n \n throw new Error('paytaca-cli not found. Try reinstalling opencode-plugin: npm install @paytaca/opencode-plugin');\n}\n\n// Load paytaca-cli modules\nlet loadMnemonic, loadWallet, LibauthHDWallet, X402Payer, parsePaymentRequiredJson, selectBchPaymentRequirements, BCH_DERIVATION_PATH;\n\ntry {\n const basePath = findPaytacaCliPath();\n \n ({ loadMnemonic, loadWallet } = await import(join(basePath, 'dist', 'wallet', 'index.js')));\n ({ LibauthHDWallet } = await import(join(basePath, 'dist', 'wallet', 'keys.js')));\n ({ X402Payer } = await import(join(basePath, 'dist', 'wallet', 'x402.js')));\n ({ parsePaymentRequiredJson, selectBchPaymentRequirements } = await import(join(basePath, 'dist', 'utils', 'x402.js')));\n ({ BCH_DERIVATION_PATH } = await import(join(basePath, 'dist', 'utils', 'network.js')));\n} catch (err) {\n console.log(JSON.stringify({ success: false, error: 'Failed to load paytaca-cli: ' + err.message }));\n process.exit(1);\n}\n\n// Cauldron payment support (opt-in via config.paymentMethod === 'lift').\n// The LIFT token is sold in a single swap transaction whose output pays the\n// x402 payTo address directly. Uses the same machinery as paytaca-cli's\n// \"paytaca swap\" command, imported via absolute paths because the wrapper runs\n// outside any node_modules tree.\nconst LIFT_TOKEN_ID = process.env.PAYTACA_PAYMENT_TOKEN_ID || '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\nlet cauldronLoaded = false;\nlet fetchPoolsForToken, apiPoolToMicroPool, microPoolToPoolV0, attemptTrade, watchtowerUtxosToSpendableCoins, ExchangeLab, PayoutAmountRuleType, cashAddressToLockingBytecode, binToHex;\ntry {\n const basePath = findPaytacaCliPath();\n const cauldronDir = join(basePath, 'dist', 'wallet', 'cauldron');\n const cashlabDir = findCashlabDir(basePath);\n ({ fetchPoolsForToken } = await import(join(cauldronDir, 'api.js')));\n ({ apiPoolToMicroPool, microPoolToPoolV0 } = await import(join(cauldronDir, 'pools.js')));\n ({ attemptTrade, watchtowerUtxosToSpendableCoins } = await import(join(cauldronDir, 'transact.js')));\n ({ default: ExchangeLab } = await import(join(cashlabDir, 'cauldron', 'out', 'exchange-lab.js')));\n ({ PayoutAmountRuleType } = await import(join(cashlabDir, 'common', 'out', 'constants.js')));\n ({ cashAddressToLockingBytecode, binToHex } = await import(join(cashlabDir, 'common', 'out', 'libauth.js')));\n cauldronLoaded = true;\n} catch (err) {\n // Cauldron modules are only needed for LIFT payments; BCH payments still work.\n cauldronLoaded = false;\n}\n\n// Locate the @cashlab package dir by walking up from the paytaca-cli path.\n// Handles hoisted installs (top-level node_modules) and nested installs.\nfunction findCashlabDir(cliPath) {\n for (let dir = cliPath, i = 0; i < 8; i++) {\n const candidate = join(dir, 'node_modules', '@cashlab');\n try {\n readFileSync(join(candidate, 'cauldron', 'out', 'exchange-lab.js'));\n return candidate;\n } catch {}\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\nasync function main() {\n const configPath = process.argv[2];\n if (!configPath) {\n console.log(JSON.stringify({ success: false, error: 'Usage: node paytaca-pay-wrapper.mjs <config.json>' }));\n process.exit(1);\n }\n\n const config = JSON.parse(readFileSync(configPath, 'utf8'));\n const { url, method, headers, bodyFile, chipnet, confirmed, paymentMethod } = config;\n\n const body = readFileSync(bodyFile, 'utf8');\n\n const data = loadMnemonic();\n if (!data) {\n console.log(JSON.stringify({ success: false, error: 'No wallet found. Run paytaca wallet create first.' }));\n process.exit(1);\n }\n\n const wallet = loadWallet();\n const isChipnet = Boolean(chipnet);\n const bchWallet = wallet.forNetwork(isChipnet);\n const hdWallet = new LibauthHDWallet(data.mnemonic, BCH_DERIVATION_PATH, isChipnet ? 'chipnet' : 'mainnet');\n const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 });\n\n try {\n if (PLAN_PROBE_ENABLED) {\n await probePlanStability(url, headers);\n }\n const result = await executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod);\n console.log(JSON.stringify(result, null, 2));\n } catch (err) {\n console.log(JSON.stringify({ success: false, error: err.message || String(err) }, null, 2));\n process.exit(1);\n }\n}\n\n// Sell LIFT tokens via Cauldron in a single swap transaction that pays the\n// x402 payTo address directly. Returns { txid, vout } for the payment payload.\nasync function payWithLift(bchWallet, hdWallet, requirements, changeAddress) {\n if (!cauldronLoaded) {\n throw new Error('Cauldron payment modules unavailable. Update paytaca-cli to 0.5.0+ to pay with LIFT.');\n }\n const tokenId = LIFT_TOKEN_ID;\n const amountSats = BigInt(requirements.amount);\n\n const [apiPools, allUtxos, tokenUtxos] = await Promise.all([\n fetchPoolsForToken(tokenId),\n bchWallet.getUtxos(),\n bchWallet.getUtxos({ category: tokenId }),\n ]);\n if (!apiPools || apiPools.length === 0) {\n throw new Error('No active Cauldron pools for the payment token.');\n }\n const pools = apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0);\n\n const tokenBalance = (tokenUtxos || []).reduce((sum, u) => sum + BigInt(u.amount || 0), 0n);\n if (tokenBalance <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or pay with BCH.');\n }\n\n const bchUtxos = allUtxos.filter((utxo) => !utxo.is_cashtoken);\n const spendableCoins = watchtowerUtxosToSpendableCoins({\n utxos: [...bchUtxos, ...(tokenUtxos || [])],\n wallet: hdWallet,\n });\n if (spendableCoins.length === 0) {\n throw new Error('No spendable UTXOs available.');\n }\n\n const payToDecoded = cashAddressToLockingBytecode(requirements.payTo);\n if (!payToDecoded || typeof payToDecoded === 'string' || !payToDecoded.bytecode) {\n throw new Error('Invalid payment address: ' + requirements.payTo);\n }\n const changeDecoded = cashAddressToLockingBytecode(changeAddress);\n if (!changeDecoded || typeof changeDecoded === 'string' || !changeDecoded.bytecode) {\n throw new Error('Invalid change address: ' + changeAddress);\n }\n\n const exlab = new ExchangeLab();\n const payoutRules = [\n { type: PayoutAmountRuleType.FIXED, locking_bytecode: payToDecoded.bytecode, amount: amountSats },\n { 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 },\n ];\n\n // Back-compute the token supply for a demand target slightly above the plan\n // cost so the received BCH covers the fixed payout plus fees (excess becomes\n // change). Retry with a bigger buffer if the first target leaves no change.\n let trade = null;\n let tradeTx = null;\n let lastError = null;\n for (const buffer of [2000n, 20000n, 100000n]) {\n try {\n trade = attemptTrade({ pools, isBuyingToken: false, supply: undefined, demand: amountSats + buffer });\n tradeTx = exlab.createTradeTx(trade.entries, spendableCoins, payoutRules, null, 1n);\n exlab.verifyTradeTx(tradeTx);\n break;\n } catch (e) {\n lastError = e;\n }\n }\n if (!tradeTx) {\n const supply = trade?.summary?.supply;\n if (supply && tokenBalance < supply) {\n throw new Error('Insufficient LIFT balance: this payment needs ' + supply + ' base units but the wallet has ' + tokenBalance + '.');\n }\n throw new Error('Could not fund the payment by selling LIFT: ' + (lastError?.message || 'unknown error'));\n }\n\n const tx = tradeTx.libauth_generated_transaction;\n const payToHex = binToHex(payToDecoded.bytecode);\n const vout = tx.outputs.findIndex((o) => binToHex(o.lockingBytecode) === payToHex);\n if (vout === -1) {\n throw new Error('Payment output missing from built transaction.');\n }\n\n const txHex = binToHex(tradeTx.txbin);\n const broadcastResponse = await bchWallet.watchtower.BCH._api.post('broadcast/', { transaction: txHex });\n const data = broadcastResponse.data;\n if (data?.result) {\n data[data.success ? 'txid' : 'error'] = data.result;\n delete data.result;\n }\n if (!data?.success || !data?.txid) {\n throw new Error(data?.error || 'Broadcast failed');\n }\n return { txid: data.txid, vout };\n}\n\nasync function executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod) {\n // Tell the backend the payment method so it can apply the LIFT discount and\n // record how the plan was paid. Set once here \u2014 the same headers object is\n // reused for the 402 fetch and the PAYMENT-SIGNATURE retry.\n if (paymentMethod === 'lift') {\n headers['X-Payment-Method'] = 'lift';\n }\n\n const response = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n\n const responseHeaders = {};\n response.headers.forEach((value, key) => { responseHeaders[key] = value; });\n const responseText = await response.text();\n let responseData;\n try { responseData = JSON.parse(responseText); } catch { responseData = responseText; }\n\n if (response.status === 402) {\n const paymentRequired = parsePaymentRequiredJson(responseData);\n if (!paymentRequired) {\n return { success: false, status: 402, error: 'Could not parse PaymentRequired from 402 response body' };\n }\n const requirements = selectBchPaymentRequirements(paymentRequired, isChipnet ? 'chipnet' : 'mainnet');\n if (!requirements) {\n return {\n success: false, status: 402, error: 'Server does not accept BCH payment',\n data: { acceptedSchemes: paymentRequired.accepts.map(a => ({ scheme: a.scheme, network: a.network })) },\n };\n }\n\n const payerAddress = x402Payer.getPayerAddress();\n const address = requirements.payTo;\n const amountBch = Number(requirements.amount) / 1e8;\n const changeAddressSet = bchWallet.getAddressSetAt(0);\n const changeAddress = changeAddressSet.change;\n\n if (!confirmed) {\n return {\n success: false, status: 402, error: 'Payment not confirmed.',\n payment: { required: true, amount: requirements.amount, payTo: address },\n };\n }\n\n let txid, vout = 0;\n if (paymentMethod === 'lift') {\n // Sell LIFT via Cauldron; the swap transaction pays the plan directly.\n const liftPayment = await payWithLift(bchWallet, hdWallet, requirements, changeAddress);\n txid = liftPayment.txid;\n vout = liftPayment.vout;\n } else {\n const sendResult = await bchWallet.sendBch(amountBch, address, changeAddress);\n if (!sendResult.success) {\n return { success: false, status: 402, payment: { required: true, error: sendResult.error }, error: sendResult.error };\n }\n txid = sendResult.txid;\n }\n\n const paymentPayload = await x402Payer.createPaymentPayload(requirements, paymentRequired.resource.url, txid, vout, requirements.amount);\n headers['PAYMENT-SIGNATURE'] = JSON.stringify(paymentPayload);\n\n let retryResponse;\n try {\n retryResponse = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n } catch (e) {\n if (e.name === 'AbortError') {\n return { success: false, timeout: true, error: 'Response timed out from server.' };\n }\n throw e;\n }\n const retryResponseHeaders = {};\n retryResponse.headers.forEach((value, key) => { retryResponseHeaders[key] = value; });\n const retryResponseText = await retryResponse.text();\n let retryResponseData;\n try { retryResponseData = JSON.parse(retryResponseText); } catch { retryResponseData = retryResponseText; }\n\n return {\n success: retryResponse.ok,\n status: retryResponse.status,\n statusText: retryResponse.statusText,\n headers: retryResponseHeaders,\n data: retryResponseData,\n payment: { required: true, txid, recipientAddress: address, method: paymentMethod === 'lift' ? 'lift' : 'bch' },\n };\n }\n\n return {\n success: response.ok,\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n data: responseData,\n payment: { required: false },\n };\n}\n\nmain();\n";
2
2
  //# sourceMappingURL=wrapper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,6lbAwUlC,CAAC"}
1
+ {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,+wkBAyblC,CAAC"}
@@ -10,7 +10,7 @@ exports.WRAPPER_SCRIPT_CONTENT = `#!/usr/bin/env node
10
10
  * Imports paytaca-cli modules directly (avoids CLI argument size limits).
11
11
  */
12
12
 
13
- import { readFileSync } from 'fs';
13
+ import { readFileSync, realpathSync } from 'fs';
14
14
  import { execSync } from 'child_process';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { dirname, join } from 'path';
@@ -20,10 +20,99 @@ import { dirname, join } from 'path';
20
20
  // complete; override with PAYTACA_PAY_TIMEOUT_MS.
21
21
  const PAY_TIMEOUT_MS = Number(process.env.PAYTACA_PAY_TIMEOUT_MS || 240000);
22
22
 
23
+ // Pre-payment stability probe. Before broadcasting the payment we sample the
24
+ // plan/config endpoint several times ~500ms apart to catch a "flapping"
25
+ // backend (reachable one moment, unreachable the next, e.g. right after a
26
+ // purchase). If any sample fails while others succeed, or the plan price
27
+ // changes between samples, we abort BEFORE any money moves. Disable with
28
+ // PAYTACA_PLAN_PROBE=0.
29
+ const PLAN_PROBE_ENABLED = process.env.PAYTACA_PLAN_PROBE !== '0';
30
+ const PLAN_PROBE_SAMPLES = 3;
31
+ const PLAN_PROBE_SPACING_MS = 500;
32
+ const PLAN_PROBE_TIMEOUT_MS = 6000;
33
+
34
+ function planProbeSleep(ms) {
35
+ return new Promise(function (resolve) { setTimeout(resolve, ms); });
36
+ }
37
+
38
+ // Probe /v1/config (models + price tiers) repeatedly. Throws when the endpoint
39
+ // is flapping or the plan price changes between samples. Returns undefined on
40
+ // a stable backend.
41
+ async function probePlanStability(url, headers) {
42
+ const modelId = headers['X-Model-Id'];
43
+ const minutes = headers['X-Duration-Minutes'];
44
+ if (!modelId || !minutes) return;
45
+ let origin = null;
46
+ try { origin = new URL(url).origin; } catch (e) { return; }
47
+ if (!origin) return;
48
+
49
+ let okSamples = 0;
50
+ let totalSamples = 0;
51
+ const priceSamples = [];
52
+ let lastError = '';
53
+
54
+ for (let i = 0; i < PLAN_PROBE_SAMPLES; i++) {
55
+ totalSamples++;
56
+ try {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(function () { controller.abort(); }, PLAN_PROBE_TIMEOUT_MS);
59
+ let res;
60
+ try {
61
+ res = await fetch(origin + '/v1/config', { signal: controller.signal });
62
+ } finally {
63
+ clearTimeout(timer);
64
+ }
65
+ if (!res.ok) { lastError = 'HTTP ' + res.status; continue; }
66
+ const data = await res.json();
67
+ const models = Array.isArray(data.models) ? data.models : [];
68
+ const model = models.find(function (m) {
69
+ const id = String(m.id || '').toLowerCase();
70
+ const name = String(m.display_name || '').toLowerCase();
71
+ const q = String(modelId).toLowerCase();
72
+ return id.indexOf(q) !== -1 || name.indexOf(q) !== -1;
73
+ });
74
+ if (!model) { lastError = 'model ' + modelId + ' not in plan config'; continue; }
75
+ const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];
76
+ const tier = tiers.find(function (t) { return Number(t.minutes) === Number(minutes); });
77
+ if (!tier) { lastError = 'no ' + minutes + '-min plan for ' + modelId + ' in plan config'; continue; }
78
+ priceSamples.push(Number(tier.price_sats) || 0);
79
+ okSamples++;
80
+ } catch (e) {
81
+ lastError = (e && e.name === 'AbortError') ? 'timeout' : (e && e.message) || String(e);
82
+ }
83
+ if (i < PLAN_PROBE_SAMPLES - 1) await planProbeSleep(PLAN_PROBE_SPACING_MS);
84
+ }
85
+
86
+ // Stable = every sample succeeded AND every sample quoted the same price.
87
+ if (okSamples === totalSamples && priceSamples.length > 0) {
88
+ const first = priceSamples[0];
89
+ const allEqual = priceSamples.every(function (p) { return p === first; });
90
+ if (allEqual) return;
91
+ }
92
+ throw new Error('Backend plan endpoint looked unstable before purchase (reachable ' + okSamples + '/' + totalSamples + (lastError ? ', last error: ' + lastError : '') + '). No payment was broadcast — please retry in a few seconds.');
93
+ }
94
+
23
95
  // Find paytaca-cli installation
24
96
  function findPaytacaCliPath() {
25
97
  const possiblePaths = [];
26
-
98
+
99
+ // Resolve the paytaca command itself when it's on PATH. This covers local
100
+ // installs (e.g. the plugin's own node_modules) and per-node asdf globals
101
+ // that \`npm root -g\` may not reflect when multiple node versions exist.
102
+ try {
103
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
104
+ const bin = execSync(\`\${whichCmd} paytaca\`, { encoding: 'utf8' }).trim();
105
+ if (bin) {
106
+ let dir = dirname(realpathSync(bin));
107
+ for (let i = 0; i < 6; i++) {
108
+ const parent = dirname(dir);
109
+ if (parent === dir) break;
110
+ possiblePaths.push(dir);
111
+ dir = parent;
112
+ }
113
+ }
114
+ } catch {}
115
+
27
116
  // Try to get global npm root
28
117
  try {
29
118
  const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();
@@ -40,6 +129,11 @@ function findPaytacaCliPath() {
40
129
  '/opt/homebrew/lib/node_modules/paytaca-cli',
41
130
  );
42
131
 
132
+ // Windows global npm location
133
+ if (process.platform === 'win32' && process.env.APPDATA) {
134
+ possiblePaths.push(join(process.env.APPDATA, 'npm', 'node_modules', 'paytaca-cli'));
135
+ }
136
+
43
137
  // Try current file's node_modules (for bundled installs)
44
138
  try {
45
139
  const currentFile = fileURLToPath(import.meta.url);
@@ -89,7 +183,7 @@ let fetchPoolsForToken, apiPoolToMicroPool, microPoolToPoolV0, attemptTrade, wat
89
183
  try {
90
184
  const basePath = findPaytacaCliPath();
91
185
  const cauldronDir = join(basePath, 'dist', 'wallet', 'cauldron');
92
- const cashlabDir = join(basePath, 'node_modules', '@cashlab');
186
+ const cashlabDir = findCashlabDir(basePath);
93
187
  ({ fetchPoolsForToken } = await import(join(cauldronDir, 'api.js')));
94
188
  ({ apiPoolToMicroPool, microPoolToPoolV0 } = await import(join(cauldronDir, 'pools.js')));
95
189
  ({ attemptTrade, watchtowerUtxosToSpendableCoins } = await import(join(cauldronDir, 'transact.js')));
@@ -102,6 +196,22 @@ try {
102
196
  cauldronLoaded = false;
103
197
  }
104
198
 
199
+ // Locate the @cashlab package dir by walking up from the paytaca-cli path.
200
+ // Handles hoisted installs (top-level node_modules) and nested installs.
201
+ function findCashlabDir(cliPath) {
202
+ for (let dir = cliPath, i = 0; i < 8; i++) {
203
+ const candidate = join(dir, 'node_modules', '@cashlab');
204
+ try {
205
+ readFileSync(join(candidate, 'cauldron', 'out', 'exchange-lab.js'));
206
+ return candidate;
207
+ } catch {}
208
+ const parent = dirname(dir);
209
+ if (parent === dir) break;
210
+ dir = parent;
211
+ }
212
+ return null;
213
+ }
214
+
105
215
  async function main() {
106
216
  const configPath = process.argv[2];
107
217
  if (!configPath) {
@@ -127,6 +237,9 @@ async function main() {
127
237
  const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 });
128
238
 
129
239
  try {
240
+ if (PLAN_PROBE_ENABLED) {
241
+ await probePlanStability(url, headers);
242
+ }
130
243
  const result = await executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod);
131
244
  console.log(JSON.stringify(result, null, 2));
132
245
  } catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"wrapper.js","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":";AAAA,oEAAoE;AACpE,4EAA4E;AAC5E,mFAAmF;;;AAEtE,QAAA,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwUrC,CAAC"}
1
+ {"version":3,"file":"wrapper.js","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":";AAAA,oEAAoE;AACpE,4EAA4E;AAC5E,mFAAmF;;;AAEtE,QAAA,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAybrC,CAAC"}
@@ -0,0 +1,9 @@
1
+ export interface CreditsToastDeps {
2
+ client: any;
3
+ backendUrl: () => string;
4
+ walletHash: () => string;
5
+ }
6
+ export declare function createCreditsToastWatch(deps: CreditsToastDeps): {
7
+ check: () => Promise<void>;
8
+ };
9
+ //# sourceMappingURL=credits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credits.d.ts","sourceRoot":"","sources":["../src/credits.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,GAAG,CAAC;IACZ,UAAU,EAAE,MAAM,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,MAAM,CAAC;CAC1B;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,gBAAgB;;EAiG7D"}