From 0889d114bbbdffb807e7cf2de41e157086095690 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 21:23:36 +0800 Subject: [PATCH 01/12] wallet info: add BCH-USD conversion, hide Spendable - Fetch BCH-USD price via watchtower.cash asset-prices API - Display USD value below the BCH balance line - Remove Spendable balance line (causes more confusion than clarity) --- src/commands/wallet.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index 56a122c..d75ddb5 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -16,6 +16,7 @@ import { loadWallet, loadMnemonic, } from '../wallet/index.js' +import { getBchUsdPrice, formatUsd } from '../utils/prices.js' export function registerWalletCommands(program: Command): void { const wallet = program @@ -148,12 +149,23 @@ export function registerWalletCommands(program: Command): void { const addressSet = bchWallet.getAddressSetAt(0) console.log(` Address: ${addressSet.receiving}`) - // Fetch balance + // Fetch balance with USD conversion try { const balance = await bchWallet.getBalance() + + // Fetch USD price per BCH (non-critical — omit on failure) + let usdPerBch: number | null = null + try { + usdPerBch = await getBchUsdPrice(isChipnet) + } catch { + // Pricing unavailable — proceed without USD values + } + console.log(` Balance: ${balance.balance} BCH`) - if (balance.spendable !== balance.balance) { - console.log(chalk.dim(` Spendable: ${balance.spendable} BCH`)) + if (usdPerBch !== null) { + console.log( + chalk.green(` ≈ ${formatUsd(balance.balance * usdPerBch)}`) + ) } } catch { console.log(chalk.yellow(' Balance: (unable to fetch)')) From dd07eafeca4b7e62a8f1ff761a3899cf4c0bf081 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 21:35:49 +0800 Subject: [PATCH 02/12] history: show USD value at time of transaction using watchtower's usd_price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each history entry from Watchtower already includes the BCH-USD rate at that moment (usd_price), so we display the USD conversion inline: IN +0.00362899 BCH | ≈ /bin/zsh.93 --- src/commands/history.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/history.ts b/src/commands/history.ts index 16e6ac7..0a936ac 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -11,6 +11,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { formatUsd } from '../utils/prices.js' /** Convert BCH to satoshis (1 BCH = 100,000,000 sats) */ function bchToSats(bch: number): number { @@ -139,9 +140,17 @@ export function registerHistoryCommand(program: Command): void { ? `${bchToSats(tx.amount).toLocaleString('en-US')} sats` : `${tx.amount} BCH` - const amountColored = isIncoming + let usdSuffix = '' + if (!tokenId && typeof tx.usd_price === 'number' && tx.usd_price > 0 && tx.amount != null) { + const usdValue = tx.amount * tx.usd_price + if (usdValue > 0) { + usdSuffix = chalk.dim(` | ≈ ${formatUsd(usdValue)}`) + } + } + + const amountColored = (isIncoming ? chalk.green(`+${amount}`) - : chalk.red(`-${amount}`) + : chalk.red(`-${amount}`)) + usdSuffix const date = formatDate(tx.tx_timestamp || tx.date_created) From 3bebd8ffd741d094be5559b184e47e97cc120c79 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 21:46:37 +0800 Subject: [PATCH 03/12] send: add --unit usd option using watchtower BCH-USD rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paytaca send 5 --unit usd → converts $5 to BCH at current rate paytaca send 1000 --unit sats → converts 1000 sats to 0.00001 BCH paytaca send 0.01 → default BCH (unchanged) Displays the exchange rate when using USD. --- src/commands/send.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/commands/send.ts b/src/commands/send.ts index 4a61064..9bbbf9e 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -18,6 +18,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { Address } from 'watchtower-cash-js' import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { getBchUsdPrice, formatUsd } from '../utils/prices.js' export function registerSendCommand(program: Command): void { program @@ -25,7 +26,7 @@ export function registerSendCommand(program: Command): void { .description('Send BCH to an address') .argument('
', 'Recipient BCH address (CashAddr format)') .argument('', 'Amount to send') - .option('--unit ', 'Amount unit: bch or sats (default: bch)', 'bch') + .option('--unit ', 'Amount unit: bch, sats, or usd (default: bch)', 'bch') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') .action(async (address: string, amountStr: string, opts) => { const isChipnet = Boolean(opts.chipnet) @@ -48,10 +49,18 @@ export function registerSendCommand(program: Command): void { process.exit(1) } + let usdPrice: number | null = null if (unit === 'sats') { amountBch = amountBch / 1e8 + } else if (unit === 'usd') { + usdPrice = await getBchUsdPrice(isChipnet) + if (usdPrice === null) { + console.log(chalk.red('\nError: Unable to fetch current BCH-USD price.\n')) + process.exit(1) + } + amountBch = amountBch / usdPrice } else if (unit !== 'bch') { - console.log(chalk.red('\nError: Unit must be "bch" or "sats".\n')) + console.log(chalk.red('\nError: Unit must be "bch", "sats", or "usd".\n')) process.exit(1) } @@ -75,6 +84,10 @@ export function registerSendCommand(program: Command): void { const changeAddress = changeAddressSet.change console.log(`\n Sending ${chalk.bold(amountBch + ' BCH')} on ${chalk.cyan(network)}`) + if (usdPrice !== null) { + console.log(chalk.dim(` Rate: 1 BCH = ${formatUsd(usdPrice)}`)) + console.log(chalk.dim(` ≈ ${formatUsd(amountBch * usdPrice)}`)) + } console.log(chalk.dim(` To: ${address}`)) console.log(chalk.dim(` Change: ${changeAddress}`)) console.log() From 534ff40839fe531d5904790909e0bf2406d7ad03 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 21:53:03 +0800 Subject: [PATCH 04/12] send: round BCH amount to max 8 decimal places --- src/commands/send.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/send.ts b/src/commands/send.ts index 9bbbf9e..a71f62b 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -83,7 +83,8 @@ export function registerSendCommand(program: Command): void { const changeAddressSet = bchWallet.getAddressSetAt(0) const changeAddress = changeAddressSet.change - console.log(`\n Sending ${chalk.bold(amountBch + ' BCH')} on ${chalk.cyan(network)}`) + const bchFormatted = amountBch.toFixed(8).replace(/\.?0+$/, '') + console.log(`\n Sending ${chalk.bold(bchFormatted + ' BCH')} on ${chalk.cyan(network)}`) if (usdPrice !== null) { console.log(chalk.dim(` Rate: 1 BCH = ${formatUsd(usdPrice)}`)) console.log(chalk.dim(` ≈ ${formatUsd(amountBch * usdPrice)}`)) From 7f11ba02ab98b2daaddaac40a8aadf9b46d45cee Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 22:52:14 +0800 Subject: [PATCH 05/12] send: show USD conversion inline with amount --- src/commands/send.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/send.ts b/src/commands/send.ts index a71f62b..24e0a9b 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,7 +1,8 @@ /** - * CLI command: send
+ * CLI command: send
[currency] * * Sends BCH from the wallet to an external address. + * currency defaults to "bch"; also accepts "sats" / "satoshis" or "usd". * * The transaction flow is identical to paytaca-app: * 1. Load mnemonic + walletHash from keychain @@ -26,11 +27,11 @@ export function registerSendCommand(program: Command): void { .description('Send BCH to an address') .argument('
', 'Recipient BCH address (CashAddr format)') .argument('', 'Amount to send') - .option('--unit ', 'Amount unit: bch, sats, or usd (default: bch)', 'bch') + .argument('[currency]', 'Currency: bch (default), sats / satoshis, or usd', 'bch') .option('--chipnet', 'Use chipnet (testnet) instead of mainnet') - .action(async (address: string, amountStr: string, opts) => { + .action(async (address: string, amountStr: string, currency: string, opts) => { const isChipnet = Boolean(opts.chipnet) - const unit: string = opts.unit + const unit = currency.toLowerCase() const network = isChipnet ? 'chipnet' : 'mainnet' // ── Validate wallet ────────────────────────────────────────────── @@ -50,7 +51,7 @@ export function registerSendCommand(program: Command): void { } let usdPrice: number | null = null - if (unit === 'sats') { + if (unit === 'sats' || unit === 'satoshis') { amountBch = amountBch / 1e8 } else if (unit === 'usd') { usdPrice = await getBchUsdPrice(isChipnet) @@ -60,7 +61,7 @@ export function registerSendCommand(program: Command): void { } amountBch = amountBch / usdPrice } else if (unit !== 'bch') { - console.log(chalk.red('\nError: Unit must be "bch", "sats", or "usd".\n')) + console.log(chalk.red("\nError: Currency must be 'bch', 'sats'/'satoshis', or 'usd'.\n")) process.exit(1) } @@ -84,10 +85,9 @@ export function registerSendCommand(program: Command): void { const changeAddress = changeAddressSet.change const bchFormatted = amountBch.toFixed(8).replace(/\.?0+$/, '') - console.log(`\n Sending ${chalk.bold(bchFormatted + ' BCH')} on ${chalk.cyan(network)}`) + console.log(`\n Sending ${chalk.bold(bchFormatted + ' BCH')}${usdPrice !== null ? chalk.dim(` (≈ ${formatUsd(amountBch * usdPrice)})`) : ''} on ${chalk.cyan(network)}`) if (usdPrice !== null) { console.log(chalk.dim(` Rate: 1 BCH = ${formatUsd(usdPrice)}`)) - console.log(chalk.dim(` ≈ ${formatUsd(amountBch * usdPrice)}`)) } console.log(chalk.dim(` To: ${address}`)) console.log(chalk.dim(` Change: ${changeAddress}`)) From 7a65f89f4b0ddda1639c22f95428ae05441f195e Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 23:09:47 +0800 Subject: [PATCH 06/12] balance: show fiat conversion for BCH and token balances --- src/commands/balance.ts | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/commands/balance.ts b/src/commands/balance.ts index c5e96fc..5022275 100644 --- a/src/commands/balance.ts +++ b/src/commands/balance.ts @@ -11,6 +11,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { loadWallet, loadMnemonic } from '../wallet/index.js' +import { getBchUsdPrice, formatUsd, getUsdPerToken, tokenAmountToUsd } from '../utils/prices.js' /** Convert BCH to satoshis (1 BCH = 100,000,000 sats) */ function bchToSats(bch: number): number { @@ -83,14 +84,22 @@ export function registerBalanceCommand(program: Command): void { const displayBalance = decimals > 0 ? (result.balance / 10 ** decimals) : result.balance - const displaySpendable = decimals > 0 - ? (result.spendable / 10 ** decimals) - : result.spendable const unit = tokenSymbol || 'tokens' + let usdPerToken: number | undefined + try { + const p = await getUsdPerToken(tokenId, isChipnet) + if (p !== null) usdPerToken = p + } catch { + // Pricing unavailable — show token only + } + console.log(` Balance: ${displayBalance} ${unit}`) - if (result.spendable !== result.balance) { - console.log(chalk.dim(` Spendable: ${displaySpendable} ${unit}`)) + if (usdPerToken !== undefined) { + const usdValue = tokenAmountToUsd(result.balance, decimals, usdPerToken) + console.log( + chalk.dim(` ≈ ${formatUsd(usdValue)}`) + ) } } else { // ── BCH balance ────────────────────────────────────────────── @@ -109,14 +118,16 @@ export function registerBalanceCommand(program: Command): void { ) } } else { + let usdPerBch: number | null = null + try { + usdPerBch = await getBchUsdPrice(isChipnet) + } catch { + // USD price unavailable — show BCH only + } console.log(` Balance: ${result.balance} BCH`) - console.log( - chalk.dim(` ${formatSats(balanceSats)} sats`) - ) - if (result.spendable !== result.balance) { - console.log(` Spendable: ${result.spendable} BCH`) + if (usdPerBch !== null) { console.log( - chalk.dim(` ${formatSats(spendableSats)} sats`) + chalk.dim(` ≈ ${formatUsd(result.balance * usdPerBch)}`) ) } } From 34a1920230719c3dfe75112731fa653663d6eeca Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 23:39:01 +0800 Subject: [PATCH 07/12] swap: rename --direction to --action; bump to v0.5.1 --- package.json | 2 +- src/commands/swap.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c1ee917..d1b15d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paytaca-cli", - "version": "0.5.0", + "version": "0.5.1", "description": "Command-line interface for the Paytaca Bitcoin Cash wallet", "type": "module", "main": "dist/index.js", diff --git a/src/commands/swap.ts b/src/commands/swap.ts index e8ad8b7..beb338f 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -50,8 +50,8 @@ export function registerSwapCommand(program: Command): void { .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)', + '--action ', + 'Swap action: sell (token→BCH) or buy (BCH→token) (default: sell)', 'sell' ) .option( @@ -69,8 +69,8 @@ export function registerSwapCommand(program: Command): void { } const isChipnet = false const network = 'mainnet' - const direction: SwapDirection = - opts.direction === 'buy' ? 'buy' : 'sell' +const direction: SwapDirection = + opts.action === 'buy' ? 'buy' : 'sell' const data = loadMnemonic() if (!data) { From 8a6fe210796dc52c639f5316e7494d6c4ebc3245 Mon Sep 17 00:00:00 2001 From: joemarct Date: Mon, 7 Sep 2026 23:39:01 +0800 Subject: [PATCH 08/12] prices: format fiat as 'N USD' instead of '$N' --- src/utils/prices.test.ts | 4 ++-- src/utils/prices.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/utils/prices.test.ts b/src/utils/prices.test.ts index 5573baa..c327c6b 100644 --- a/src/utils/prices.test.ts +++ b/src/utils/prices.test.ts @@ -34,8 +34,8 @@ describe('tokenAmountToUsd', () => { }) describe('formatUsd', () => { - it('formats as USD currency', () => { - expect(formatUsd(2.007)).toBe('$2.01') + it('formats as USD', () => { + expect(formatUsd(2.007)).toBe('2.01 USD') }) it('returns an em dash for non-finite input', () => { diff --git a/src/utils/prices.ts b/src/utils/prices.ts index 2bb468a..a73fcc0 100644 --- a/src/utils/prices.ts +++ b/src/utils/prices.ts @@ -116,10 +116,9 @@ function priceInUsd(prices: AssetPrice[]): number | 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', + const formatted = usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }) + return `${formatted} USD` } \ No newline at end of file From cd23e588b9e9a90f29a5917783d7d62383ff687d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 7 Sep 2026 15:47:19 +0000 Subject: [PATCH 09/12] Build/tests pass; approve with minor fixes. Co-authored-by: joemarct --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 90bf8d4..6e0c207 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paytaca-cli", - "version": "0.4.1", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paytaca-cli", - "version": "0.4.1", + "version": "0.5.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", From 37999ac643292cc9e2074383c6d51b97960965a9 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 8 Sep 2026 00:17:56 +0800 Subject: [PATCH 10/12] fix review nits: swap indentation, wallet dim fiat, prices newline --- src/commands/swap.ts | 4 ++-- src/commands/wallet.ts | 2 +- src/utils/prices.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/swap.ts b/src/commands/swap.ts index beb338f..767aa42 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -69,8 +69,8 @@ export function registerSwapCommand(program: Command): void { } const isChipnet = false const network = 'mainnet' -const direction: SwapDirection = - opts.action === 'buy' ? 'buy' : 'sell' + const direction: SwapDirection = + opts.action === 'buy' ? 'buy' : 'sell' const data = loadMnemonic() if (!data) { diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index d75ddb5..336a578 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -164,7 +164,7 @@ export function registerWalletCommands(program: Command): void { console.log(` Balance: ${balance.balance} BCH`) if (usdPerBch !== null) { console.log( - chalk.green(` ≈ ${formatUsd(balance.balance * usdPerBch)}`) + chalk.dim(` ≈ ${formatUsd(balance.balance * usdPerBch)}`) ) } } catch { diff --git a/src/utils/prices.ts b/src/utils/prices.ts index a73fcc0..f8225f1 100644 --- a/src/utils/prices.ts +++ b/src/utils/prices.ts @@ -121,4 +121,4 @@ export function formatUsd(usd: number): string { maximumFractionDigits: 2, }) return `${formatted} USD` -} \ No newline at end of file +} From 0c1219d6c5aa3eba3a3ffc26b62fb9b18e11aa20 Mon Sep 17 00:00:00 2001 From: joemarct Date: Tue, 8 Sep 2026 00:17:56 +0800 Subject: [PATCH 11/12] ci: pass TOKEN to opencode action to skip collaborator permission check --- .github/workflows/opencode.yml | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index d81d358..28e9fc4 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -26,32 +26,28 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 with: - persist-credentials: true + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - - name: Configure git + - name: Configure git identity and auth run: | - git config user.name "opencode-agent[bot]" - git config user.email "opencode-agent[bot]@users.noreply.github.com" - - - name: Get latest opencode version - id: version - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - echo "version=$(gh release view --repo anomalyco/opencode --json tagName --jq .tagName | sed 's/^v//')" >> "$GITHUB_OUTPUT" + git config user.name "opencode[bot]" + git config user.email "opencode[bot]@users.noreply.github.com" + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git - name: Run opencode uses: anomalyco/opencode/github@latest env: - VERSION: ${{ steps.version.outputs.version }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + # The action reads TOKEN (not GITHUB_TOKEN) for API calls and to + # skip the collaborator-permission assertion — without it the check + # fails for github-actions[bot], which is never a collaborator. + TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: opencode/kimi-k2.6 use_github_token: true From e9afc2727e3865bb2a7b1df2bfa6d9cb08d17cdb Mon Sep 17 00:00:00 2001 From: "opencode[bot]" Date: Mon, 7 Sep 2026 16:22:01 +0000 Subject: [PATCH 12/12] Build passes, 88 tests pass, approved. Co-authored-by: joemarct --- src/utils/prices.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/prices.ts b/src/utils/prices.ts index f8225f1..b08bc87 100644 --- a/src/utils/prices.ts +++ b/src/utils/prices.ts @@ -105,7 +105,7 @@ 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 + 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 @@ -122,3 +122,4 @@ export function formatUsd(usd: number): string { }) return `${formatted} USD` } +