From 729788f74975a7712b2aeb3220d40d837694104b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 8 Sep 2026 21:41:12 -0400 Subject: [PATCH] feat(donations): add development fund addresses and public activity monitor --- README.md | 16 +++++ css/donations.css | 19 ++++++ donations.html | 59 +++++++++++++++++ index.html | 7 ++ js/donations.js | 148 ++++++++++++++++++++++++++++++++++++++++++ js/donations.test.cjs | 56 ++++++++++++++++ mining.html | 7 ++ sitemap.xml | 4 ++ 8 files changed, 316 insertions(+) create mode 100644 css/donations.css create mode 100644 donations.html create mode 100644 js/donations.js create mode 100644 js/donations.test.cjs diff --git a/README.md b/README.md index 4254f1e..8f8aa5c 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,19 @@ ## This is the website ## Pull requests are welcome + +## Development fund + +The home and mining pages link to `donations.html`, which holds the public fund +addresses and a browser-based BTC, ETH and SOL monitor. No build step is required. +Balances load only when requested. Provider failures show as unavailable, never zero. +WOW and XMR monitoring is not yet connected; wallet keys must never enter this repository. + +Run `node --test js/donations.test.cjs` and `git diff --check` before publishing changes +to this feature. Preview with a local static HTTP server and check a narrow viewport. + +Provider contracts: [mempool.space](https://mempool.space/docs/api/rest), +[Blockscout](https://docs.blockscout.com/devs/apis/rpc/account), +[Solana balance](https://solana.com/docs/rpc/http/getbalance), and +[Solana activity](https://solana.com/docs/rpc/http/getsignaturesforaddress). +Solana requests use the browser-accessible [PublicNode endpoint](https://solana.publicnode.com/). diff --git a/css/donations.css b/css/donations.css new file mode 100644 index 0000000..140aaf9 --- /dev/null +++ b/css/donations.css @@ -0,0 +1,19 @@ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +body { margin: 0; background: #1a1a1a; color: #f6f6f6; font: 1rem/1.6 system-ui, sans-serif; } +main { max-width: 900px; margin: auto; padding: 2rem 1.25rem 4rem; } +h1 { font-size: clamp(2rem, 6vw, 3rem); line-height: 1.15; color: #ff80e3; } +h2 { line-height: 1.3; } +small { display: block; font-size: 1rem; font-weight: normal; color: #ccc; } +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; } +.balance { font-size: 1.2rem; font-weight: 600; } +.status { color: #ddd; } +.activity { padding-left: 1.25rem; overflow-wrap: anywhere; } +.activity li { margin-bottom: .6rem; } +button { padding: .8rem 1.2rem; border: 0; border-radius: .3rem; background: #ff80e3; color: #191919; font: inherit; font-weight: 600; cursor: pointer; } +button:disabled { opacity: .6; cursor: wait; } +:focus-visible { outline: 3px solid #ffe600; outline-offset: 4px; } diff --git a/donations.html b/donations.html new file mode 100644 index 0000000..c676252 --- /dev/null +++ b/donations.html @@ -0,0 +1,59 @@ + + + + + + + Development fund | Wownero + + + + + +
+ ← Wownero +

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.

+
+
+

Monero XMR

+ 44SQVPGLufPasUcuUQSZiF5c9BFzjcP8ucDxzzFDgLf1VkCEFaidJ3u2AhSKMhPLKA3jc2iS8wQHFcaigM6fXmo6AnFRn5B +

Balance and activity monitoring planned.

+
+

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.

+ +

+ +
+

Bitcoin BTC, Bitcoin mainnet

+ bc1qcw9zglp3fxyl25zswemw7jczlqryms2lsmu464 +

Balance not loaded.

+

+
    + View full Bitcoin activity → +
    +
    +

    Ethereum ETH, Ethereum mainnet

    + 0xcBc9DC45AE61E5e69eE9D0b33625e4E52662a563 +

    Balance not loaded.

    +

    +
      + View full Ethereum activity → +
      +
      +

      Solana SOL, Solana mainnet

      + 2PHUHnsK6YXGD87onFjqrQTrPd5yv1E7Wgum1FHdqFuB +

      Balance not loaded.

      +

      +
        + View full Solana activity → +
        +
        + + diff --git a/index.html b/index.html index 38e92d7..c4425eb 100644 --- a/index.html +++ b/index.html @@ -688,6 +688,13 @@ +
        +

        Support Wownero development

        +

        Help fund Wownero development. Donate WOW to the community development fund:

        +

        Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHuLqHkweDxwZZU42d16v94mP

        +

        Donate XMR, BTC, ETH or SOL and view fund activity

        +
        +
        diff --git a/js/donations.js b/js/donations.js new file mode 100644 index 0000000..505455c --- /dev/null +++ b/js/donations.js @@ -0,0 +1,148 @@ +/* Public addresses live in donations.html. No wallet keys are used here. */ +'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 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 : ''); +} + +async function request(url, options = {}) { + 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(); + } 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); + } + 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 () => { + button.disabled = true; + const status = document.getElementById('refresh-status'); + status.textContent = 'Checking public providers…'; + try { + await Promise.allSettled([...document.querySelectorAll('[data-chain]')].map(refreshCard)); + status.textContent = 'Check finished. Each coin shows its result below.'; + } finally { + button.textContent = 'Refresh balances and activity'; + // A short cooldown avoids accidental bursts against public providers. + setTimeout(() => { button.disabled = false; }, 10000); + } + }); +} + +if (typeof module !== 'undefined') module.exports = { units, providers }; diff --git a/js/donations.test.cjs b/js/donations.test.cjs new file mode 100644 index 0000000..d3cc7a7 --- /dev/null +++ b/js/donations.test.cjs @@ -0,0 +1,56 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { units, providers } = 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('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('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; } +}); diff --git a/mining.html b/mining.html index f44b8fb..2978ba7 100644 --- a/mining.html +++ b/mining.html @@ -278,6 +278,13 @@ WORKER NAME | LAST IP | COUNT | ACCEPTED | REJ | 10 MINUTES
        +
        +

        Support Wownero development

        +

        Help fund Wownero development. Donate WOW to the community development fund:

        +

        Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHuLqHkweDxwZZU42d16v94mP

        +

        Donate XMR, BTC, ETH or SOL and view fund activity

        +
        +
        diff --git a/sitemap.xml b/sitemap.xml index 2780cbb..49f5cda 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -8,4 +8,8 @@ https://wownero.org/mining.html 0.8 + + https://wownero.org/donations.html + 0.6 +