From 40ed2fa22131a3fb508b605e5b608a32aa5e7521 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 4 Oct 2026 19:59:15 -0400 Subject: [PATCH] feat(donations): live fund balances from wownero.org's own nodes The page asked mempool.space, Blockscout and PublicNode for three of the five balances from each visitor's browser, and showed nothing for WOW and XMR. wownero.org now reads every balance once a minute, from its own Wownero, Bitcoin and Ethereum nodes and PublicNode for Solana, and publishes /donations/balances.json. The page reads only that. WOW gains a live balance from a view-only wallet and its view key is published so anyone can verify incoming donations; XMR follows once its view key is. A source that cannot be read shows its last value marked as such, or says it is unavailable; it is never shown as zero. --- css/donations.css | 2 +- donations.html | 39 ++++---- js/donations.js | 213 ++++++++++++++++++------------------------ js/donations.test.cjs | 71 +++++--------- 4 files changed, 137 insertions(+), 188 deletions(-) diff --git a/css/donations.css b/css/donations.css index 140aaf9..cebf9e5 100644 --- a/css/donations.css +++ b/css/donations.css @@ -9,7 +9,7 @@ a { color: #82e4f5; text-underline-offset: .2em; } a:hover { color: #fff; } section { margin: 1.5rem 0; padding: 1.25rem; border: 1px solid #555; border-radius: .5rem; background: #232323; } section h2 { margin-top: 0; } -.address { display: block; overflow-wrap: anywhere; font-size: .95rem; user-select: all; } +.address, .viewkey { display: block; overflow-wrap: anywhere; font-size: .95rem; user-select: all; } .balance { font-size: 1.2rem; font-weight: 600; } .status { color: #ddd; } .activity { padding-left: 1.25rem; overflow-wrap: anywhere; } diff --git a/donations.html b/donations.html index c676252..eb0bd02 100644 --- a/donations.html +++ b/donations.html @@ -3,7 +3,7 @@ - + Development fund | Wownero @@ -15,44 +15,47 @@

Wownero development fund

Support community development. Choose a coin below and send on its native network.

These addresses support the development fund. The website creator has a separate donation address on the home page.

-
+

Wownero WOW

Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHuLqHkweDxwZZU42d16v94mP -

Balance and activity monitoring planned.

+

Balance not loaded.

+

+

View key, public so anyone can verify incoming donations:

+ e62e40bfd5ca7e3a7f199602a3c97df511780489e1c1861884b00c28abaea406 + Wownero explorer →
-
+

Monero XMR

44SQVPGLufPasUcuUQSZiF5c9BFzjcP8ucDxzzFDgLf1VkCEFaidJ3u2AhSKMhPLKA3jc2iS8wQHFcaigM6fXmo6AnFRn5B -

Balance and activity monitoring planned.

+

Balance not loaded.

+

+

The view key will be published here so anyone can verify incoming donations.

-

Public fund activity

-

Load current native-coin balances and recent activity from mempool.space, Blockscout and PublicNode. Loading contacts these services from your browser. Tokens are not included; balances are not lifetime donation totals.

- +

Live balances

+

wownero.org reads these balances once a minute from its own Wownero, Monero, Bitcoin and Ethereum nodes, and from PublicNode for Solana. Your browser contacts only this site. Tokens are not included, and balances are not lifetime donation totals.

+

- -
+ +

Bitcoin BTC, Bitcoin mainnet

bc1qcw9zglp3fxyl25zswemw7jczlqryms2lsmu464

Balance not loaded.

-
    - View full Bitcoin activity → + Bitcoin activity →
    -
    +

    Ethereum ETH, Ethereum mainnet

    0xcBc9DC45AE61E5e69eE9D0b33625e4E52662a563

    Balance not loaded.

    -
      - View full Ethereum activity → + Ethereum activity →
      -
      +

      Solana SOL, Solana mainnet

      2PHUHnsK6YXGD87onFjqrQTrPd5yv1E7Wgum1FHdqFuB

      Balance not loaded.

      -
        - View full Solana activity → + Solana activity →
        diff --git a/js/donations.js b/js/donations.js index 505455c..5a5ee21 100644 --- a/js/donations.js +++ b/js/donations.js @@ -1,148 +1,115 @@ -/* Public addresses live in donations.html. No wallet keys are used here. */ +/* Development fund balances, read by wownero.org from its own nodes once a minute + and published as /donations/balances.json. The browser contacts only this site. + Public addresses and view keys live in donations.html and in that file; no + spending credential is involved anywhere. */ 'use strict'; -function units(value, decimals) { - if (!/^-?\d+$/.test(String(value)) || - (typeof value === 'number' && !Number.isSafeInteger(value))) { - throw new Error('Provider returned an invalid amount'); +const BALANCES_URL = '/donations/balances.json'; + +/* Server amounts are exact decimal strings. Show them without trailing zeros and + refuse anything else rather than guess. */ +function amount(value) { + if (typeof value !== 'string' || !/^-?\d+(\.\d+)?$/.test(value)) { + throw new Error('Invalid amount in balances.json'); } - const amount = BigInt(value); - const digits = (amount < 0n ? -amount : amount).toString().padStart(decimals + 1, '0'); - const fraction = digits.slice(-decimals).replace(/0+$/, ''); - return (amount < 0n ? '-' : '') + digits.slice(0, -decimals) + (fraction ? '.' + fraction : ''); + if (!value.includes('.')) return value; + const trimmed = value.replace(/0+$/, '').replace(/\.$/, ''); + return trimmed === '-0' ? '0' : trimmed; } -async function request(url, options = {}) { +function when(iso) { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? 'an unknown time' : date.toLocaleString(); +} + +/* Lines for one coin. A coin whose source could not be read keeps its last good + value, marked stale; one never read says so. Neither is ever shown as zero. */ +function describe(symbol, coin) { + if (!coin) return { balance: 'Not monitored yet.', details: ['Monitoring starts when this view key is published.'] }; + const details = []; + let balance; + if (coin.balance === undefined) { + balance = 'Balance unavailable.'; + details.push('Not read yet' + (coin.error ? ': the source could not be reached.' : '.')); + } else { + balance = amount(coin.balance) + ' ' + symbol; + if (coin.status === 'stale') { + balance += ' (last read ' + when(coin.checked_at) + '; the source is currently unreachable)'; + } + } + if (coin.total_received !== undefined) { + details.push('Received in total: ' + amount(coin.total_received) + ' ' + symbol + + ' over ' + coin.incoming_transfers + ' incoming transfer' + (coin.incoming_transfers === 1 ? '' : 's') + '.'); + } + if (coin.scanning) { + details.push('The view-only wallet is still scanning the chain (' + + Math.max(0, coin.chain_height - coin.wallet_height) + ' blocks to go), so these figures are partial.'); + } else if (coin.total_received !== undefined) { + details.push(coin.key_images_synced_at + ? 'Spending is reflected as of ' + when(coin.key_images_synced_at) + '.' + : 'A view key shows incoming funds only; spending appears after the fund imports its key images.'); + } + if (coin.unconfirmed !== undefined && amount(coin.unconfirmed) !== '0') { + details.push('Unconfirmed: ' + amount(coin.unconfirmed) + ' ' + symbol + '.'); + } + if (coin.status === 'ok' && coin.checked_at) { + details.push('Read ' + when(coin.checked_at) + ' from ' + coin.source + '.'); + } + return { balance, details }; +} + +async function load() { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); try { - const response = await fetch(url, { ...options, signal: controller.signal, - credentials: 'omit', referrerPolicy: 'no-referrer' }); - if (!response.ok) throw new Error('Provider returned HTTP ' + response.status); - return await response.json(); + const response = await fetch(BALANCES_URL, { cache: 'no-store', signal: controller.signal }); + if (!response.ok) throw new Error('HTTP ' + response.status); + const doc = await response.json(); + if (doc.schema !== 'devfund-balances/v1') throw new Error('Unknown balances format'); + return doc; } finally { clearTimeout(timeout); } } -async function solana(method, address, extra = {}) { - const data = await request('https://solana-rpc.publicnode.com', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, - params: [address, { commitment: 'finalized', ...extra }] }) - }); - if (data.error || !Object.hasOwn(data, 'result')) throw new Error('Solana RPC request failed'); - return data.result; -} - -const providers = { - btc: { - async balance(address) { - const data = await request('https://mempool.space/api/address/' + address); - const chain = data.chain_stats; - const pending = data.mempool_stats; - // Validate each integer before arithmetic; never coerce missing data to zero. - [chain.funded_txo_sum, chain.spent_txo_sum, pending.funded_txo_sum, - pending.spent_txo_sum].forEach(value => units(value, 8)); - return units(BigInt(chain.funded_txo_sum) - BigInt(chain.spent_txo_sum), 8) + - ' BTC confirmed; pending net change ' + - units(BigInt(pending.funded_txo_sum) - BigInt(pending.spent_txo_sum), 8) + ' BTC'; - }, - async activity(address) { - const data = await request('https://mempool.space/api/address/' + address + '/txs'); - return data.slice(0, 10).map(tx => { - const received = tx.vout.filter(out => out.scriptpubkey_address === address) - .reduce((sum, out) => sum + BigInt(out.value), 0n); - const spent = tx.vin.filter(input => input.prevout?.scriptpubkey_address === address) - .reduce((sum, input) => sum + BigInt(input.prevout.value), 0n); - return { id: tx.txid, url: 'https://mempool.space/tx/' + encodeURIComponent(tx.txid), - label: units(received - spent, 8) + ' BTC net change, ' + - (tx.status.confirmed ? 'confirmed' : 'pending') }; - }); - }, - note: 'Latest 10 transactions. Net change includes change outputs and fees on outgoing transactions.' - }, - eth: { - async balance(address) { - const data = await request('https://eth.blockscout.com/api?module=account&action=balance&address=' + address); - if (data.status !== '1') throw new Error('Ethereum balance provider could not return a balance'); - return units(data.result, 18) + ' ETH (latest indexed balance)'; - }, - async activity(address) { - const data = await request('https://eth.blockscout.com/api/v2/addresses/' + address + '/transactions'); - return data.items.slice(0, 10).map(tx => { - const from = tx.from?.hash?.toLowerCase() === address.toLowerCase(); - const to = tx.to?.hash?.toLowerCase() === address.toLowerCase(); - return { id: tx.hash, url: 'https://eth.blockscout.com/tx/' + encodeURIComponent(tx.hash), - label: (from && to ? 'Self-transfer' : from ? 'Outgoing' : 'Incoming') + - ' ' + units(tx.value, 18) + ' ETH, ' + - (tx.status === 'ok' ? 'successful' : tx.status === 'error' ? 'failed' : 'pending or unconfirmed') }; - }); - }, - note: 'Latest 10 regular transactions. Transfer values exclude fees. Internal transfers and tokens are available in the explorer.' - }, - sol: { - async balance(address) { - const data = await solana('getBalance', address); - return units(data.value, 9) + ' SOL finalized'; - }, - async activity(address) { - const data = await solana('getSignaturesForAddress', address, { limit: 10 }); - return data.map(tx => ({ id: tx.signature, - url: 'https://explorer.solana.com/tx/' + encodeURIComponent(tx.signature), - label: (tx.err ? 'Failed' : 'Finalized') + ' transaction referencing this account' })); - }, - note: 'Latest 10 account transactions. Open a transaction for incoming and outgoing transfer details; account activity is not necessarily a donation.' - } -}; - -async function refreshCard(card) { - const provider = providers[card.dataset.chain]; - const address = card.querySelector('.address').textContent.trim(); - const balance = card.querySelector('.balance'); - const status = card.querySelector('.status'); - const activity = card.querySelector('.activity'); - balance.textContent = 'Loading balance…'; - status.textContent = 'Loading activity…'; - activity.replaceChildren(); - const results = await Promise.allSettled([provider.balance(address), provider.activity(address)]); - const checked = new Date().toLocaleString(); - balance.textContent = results[0].status === 'fulfilled' ? results[0].value : - 'Balance unavailable. Try again or use the explorer below.'; - if (results[1].status === 'fulfilled') { - for (const tx of results[1].value) { - const item = document.createElement('li'); - const link = document.createElement('a'); - link.href = tx.url; - link.rel = 'noreferrer'; - link.textContent = tx.label + ' (' + tx.id.slice(0, 12) + '…)'; - item.append(link); - activity.append(item); +function render(doc) { + for (const card of document.querySelectorAll('[data-coin]')) { + const symbol = card.dataset.coin; + const balance = card.querySelector('.balance'); + const status = card.querySelector('.status'); + try { + const view = describe(symbol, doc.coins[symbol]); + balance.textContent = view.balance; + status.textContent = view.details.join(' '); + } catch (error) { + balance.textContent = 'Balance unavailable.'; + status.textContent = 'The published balance could not be read.'; } - status.textContent = (results[1].value.length ? provider.note : 'No recent activity returned by the provider.') + - ' Checked ' + checked + '.'; - } else { - status.textContent = 'Activity unavailable. Try again or use the explorer below. Checked ' + checked + '.'; } - if (results[0].status === 'fulfilled') balance.textContent += '. Retrieved ' + checked + '.'; } if (typeof document !== 'undefined') { const button = document.getElementById('refresh'); - button.hidden = false; - button.addEventListener('click', async () => { + const status = document.getElementById('refresh-status'); + const refresh = async () => { button.disabled = true; - const status = document.getElementById('refresh-status'); - status.textContent = 'Checking public providers…'; + status.textContent = 'Loading balances…'; try { - await Promise.allSettled([...document.querySelectorAll('[data-chain]')].map(refreshCard)); - status.textContent = 'Check finished. Each coin shows its result below.'; + const doc = await load(); + render(doc); + status.textContent = 'Balances published ' + when(doc.generated_at) + '.'; + } catch (error) { + for (const card of document.querySelectorAll('[data-coin] .balance')) { + card.textContent = 'Balance unavailable.'; + } + status.textContent = 'Balances could not be loaded. Use the explorer links below.'; } finally { - button.textContent = 'Refresh balances and activity'; - // A short cooldown avoids accidental bursts against public providers. - setTimeout(() => { button.disabled = false; }, 10000); + button.disabled = false; } - }); + }; + button.hidden = false; + button.addEventListener('click', refresh); + refresh(); } -if (typeof module !== 'undefined') module.exports = { units, providers }; +if (typeof module !== 'undefined') module.exports = { amount, describe }; diff --git a/js/donations.test.cjs b/js/donations.test.cjs index d3cc7a7..0a554b2 100644 --- a/js/donations.test.cjs +++ b/js/donations.test.cjs @@ -1,56 +1,35 @@ 'use strict'; const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { units, providers } = require('./donations.js'); +const { amount, describe } = require('./donations.js'); -test('native amounts retain precision and represent negative net changes', () => { - assert.equal(units('1000000000000000001', 18), '1.000000000000000001'); - assert.equal(units(1, 8), '0.00000001'); - assert.equal(units(-123456789, 8), '-1.23456789'); - assert.equal(units(0, 9), '0'); - for (const invalid of [null, undefined, '', '1.5', NaN, Number.MAX_SAFE_INTEGER + 1]) { - assert.throws(() => units(invalid, 8)); +test('server amounts keep their precision and lose only trailing zeros', () => { + assert.equal(amount('1.000000000000000001'), '1.000000000000000001'); + assert.equal(amount('0.00027878'), '0.00027878'); + assert.equal(amount('5.637709531'), '5.637709531'); + assert.equal(amount('0.000000000000000000'), '0'); + assert.equal(amount('12'), '12'); + for (const invalid of [null, undefined, '', '1.5e3', 'NaN', 1.5, '1.']) { + assert.throws(() => amount(invalid)); } }); -test('provider failures never become zero balances', async () => { - const original = global.fetch; - try { - global.fetch = async () => ({ ok: false, status: 429 }); - for (const provider of Object.values(providers)) { - await assert.rejects(provider.balance('address')); - } - global.fetch = async () => ({ ok: true, json: async () => ({ status: '0', result: '0' }) }); - await assert.rejects(providers.eth.balance('address')); - global.fetch = async () => ({ ok: true, json: async () => ({ error: { code: -32000 } }) }); - await assert.rejects(providers.sol.balance('address')); - } finally { global.fetch = original; } +test('a source that cannot be read is never shown as zero', () => { + const never = describe('ETH', { status: 'unavailable', error: 'OSError: no route', source: 'x' }); + assert.equal(never.balance, 'Balance unavailable.'); + const stale = describe('ETH', { status: 'stale', balance: '0.5', checked_at: '2026-10-05T00:00:00Z', source: 'x' }); + assert.match(stale.balance, /^0\.5 ETH \(last read .+unreachable\)$/); + assert.equal(describe('XMR', undefined).balance, 'Not monitored yet.'); }); -test('Bitcoin net activity accounts for change and preserves pending status', async () => { - const original = global.fetch; - try { - global.fetch = async () => ({ ok: true, json: async () => [{ txid: 'abc', - vin: [{ prevout: { scriptpubkey_address: 'fund', value: 100000000 } }], - vout: [{ scriptpubkey_address: 'fund', value: 70000000 }, - { scriptpubkey_address: 'other', value: 29000000 }], - status: { confirmed: false } }] }); - const [activity] = await providers.btc.activity('fund'); - assert.match(activity.label, /-0\.3 BTC/); - assert.match(activity.label, /pending/); - } finally { global.fetch = original; } -}); - -test('Ethereum failed transfers are marked failed', async () => { - const original = global.fetch; - try { - global.fetch = async () => ({ ok: true, json: async () => ({ items: [{ - hash: 'abc', from: { hash: 'FUND' }, to: { hash: 'other' }, - value: '1000000000000000001', status: 'error' - }] }) }); - const [activity] = await providers.eth.activity('fund'); - assert.match(activity.label, /Outgoing/); - assert.match(activity.label, /failed/); - assert.match(activity.label, /1\.000000000000000001/); - } finally { global.fetch = original; } +test('a view-only wallet says what its balance does and does not show', () => { + const base = { status: 'ok', balance: '10', total_received: '10', incoming_transfers: 2, + wallet_height: 100, chain_height: 100, checked_at: '2026-10-05T00:00:00Z', source: 'our node' }; + const unsynced = describe('WOW', base).details.join(' '); + assert.match(unsynced, /Received in total: 10 WOW over 2 incoming transfers/); + assert.match(unsynced, /incoming funds only/); + const synced = describe('WOW', { ...base, key_images_synced_at: '2026-10-05T00:00:00Z' }).details.join(' '); + assert.match(synced, /Spending is reflected as of/); + const scanning = describe('WOW', { ...base, scanning: true, wallet_height: 40 }).details.join(' '); + assert.match(scanning, /still scanning the chain \(60 blocks to go\)/); });