From f5f7dd63422ed33a38c36214b099443e3a273049 Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 10:55:23 +0800 Subject: [PATCH 1/8] add USD pricing for CashTokens via watchtower asset-prices Add src/utils/prices.ts (fetchAssetPrices, getUsdPerToken, getBchUsdPrice, tokenAmountToUsd, formatUsd) mirroring paytaca-app's market price handling. Show USD value in `token list` and `token info`, and add a new `token price [amount]` command that prices a given amount (defaults to wallet balance). Unpriced tokens fall back gracefully. --- README.md | 8 +- src/commands/token.ts | 170 ++++++++++++++++++++++++++++++++++++++- src/utils/prices.test.ts | 79 ++++++++++++++++++ src/utils/prices.ts | 125 ++++++++++++++++++++++++++++ 4 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 src/utils/prices.test.ts create mode 100644 src/utils/prices.ts diff --git a/README.md b/README.md index cba9c63..161263e 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ paytaca address list --token # List token-aware z-prefix addresses ### CashTokens ```bash -paytaca token list # List fungible tokens with balances -paytaca token info # Token metadata, balance, and NFTs +paytaca token list # List fungible tokens with balances and USD values +paytaca token info # Token metadata, balance, USD value, and NFTs +paytaca token price [amount] # USD price of a token and value of an amount (default: balance) paytaca token send
--token # Send fungible tokens paytaca token send-nft
--token --commitment # Send an NFT ``` @@ -180,7 +181,7 @@ src/ send.ts BCH sending history.ts transaction history (BCH and CashTokens) address.ts HD address derivation (standard and z-prefix) - token.ts CashToken commands (list, info, send, send-nft) + token.ts CashToken commands (list, info, price, send, send-nft) pay.ts x402 BCH payment handler for HTTP requests check.ts Check if URL requires x402 payment wallet/ @@ -193,6 +194,7 @@ src/ utils/ crypto.ts pubkey -> CashAddress pipeline network.ts Watchtower URLs, derivation paths + prices.ts Watchtower asset-prices client (USD per token/BCH) x402.ts x402 header parsing, payment requirement selection types/ x402.ts x402 payment types (PaymentRequired, PaymentPayload, etc.) diff --git a/src/commands/token.ts b/src/commands/token.ts index 168ffa4..952febb 100644 --- a/src/commands/token.ts +++ b/src/commands/token.ts @@ -16,6 +16,12 @@ import { Command } from 'commander' import chalk from 'chalk' import { Address } from 'watchtower-cash-js' import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { + fetchAssetPrices, + getUsdPerToken, + tokenAmountToUsd, + formatUsd, +} from '../utils/prices.js' /** Truncate a hex string for display */ function shortHex(hex: string, len: number = 8): string { @@ -69,20 +75,58 @@ export function registerTokenCommands(program: Command): void { return } + // Fetch USD prices for all held tokens (batched by the util) + let prices = new Map() + try { + const priceData = await fetchAssetPrices( + tokens.map((t) => `ct/${t.category}`), + ['USD'], + isChipnet + ) + prices = new Map() + for (const p of priceData) { + if (String(p.currency || '').toLowerCase() !== 'usd') continue + const catMatch = String(p.asset || '').match(/^ct\/([a-fA-F0-9]+)$/) + if (!catMatch) continue + const raw = parseFloat(p.price_value) + if (!isFinite(raw) || raw === 0) continue + prices.set(catMatch[1], 1 / raw) + } + } catch { + // Pricing unavailable — proceed without USD values + } + + let totalUsd = 0 + let pricedCount = 0 + for (const t of tokens) { const amount = formatTokenAmount(t.balance, t.decimals) const symbol = t.symbol ? ` ${t.symbol}` : '' const name = t.name !== 'Unknown Token' ? t.name : '' + const usdPerToken = prices.get(t.category) console.log(` ${chalk.bold(amount + symbol)}`) if (name) { console.log(chalk.dim(` ${name}`)) } console.log(chalk.dim(` ${t.category}`)) + if (usdPerToken !== undefined) { + const usdValue = tokenAmountToUsd(t.balance, t.decimals, usdPerToken) + totalUsd += usdValue + pricedCount += 1 + console.log(chalk.green(` ≈ ${formatUsd(usdValue)}`)) + } console.log() } - console.log(chalk.dim(` ${tokens.length} token${tokens.length !== 1 ? 's' : ''} total`)) + if (pricedCount > 0) { + console.log(chalk.dim(` ${tokens.length} token${tokens.length !== 1 ? 's' : ''} total`)) + console.log(chalk.dim(` ${pricedCount} priced`)) + console.log(chalk.bold(` Total: ${formatUsd(totalUsd)}`)) + console.log() + } else { + console.log(chalk.dim(` ${tokens.length} token${tokens.length !== 1 ? 's' : ''} total`)) + } } catch (err: any) { const status = err?.response?.status if (status === 404) { @@ -146,17 +190,38 @@ export function registerTokenCommands(program: Command): void { console.log(` Decimals: ${tokenInfo.decimals}`) console.log(` Category: ${tokenInfo.category}`) + // Fetch token USD price (watchtower.cash asset-prices) + let usdPerToken: number | undefined + try { + const p = await getUsdPerToken(category, isChipnet) + if (p !== null) usdPerToken = p + } catch { + // Pricing unavailable — omit USD lines + } + // Fetch wallet-specific balance try { const balResult = await bchWallet.getTokenBalance(category) const amount = formatTokenAmount(balResult.balance, tokenInfo.decimals) const symbol = tokenInfo.symbol ? ` ${tokenInfo.symbol}` : '' console.log(` Balance: ${amount}${symbol}`) + if (usdPerToken !== undefined) { + const usdValue = tokenAmountToUsd(balResult.balance, tokenInfo.decimals, usdPerToken) + console.log(chalk.green(` Value: ≈ ${formatUsd(usdValue)}`)) + } } catch { // Balance may not be available if wallet doesn't hold this token console.log(chalk.dim(' Balance: 0')) } + if (usdPerToken !== undefined) { + console.log( + chalk.dim( + ` Price: ${formatUsd(usdPerToken)} per ${tokenInfo.symbol || 'token'}` + ) + ) + } + // Show NFTs for this category try { const nfts = await bchWallet.getNftUtxos(category) @@ -181,6 +246,109 @@ export function registerTokenCommands(program: Command): void { console.log() }) + // ── token price ──────────────────────────────────────────────────── + + token + .command('price') + .description('Show USD price of a CashToken and value of a given amount') + .argument('', 'Token category ID (64-character hex)') + .argument('[amount]', 'Token amount in display units (defaults to wallet balance)') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .action(async (category: string, amountStr: string | undefined, opts) => { + const isChipnet = Boolean(opts.chipnet) + const network = isChipnet ? 'chipnet' : 'mainnet' + + // Validate category format + if (!/^[a-fA-F0-9]{64}$/.test(category)) { + console.log(chalk.red('\nError: Category must be a 64-character hex string.\n')) + process.exit(1) + } + + // Validate amount if provided + let requestedAmount: number | null = null + if (amountStr !== undefined) { + requestedAmount = Number(amountStr) + if (!isFinite(requestedAmount) || requestedAmount < 0) { + console.log(chalk.red('\nError: Amount must be a non-negative number.\n')) + process.exit(1) + } + } + + const data = loadMnemonic() + if (!data) { + console.log( + chalk.red('\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n') + ) + process.exit(1) + } + + const w = loadWallet()! + const bchWallet = w.forNetwork(isChipnet) + + console.log(chalk.bold(`\n Token Price (${network})\n`)) + + // Resolve token metadata (symbol/decimals) — optional, non-fatal + let symbol = '' + let decimals = 0 + let name = '' + try { + const info = await bchWallet.getTokenInfo(category) + if (info) { + symbol = info.symbol || '' + name = info.name !== 'Unknown Token' ? info.name : '' + decimals = info.decimals || 0 + } + } catch { + // Token metadata unavailable — proceed without it + } + + // Resolve the display amount to price + let displayAmount: number + if (requestedAmount !== null) { + displayAmount = requestedAmount + } else { + try { + const balResult = await bchWallet.getTokenBalance(category) + displayAmount = balResult.balance / Math.pow(10, decimals) + } catch { + console.log(chalk.yellow(' Wallet balance unavailable; specify an to price.\n')) + process.exit(1) + } + } + + // Fetch USD price + let usdPerToken: number | null = null + try { + usdPerToken = await getUsdPerToken(category, isChipnet) + } catch { + // leave null + } + + const label = symbol || name || shortHex(category) + console.log(` Token: ${label}`) + if (name) console.log(chalk.dim(` Name: ${name}`)) + console.log(chalk.dim(` Category: ${category}`)) + + if (usdPerToken === null) { + console.log(chalk.yellow('\n No market price available for this token.\n')) + return + } + + const usdValue = displayAmount * usdPerToken + console.log( + chalk.green( + ` Price: ${formatUsd(usdPerToken)} per ${symbol || 'token'}` + ) + ) + console.log( + chalk.bold( + ` Value: ${formatUsd(usdValue)} for ${displayAmount} ${symbol || 'token(s)'}` + ) + ) + + console.log() + }) + // ── token send ───────────────────────────────────────────────────── token diff --git a/src/utils/prices.test.ts b/src/utils/prices.test.ts new file mode 100644 index 0000000..5573baa --- /dev/null +++ b/src/utils/prices.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for the watchtower asset-prices utilities. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + fetchAssetPrices, + getUsdPerToken, + getBchUsdPrice, + tokenAmountToUsd, + formatUsd, +} from './prices.js' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function stubFetch(json: unknown) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(json), + })) +} + +describe('tokenAmountToUsd', () => { + it('converts raw base units to USD using the display-token price', () => { + // 10000 base units, 2 decimals = 100.00 tokens @ $0.02 each + expect(tokenAmountToUsd(10000, 2, 0.02)).toBeCloseTo(2, 10) + }) + + it('handles zero decimals', () => { + expect(tokenAmountToUsd(5, 0, 1.5)).toBeCloseTo(7.5, 10) + }) +}) + +describe('formatUsd', () => { + it('formats as USD currency', () => { + expect(formatUsd(2.007)).toBe('$2.01') + }) + + it('returns an em dash for non-finite input', () => { + expect(formatUsd(Number.NaN)).toBe('—') + }) +}) + +describe('fetchAssetPrices', () => { + it('returns the raw prices array', async () => { + stubFetch({ prices: [{ asset: 'ct/abc', currency: 'USD', price_value: '10' }] }) + const result = await fetchAssetPrices(['ct/abc']) + expect(result).toHaveLength(1) + expect(result[0].price_value).toBe('10') + }) + + it('returns [] when the response has no prices', async () => { + stubFetch({ prices: [] }) + expect(await fetchAssetPrices(['ct/abc'])).toEqual([]) + }) +}) + +describe('getUsdPerToken', () => { + it('takes the reciprocal of the tokens-per-USD quote', async () => { + stubFetch({ + prices: [{ asset: 'ct/abc', currency: 'USD', price_value: '50' }], + }) + expect(await getUsdPerToken('abc')).toBeCloseTo(0.02, 10) + }) + + it('returns null for a token with no market price', async () => { + stubFetch({ prices: [] }) + expect(await getUsdPerToken('abc')).toBeNull() + }) +}) + +describe('getBchUsdPrice', () => { + it('returns the USD-per-BCH quote directly', async () => { + stubFetch({ prices: [{ asset: 'BCH', currency: 'USD', price_value: '247.8' }] }) + expect(await getBchUsdPrice()).toBeCloseTo(247.8, 10) + }) +}) \ No newline at end of file diff --git a/src/utils/prices.ts b/src/utils/prices.ts new file mode 100644 index 0000000..2bb468a --- /dev/null +++ b/src/utils/prices.ts @@ -0,0 +1,125 @@ +/** + * Asset pricing via the watchtower.cash /api/asset-prices/ endpoint. + * + * Mirrors paytaca-app's market price handling in + * src/store/market/actions.js (updateAssetPrices). + * + * Unit conventions from watchtower.cash: + * - Coins (e.g. BCH): price_value is fiat per unit (USD per BCH). + * - CashTokens: price_value is units per fiat (tokens per USD), + * so fiat per token = 1 / price_value. + */ + +import { getWatchtowerApiUrl } from './network.js' + +export interface AssetPrice { + id: number + asset: string + asset_type: string + asset_name: string + asset_symbol: string + currency: string + price_value: string + timestamp: string + source: string +} + +/** Max asset IDs per request — paytaca-app batches at 10. */ +const BATCH_SIZE = 10 + +/** + * Fetch prices from watchtower.cash /api/asset-prices/. + * + * @param assetIds - Asset IDs, e.g. ['BCH'] or ['ct/'] + * @param vsCurrencies - Quote currencies, e.g. ['USD'] + */ +export async function fetchAssetPrices( + assetIds: string[], + vsCurrencies: string[] = ['USD'], + isChipnet: boolean = false +): Promise { + const baseUrl = getWatchtowerApiUrl(isChipnet) + const uniqueIds = [...new Set(assetIds.filter(Boolean))] + if (uniqueIds.length === 0) return [] + + const batches: string[][] = [] + for (let i = 0; i < uniqueIds.length; i += BATCH_SIZE) { + batches.push(uniqueIds.slice(i, i + BATCH_SIZE)) + } + + const results = await Promise.all( + batches.map(async (batch) => { + const params = new URLSearchParams() + params.set('assets', batch.join(',')) + params.set('vs_currencies', vsCurrencies.join(',')) + const res = await fetch(`${baseUrl}/asset-prices/?${params.toString()}`) + if (!res.ok) throw new Error(`asset-prices request failed (${res.status})`) + const data = await res.json() + return Array.isArray(data?.prices) ? data.prices : [] + }) + ) + + return results.flat() as AssetPrice[] +} + +/** + * USD price per display unit of a CashToken. + * Returns null when the token has no market price. + */ +export async function getUsdPerToken( + category: string, + isChipnet: boolean = false +): Promise { + const prices = await fetchAssetPrices([`ct/${category}`], ['USD'], isChipnet) + return priceInUsd(prices) +} + +/** + * USD price per BCH. + * Returns null when unavailable. + */ +export async function getBchUsdPrice( + isChipnet: boolean = false +): Promise { + const prices = await fetchAssetPrices(['BCH'], ['USD'], isChipnet) + return priceInUsd(prices) +} + +/** + * Convert a raw token amount (base units) to USD given USD per display token. + */ +export function tokenAmountToUsd( + rawAmount: number, + decimals: number, + usdPerToken: number +): number { + const displayAmount = rawAmount / Math.pow(10, decimals) + return displayAmount * usdPerToken +} + +/** + * Extract the USD quote from an asset-prices response, applying the + * token reciprocal rule used by paytaca-app. + */ +function priceInUsd(prices: AssetPrice[]): number | null { + for (const p of prices) { + if (String(p.currency || '').toLowerCase() !== 'usd') continue + const raw = parseFloat(p.price_value) + if (!isFinite(raw) || raw === 0) continue + const asset = String(p.asset || '').toLowerCase() + // Tokens are quoted as tokens-per-USD; take the reciprocal for USD-per-token. + return asset.startsWith('ct/') ? 1 / raw : raw + } + return null +} + +/** Format a USD amount for display. */ +export function formatUsd(usd: number): string { + if (!isFinite(usd)) return '—' + return usd.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} \ No newline at end of file From 53f37d72e3596feb8795b344a52d0680f3fad06f Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 13:30:59 +0800 Subject: [PATCH 2/8] add Cauldron DEX swap for CashTokens Generic token <-> BCH swap via the Cauldron DEX (mirrors paytaca-app): - fetch active pools + token data from the riften indexer - best-rate trade quoting with attemptTrade (ExchangeLab) - signed trade tx built via createTradeTx and broadcast via watchtower - new 'paytaca swap' command (sell token for BCH / buy token with BCH) --- package-lock.json | 32 ++- package.json | 3 +- src/commands/swap.ts | 155 +++++++++++ src/index.ts | 2 + src/wallet/bch.ts | 30 +++ src/wallet/cauldron/api.ts | 114 ++++++++ src/wallet/cauldron/pools.test.ts | 173 ++++++++++++ src/wallet/cauldron/pools.ts | 140 ++++++++++ src/wallet/cauldron/swap.ts | 265 ++++++++++++++++++ src/wallet/cauldron/transact.ts | 428 ++++++++++++++++++++++++++++++ 10 files changed, 1339 insertions(+), 3 deletions(-) create mode 100644 src/commands/swap.ts create mode 100644 src/wallet/cauldron/api.ts create mode 100644 src/wallet/cauldron/pools.test.ts create mode 100644 src/wallet/cauldron/pools.ts create mode 100644 src/wallet/cauldron/swap.ts create mode 100644 src/wallet/cauldron/transact.ts diff --git a/package-lock.json b/package-lock.json index cfbeca4..90bf8d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "paytaca-cli", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paytaca-cli", - "version": "0.4.0", + "version": "0.4.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", + "@cashlab/cauldron": "^1.0.3", "@napi-rs/keyring": "^1.2.0", "bip39": "^3.1.0", "chalk": "^5.4.1", @@ -42,6 +43,33 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/@cashlab/cauldron": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@cashlab/cauldron/-/cauldron-1.0.3.tgz", + "integrity": "sha512-prWQTiXf9sUpqP8qMK2pr/Y5RQw1b1hk5aH3nqZEyTF46fdaDyQwoKwM+szPrLQhxarLgEHWKFSAPL2Ga+uoHQ==", + "license": "ISC", + "dependencies": { + "@cashlab/common": "1.0.5" + } + }, + "node_modules/@cashlab/common": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cashlab/common/-/common-1.0.5.tgz", + "integrity": "sha512-qMzoOJSkWyWdttNn956MRb1dZHqVaB4s0HmQyn+4jOm4ipgLhazsRdJoa6elZeHSA6ofMEZpTXjXUQRSeTYjmw==", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "3.1.0-next.6" + } + }, + "node_modules/@cashlab/common/node_modules/@bitauth/libauth": { + "version": "3.1.0-next.6", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.6.tgz", + "integrity": "sha512-FJ4ZChczVx779T6yElNGV1pWuyN4+mwF7P6ro+eFt5g6GPKqJe4S7YYBJySRGyER2CvTVnmx0gArzSbp/2u3oA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/@chris.troutner/bip32-utils": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@chris.troutner/bip32-utils/-/bip32-utils-1.0.5.tgz", diff --git a/package.json b/package.json index bb6acc9..64abd36 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,13 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", + "@cashlab/cauldron": "^1.0.3", "@napi-rs/keyring": "^1.2.0", "bip39": "^3.1.0", "chalk": "^5.4.1", "commander": "^12.1.0", - "nostr-tools": "^2.23.3", "js-sha256": "^0.9.0", + "nostr-tools": "^2.23.3", "qrcode-terminal": "^0.12.0", "watchtower-cash-js": "^0.2.4" }, diff --git a/src/commands/swap.ts b/src/commands/swap.ts new file mode 100644 index 0000000..e02d399 --- /dev/null +++ b/src/commands/swap.ts @@ -0,0 +1,155 @@ +/** + * CLI command: swap + * + * Swap a CashToken against BCH through Cauldron pools. + * + * Two directions: + * - sell (default): sell tokens for BCH + * - buy: spend BCH to buy tokens + * + * Shows a quote (rate, amounts, trade fee), asks for confirmation, then + * builds + signs the trade transaction and broadcasts it. + * + * Cauldron is a liquidity protocol on Bitcoin Cash. Swaps execute against + * the active pools advertised by the riften indexer; the trade transaction + * is built with @cashlab/cauldron's ExchangeLab (libauth templates). + */ + +import { Command } from 'commander' +import readline from 'readline' +import chalk from 'chalk' +import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { BCH_DERIVATION_PATH } from '../utils/network.js' +import { + executeSwap, + estimateSwap, + formatQuote, + type SwapDirection, +} from '../wallet/cauldron/swap.js' +import { fetchTokenData } from '../wallet/cauldron/api.js' + +function promptConfirmation(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(chalk.bold(`\n ${message} (y/N): `), (answer) => { + rl.close() + const confirmed = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes' + resolve(confirmed) + }) + }) +} + +export function registerSwapCommand(program: Command): void { + program + .command('swap') + .description('Swap a CashToken for BCH (sell) or BCH for a CashToken (buy)') + .argument('', '64-char hex token category to swap') + .argument('', 'Token amount (in the token\'s units)') + .option( + '--direction ', + 'Swap direction: sell (token→BCH) or buy (BCH→token) (default: sell)', + 'sell' + ) + .option( + '--raw', + 'Interpret as raw base units instead of decimal token units' + ) + .option('--yes', 'Skip the confirmation prompt') + .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') + .action(async (tokenId: string, amountStr: string, opts) => { + const isChipnet = Boolean(opts.chipnet) + const network = isChipnet ? 'chipnet' : 'mainnet' + const direction: SwapDirection = + opts.direction === 'buy' ? 'buy' : 'sell' + + const data = loadMnemonic() + if (!data) { + console.log( + chalk.red( + '\nNo wallet found. Run `paytaca wallet create` or `paytaca wallet import` first.\n' + ) + ) + process.exit(1) + } + + if (!/^[a-fA-F0-9]{64}$/.test(tokenId)) { + console.log(chalk.red('\nError: tokenId must be a 64-char hex token category.\n')) + process.exit(1) + } + + const parsedAmount = parseFloat(amountStr) + if (isNaN(parsedAmount) || parsedAmount <= 0) { + console.log(chalk.red('\nError: Amount must be a positive number.\n')) + process.exit(1) + } + + console.log(chalk.bold(`\n ${direction === 'buy' ? 'Buying' : 'Selling'} token on ${network}`)) + console.log(chalk.dim(` Token: ${tokenId}`)) + console.log() + + try { + // Resolve token metadata for decimals + symbol + const tokenData = await fetchTokenData(tokenId) + if (!tokenData) { + console.log(chalk.red('\nError: No cauldron token data found for this token.\n')) + process.exit(1) + } + const decimals = tokenData.bcmr.token.decimals + const symbol = tokenData.bcmr.token.symbol || tokenData.display_symbol + + // Convert decimal amount to base units + const amount = opts.raw + ? BigInt(parsedAmount) + : BigInt(Math.round(parsedAmount * 10 ** decimals)) + + // Show quote first + const quote = await estimateSwap({ tokenId, direction, amount }) + console.log(chalk.cyan(' Quote:')) + for (const line of formatQuote(quote).split('\n')) { + console.log(` ${line}`) + } + console.log() + + if (!opts.yes) { + const confirmed = await promptConfirmation('Confirm swap?') + if (!confirmed) { + console.log(chalk.yellow(' Swap cancelled.\n')) + process.exit(0) + } + } + + const w = loadWallet()! + const bchWallet = w.forNetwork(isChipnet) + + const result = await executeSwap({ + tokenId, + direction, + amount, + bchWallet, + mnemonic: data.mnemonic, + derivationPath: BCH_DERIVATION_PATH, + }) + + if (result.success) { + console.log(chalk.green('\n Swap successful!\n')) + if (result.txid) { + console.log(` txid: ${result.txid}`) + const explorer = isChipnet + ? 'https://chipnet.chaingraph.cash/tx/' + : 'https://bchexplorer.info/tx/' + console.log(chalk.dim(` ${explorer}${result.txid}`)) + } + } else { + console.log(chalk.red(`\n Swap failed: ${result.error || 'Unknown error'}\n`)) + process.exit(1) + } + } catch (err: any) { + console.log(chalk.red(`\n Error: ${err.message || err}\n`)) + process.exit(1) + } + }) +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8f0926c..d15dcad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { registerTokenCommands } from './commands/token.js' import { registerPayCommand } from './commands/pay.js' import { registerCheckCommand } from './commands/check.js' import { registerChatCommands } from './commands/chat.js' +import { registerSwapCommand } from './commands/swap.js' const packageJson = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8') @@ -40,5 +41,6 @@ registerTokenCommands(program) registerPayCommand(program) registerCheckCommand(program) registerChatCommands(program) +registerSwapCommand(program) program.parse() diff --git a/src/wallet/bch.ts b/src/wallet/bch.ts index 103cec8..9aa6421 100644 --- a/src/wallet/bch.ts +++ b/src/wallet/bch.ts @@ -274,6 +274,36 @@ export class BchWallet { return result as SendResult } + /** + * Get raw UTXOs from Watchtower. + * Adapted from paytaca-app BchWallet.getUtxos(). + * + * With no category: returns all UTXOs (BCH + CashTokens). + * With a category: returns only that token's UTXOs (is_cashtoken=true). + * + * @param opts.category - Token category ID (64-char hex) to filter by + * @param opts.nft - Only return NFT UTXOs for the category + */ + async getUtxos(opts?: { + category?: string + nft?: boolean + }): Promise { + const params: Record = {} + let url = `utxo/wallet/${this.walletHash}/` + if (opts?.category) { + url += opts.category + '/' + params.is_cashtoken = true + params.is_cashtoken_nft = Boolean(opts.nft) + } + + const response = await (this.watchtower as any).BCH._api.get(url, { params }) + if (!Array.isArray(response.data?.utxos)) { + return Promise.reject({ response }) + } + + return response.data.utxos + } + /** * Trigger a UTXO scan on the Watchtower backend. */ diff --git a/src/wallet/cauldron/api.ts b/src/wallet/cauldron/api.ts new file mode 100644 index 0000000..5dc5a02 --- /dev/null +++ b/src/wallet/cauldron/api.ts @@ -0,0 +1,114 @@ +/** + * Cauldron (riften indexer) REST client. + * + * Adapted from: paytaca-app/src/wallet/cauldron/api.js, tokens.js, + * and pool-tracker.ts. Uses the global fetch API (Node 20+) instead of axios. + */ + +const CAULDRON_INDEXER_BASE_URL = 'https://indexer.riften.net' + +/** + * An active pool as returned by the cauldron indexer. + * GET /cauldron/pool/active?token= + */ +export interface ApiPool { + owner_p2pkh_addr: string + owner_pkh: string + pool_id: string + sats: number + token_id: string + tokens: number + tx_pos: number + txid: string +} + +/** + * A cauldron-listed token (market info + BCMR metadata). + * GET /cauldron/tokens/list_cached_by_ids?ids= + */ +export interface CauldronTokenData { + token_id: string + display_name: string + display_symbol: string + price_now: number + price_now_usd: number + tvl_sats: number + bcmr: { + name: string + description: string + token: { + category: string + decimals: number + symbol: string + } + uris?: { icon?: string; web?: string } + } +} + +export class CauldronApiError extends Error { + status?: number + constructor(message: string, status?: number) { + super(message) + this.name = 'CauldronApiError' + this.status = status + } +} + +async function getJson( + path: string, + params: Record = {} +): Promise { + const url = new URL(CAULDRON_INDEXER_BASE_URL + path) + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue + url.searchParams.set(key, String(value)) + } + + let response: Response + try { + response = await fetch(url.toString()) + } catch (err: any) { + throw new CauldronApiError(`Cauldron indexer unreachable: ${err?.message || err}`) + } + + if (!response.ok) { + throw new CauldronApiError( + `Cauldron indexer request failed (${response.status} ${response.statusText})`, + response.status + ) + } + + return response.json() as Promise +} + +/** + * Fetch the active cauldron pools for a token category. + */ +export async function fetchPoolsForToken(tokenId: string): Promise { + const data = await getJson<{ active?: unknown }>('/cauldron/pool/active', { + token: tokenId, + }) + if (!Array.isArray(data.active)) { + throw new CauldronApiError('Unexpected response from cauldron pool endpoint') + } + return data.active as ApiPool[] +} + +/** + * Fetch market + BCMR data for a single token category. + */ +export async function fetchTokenData( + tokenId: string +): Promise { + const data = await getJson( + '/cauldron/tokens/list_cached_by_ids', + { + ids: tokenId, + limit: 1, + offset: 0, + by: 'score', + order: 'desc', + } + ) + return Array.isArray(data) ? data[0] : undefined +} diff --git a/src/wallet/cauldron/pools.test.ts b/src/wallet/cauldron/pools.test.ts new file mode 100644 index 0000000..4ee0cd0 --- /dev/null +++ b/src/wallet/cauldron/pools.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import { NATIVE_BCH_TOKEN_ID } from '@cashlab/common' +import { binToHex, hexToBin } from '@cashlab/common/libauth.js' +import { + apiPoolToMicroPool, + microPoolToPoolV0, + poolTradeToOutput, + parseRate, + type MicroPool, +} from './pools.js' +import { attemptTrade, getEntriesSize } from './transact.js' + +// Real active LIFT pool from the riften indexer +const LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc' + +const apiPools = [ + { + owner_p2pkh_addr: 'bitcoincash:zqytt7jjxds269xxm60lprpd8ea786xw3cjfh7pwpk', + owner_pkh: '08b5fa523360ad14c6de9ff08c2d3e7be3e8ce8e', + pool_id: '5a6a8da19aecf4198537ac006bcc32bb6c3be514338db813a5610a11ff8c9500', + sats: 7746405, + token_id: LIFT_TOKEN_ID, + tokens: 95698, + tx_pos: 5, + txid: 'd49d46592026c574e877c3d6cbad0bbd3302262ae08e318669eaa39d19b94ed5', + }, + { + owner_p2pkh_addr: 'bitcoincash:zqwap58dm58cdvz9hgpg83lghd8rjjycn59gkys6cc', + owner_pkh: '1dd0d0eddd0f86b045ba0283c7e8bb4e3948989d', + pool_id: '6ac2944e341080772c4ed274033687fd4441d6e6ee976560dae9c83581b9f809', + sats: 10848211, + token_id: LIFT_TOKEN_ID, + tokens: 133950, + tx_pos: 1, + txid: '09b048f14763e4a4bba08e236c653e678b79991ec500f8d0a457874de62e3980', + }, +] + +function pools() { + return apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0) +} + +describe('apiPoolToMicroPool', () => { + it('maps indexer pool fields onto a MicroPool', () => { + const micro = apiPoolToMicroPool(apiPools[0]!) + expect(micro.pkh).toBe(apiPools[0]!.owner_pkh) + expect(micro.new_utxo_txid).toBe(apiPools[0]!.txid) + expect(micro.new_utxo_n).toBe(apiPools[0]!.tx_pos) + expect(micro.token_id).toBe(LIFT_TOKEN_ID) + expect(micro.sats).toBe(apiPools[0]!.sats) + expect(micro.token_amount).toBe(apiPools[0]!.tokens) + expect(micro.is_withdrawn).toBe(false) + }) +}) + +describe('microPoolToPoolV0', () => { + it('produces a valid PoolV0 with generated locking bytecode', () => { + const micro = apiPoolToMicroPool(apiPools[0]!) + const poolV0 = microPoolToPoolV0(micro) + + expect(poolV0.version).toBe('0') + expect(binToHex(poolV0.parameters.withdraw_pubkey_hash)).toBe(apiPools[0]!.owner_pkh) + expect(poolV0.outpoint.index).toBe(apiPools[0]!.tx_pos) + expect(binToHex(poolV0.outpoint.txhash)).toBe(apiPools[0]!.txid) + expect(poolV0.output.token.token_id).toBe(LIFT_TOKEN_ID) + expect(poolV0.output.token.amount).toBe(BigInt(apiPools[0]!.tokens)) + expect(poolV0.output.amount).toBe(BigInt(apiPools[0]!.sats)) + // Locking bytecode must be non-empty and start with a valid pool script + expect(poolV0.output.locking_bytecode.length).toBeGreaterThan(0) + }) +}) + +describe('attemptTrade', () => { + it('computes a best-rate sell trade across pools', () => { + const result = attemptTrade({ + pools: pools(), + isBuyingToken: false, + supply: 1000n, // 10.00 LIFT + }) + + expect(result.entries.length).toBeGreaterThan(0) + expect(result.summary.supply).toBe(1000n) + expect(result.summary.demand).toBeGreaterThan(0n) + expect(result.entries[0]!.supply_token_id).toBe(LIFT_TOKEN_ID) + expect(result.entries[0]!.demand_token_id).toBe(NATIVE_BCH_TOKEN_ID) + expect(result.summary.trade_fee).toBeGreaterThan(0n) + }) + + it('computes a best-rate buy trade across pools', () => { + const result = attemptTrade({ + pools: pools(), + isBuyingToken: true, + demand: 1000n, + }) + + expect(result.entries[0]!.supply_token_id).toBe(NATIVE_BCH_TOKEN_ID) + expect(result.entries[0]!.demand_token_id).toBe(LIFT_TOKEN_ID) + expect(result.summary.supply).toBeGreaterThan(0n) + expect(result.summary.demand).toBe(1000n) + }) +}) + +describe('getEntriesSize', () => { + it('returns positive input and output sizes', () => { + const tradeResult = attemptTrade({ + pools: pools(), + isBuyingToken: false, + supply: 1000n, + }) + const sizes = getEntriesSize(tradeResult) + expect(sizes.inputFees).toBeGreaterThan(0) + expect(sizes.outputFees).toBeGreaterThan(0) + }) +}) + +describe('poolTradeToOutput', () => { + it('computes the post-trade pool output', () => { + const tradeResult = attemptTrade({ + pools: pools(), + isBuyingToken: false, + supply: 1000n, + }) + const entry = tradeResult.entries[0]! + const output = poolTradeToOutput(entry) + expect(output.amount).toBe(entry.pool.output.amount - entry.demand) + expect(output.token.amount).toBe(entry.pool.output.token.amount + entry.supply) + expect(output.token.category).toBe(entry.pool.output.token.token_id) + }) +}) + +describe('parseRate', () => { + it('formats a sell price (token→BCH)', () => { + // rate = supply/demand scaled to denominator 1e13. + // For a sell: price = (num*10^8/den) / 10^tokenDecimals + // num=3, den=1, decimals=2 → (3*10^8/1)/100 = 3000000 + const rate = { numerator: 3n, denominator: 1n } + expect(parseRate(rate, 2, false)).toBe('3000000.00') + }) + + it('formats a buy price (BCH→token)', () => { + // For a buy: price = (num*10^tokenDecimals/den) / 10^8 + // num=1, den=3, decimals=2 → (1*100/3)/10^8 = 0.00000033 + const rate = { numerator: 1n, denominator: 3n } + expect(parseRate(rate, 2, true)).toBe('0.00000033') + }) + + it('matches the app formula on a real LIFT trade', () => { + // SELL: supply=1000 token base units → demand=80378 sats across 2 pools + const sellTrade = attemptTrade({ + pools: pools(), + isBuyingToken: false, + supply: 1000n, + }) + // rate = supply*1e13/demand + const sellRate = sellTrade.summary.rate + expect(sellRate.denominator).toBe(10000000000000n) + expect(sellRate.numerator).toBe(124412152579n) + expect(parseRate(sellRate, 2, false)).toBe('12441.21') + + // BUY: demand=1000 token base units → supply=81569 sats across 2 pools + const buyTrade = attemptTrade({ + pools: pools(), + isBuyingToken: true, + demand: 1000n, + }) + const buyRate = buyTrade.summary.rate + expect(buyRate.numerator).toBe(815690000000000n) + expect(parseRate(buyRate, 2, true)).toBe('0.00008156') + }) +}) + +// keep a reference to MicroPool for type checks +export type { MicroPool } \ No newline at end of file diff --git a/src/wallet/cauldron/pools.ts b/src/wallet/cauldron/pools.ts new file mode 100644 index 0000000..7d2f055 --- /dev/null +++ b/src/wallet/cauldron/pools.ts @@ -0,0 +1,140 @@ +/** + * Cauldron pool conversions and helpers. + * + * Adapted from: paytaca-app/src/wallet/cauldron/utils.js and pool-tracker.ts. + * Converts indexer ApiPool entries into @cashlab/cauldron PoolV0 objects. + */ + +import { ExchangeLab, type PoolV0 } from '@cashlab/cauldron' +import { NATIVE_BCH_TOKEN_ID } from '@cashlab/common' +import { hexToBin } from '@cashlab/common/libauth.js' +import { fetchPoolsForToken, type ApiPool } from './api.js' + +export interface MicroPool { + pool_id: string + pkh: string + is_withdrawn: boolean + spent_utxo_hash: string + new_utxo_hash: string + new_utxo_txid: string + new_utxo_n: number + token_id: string + sats: number + token_amount: number +} + +/** + * Convert an indexer ApiPool to a MicroPool. + */ +export function apiPoolToMicroPool(pool: ApiPool): MicroPool { + return { + pool_id: pool.pool_id, + pkh: pool.owner_pkh, + is_withdrawn: false, + spent_utxo_hash: '', + new_utxo_hash: pool.txid, + new_utxo_txid: pool.txid, + new_utxo_n: pool.tx_pos, + token_id: pool.token_id, + sats: pool.sats, + token_amount: pool.tokens, + } +} + +/** + * Convert a MicroPool to a @cashlab/cauldron PoolV0. + */ +export function microPoolToPoolV0(pool: MicroPool): PoolV0 { + const pool0Params = { withdraw_pubkey_hash: hexToBin(pool.pkh) } + const exlab = new ExchangeLab() + const pool0LockingBytecode = exlab.generatePoolV0LockingBytecode(pool0Params) + return { + version: '0', + parameters: pool0Params, + outpoint: { + index: pool.new_utxo_n, + txhash: hexToBin(pool.new_utxo_txid), + }, + output: { + locking_bytecode: pool0LockingBytecode, + token: { + amount: BigInt(pool.token_amount), + token_id: pool.token_id, + }, + amount: BigInt(pool.sats), + }, + } +} + +/** + * Fetch + convert active pools for a token category into PoolV0 objects. + */ +export async function fetchPoolV0ForToken(tokenId: string): Promise { + const pools = await fetchPoolsForToken(tokenId) + return pools.map(apiPoolToMicroPool).map(microPoolToPoolV0) +} + +/** + * Compute the post-trade state of a pool output (used for fee estimation). + * Mirrors paytaca-app poolTradeToCashscriptOutput(). + */ +export interface PoolTradeOutput { + to: Uint8Array + amount: bigint + token: { + amount: bigint + category: string + } +} + +/** + * @param poolTrade A @cashlab/cauldron PoolTrade + * @returns The pool's resulting output after the trade + */ +export function poolTradeToOutput(poolTrade: { + supply_token_id: string + demand_token_id: string + supply: bigint + demand: bigint + pool: PoolV0 +}): PoolTradeOutput { + const isSupplyingBch = poolTrade.supply_token_id === NATIVE_BCH_TOKEN_ID + const satoshisDelta = isSupplyingBch ? poolTrade.supply : poolTrade.demand * -1n + const tokenDelta = isSupplyingBch ? poolTrade.demand * -1n : poolTrade.supply + + const poolOutput = poolTrade.pool.output + return { + to: poolOutput.locking_bytecode, + amount: poolOutput.amount + satoshisDelta, + token: { + amount: poolOutput.token.amount + tokenDelta, + category: poolOutput.token.token_id, + }, + } +} + +/** + * Parse a trade rate (price) into a human-readable string. + * Mirrors paytaca-app parseRate(). + * + * @param isBuyingToken true when BCH is spent to buy tokens (price in BCH per token) + */ +export function parseRate( + rate: { numerator: bigint; denominator: bigint }, + tokenDecimals: number, + isBuyingToken: boolean +): string { + let multiplerDecimals = 8 + let divisorDecimals = tokenDecimals + if (isBuyingToken) { + multiplerDecimals = tokenDecimals + divisorDecimals = 8 + } + + const multiplier = 10n ** BigInt(multiplerDecimals) + const _price = (rate.numerator * multiplier) / rate.denominator + + const divisor = 10 ** divisorDecimals + const price = Number(_price) / divisor + return price.toFixed(divisorDecimals) +} \ No newline at end of file diff --git a/src/wallet/cauldron/swap.ts b/src/wallet/cauldron/swap.ts new file mode 100644 index 0000000..ad1dcec --- /dev/null +++ b/src/wallet/cauldron/swap.ts @@ -0,0 +1,265 @@ +/** + * Cauldron swap orchestration: quote estimation and on-chain execution. + * + * This is the generic swap primitive (token ⇄ BCH). Higher-level callers + * (e.g. an MCP payment wrapper) can use estimateSwap() to price a swap and + * executeSwap() to broadcast it, mirroring the paytaca-app cauldron flow. + */ + +import { ExchangeLab, type PoolV0, type TradeResult } from '@cashlab/cauldron' +import { binToHex } from '@cashlab/common/libauth.js' +import type { BchWallet } from '../bch.js' +import { LibauthHDWallet } from '../keys.js' +import { + fetchPoolsForToken, + fetchTokenData, + type CauldronTokenData, +} from './api.js' +import { apiPoolToMicroPool, microPoolToPoolV0, parseRate } from './pools.js' +import { + attemptTrade, + createInputAndOutput, + watchtowerUtxosToSpendableCoins, + type WatchtowerUtxo, +} from './transact.js' + +export type SwapDirection = 'buy' | 'sell' + +export interface SwapQuote { + tokenId: string + tokenData: CauldronTokenData + direction: SwapDirection + isBuyingToken: boolean + pools: PoolV0[] + tradeResult: TradeResult + /** Human-readable price. */ + rate: string + /** Token amount in base units. */ + tokenAmount: bigint + /** BCH amount involved in base units (satoshis). */ + bchAmount: bigint + /** Trade fee in satoshis. */ + tradeFee: bigint +} + +export interface EstimateSwapOpts { + /** 64-char hex token category. */ + tokenId: string + /** 'buy' = spend BCH to receive tokens, 'sell' = spend tokens to receive BCH. */ + direction: SwapDirection + /** Token amount in base units (sell: amount supplied; buy: amount received). */ + amount: bigint +} + +export interface ExecuteSwapOpts extends EstimateSwapOpts { + bchWallet: BchWallet + mnemonic: string + derivationPath: string +} + +export interface SwapResult { + success: boolean + txid?: string + transaction?: string + error?: string + quote?: SwapQuote +} + +/** + * Estimate a swap: fetch pools + token data and compute the best-rate trade. + */ +export async function estimateSwap( + opts: EstimateSwapOpts +): Promise { + const { tokenId, direction, amount } = opts + + const [tokenData, apiPools] = await Promise.all([ + fetchTokenData(tokenId), + fetchPoolsForToken(tokenId), + ]) + if (!tokenData) { + throw new Error(`No cauldron token data found for ${tokenId}`) + } + if (apiPools.length === 0) { + throw new Error(`No active cauldron pools for token ${tokenId}`) + } + + const pools = apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0) + const isBuyingToken = direction === 'buy' + const tradeResult = attemptTrade({ + pools, + isBuyingToken, + supply: isBuyingToken ? undefined : amount, + demand: isBuyingToken ? amount : undefined, + }) + + // supply/demand semantics depend on direction: + // sell: supply=token, demand=BCH + // buy: supply=BCH, demand=token + const tokenAmount = isBuyingToken + ? tradeResult.summary.demand + : tradeResult.summary.supply + const bchAmount = isBuyingToken + ? tradeResult.summary.supply + : tradeResult.summary.demand + const decimals = tokenData.bcmr.token.decimals + + return { + tokenId, + tokenData, + direction, + isBuyingToken, + pools, + tradeResult, + rate: parseRate(tradeResult.summary.rate, decimals, isBuyingToken), + tokenAmount, + bchAmount, + tradeFee: tradeResult.summary.trade_fee, + } +} + +/** + * Format a SwapQuote for human display. + */ +export function formatQuote(quote: SwapQuote): string { + const { tokenData, direction, rate, tokenAmount, bchAmount, tradeFee } = quote + const decimals = tokenData.bcmr.token.decimals + const tokenSymbol = tokenData.bcmr.token.symbol || tokenData.display_symbol + + const tokenFormatted = (Number(tokenAmount) / 10 ** decimals).toFixed(decimals) + const bchFormatted = (Number(bchAmount) / 10 ** 8).toFixed(8) + const feeFormatted = (Number(tradeFee) / 10 ** 8).toFixed(8) + + if (direction === 'sell') { + // Rate semantics (matches paytaca-app): '1 {demandSymbol} ≈ {rate} {supplySymbol}'. + // Selling tokens → demand is BCH, rate is tokens-per-BCH. + return [ + `Sell ${tokenFormatted} ${tokenSymbol} for ${bchFormatted} BCH`, + `Rate: 1 BCH ≈ ${rate} ${tokenSymbol}`, + `Trade fee: ~${feeFormatted} BCH`, + ].join('\n') + } + // Buying tokens → demand is the token, rate is BCH-per-token. + return [ + `Buy ${tokenFormatted} ${tokenSymbol} for ${bchFormatted} BCH`, + `Rate: 1 ${tokenSymbol} ≈ ${rate} BCH`, + `Trade fee: ~${feeFormatted} BCH`, + ].join('\n') +} + +/** + * Execute a swap: build + sign the trade transaction and broadcast it. + */ +export async function executeSwap(opts: ExecuteSwapOpts): Promise { + const { bchWallet, mnemonic, derivationPath } = opts + + let quote: SwapQuote + try { + quote = await estimateSwap(opts) + } catch (err: any) { + return { success: false, error: err?.message || String(err) } + } + + let txHex: string + try { + const tradeTx = await buildSignedTradeTx({ + bchWallet, + mnemonic, + derivationPath, + quote, + }) + txHex = binToHex(tradeTx.txbin) + } catch (err: any) { + return { success: false, error: err?.message || String(err), quote } + } + + try { + const broadcastResponse = await ( + bchWallet.watchtower as any + ).BCH._api.post('broadcast/', { transaction: txHex }) + const data = broadcastResponse.data + + // Mempool test API compatibility + if (data?.result) { + data[data.success ? 'txid' : 'error'] = data.result + delete data.result + } + + return { + success: Boolean(data?.success), + txid: data?.txid, + transaction: txHex, + error: data?.error, + quote, + } + } catch (err: any) { + return { + success: false, + transaction: txHex, + error: err?.message || String(err), + quote, + } + } +} + +/** + * Build + sign the trade transaction (no broadcast). + */ +export async function buildSignedTradeTx(opts: { + bchWallet: BchWallet + mnemonic: string + derivationPath: string + quote: SwapQuote +}) { + const { bchWallet, mnemonic, derivationPath, quote } = opts + + const hdWallet = new LibauthHDWallet(mnemonic, derivationPath) + const spendableCoins = await collectSpendableCoins({ + bchWallet, + hdWallet, + tokenId: quote.tokenId, + isBuyingToken: quote.isBuyingToken, + }) + + const { inputCoins, payouts } = createInputAndOutput({ + tradeResult: quote.tradeResult, + spendableCoins, + }) + + const exlab = new ExchangeLab() + const tradeTx = exlab.createTradeTx( + quote.tradeResult.entries, + inputCoins, + payouts, + null, + 1n + ) + exlab.verifyTradeTx(tradeTx) + + return tradeTx +} + +/** + * Collect the wallet UTXOs needed for the swap as SpendableCoins. + * Selling needs the token's UTXOs + BCH; buying needs BCH only. + */ +async function collectSpendableCoins(opts: { + bchWallet: BchWallet + hdWallet: LibauthHDWallet + tokenId: string + isBuyingToken: boolean +}): Promise> { + const { bchWallet, hdWallet, tokenId, isBuyingToken } = opts + + // BCH inputs: all UTXOs, filtered client-side to non-token ones so that + // other token holdings are never accidentally consumed. + const [allUtxos, tokenUtxos] = await Promise.all([ + bchWallet.getUtxos(), + isBuyingToken ? Promise.resolve([] as WatchtowerUtxo[]) : bchWallet.getUtxos({ category: tokenId }), + ]) + + const bchUtxos = allUtxos.filter((utxo: WatchtowerUtxo) => !utxo.is_cashtoken) + const utxos = [...bchUtxos, ...(tokenUtxos as WatchtowerUtxo[])] + + return watchtowerUtxosToSpendableCoins({ utxos, wallet: hdWallet }) +} \ No newline at end of file diff --git a/src/wallet/cauldron/transact.ts b/src/wallet/cauldron/transact.ts new file mode 100644 index 0000000..1f0cb03 --- /dev/null +++ b/src/wallet/cauldron/transact.ts @@ -0,0 +1,428 @@ +/** + * Cauldron trade transaction building. + * + * Adapted from: paytaca-app/src/wallet/cauldron/transact.js. + * + * Uses @cashlab/cauldron's ExchangeLab to construct a best-rate trade across + * the active pools, then createInputAndOutput() selects the wallet coins and + * payout rules needed to fund and settle it. The final signed transaction is + * produced by ExchangeLab.createTradeTx() (libauth templates). + */ + +import { + ExchangeLab, + buildPoolV0UnlockingBytecode, + type PoolV0, + type TradeResult, +} from '@cashlab/cauldron' +import { + NATIVE_BCH_TOKEN_ID, + PayoutAmountRuleType, + SpendableCoinType, + type Output, + type PayoutRule, + type SpendableCoin, + type SpendableCoinP2PKH, +} from '@cashlab/common' +import { + bigIntToCompactUint, + cashAddressToLockingBytecode, + compactUintPrefixToLength, + decodePrivateKeyWif, + hexToBin, + privateKeyToP2pkhLockingBytecode, +} from '@cashlab/common/libauth.js' +import { poolTradeToOutput } from './pools.js' +import type { LibauthHDWallet } from '../keys.js' + +const PLACEHOLDER_TOKEN_ID_FOR_SIZE_CALC = Array.from({ length: 64 }) + .fill('0') + .join('') + +/** Estimated on-chain size of a P2PKH input (Schnorr signature). */ +const P2PKH_INPUT_SIZE = 141n + +export interface AttemptTradeOpts { + exlab?: ExchangeLab + pools: PoolV0[] + isBuyingToken: boolean + supply?: bigint + demand?: bigint + txFeePerByte?: bigint +} + +/** + * Attempt a best-rate trade across the given pools. + * + * - Selling token → BCH: pass `supply` (token amount in base units). + * - Buying token with BCH: pass `demand` (token amount in base units to receive). + */ +export function attemptTrade(opts: AttemptTradeOpts): TradeResult { + const exlab = opts.exlab ?? new ExchangeLab() + const { pools, isBuyingToken, supply, demand } = opts + const txFeePerByte = opts.txFeePerByte || 1n + + let supplyTokenId = pools[0]!.output.token.token_id + let demandTokenId = NATIVE_BCH_TOKEN_ID + if (isBuyingToken) { + supplyTokenId = NATIVE_BCH_TOKEN_ID + demandTokenId = pools[0]!.output.token.token_id + } + + if (demand) { + return exlab.constructTradeBestRateForTargetDemand( + supplyTokenId, + demandTokenId, + demand, + pools, + txFeePerByte + ) + } + return exlab.constructTradeBestRateForTargetSupply( + supplyTokenId, + demandTokenId, + supply!, + pools, + txFeePerByte + ) +} + +export interface PlatformFee { + to: string + amount: bigint +} + +/** + * Select the wallet coins and payout rules needed to fund + settle a trade. + * Mirrors paytaca-app createInputAndOutput(). + */ +export function createInputAndOutput(opts: { + tradeResult: TradeResult + spendableCoins: SpendableCoin[] + platformFee?: PlatformFee + tokenOutputSats?: bigint +}): { inputCoins: SpendableCoin[]; payouts: PayoutRule[] } { + const { tradeResult, spendableCoins, platformFee } = opts + const tokenOutputSats = opts.tokenOutputSats ?? 1000n + + const privateKey = (spendableCoins[0] as SpendableCoinP2PKH).key + const lockingBytecode = privateKeyToP2pkhLockingBytecode({ + privateKey, + throwErrors: true, + }) + const tokenCoins = spendableCoins.filter((coin) => coin.output?.token) + const bchCoins = spendableCoins.filter((coin) => !coin.output.token) + + const isBuyingToken = tradeResult.entries[0]!.supply_token_id === NATIVE_BCH_TOKEN_ID + + const entriesSizes = getEntriesSize(tradeResult) + const totalPoolTxFee = BigInt(entriesSizes.inputFees + entriesSizes.outputFees) + + let tokensToSupply = !isBuyingToken ? tradeResult.summary.supply : 0n + let satoshisToSupply = isBuyingToken ? tradeResult.summary.supply : 0n + + satoshisToSupply += totalPoolTxFee + if (platformFee) { + satoshisToSupply += platformFee.amount + satoshisToSupply += BigInt(getOutputSize(platformFee)) + } + + const inputCoins: SpendableCoin[] = [] + const payouts: PayoutRule[] = [] + + let remainingTokens = 0n + if (!isBuyingToken) { + // Selling tokens: consume token UTXOs until we've covered the supply + remainingTokens = tokensToSupply + for (const spendableCoin of tokenCoins) { + if (remainingTokens <= 0n) break + inputCoins.push(spendableCoin) + remainingTokens -= spendableCoin.output.token!.amount + satoshisToSupply += P2PKH_INPUT_SIZE + satoshisToSupply -= spendableCoin.output.amount + } + + // Excess tokens supplied → a token change output (counted in size calc) + if (remainingTokens < 0n) { + const changeTokenOutput = { + to: lockingBytecode, + amount: tokenOutputSats, + token: { + category: PLACEHOLDER_TOKEN_ID_FOR_SIZE_CALC, + amount: remainingTokens * -1n, + }, + } + satoshisToSupply += changeTokenOutput.amount + satoshisToSupply += BigInt(getOutputSize(changeTokenOutput)) + } + } else { + // Buying tokens: fixed payout for the received token amount + payouts.push({ + type: PayoutAmountRuleType.FIXED, + locking_bytecode: lockingBytecode, + amount: tokenOutputSats, + token: { + token_id: tradeResult.entries[0]!.demand_token_id, + amount: tradeResult.summary.demand, + }, + }) + + const outputSize = getOutputSize({ + to: lockingBytecode, + amount: tokenOutputSats, + token: { + category: tradeResult.entries[0]!.demand_token_id, + amount: tradeResult.summary.demand, + }, + }) + satoshisToSupply += tokenOutputSats + BigInt(outputSize) + } + + if (platformFee) { + const decoded = cashAddressToLockingBytecode(platformFee.to) + if (!decoded || typeof decoded === 'string' || !decoded.bytecode) { + throw new Error(`Invalid platform fee address: ${platformFee.to}`) + } + payouts.push({ + type: PayoutAmountRuleType.FIXED, + locking_bytecode: decoded.bytecode, + amount: platformFee.amount, + }) + satoshisToSupply += platformFee.amount + BigInt(getOutputSize(platformFee)) + } + + // Base tx overhead (version + locktime) and varint prefixes for inputs/outputs + satoshisToSupply += 8n + const inputSizePrefixLength = compactUintPrefixToLength( + bigIntToCompactUint(BigInt(tradeResult.entries.length + inputCoins.length))[0]! + ) + const outputSizePrefixLength = compactUintPrefixToLength( + bigIntToCompactUint(BigInt(tradeResult.entries.length + payouts.length))[0]! + ) + satoshisToSupply += BigInt(inputSizePrefixLength) + BigInt(outputSizePrefixLength) + + // Cover the remainder with BCH inputs + let remainingSats = satoshisToSupply + for (const spendableCoin of bchCoins) { + if (remainingSats <= 0n) break + inputCoins.push(spendableCoin) + remainingSats -= spendableCoin.output.amount + remainingSats += P2PKH_INPUT_SIZE + } + + payouts.push({ + type: PayoutAmountRuleType.CHANGE, + locking_bytecode: lockingBytecode, + 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, + }) + + return { inputCoins, payouts } +} + +/** + * Sum the on-chain byte sizes of the pool inputs and outputs for a trade. + */ +export function getEntriesSize(tradeResult: TradeResult): { + inputFees: number + outputFees: number +} { + const inputFees = tradeResult.entries + .map((entry) => buildPoolV0UnlockingBytecode(entry.pool.parameters)) + .map((unlockingBytecode) => getInputSize(unlockingBytecode)) + .reduce((subtotal, size) => subtotal + size, 0) + + const outputFees = tradeResult.entries + .map((entry) => { + const output = poolTradeToOutput(entry) + return getOutputSize(output) + }) + .reduce((subtotal, size) => subtotal + size, 0) + + return { inputFees, outputFees } +} + +/** + * Build a signed transaction for a trade using test coins, then optionally + * verify it. Used to validate a trade before broadcasting. + */ +export function testTradeResult(opts: { + exlab?: ExchangeLab + tradeResult: TradeResult + verify?: boolean +}) { + const exlab = opts.exlab ?? new ExchangeLab() + const { tradeResult } = opts + + const firstEntry = tradeResult.entries[0]! + const isSupplyBch = firstEntry.supply_token_id === NATIVE_BCH_TOKEN_ID + const tokenId = isSupplyBch ? firstEntry.demand_token_id : firstEntry.supply_token_id + + const key = new Uint8Array(32).fill(0x11) + const locking_bytecode = privateKeyToP2pkhLockingBytecode({ + privateKey: key, + throwErrors: true, + }) + + const tokenAmount = isSupplyBch ? 0n : (tradeResult.summary.supply * 3n) / 2n + const satsAmount = + (isSupplyBch ? tradeResult.summary.supply : 0n) + + tradeResult.summary.trade_fee + + 100_000n + + const coins: SpendableCoin[] = [ + { + type: SpendableCoinType.P2PKH, + key, + outpoint: { txhash: new Uint8Array(32).fill(0x22), index: 1 }, + output: { locking_bytecode, amount: satsAmount }, + }, + ] + if (tokenAmount) { + coins.push({ + type: SpendableCoinType.P2PKH, + key, + outpoint: { txhash: new Uint8Array(32).fill(0x33), index: 1 }, + output: { + locking_bytecode, + amount: 1000n, + token: { token_id: tokenId, amount: tokenAmount }, + }, + }) + } + + const payoutRules: PayoutRule[] = [] + if (isSupplyBch) { + payoutRules.push({ + type: PayoutAmountRuleType.FIXED, + locking_bytecode, + amount: 1000n, + token: { token_id: tokenId, amount: tradeResult.summary.demand }, + }) + } + payoutRules.push({ + type: PayoutAmountRuleType.CHANGE, + locking_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, + }) + + const tradeTx = exlab.createTradeTx( + tradeResult.entries, + coins, + payoutRules, + null, + 1n + ) + if (opts.verify) exlab.verifyTradeTx(tradeTx) + return tradeTx +} + +/** + * Convert watchtower UTXOs into @cashlab/common SpendableCoins. + * Mirrors paytaca-app watchtowerUtxosToSpendableCoins(). + */ +export interface WatchtowerUtxo { + txid: string + vout: number + value: number + tokenid?: string + amount?: number + address_path: string + is_cashtoken?: boolean +} + +export function watchtowerUtxosToSpendableCoins(opts: { + utxos: WatchtowerUtxo[] + wallet: LibauthHDWallet +}): SpendableCoin[] { + const { utxos, wallet } = opts + + const addressPathPrivkeyMap = new Map() + return utxos.map((utxo) => { + let privateKey: Uint8Array | undefined = addressPathPrivkeyMap.get( + utxo.address_path + ) + if (!privateKey) { + const wif = wallet.getPrivateKeyWifAt(utxo.address_path) + const decodedWif = decodePrivateKeyWif(wif) + if (typeof decodedWif === 'string') throw new Error(decodedWif) + privateKey = decodedWif.privateKey + addressPathPrivkeyMap.set(utxo.address_path, privateKey) + } + + return { + type: SpendableCoinType.P2PKH, + key: privateKey, + outpoint: { txhash: hexToBin(utxo.txid), index: utxo.vout }, + output: { + locking_bytecode: privateKeyToP2pkhLockingBytecode({ + privateKey, + throwErrors: true, + }), + amount: BigInt(utxo.value), + token: !utxo.is_cashtoken + ? undefined + : { + token_id: utxo.tokenid!, + amount: BigInt(utxo.amount ?? 0), + }, + }, + } + }) +} + +/** + * On-chain byte size of a transaction input for a given unlocking script. + * Mirrors cashscript getInputSize(). + */ +export function getInputSize(inputScript: Uint8Array): number { + const scriptSize = inputScript.length + const prefixSize = scriptSize > 252 ? 3 : 1 + return 32 + 4 + prefixSize + scriptSize + 4 +} + +interface SizedOutput { + to: Uint8Array | string + amount: bigint + token?: { category?: string; amount: bigint } +} + +/** + * On-chain byte size of a transaction output. + * Mirrors cashscript getOutputSize() (string `to` is treated as a CashAddress). + */ +export function getOutputSize(output: SizedOutput): number { + let lockingBytecode: Uint8Array | undefined + if (typeof output.to === 'string') { + const decoded = cashAddressToLockingBytecode(output.to) + if (typeof decoded === 'string') { + throw new Error(`Invalid CashAddress: ${decoded}`) + } + lockingBytecode = decoded?.bytecode + } else { + lockingBytecode = output.to + } + if (!lockingBytecode) { + throw new Error('Invalid locking bytecode for output size calculation') + } + + let size = + 8 + + compactUintPrefixToLength( + bigIntToCompactUint(BigInt(lockingBytecode.length))[0]! + ) + + lockingBytecode.length + + if (output.token) { + size += + 34 + + compactUintPrefixToLength( + bigIntToCompactUint(BigInt(output.token.amount))[0]! + ) + } + + return size +} \ No newline at end of file From 828cb7a5f7c9b468a0960fa2caa9e15e82bf45af Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 15:14:25 +0800 Subject: [PATCH 3/8] test: silence and reset error mocks in store test to prevent leak --- src/nostr/store.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/nostr/store.test.ts b/src/nostr/store.test.ts index 9c24732..f2492d1 100644 --- a/src/nostr/store.test.ts +++ b/src/nostr/store.test.ts @@ -162,12 +162,19 @@ describe('ChatStore', () => { }) it('should handle errors gracefully', async () => { - mockPublish.mockRejectedValue(new Error('publish fail')) - mockFetchHistoricalGiftWraps.mockRejectedValue(new Error('fetch fail')) - - await store.initialize('test mnemonic') - - expect(store.initialized).toBe(true) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + mockPublish.mockRejectedValue(new Error('publish fail')) + mockFetchHistoricalGiftWraps.mockRejectedValue(new Error('fetch fail')) + + await store.initialize('test mnemonic') + + expect(store.initialized).toBe(true) + } finally { + errorSpy.mockRestore() + mockPublish.mockReset() + mockFetchHistoricalGiftWraps.mockReset() + } }) }) From fa0a11426a21f377b539cc0336ef3043ca3e25c0 Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 15:23:13 +0800 Subject: [PATCH 4/8] chore(ci): bump opencode workflow model to kimi-k2.6 --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index e4f35b8..d81d358 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -53,5 +53,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: opencode/kimi-k2.5 + model: opencode/kimi-k2.6 use_github_token: true From 359d30ae592d86eb8d6d5a6d3ebf7416eaea7afe Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 15:23:24 +0800 Subject: [PATCH 5/8] docs: add AGENTS.md adapted from paytaca-app for CLI security review --- AGENTS.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9c14093 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AI Agent Instructions + +## General Rules + +- Do NOT auto-commit changes — ask before committing. +- Do NOT write new files unless explicitly asked; prefer editing existing code. +- Do NOT add comments to code unless requested. +- Keep explanations and responses concise. +- Follow existing code style and patterns in the codebase (TypeScript, ESM with `.js` import suffixes, 2-space indent). +- Prefer the dedicated tools (Read, Grep, Glob, Edit, Write) over bash for file operations. + +## Environment + +- This is a **command-line** tool for **desktop** (macOS, Linux, Windows) — there is no UI, no frontend code, no mobile code. +- The binary is `paytaca` (Commander.js), invoked as `node bin/paytaca.js` or the global `paytaca` command. +- `--chipnet` selects the testnet network; everything defaults to mainnet. + +## Tech Stack + +- **Language:** TypeScript (ES modules, compiled with `tsc` to `dist/`; imports use `.js` suffix). +- **CLI framework:** Commander.js +- **Key derivation:** `@bitauth/libauth` (pinned to 2.0.0-alpha.8) + `bip39` (BIP44: `m/44'/145'/0'` for BCH; `m/44'/1237'/0'/0/0` for Nostr). +- **Transactions:** `watchtower-cash-js` (UTXO fetching, tx building/signing/broadcasting). +- **Secret storage:** OS-native keychain via `@napi-rs/keyring` (prebuilt Rust binaries, no node-gyp). +- **Testing:** Vitest (`npm test` → `vitest run`). There is **no lint script** — run `npm run build` (tsc) to type-check. +- **Dev scripts:** `npm run build` (tsc), `npm run dev` (tsc --watch), `npm test` (vitest run). + +## Conventions + +- Run `npm run build` before signaling completion — it type-checks the whole project. +- Run `npm test` when changes affect tested code (tests live alongside sources as `*.test.ts`). +- Keep `console.error` out of test output — spy/mock expected error paths in tests or they leak noise into other tests. + +## Key Storage (differs from paytaca-app) + +- Secrets are stored in the **OS keychain**, NOT in app storage or capacitor plugins: + - macOS — Keychain; Linux — GNOME Keyring / KWallet; Windows — Credential Manager. + (Backed by `@napi-rs/keyring`, service name `paytaca-cli`.) +- Key names mirror paytaca-app for continuity: + - Mnemonic: `mn_{walletHash}`; Active wallet: `active_wallet`. + (`src/storage/keychain.ts`, `src/wallet/index.ts`) +- Nostr chat keys are **not stored** — they are re-derived from the wallet mnemonic at runtime via HD path `m/44'/1237'/0'/0/0` and held only in memory during a chat session (`src/nostr/keys.ts`). +- Chat state (contacts, rooms, messages) is persisted as JSON at `~/.paytaca/chat-state.json` (mode 0600) — this is non-secret metadata only;never write keys to it. + +## Automated Code Review (Security) + +Every pull request undergoes an AI-assisted security review. Pay particular attention to changes that could affect: + +- Entropy and recovery-phrase generation (`bip39`, `src/wallet/index.ts`) +- Private-key derivation (`src/wallet/keys.ts`, `src/nostr/keys.ts`) — HD paths, WIF encoding, in-memory key material +- Recovery-phrase storage and retrieval (`src/storage/keychain.ts`, `src/wallet/index.ts`) — keychain-only, no plaintext files, no logging of phrases, no persistence beyond the keychain aside from `~/.paytaca/chat-state.json` metadata +- Encryption and signing (transactions via `watchtower-cash-js`/libauth, x402 payment signing, Nostr event signing in memory) +- Transaction construction (UTXO selection, change addresses, token-aware z-prefix addresses, `src/wallet/cauldron/*`) +- Smart contracts / token pools (Cauldron DEX swaps in `src/wallet/cauldron/*`) +- Secret material in memory — derived keys and mnemonic must not be logged, printed, or serialized into persisted state or CLI output except where explicitly requested (e.g. `wallet export` shows the seed phrase with a warning) +- Migration of sensitive data — no legacy key schemes exist in the CLI; do not introduce any +- External interfaces that could influence signing behavior — relay URLs (`kind 10050`), x402 `PaymentRequired` payloads, UTXO data from watchtower, cauldron pool data: never sign data without validating it is well-formed and intended +- CLI output handling — avoid echoing secrets into logs, `--json` output, or error messages (sanitize addresses/hex in errors). + +For findings, reference the specific file and line, describe the risk and severity, and suggest a concrete fix. \ No newline at end of file From d0642a7f3ddc81335df485f9167db11a992d4b29 Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 17:07:31 +0800 Subject: [PATCH 6/8] fix(swap): reject chipnet, guard empty pools and UTXOs, encode category --- src/commands/swap.ts | 10 ++++++++-- src/wallet/bch.ts | 2 +- src/wallet/cauldron/transact.ts | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/commands/swap.ts b/src/commands/swap.ts index e02d399..e8ad8b7 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -61,8 +61,14 @@ export function registerSwapCommand(program: Command): void { .option('--yes', 'Skip the confirmation prompt') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') .action(async (tokenId: string, amountStr: string, opts) => { - const isChipnet = Boolean(opts.chipnet) - const network = isChipnet ? 'chipnet' : 'mainnet' + if (opts.chipnet) { + console.log( + chalk.red('\nError: Cauldron swaps are not yet supported on chipnet.\n') + ) + process.exit(1) + } + const isChipnet = false + const network = 'mainnet' const direction: SwapDirection = opts.direction === 'buy' ? 'buy' : 'sell' diff --git a/src/wallet/bch.ts b/src/wallet/bch.ts index 9aa6421..020dfd7 100644 --- a/src/wallet/bch.ts +++ b/src/wallet/bch.ts @@ -291,7 +291,7 @@ export class BchWallet { const params: Record = {} let url = `utxo/wallet/${this.walletHash}/` if (opts?.category) { - url += opts.category + '/' + url += encodeURIComponent(opts.category) + '/' params.is_cashtoken = true params.is_cashtoken_nft = Boolean(opts.nft) } diff --git a/src/wallet/cauldron/transact.ts b/src/wallet/cauldron/transact.ts index 1f0cb03..58d536a 100644 --- a/src/wallet/cauldron/transact.ts +++ b/src/wallet/cauldron/transact.ts @@ -62,6 +62,8 @@ export function attemptTrade(opts: AttemptTradeOpts): TradeResult { const { pools, isBuyingToken, supply, demand } = opts const txFeePerByte = opts.txFeePerByte || 1n + if (pools.length === 0) throw new Error('No pools provided') + let supplyTokenId = pools[0]!.output.token.token_id let demandTokenId = NATIVE_BCH_TOKEN_ID if (isBuyingToken) { @@ -105,6 +107,10 @@ export function createInputAndOutput(opts: { const { tradeResult, spendableCoins, platformFee } = opts const tokenOutputSats = opts.tokenOutputSats ?? 1000n + if (spendableCoins.length === 0) { + throw new Error('No UTXOs available to fund the trade') + } + const privateKey = (spendableCoins[0] as SpendableCoinP2PKH).key const lockingBytecode = privateKeyToP2pkhLockingBytecode({ privateKey, From d88a0b11c3de02636206b2504ff61f095817b563 Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 17:27:28 +0800 Subject: [PATCH 7/8] fix(cauldron): fix multiplierDecimals typo, add insufficient BCH guard --- src/wallet/cauldron/pools.ts | 6 +++--- src/wallet/cauldron/transact.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wallet/cauldron/pools.ts b/src/wallet/cauldron/pools.ts index 7d2f055..79bce05 100644 --- a/src/wallet/cauldron/pools.ts +++ b/src/wallet/cauldron/pools.ts @@ -124,14 +124,14 @@ export function parseRate( tokenDecimals: number, isBuyingToken: boolean ): string { - let multiplerDecimals = 8 + let multiplierDecimals = 8 let divisorDecimals = tokenDecimals if (isBuyingToken) { - multiplerDecimals = tokenDecimals + multiplierDecimals = tokenDecimals divisorDecimals = 8 } - const multiplier = 10n ** BigInt(multiplerDecimals) + const multiplier = 10n ** BigInt(multiplierDecimals) const _price = (rate.numerator * multiplier) / rate.denominator const divisor = 10 ** divisorDecimals diff --git a/src/wallet/cauldron/transact.ts b/src/wallet/cauldron/transact.ts index 58d536a..4cfb6dc 100644 --- a/src/wallet/cauldron/transact.ts +++ b/src/wallet/cauldron/transact.ts @@ -216,6 +216,10 @@ export function createInputAndOutput(opts: { remainingSats += P2PKH_INPUT_SIZE } + if (remainingSats > 0n) { + throw new Error('Insufficient BCH to fund the trade and transaction fees') + } + payouts.push({ type: PayoutAmountRuleType.CHANGE, locking_bytecode: lockingBytecode, From 18b1eb31c0b73ca110ff4f4ba65e4ebbf020e6d7 Mon Sep 17 00:00:00 2001 From: joemarct Date: Thu, 3 Sep 2026 17:39:55 +0800 Subject: [PATCH 8/8] fix(cauldron): guard insufficient tokens for sell trades, pass network to HD wallet --- src/wallet/cauldron/swap.ts | 6 +++++- src/wallet/cauldron/transact.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/wallet/cauldron/swap.ts b/src/wallet/cauldron/swap.ts index ad1dcec..fb8b2db 100644 --- a/src/wallet/cauldron/swap.ts +++ b/src/wallet/cauldron/swap.ts @@ -213,7 +213,11 @@ export async function buildSignedTradeTx(opts: { }) { const { bchWallet, mnemonic, derivationPath, quote } = opts - const hdWallet = new LibauthHDWallet(mnemonic, derivationPath) + const hdWallet = new LibauthHDWallet( + mnemonic, + derivationPath, + bchWallet.isChipnet ? 'chipnet' : 'mainnet' + ) const spendableCoins = await collectSpendableCoins({ bchWallet, hdWallet, diff --git a/src/wallet/cauldron/transact.ts b/src/wallet/cauldron/transact.ts index 4cfb6dc..ebc15bd 100644 --- a/src/wallet/cauldron/transact.ts +++ b/src/wallet/cauldron/transact.ts @@ -148,6 +148,10 @@ export function createInputAndOutput(opts: { satoshisToSupply -= spendableCoin.output.amount } + if (remainingTokens > 0n) { + throw new Error('Insufficient tokens to fund the trade') + } + // Excess tokens supplied → a token change output (counted in size calc) if (remainingTokens < 0n) { const changeTokenOutput = {