From 04cfc1b23471eb5375d9b0f182614a9297852a7e Mon Sep 17 00:00:00 2001 From: joemarct Date: Wed, 9 Sep 2026 00:51:24 +0800 Subject: [PATCH 1/5] feat: apply Paytaca platform fee on Cauldron swaps (0.3%, 1 USD cap) --- src/wallet/cauldron/api.ts | 45 +++++++ src/wallet/cauldron/swap.test.ts | 216 +++++++++++++++++++++++++++++++ src/wallet/cauldron/swap.ts | 99 +++++++++++++- 3 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 src/wallet/cauldron/swap.test.ts diff --git a/src/wallet/cauldron/api.ts b/src/wallet/cauldron/api.ts index 5dc5a02..f1c68bd 100644 --- a/src/wallet/cauldron/api.ts +++ b/src/wallet/cauldron/api.ts @@ -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' /** @@ -112,3 +114,46 @@ 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 { + 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() + return { + address: + typeof data?.address === 'string' && data.address !== '' + ? data.address + : null, + feeRateBps: Number(data?.fee_rate_bps ?? 30), + maxUsd: Number(data?.max_usd ?? 1), + } +} diff --git a/src/wallet/cauldron/swap.test.ts b/src/wallet/cauldron/swap.test.ts new file mode 100644 index 0000000..9d54cff --- /dev/null +++ b/src/wallet/cauldron/swap.test.ts @@ -0,0 +1,216 @@ +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 }, + platformFeeRateBps: 30, + })) + expect(text).toContain('Platform fee (0.3%): ~0.00002997 BCH') + expect(text).toContain('Trade fee: ~0.00001000 BCH') + }) + + it('marks capped fees and omits the line entirely when no fee', () => { + const capped = formatQuote(quote({ + platformFee: { to: FEE_ADDRESS, amount: 2_000n }, + platformFeeRateBps: 30, + })) + expect(capped).toContain('Platform fee (0.3%, capped)') + + 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') + }) +}) diff --git a/src/wallet/cauldron/swap.ts b/src/wallet/cauldron/swap.ts index fb8b2db..4d9cc85 100644 --- a/src/wallet/cauldron/swap.ts +++ b/src/wallet/cauldron/swap.ts @@ -10,9 +10,12 @@ 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 { getBchUsdPrice } from '../../utils/prices.js' import { + fetchCauldronFee, fetchPoolsForToken, fetchTokenData, + type PlatformFeeConfig, type CauldronTokenData, } from './api.js' import { apiPoolToMicroPool, microPoolToPoolV0, parseRate } from './pools.js' @@ -20,11 +23,15 @@ import { attemptTrade, createInputAndOutput, watchtowerUtxosToSpendableCoins, + type PlatformFee, type WatchtowerUtxo, } from './transact.js' export type SwapDirection = 'buy' | 'sell' +/** Platform fees below this are not charged at all (P2PKH dust threshold). */ +export const PLATFORM_FEE_DUST_LIMIT = 546n + export interface SwapQuote { tokenId: string tokenData: CauldronTokenData @@ -40,6 +47,10 @@ export interface SwapQuote { bchAmount: bigint /** Trade fee in satoshis. */ tradeFee: bigint + /** Paytaca platform fee charged on top of the trade (absent = no fee). */ + platformFee?: PlatformFee + /** Platform fee rate in basis points, for display (30 = 0.3%). */ + platformFeeRateBps?: number } export interface EstimateSwapOpts { @@ -65,6 +76,45 @@ export interface SwapResult { quote?: SwapQuote } +/** + * Compute the Paytaca platform fee for a trade (mirrors paytaca-app + * trade.vue: 0.3% of the trade size, capped at max_usd worth of BCH). + * + * The fee is charged only when ALL of these hold: + * - the fee address is configured (feature enabled) + * - the live BCH price is fetchable (price failure -> no fee) + * - the computed fee is >= PLATFORM_FEE_DUST_LIMIT (below -> no fee) + */ +export function computePlatformFee(opts: { + tradeResult: TradeResult + isBuyingToken: boolean + feeConfig: PlatformFeeConfig + bchUsdPrice: number | null +}): PlatformFee | null { + const { tradeResult, isBuyingToken, feeConfig, bchUsdPrice } = opts + + if (!feeConfig.address) return null + if (bchUsdPrice == null || !isFinite(bchUsdPrice) || bchUsdPrice <= 0) { + return null + } + + const summary = tradeResult.summary + // BCH side of the trade, excluding the DEX's own trade fee. + const tradeSizeSats = (isBuyingToken ? summary.supply : summary.demand) - summary.trade_fee + if (tradeSizeSats <= 0n) return null + + const rateBps = BigInt(Math.max(0, Math.round(feeConfig.feeRateBps))) + let feeSats = tradeSizeSats * rateBps / 10000n + + // Cap the fee at max_usd worth of BCH at the current price. + const capSats = BigInt(Math.floor((feeConfig.maxUsd / bchUsdPrice) * 1e8)) + if (capSats <= 0n) return null + if (feeSats > capSats) feeSats = capSats + + if (feeSats < PLATFORM_FEE_DUST_LIMIT) return null + return { to: feeConfig.address, amount: feeSats } +} + /** * Estimate a swap: fetch pools + token data and compute the best-rate trade. */ @@ -73,9 +123,11 @@ export async function estimateSwap( ): Promise { const { tokenId, direction, amount } = opts - const [tokenData, apiPools] = await Promise.all([ + const [tokenData, apiPools, feeConfig] = await Promise.all([ fetchTokenData(tokenId), fetchPoolsForToken(tokenId), + // Fee config failure -> feature off for this swap (degrade silently) + fetchCauldronFee().catch(() => null), ]) if (!tokenData) { throw new Error(`No cauldron token data found for ${tokenId}`) @@ -104,6 +156,18 @@ export async function estimateSwap( : tradeResult.summary.demand const decimals = tokenData.bcmr.token.decimals + // Platform fee: only when enabled + live price available (no fee otherwise) + let platformFee: PlatformFee | null = null + if (feeConfig) { + const bchUsdPrice = await getBchUsdPrice().catch(() => null) + platformFee = computePlatformFee({ + tradeResult, + isBuyingToken, + feeConfig, + bchUsdPrice, + }) + } + return { tokenId, tokenData, @@ -115,6 +179,9 @@ export async function estimateSwap( tokenAmount, bchAmount, tradeFee: tradeResult.summary.trade_fee, + ...(platformFee + ? { platformFee, platformFeeRateBps: feeConfig!.feeRateBps } + : {}), } } @@ -130,21 +197,42 @@ export function formatQuote(quote: SwapQuote): string { const bchFormatted = (Number(bchAmount) / 10 ** 8).toFixed(8) const feeFormatted = (Number(tradeFee) / 10 ** 8).toFixed(8) + let platformFeeLine: string | null = null + if (quote.platformFee) { + const feeFormattedPlatform = (Number(quote.platformFee.amount) / 10 ** 8).toFixed(8) + const rateBps = quote.platformFeeRateBps ?? 30 + const summary = quote.tradeResult.summary + const tradeSizeSats = + (quote.isBuyingToken ? summary.supply : summary.demand) - summary.trade_fee + const rawSats = tradeSizeSats * BigInt(Math.max(0, Math.round(rateBps))) / 10000n + const capped = rawSats > quote.platformFee.amount + const percentValue = rateBps / 100 + const percentLabel = Number.isInteger(percentValue * 10) + ? String(percentValue) + : percentValue.toFixed(2) + const rateLabel = capped ? `${percentLabel}%, capped` : `${percentLabel}%` + platformFeeLine = `Platform fee (${rateLabel}): ~${feeFormattedPlatform} BCH` + } + if (direction === 'sell') { // Rate semantics (matches paytaca-app): '1 {demandSymbol} ≈ {rate} {supplySymbol}'. // Selling tokens → demand is BCH, rate is tokens-per-BCH. - return [ + const lines = [ `Sell ${tokenFormatted} ${tokenSymbol} for ${bchFormatted} BCH`, `Rate: 1 BCH ≈ ${rate} ${tokenSymbol}`, `Trade fee: ~${feeFormatted} BCH`, - ].join('\n') + ] + if (platformFeeLine) lines.push(platformFeeLine) + return lines.join('\n') } // Buying tokens → demand is the token, rate is BCH-per-token. - return [ + const lines = [ `Buy ${tokenFormatted} ${tokenSymbol} for ${bchFormatted} BCH`, `Rate: 1 ${tokenSymbol} ≈ ${rate} BCH`, `Trade fee: ~${feeFormatted} BCH`, - ].join('\n') + ] + if (platformFeeLine) lines.push(platformFeeLine) + return lines.join('\n') } /** @@ -228,6 +316,7 @@ export async function buildSignedTradeTx(opts: { const { inputCoins, payouts } = createInputAndOutput({ tradeResult: quote.tradeResult, spendableCoins, + platformFee: quote.platformFee, }) const exlab = new ExchangeLab() From 0909fd5fd0e9e7ee09128b31b87803f5d697ec88 Mon Sep 17 00:00:00 2001 From: joemarct Date: Wed, 9 Sep 2026 01:16:09 +0800 Subject: [PATCH 2/5] chore: bump version to 0.5.2 and drop percentage from platform fee display --- package.json | 2 +- src/wallet/cauldron/swap.test.ts | 11 ++--------- src/wallet/cauldron/swap.ts | 19 ++----------------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index d1b15d0..021d8ca 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/wallet/cauldron/swap.test.ts b/src/wallet/cauldron/swap.test.ts index 9d54cff..5e1d01b 100644 --- a/src/wallet/cauldron/swap.test.ts +++ b/src/wallet/cauldron/swap.test.ts @@ -168,19 +168,12 @@ describe('formatQuote', () => { it('lists the platform fee line when a fee is applied', () => { const text = formatQuote(quote({ platformFee: { to: FEE_ADDRESS, amount: 2_997n }, - platformFeeRateBps: 30, })) - expect(text).toContain('Platform fee (0.3%): ~0.00002997 BCH') + expect(text).toContain('Platform fee: ~0.00002997 BCH') expect(text).toContain('Trade fee: ~0.00001000 BCH') }) - it('marks capped fees and omits the line entirely when no fee', () => { - const capped = formatQuote(quote({ - platformFee: { to: FEE_ADDRESS, amount: 2_000n }, - platformFeeRateBps: 30, - })) - expect(capped).toContain('Platform fee (0.3%, capped)') - + it('omits the line entirely when no fee', () => { const noFee = formatQuote(quote()) expect(noFee).not.toContain('Platform fee') }) diff --git a/src/wallet/cauldron/swap.ts b/src/wallet/cauldron/swap.ts index 4d9cc85..de82500 100644 --- a/src/wallet/cauldron/swap.ts +++ b/src/wallet/cauldron/swap.ts @@ -49,8 +49,6 @@ export interface SwapQuote { tradeFee: bigint /** Paytaca platform fee charged on top of the trade (absent = no fee). */ platformFee?: PlatformFee - /** Platform fee rate in basis points, for display (30 = 0.3%). */ - platformFeeRateBps?: number } export interface EstimateSwapOpts { @@ -179,9 +177,7 @@ export async function estimateSwap( tokenAmount, bchAmount, tradeFee: tradeResult.summary.trade_fee, - ...(platformFee - ? { platformFee, platformFeeRateBps: feeConfig!.feeRateBps } - : {}), + ...(platformFee ? { platformFee } : {}), } } @@ -200,18 +196,7 @@ export function formatQuote(quote: SwapQuote): string { let platformFeeLine: string | null = null if (quote.platformFee) { const feeFormattedPlatform = (Number(quote.platformFee.amount) / 10 ** 8).toFixed(8) - const rateBps = quote.platformFeeRateBps ?? 30 - const summary = quote.tradeResult.summary - const tradeSizeSats = - (quote.isBuyingToken ? summary.supply : summary.demand) - summary.trade_fee - const rawSats = tradeSizeSats * BigInt(Math.max(0, Math.round(rateBps))) / 10000n - const capped = rawSats > quote.platformFee.amount - const percentValue = rateBps / 100 - const percentLabel = Number.isInteger(percentValue * 10) - ? String(percentValue) - : percentValue.toFixed(2) - const rateLabel = capped ? `${percentLabel}%, capped` : `${percentLabel}%` - platformFeeLine = `Platform fee (${rateLabel}): ~${feeFormattedPlatform} BCH` + platformFeeLine = `Platform fee: ~${feeFormattedPlatform} BCH` } if (direction === 'sell') { From 47cc0687fcf4a702aceacddd93c9ed25817c7397 Mon Sep 17 00:00:00 2001 From: "opencode[bot]" Date: Tue, 8 Sep 2026 17:25:11 +0000 Subject: [PATCH 3/5] Fixed double-count bug and NaN crash. Co-authored-by: joemarct --- package-lock.json | 4 ++-- src/wallet/cauldron/api.ts | 6 ++++-- src/wallet/cauldron/transact.ts | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e0c207..6616091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paytaca-cli", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paytaca-cli", - "version": "0.5.1", + "version": "0.5.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitauth/libauth": "2.0.0-alpha.8", diff --git a/src/wallet/cauldron/api.ts b/src/wallet/cauldron/api.ts index f1c68bd..34fc14c 100644 --- a/src/wallet/cauldron/api.ts +++ b/src/wallet/cauldron/api.ts @@ -148,12 +148,14 @@ export async function fetchCauldronFee( ) } 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(data?.fee_rate_bps ?? 30), - maxUsd: Number(data?.max_usd ?? 1), + feeRateBps: Number.isFinite(feeRateBps) && feeRateBps >= 0 ? feeRateBps : 30, + maxUsd: Number.isFinite(maxUsd) && maxUsd >= 0 ? maxUsd : 1, } } diff --git a/src/wallet/cauldron/transact.ts b/src/wallet/cauldron/transact.ts index ebc15bd..dbe0aec 100644 --- a/src/wallet/cauldron/transact.ts +++ b/src/wallet/cauldron/transact.ts @@ -198,7 +198,6 @@ export function createInputAndOutput(opts: { locking_bytecode: decoded.bytecode, amount: platformFee.amount, }) - satoshisToSupply += platformFee.amount + BigInt(getOutputSize(platformFee)) } // Base tx overhead (version + locktime) and varint prefixes for inputs/outputs From 7c8f62af51f315afafbefab473e55323f66d9fcc Mon Sep 17 00:00:00 2001 From: joemarct Date: Wed, 9 Sep 2026 16:56:24 +0800 Subject: [PATCH 4/5] ci: skip opencode workflow for bot-opened PRs to avoid permission check failure --- .github/workflows/opencode.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 28e9fc4..6b597b4 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -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 @@ -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 From 1e30b027a7f5e815a9d1ff594d90d8d126e91e04 Mon Sep 17 00:00:00 2001 From: joemarct Date: Wed, 9 Sep 2026 17:33:01 +0800 Subject: [PATCH 5/5] fix: bigint-safe fee cap, isChipnet propagation, single fee-address validation --- src/commands/swap.ts | 2 +- src/wallet/cauldron/swap.ts | 15 +++++++++++---- src/wallet/cauldron/transact.ts | 24 ++++++++++++++++-------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/commands/swap.ts b/src/commands/swap.ts index 767aa42..020aebc 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -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}`) diff --git a/src/wallet/cauldron/swap.ts b/src/wallet/cauldron/swap.ts index de82500..7b458b2 100644 --- a/src/wallet/cauldron/swap.ts +++ b/src/wallet/cauldron/swap.ts @@ -58,6 +58,8 @@ export interface EstimateSwapOpts { direction: SwapDirection /** Token amount in base units (sell: amount supplied; buy: amount received). */ amount: bigint + /** Use chipnet watchtower endpoints for fee config and BCH price. */ + isChipnet?: boolean } export interface ExecuteSwapOpts extends EstimateSwapOpts { @@ -104,8 +106,12 @@ export function computePlatformFee(opts: { const rateBps = BigInt(Math.max(0, Math.round(feeConfig.feeRateBps))) let feeSats = tradeSizeSats * rateBps / 10000n - // Cap the fee at max_usd worth of BCH at the current price. - const capSats = BigInt(Math.floor((feeConfig.maxUsd / bchUsdPrice) * 1e8)) + // Cap the fee at max_usd worth of BCH at the current price. Computed in + // bigint space so large caps / tiny prices cannot lose integer precision. + const maxUsdScaled = BigInt(Math.round(feeConfig.maxUsd * 1e8)) + const priceScaled = BigInt(Math.round(bchUsdPrice * 1e8)) + if (maxUsdScaled <= 0n || priceScaled <= 0n) return null + const capSats = maxUsdScaled * 100_000_000n / priceScaled if (capSats <= 0n) return null if (feeSats > capSats) feeSats = capSats @@ -120,12 +126,13 @@ export async function estimateSwap( opts: EstimateSwapOpts ): Promise { const { tokenId, direction, amount } = opts + const isChipnet = opts.isChipnet ?? false const [tokenData, apiPools, feeConfig] = await Promise.all([ fetchTokenData(tokenId), fetchPoolsForToken(tokenId), // Fee config failure -> feature off for this swap (degrade silently) - fetchCauldronFee().catch(() => null), + fetchCauldronFee(isChipnet).catch(() => null), ]) if (!tokenData) { throw new Error(`No cauldron token data found for ${tokenId}`) @@ -157,7 +164,7 @@ export async function estimateSwap( // Platform fee: only when enabled + live price available (no fee otherwise) let platformFee: PlatformFee | null = null if (feeConfig) { - const bchUsdPrice = await getBchUsdPrice().catch(() => null) + const bchUsdPrice = await getBchUsdPrice(isChipnet).catch(() => null) platformFee = computePlatformFee({ tradeResult, isBuyingToken, diff --git a/src/wallet/cauldron/transact.ts b/src/wallet/cauldron/transact.ts index dbe0aec..d9760ec 100644 --- a/src/wallet/cauldron/transact.ts +++ b/src/wallet/cauldron/transact.ts @@ -121,6 +121,16 @@ export function createInputAndOutput(opts: { const isBuyingToken = tradeResult.entries[0]!.supply_token_id === NATIVE_BCH_TOKEN_ID + // Decode the fee address once; reused for both sizing and the payout rule. + let platformFeeBytecode: Uint8Array | undefined + if (platformFee) { + const decoded = cashAddressToLockingBytecode(platformFee.to) + if (!decoded || typeof decoded === 'string' || !decoded.bytecode) { + throw new Error(`Invalid platform fee address: ${platformFee.to}`) + } + platformFeeBytecode = decoded.bytecode + } + const entriesSizes = getEntriesSize(tradeResult) const totalPoolTxFee = BigInt(entriesSizes.inputFees + entriesSizes.outputFees) @@ -128,9 +138,11 @@ export function createInputAndOutput(opts: { let satoshisToSupply = isBuyingToken ? tradeResult.summary.supply : 0n satoshisToSupply += totalPoolTxFee - if (platformFee) { + if (platformFee && platformFeeBytecode) { satoshisToSupply += platformFee.amount - satoshisToSupply += BigInt(getOutputSize(platformFee)) + satoshisToSupply += BigInt( + getOutputSize({ to: platformFeeBytecode, amount: platformFee.amount }) + ) } const inputCoins: SpendableCoin[] = [] @@ -188,14 +200,10 @@ export function createInputAndOutput(opts: { 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}`) - } + if (platformFee && platformFeeBytecode) { payouts.push({ type: PayoutAmountRuleType.FIXED, - locking_bytecode: decoded.bytecode, + locking_bytecode: platformFeeBytecode, amount: platformFee.amount, }) }