Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 22 additions & 11 deletions src/commands/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────
Expand All @@ -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)}`)
)
}
}
Expand Down
13 changes: 11 additions & 2 deletions src/commands/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down
28 changes: 21 additions & 7 deletions src/commands/send.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* CLI command: send <address> <amount>
* CLI command: send <address> <amount> [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
Expand All @@ -18,18 +19,19 @@ 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
.command('send')
.description('Send BCH to an address')
.argument('<address>', 'Recipient BCH address (CashAddr format)')
.argument('<amount>', 'Amount to send')
.option('--unit <unit>', 'Amount unit: bch or sats (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 ──────────────────────────────────────────────
Expand All @@ -48,10 +50,18 @@ export function registerSendCommand(program: Command): void {
process.exit(1)
}

if (unit === 'sats') {
let usdPrice: number | null = null
if (unit === 'sats' || unit === 'satoshis') {
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: Currency must be 'bch', 'sats'/'satoshis', or 'usd'.\n"))
process.exit(1)
}

Expand All @@ -74,7 +84,11 @@ 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')}${usdPrice !== null ? chalk.dim(` (≈ ${formatUsd(amountBch * usdPrice)})`) : ''} on ${chalk.cyan(network)}`)
if (usdPrice !== null) {
console.log(chalk.dim(` Rate: 1 BCH = ${formatUsd(usdPrice)}`))
}
console.log(chalk.dim(` To: ${address}`))
console.log(chalk.dim(` Change: ${changeAddress}`))
console.log()
Expand Down
6 changes: 3 additions & 3 deletions src/commands/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export function registerSwapCommand(program: Command): void {
.argument('<tokenId>', '64-char hex token category to swap')
.argument('<amount>', 'Token amount (in the token\'s units)')
.option(
'--direction <direction>',
'Swap direction: sell (token→BCH) or buy (BCH→token) (default: sell)',
'--action <action>',
'Swap action: sell (token→BCH) or buy (BCH→token) (default: sell)',
'sell'
)
.option(
Expand All @@ -70,7 +70,7 @@ export function registerSwapCommand(program: Command): void {
const isChipnet = false
const network = 'mainnet'
const direction: SwapDirection =
opts.direction === 'buy' ? 'buy' : 'sell'
opts.action === 'buy' ? 'buy' : 'sell'

const data = loadMnemonic()
if (!data) {
Expand Down
18 changes: 15 additions & 3 deletions src/commands/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.dim(` ≈ ${formatUsd(balance.balance * usdPerBch)}`)
)
}
} catch {
console.log(chalk.yellow(' Balance: (unable to fetch)'))
Expand Down
4 changes: 2 additions & 2 deletions src/utils/prices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
10 changes: 5 additions & 5 deletions src/utils/prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -116,10 +116,10 @@ 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`
}

Loading