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
16 changes: 7 additions & 9 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ on:
jobs:
opencode:
if: |
github.event_name == 'pull_request' ||
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
!endsWith(github.actor, '[bot]') && (
github.event_name == 'pull_request' ||
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
)
runs-on: ubuntu-latest
permissions:
id-token: write
Expand Down Expand Up @@ -43,10 +45,6 @@ jobs:
uses: anomalyco/opencode/github@latest
env:
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
Expand Down
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.1",
"version": "0.5.2",
"description": "Command-line interface for the Paytaca Bitcoin Cash wallet",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function registerSwapCommand(program: Command): void {
: BigInt(Math.round(parsedAmount * 10 ** decimals))

// Show quote first
const quote = await estimateSwap({ tokenId, direction, amount })
const quote = await estimateSwap({ tokenId, direction, amount, isChipnet })
console.log(chalk.cyan(' Quote:'))
for (const line of formatQuote(quote).split('\n')) {
console.log(` ${line}`)
Expand Down
47 changes: 47 additions & 0 deletions src/wallet/cauldron/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* and pool-tracker.ts. Uses the global fetch API (Node 20+) instead of axios.
*/

import { getWatchtowerApiUrl } from '../../utils/network.js'

const CAULDRON_INDEXER_BASE_URL = 'https://indexer.riften.net'

/**
Expand Down Expand Up @@ -112,3 +114,48 @@ export async function fetchTokenData(
)
return Array.isArray(data) ? data[0] : undefined
}

/**
* Platform fee config served by watchtower.cash
* (GET /api/cauldron-fee/). A null address means the fee feature is
* disabled — clients degrade silently (no fee charged).
*/
export interface PlatformFeeConfig {
address: string | null
/** Fee rate in basis points (30 = 0.3%). */
feeRateBps: number
/** Fee cap in USD, applied against the live BCH price. */
maxUsd: number
}

/**
* Fetch the cauldron fee config from watchtower.cash.
*/
export async function fetchCauldronFee(
isChipnet: boolean = false
): Promise<PlatformFeeConfig> {
const baseUrl = getWatchtowerApiUrl(isChipnet)
let response: Response
try {
response = await fetch(`${baseUrl}/cauldron-fee/`)
} catch (err: any) {
throw new CauldronApiError(`watchtower unreachable: ${err?.message || err}`)
}
if (!response.ok) {
throw new CauldronApiError(
`cauldron-fee request failed (${response.status} ${response.statusText})`,
response.status
)
}
const data = await response.json()
const feeRateBps = Number(data?.fee_rate_bps ?? 30)
const maxUsd = Number(data?.max_usd ?? 1)
return {
address:
typeof data?.address === 'string' && data.address !== ''
? data.address
: null,
feeRateBps: Number.isFinite(feeRateBps) && feeRateBps >= 0 ? feeRateBps : 30,
maxUsd: Number.isFinite(maxUsd) && maxUsd >= 0 ? maxUsd : 1,
}
}
209 changes: 209 additions & 0 deletions src/wallet/cauldron/swap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
import type { TradeResult } from '@cashlab/cauldron'
import { computePlatformFee, formatQuote, PLATFORM_FEE_DUST_LIMIT } from './swap.js'
import { fetchCauldronFee } from './api.js'

const FEE_ADDRESS = 'bitcoincash:qp9szr5k40z88m5jhmpytqt8pq5zfz0yvcs7q5lef7'

function fakeTradeResult(summary: {
supply: bigint
demand: bigint
trade_fee: bigint
}): TradeResult {
return {
entries: [],
summary: { ...summary, rate: 0n },
} as unknown as TradeResult
}

const feeConfig = {
address: FEE_ADDRESS,
feeRateBps: 30,
maxUsd: 1,
}

describe('computePlatformFee', () => {
it('charges 0.3% of (demand - trade_fee) on sells', () => {
// 999_000 sats * 30/10000 = 2_997 sats; cap at $500/BCH = 200_000 sats
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 500_000n, demand: 1_000_000n, trade_fee: 1_000n }),
isBuyingToken: false,
feeConfig,
bchUsdPrice: 500,
})
expect(fee).toEqual({ to: FEE_ADDRESS, amount: 2_997n })
})

it('charges 0.3% of (supply - trade_fee) on buys', () => {
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 2_000_000n, demand: 1_000_000n, trade_fee: 2_000n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: 500,
})
// 1_998_000 * 30/10000 = 5_994
expect(fee?.amount).toBe(5_994n)
})

it('caps the fee at max_usd worth of BCH at the current price', () => {
// 4 BCH trade -> 0.3% = 1_200_000 sats; $1 at $100/BCH = 1_000_000 sats
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 400_000_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: 100,
})
expect(fee?.amount).toBe(1_000_000n)
})

it('does not cap when 0.3% is below the cap', () => {
// 1 BCH trade -> 0.3% = 300_000 sats < cap 1_000_000 sats
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 100_000_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: 100,
})
expect(fee?.amount).toBe(300_000n)
})

it('does not charge below the dust limit', () => {
// 100_000 sats * 1bp/10000 = 10 sats < 546 -> no fee at all
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 100_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig: { ...feeConfig, feeRateBps: 1 },
bchUsdPrice: 100,
})
expect(fee).toBeNull()
})

it('returns null when no fee address is configured', () => {
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 100_000_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig: { ...feeConfig, address: null },
bchUsdPrice: 100,
})
expect(fee).toBeNull()
})

it('returns null when the BCH price is unavailable (no fee on price failure)', () => {
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 100_000_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: null,
})
expect(fee).toBeNull()
})

it('returns null for non-positive BCH prices', () => {
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 100_000_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: 0,
})
expect(fee).toBeNull()
})

it('returns null when a tiny price makes the cap itself dust', () => {
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 100_000_000n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: 1_000_000_000,
})
// cap = floor(1/1e9 * 1e8) = 0 sats -> no fee
expect(fee).toBeNull()
})

it('treats a zero or negative trade size as no fee', () => {
const fee = computePlatformFee({
tradeResult: fakeTradeResult({ supply: 0n, demand: 1n, trade_fee: 0n }),
isBuyingToken: true,
feeConfig,
bchUsdPrice: 100,
})
expect(fee).toBeNull()
})

it('exposes the dust limit constant as 546 sats', () => {
expect(PLATFORM_FEE_DUST_LIMIT).toBe(546n)
})
})

describe('formatQuote', () => {
const tokenData = {
token_id: 'abc',
display_name: 'Test Token',
display_symbol: 'TT',
price_now: 1,
price_now_usd: 1,
tvl_sats: 1,
bcmr: {
name: 'Test Token',
description: '',
token: { category: 'abc', decimals: 2, symbol: 'TT' },
},
} as any

function quote(overrides: any = {}) {
return {
tokenId: 'abc',
tokenData,
direction: 'sell',
isBuyingToken: false,
pools: [],
tradeResult: fakeTradeResult({ supply: 500_000n, demand: 1_000_000n, trade_fee: 1_000n }),
rate: '2.0',
tokenAmount: 500_000n,
bchAmount: 1_000_000n,
tradeFee: 1_000n,
...overrides,
}
}

it('lists the platform fee line when a fee is applied', () => {
const text = formatQuote(quote({
platformFee: { to: FEE_ADDRESS, amount: 2_997n },
}))
expect(text).toContain('Platform fee: ~0.00002997 BCH')
expect(text).toContain('Trade fee: ~0.00001000 BCH')
})

it('omits the line entirely when no fee', () => {
const noFee = formatQuote(quote())
expect(noFee).not.toContain('Platform fee')
})
})

describe('fetchCauldronFee', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('parses the watchtower response and defaults missing fields', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ address: FEE_ADDRESS, fee_rate_bps: 30, max_usd: '1.00' }), { status: 200 })
))
const config = await fetchCauldronFee()
expect(config).toEqual({ address: FEE_ADDRESS, feeRateBps: 30, maxUsd: 1 })
})

it('maps an empty address to null (feature disabled)', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ address: '' }), { status: 200 })
))
const config = await fetchCauldronFee()
expect(config.address).toBeNull()
expect(config.feeRateBps).toBe(30)
expect(config.maxUsd).toBe(1)
})

it('throws a CauldronApiError on http failure', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 500 })))
await expect(fetchCauldronFee()).rejects.toThrow('cauldron-fee request failed (500')
})
})
Loading
Loading