diff options
| author | Adam Soltys <[email protected]> | 2026-09-11 21:53:28 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-11 21:53:28 -0700 |
| commit | 8beb1c17df8d1c96a43803c0d87a5c5f1ac2494e (patch) | |
| tree | 9204b5550c8e914e642ca5533db45202fc5c579b | |
| parent | 207cb4451a07417baf63b2586e6759cf8b471482 (diff) | |
| download | coinosv3-8beb1c17df8d1c96a43803c0d87a5c5f1ac2494e.tar.gz coinosv3-8beb1c17df8d1c96a43803c0d87a5c5f1ac2494e.zip | |
Include mining fees in the Spending deposit preview
| -rw-r--r-- | src/features/ark.js | 34 | ||||
| -rw-r--r-- | tools/ark/board-fee-preview-test.js | 55 |
2 files changed, 80 insertions, 9 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 6398537..730cccd 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -25,6 +25,20 @@ import { shortAddr, shortTxid, timeAgo, ARK_ICON, ARK_MARK, BITCOIN_ICON } from // t?ark1… bech32m — an Ark address for this or another ASP. export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } +// Use the funding transaction's actual input selection and P2TR output size +// for both Max and the fee preview. Mining fees are paid from Savings; only +// the ASP service fee comes out of the amount deposited into Spending. +export function previewBoardFunding(wallet, ark, amountSat = 0, sendMax = false) { + const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; + const hrp = { bitcoin: 'bc', regtest: 'bcrt' }[ark?.info?.network] || 'tb'; + const dest = ark ? p2trAddress(ark._key(0).pubkey.slice(1), hrp) : wallet.freshChange().address; + const draft = wallet.buildTx({ recipients: [{ address: dest, amount: amountSat }], feeRate, sendMax, noSort: true }); + const fundingSat = draft.outputs[0].amount; + const serviceFeeSat = boardFee(fundingSat, ark?.info?.boardFees); + return { fundingSat, chainFeeSat: draft.fee, serviceFeeSat, + feeSat: draft.fee + serviceFeeSat, netSat: fundingSat - serviceFeeSat }; +} + // Ark movement types that actually touch the chain — each is a real bitcoin // tx with a txid, so it wears the Bitcoin mark, not the Ark one: board pulls // coins in from on-chain, offboard and exit push them back out. @@ -2743,12 +2757,7 @@ export function arkFeature(ctx) { // key stands in for the funding key — same size, always a valid point. function maxBoardSat() { try { - const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - const hrp = { bitcoin: 'bc', regtest: 'bcrt' }[ark && ark.info && ark.info.network] || 'tb'; - // _key() hands back a compressed point; the witness program is x-only - const dest = ark ? p2trAddress(ark._key(0).pubkey.slice(1), hrp) : wallet.freshChange().address; - const draft = wallet.buildTx({ recipients: [{ address: dest, amount: 0 }], feeRate, sendMax: true }); - return Math.max(0, (draft.outputs[0]?.amount || 0)); + return Math.max(0, previewBoardFunding(wallet, ark, 0, true).fundingSat); } catch { return 0; } @@ -2778,9 +2787,16 @@ export function arkFeature(ctx) { (() => { const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); if (!sats || sats < minBoard || !ark) return null; - const fee = boardFee(sats, ark.info.boardFees); - return h('div', { class: 'small muted', style: 'text-align:center' }, - t('arkBoardFeeNote', { fee: fmtAmount(fee), net: fmtAmount(sats - fee) })); + try { + const quote = previewBoardFunding(wallet, ark, sats); + return h('div', { class: 'col small muted', style: 'text-align:center;gap:4px' }, + t('arkBoardFeeNote', { fee: quote.feeSat.toLocaleString(), net: quote.netSat.toLocaleString() }), + h('div', { class: 'small faint' }, + t('arkFeeChain') + ': ' + fmtAmount(quote.chainFeeSat) + ' ' + unitLabel() + ' · ' + + t('arkFeeService') + ': ' + fmtAmount(quote.serviceFeeSat) + ' ' + unitLabel())); + } catch (e) { + return h('div', { class: 'notice err small' }, e.message); + } })(), canBoard ? h('div', { class: 'small faint', style: 'text-align:center' }, diff --git a/tools/ark/board-fee-preview-test.js b/tools/ark/board-fee-preview-test.js new file mode 100644 index 0000000..0ff96cd --- /dev/null +++ b/tools/ark/board-fee-preview-test.js @@ -0,0 +1,55 @@ +// Fee previews use the real wallet transaction builder, without signing or +// broadcasting anything. Run: bun tools/ark/board-fee-preview-test.js +import assert from 'node:assert/strict'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { Wallet } from '../../src/wallet.js'; +import { previewBoardFunding } from '../../src/features/ark.js'; +import { p2trAddress } from '../../src/ark/board.js'; + +const wallet = new Wallet(); +wallet.load({ mnemonic: generateMnemonic(wordlist), netName: 'mainnet', offline: true }); +wallet.utxos = [{ txid: 'a'.repeat(64), vout: 0, value: 300000, chain: 0, index: 0, confirmed: true }]; +wallet.feeRates = { halfHourFee: 1 }; +const ark = { info: { network: 'bitcoin', boardFees: {} }, _key: () => ({ pubkey: wallet.derive(0, 0).pubkey }) }; +// A different P2TR program stands in for the final funding address. Its +// output size matches the preview while its key is unrelated to the probe. +const fundingAddress = p2trAddress(wallet.derive(0, 1).pubkey.slice(1), 'bc'); +const actualDraft = sats => wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], + feeRate: wallet.feeRates.halfHourFee, noSort: true }); + +const max = previewBoardFunding(wallet, ark, 0, true); +assert.deepEqual(max, { fundingSat: 299878, chainFeeSat: 122, serviceFeeSat: 0, feeSat: 122, netSat: 299878 }); +const maxPreview = previewBoardFunding(wallet, ark, max.fundingSat); +assert.deepEqual(maxPreview, max); +assert.equal(maxPreview.chainFeeSat, actualDraft(max.fundingSat).fee); +assert.equal(maxPreview.netSat + maxPreview.feeSat, 300000); +console.log('✓ 300,000-sat Max shows a 122-sat fee and 299,878 sats arriving'); + +const partial = previewBoardFunding(wallet, ark, 100000); +assert.equal(partial.netSat, 100000, 'the mining fee is paid from Savings, not deducted again from the deposit'); +assert.equal(partial.chainFeeSat, actualDraft(100000).fee); +assert.ok(partial.chainFeeSat > max.chainFeeSat, 'partial deposits include the change output fee'); +console.log('✓ partial deposits include the change output and preserve the correct received amount'); + +ark.info.boardFees = { baseFeeSat: 25, ppm: 1000 }; +const service = previewBoardFunding(wallet, ark, max.fundingSat); +assert.equal(service.serviceFeeSat, 325); +assert.equal(service.feeSat, 447); +assert.equal(service.netSat, 299553); +assert.equal(service.netSat + service.feeSat, 300000); +console.log('✓ nonzero service fees are included in the total and deducted exactly once'); + +ark.info.boardFees = {}; +wallet.utxos = [ + { txid: 'a'.repeat(64), vout: 0, value: 200000, chain: 0, index: 0, confirmed: true }, + { txid: 'b'.repeat(64), vout: 0, value: 100000, chain: 0, index: 1, confirmed: true }, +]; +wallet.feeRates.halfHourFee = 1.6; +const multi = previewBoardFunding(wallet, ark, 0, true); +assert.equal(multi.chainFeeSat, actualDraft(multi.fundingSat).fee); +assert.equal(multi.netSat + multi.feeSat, 300000); +assert.ok(multi.feeSat > max.feeSat); +assert.throws(() => previewBoardFunding(wallet, ark, 300000), /Insufficient funds/); +console.log('✓ multiple inputs, fee-rate rounding, and insufficient funds follow the actual funding transaction'); +process.exit(0); |
