diff options
| author | Adam Soltys <[email protected]> | 2026-09-02 22:25:11 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-02 22:25:11 -0700 |
| commit | 0064b59c3f4543fa709f2b6166174a08d6689506 (patch) | |
| tree | e34098112f27f93fc32ac89d6aae074896860e0a | |
| parent | d28b716ae791f924d7dea901b9a9711e44bd7312 (diff) | |
| download | coinosv3-0064b59c3f4543fa709f2b6166174a08d6689506.tar.gz coinosv3-0064b59c3f4543fa709f2b6166174a08d6689506.zip | |
An offboard's price stops hiding in one number: the miners' cut and the ASP's service charge (base + lifetime ppm) record separately and the history detail shows them on their own lines — settling the mystery of a ~350-sat chain fee inside a ~650-sat total. The community row's member count anchors to last session's settled figure instead of ticking upward through the guestbook replay, and a profile's Pay button reached from search actually reaches Send — the lingering search screen was winning the router
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
| -rw-r--r-- | src/ark/manager.js | 12 | ||||
| -rw-r--r-- | src/ark/offboard.js | 14 | ||||
| -rw-r--r-- | src/features/ark.js | 11 | ||||
| -rw-r--r-- | src/features/messages.js | 16 | ||||
| -rw-r--r-- | src/i18n.js | 2 |
5 files changed, 45 insertions, 10 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index a190480..59f9f09 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -45,7 +45,7 @@ import { encodeVtxoFromDecoded, } from './refresh.js'; import { - getOffboardFeeRate, feeRateKwu, offboardFee, offboardAttestation, + getOffboardFeeRate, feeRateKwu, offboardFeeParts, offboardAttestation, prepareOffboard, validateOffboardTx, signOffboardForfeits, finishOffboard, P2TR_DUST, } from './offboard.js'; @@ -564,16 +564,18 @@ export class ArkManager { const tip = await this.chain.tipHeight(); const satVkb = await getOffboardFeeRate(this.arkUrl); const grossSat = inputs.reduce((n, v) => n + v.amountSat, 0); - const feeSat = offboardFee({ + const feeParts = offboardFeeParts({ spkLen: spk.length, satVkb, fees: this.info.offboardFees, tip, inputs: inputs.map((v) => ({ amountSat: v.amountSat, expiryHeight: v.expiryHeight })), }); + const feeSat = feeParts.totalSat; const netSat = grossSat - feeSat; if (netSat < P2TR_DUST) throw new Error('ark balance too small to offboard after fees'); const action = { id: `offboard-${Date.now()}`, type: 'offboard', step: 'created', inputIds: inputs.map((v) => v.id), address, spkHex: hex.encode(spk), - grossSat, feeSat, netSat, rateKwu: feeRateKwu(satVkb), + grossSat, feeSat, chainFeeSat: feeParts.chainSat, serviceFeeSat: feeParts.serviceSat, + netSat, rateKwu: feeRateKwu(satVkb), }; for (const v of inputs) v.state = 'pending'; this.state.actions.push(action); @@ -626,6 +628,8 @@ export class ArkManager { this._movement({ type: 'offboard', amountSat: action.netSat, status: 'complete', txid: action.txid, detail: `fee ${action.feeSat} sat`, to: action.address, + feeSat: action.feeSat, chainFeeSat: action.chainFeeSat, serviceFeeSat: action.serviceFeeSat, + inputIds: action.inputIds, }); this._save(); } else { @@ -640,6 +644,8 @@ export class ArkManager { this._movement({ type: 'offboard', amountSat: action.netSat, status: 'complete', txid: action.txid, detail: `fee ${action.feeSat} sat`, to: action.address, + feeSat: action.feeSat, chainFeeSat: action.chainFeeSat, serviceFeeSat: action.serviceFeeSat, + inputIds: action.inputIds, }); this._save(); } diff --git a/src/ark/offboard.js b/src/ark/offboard.js index ae6530d..28fd190 100644 --- a/src/ark/offboard.js +++ b/src/ark/offboard.js @@ -60,9 +60,13 @@ export const feeRateKwu = (satVkb) => Math.floor(satVkb / 4); // OffboardFees::calculate — base + ceil(rate * weight) + per-vtxo ppm-by-expiry. // inputs: [{ amountSat, expiryHeight }]; fees: info.offboardFees. -export function offboardFee({ spkLen, satVkb, fees, tip, inputs }) { +// The fee splits into what the MINERS take (this offboard's weight in the +// shared tx, at the current rate) and what the ASP charges for the service +// (base fee + the per-coin lifetime ppm, same bracket table as renewals) — +// kept separate so the wallet can show them apart. +export function offboardFeeParts({ spkLen, satVkb, fees, tip, inputs }) { const wu = (fees.fixedAdditionalVb + spkLen) * 4; - const weightFee = Math.floor((feeRateKwu(satVkb) * wu + 999) / 1000); + const chainSat = Math.floor((feeRateKwu(satVkb) * wu + 999) / 1000); // pver >= 4: sub-satoshi ppm accumulates across vtxos, one round-up at the end let ppmUnits = 0; for (const v of inputs) { @@ -70,7 +74,11 @@ export function offboardFee({ spkLen, satVkb, fees, tip, inputs }) { const entry = [...fees.ppmExpiryTable].reverse().find((e) => blocks >= e.thresholdBlocks); if (entry) ppmUnits += v.amountSat * entry.ppm; } - return fees.baseFeeSat + weightFee + Math.ceil(ppmUnits / 1_000_000); + const serviceSat = fees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000); + return { chainSat, serviceSat, totalSat: chainSat + serviceSat }; +} +export function offboardFee(args) { + return offboardFeeParts(args).totalSat; } // OffboardRequestAttestation: BIP340 signature over diff --git a/src/features/ark.js b/src/features/ark.js index c8e979b..fc5fc9f 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -205,6 +205,8 @@ export function mergeArkStates(a, b) { // a fee learned late (the rescue computed it from inputs − outputs) // upgrades a recorded zero — the row only renders when the fee is real if ((rm.feeSat || 0) > 0 && !(lm.feeSat > 0)) take.feeSat = rm.feeSat; + if (rm.chainFeeSat != null && lm.chainFeeSat == null) take.chainFeeSat = rm.chainFeeSat; + if (rm.serviceFeeSat != null && lm.serviceFeeSat == null) take.serviceFeeSat = rm.serviceFeeSat; return Object.keys(take).length ? { ...lm, ...take } : lm; }); // Two devices (or the push and the poll, pre-guard) can each record the @@ -1115,7 +1117,14 @@ export function arkFeature(ctx) { })(), m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, - m.detail ? row(t('detailsLabel'), m.detail) : null, + // An offboard's fee is two different pockets — the miners' cut for the + // on-chain weight and the ASP's service charge (base + lifetime ppm) — + // shown apart so neither reads as the other being overpriced. + m.chainFeeSat != null && m.serviceFeeSat != null + ? h('div', { class: 'col', style: 'gap:10px' }, + row(t('arkFeeChain'), fmtAmount(m.chainFeeSat) + ' ' + unitLabel()), + row(t('arkFeeService'), fmtAmount(m.serviceFeeSat) + ' ' + unitLabel())) + : m.detail ? row(t('detailsLabel'), m.detail) : null, // proof of payment: the preimage is what a merchant asks for m.preimage ? row(t('preimageLabel'), shortTxid(m.preimage)) : null, m.preimage ? h('button', { class: 'btn-block', onClick: () => ctx.copy(m.preimage) }, t('copyPreimage')) : null, diff --git a/src/features/messages.js b/src/features/messages.js index ac43e40..803afc9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2029,7 +2029,13 @@ export function messagesFeature(ctx) { } }, t('msgDmsTitle')), h('button', { class: 'grow', onClick: () => { const npubStr = npubOf(pk); + // leave the whole messaging surface, not just the profile — + // a lingering search or thread would win the screen router + // and the Send tab would never appear ui.profilePk = null; + ui.profOverThread = false; + ui.noteThread = null; + ui.userSearch = null; ui.chatOpen = false; ui.tab = 'send'; render(); @@ -2418,10 +2424,14 @@ export function messagesFeature(ctx) { kids.push(h('div', { class: 'list' }, communities().map((jm) => { const room = rooms.get(jm.community_id); const name = room?.folded?.metadata?.name || jm.name; - // until the guestbook fold lands, last session's settled count beats - // a placeholder that flips a moment later + // Last session's settled count anchors the number: while the guestbook + // replay is still streaming in (multiple relays, bursts past the fold + // debounce), the live tally climbs through intermediate values — take + // the max so the count doesn't visibly tick upward on every refresh. + // Once the replay settles, the cache is rewritten to the live figure, + // so a genuine departure still shows (one quiet step down, not a climb). const live = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0; - const memberCount = live || (st().memberCounts || {})[jm.community_id] || 0; + const memberCount = Math.max(live, (st().memberCounts || {})[jm.community_id] || 0); const unread = room ? roomUnread(room) : false; return h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), diff --git a/src/i18n.js b/src/i18n.js index d6d5740..0dbd06e 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -223,6 +223,8 @@ const STR = { arkCoinsChipExpTitle: 'Time until this coin needs renewal', arkCoinsChipDepthTitle: 'How many off-chain hops this coin carries', arkCoinsChipExitTitle: 'What pulling this coin on-chain by itself would cost', + arkFeeChain: 'On-chain fee', + arkFeeService: 'Service fee', arkCoinsExpired: 'expired', arkCoinsExpiryNote: 'Past its expiry a coin can be reclaimed by the server — the wallet renews on its own well before that.', arkCoinsExitTitle: 'Uncooperative exit', |
