feat(donations): add development fund addresses and public activity monitor

This commit is contained in:
2026-09-08 21:41:12 -04:00
parent 04abdcb02f
commit 729788f749
8 changed files with 316 additions and 0 deletions
+16
View File
@@ -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/).
+19
View File
@@ -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; }
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Support the Wownero development fund and follow public donation balances and activity.">
<title>Development fund | Wownero</title>
<link rel="icon" href="img/Wownero_Logo_ico.ico">
<link rel="stylesheet" href="css/donations.css">
<script src="js/donations.js" defer></script>
</head>
<body>
<main>
<a href="index.html">&larr; Wownero</a>
<h1>Wownero development fund</h1>
<p>Support community development. Choose a coin below and send on its native network.</p>
<p>These addresses support the development fund. The website creator has a separate donation address on the <a href="index.html#donation">home page</a>.</p>
<section aria-labelledby="wow-title">
<h2 id="wow-title">Wownero <small>WOW</small></h2>
<code class="address">Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHuLqHkweDxwZZU42d16v94mP</code>
<p>Balance and activity monitoring planned.</p>
</section>
<section aria-labelledby="xmr-title">
<h2 id="xmr-title">Monero <small>XMR</small></h2>
<code class="address">44SQVPGLufPasUcuUQSZiF5c9BFzjcP8ucDxzzFDgLf1VkCEFaidJ3u2AhSKMhPLKA3jc2iS8wQHFcaigM6fXmo6AnFRn5B</code>
<p>Balance and activity monitoring planned.</p>
</section>
<h2>Public fund activity</h2>
<p>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.</p>
<button id="refresh" type="button" hidden>Load balances and activity</button>
<p id="refresh-status" role="status"></p>
<noscript>Enable JavaScript to load balances here, or use the explorer links below.</noscript>
<section data-chain="btc" aria-labelledby="btc-title">
<h2 id="btc-title">Bitcoin <small>BTC, Bitcoin mainnet</small></h2>
<code class="address">bc1qcw9zglp3fxyl25zswemw7jczlqryms2lsmu464</code>
<p class="balance">Balance not loaded.</p>
<p class="status" role="status"></p>
<ul class="activity"></ul>
<a href="https://mempool.space/address/bc1qcw9zglp3fxyl25zswemw7jczlqryms2lsmu464" rel="noreferrer">View full Bitcoin activity &rarr;</a>
</section>
<section data-chain="eth" aria-labelledby="eth-title">
<h2 id="eth-title">Ethereum <small>ETH, Ethereum mainnet</small></h2>
<code class="address">0xcBc9DC45AE61E5e69eE9D0b33625e4E52662a563</code>
<p class="balance">Balance not loaded.</p>
<p class="status" role="status"></p>
<ul class="activity"></ul>
<a href="https://eth.blockscout.com/address/0xcBc9DC45AE61E5e69eE9D0b33625e4E52662a563" rel="noreferrer">View full Ethereum activity &rarr;</a>
</section>
<section data-chain="sol" aria-labelledby="sol-title">
<h2 id="sol-title">Solana <small>SOL, Solana mainnet</small></h2>
<code class="address">2PHUHnsK6YXGD87onFjqrQTrPd5yv1E7Wgum1FHdqFuB</code>
<p class="balance">Balance not loaded.</p>
<p class="status" role="status"></p>
<ul class="activity"></ul>
<a href="https://explorer.solana.com/address/2PHUHnsK6YXGD87onFjqrQTrPd5yv1E7Wgum1FHdqFuB" rel="noreferrer">View full Solana activity &rarr;</a>
</section>
</main>
</body>
</html>
+7
View File
@@ -688,6 +688,13 @@
</div>
</div>
<section id="development-fund" class="container py-4" aria-labelledby="development-fund-title">
<h2 id="development-fund-title">Support Wownero development</h2>
<p>Help fund Wownero development. Donate WOW to the community development fund:</p>
<p style="overflow-wrap: anywhere; font-family: monospace;">Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHuLqHkweDxwZZU42d16v94mP</p>
<p><a class="foot-link" href="donations.html">Donate XMR, BTC, ETH or SOL and view fund activity</a></p>
</section>
<div class="p-1" style="background-color: #048ba8;">
<div class= "row">
<div class="col-md-12">
+148
View File
@@ -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 };
+56
View File
@@ -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; }
});
+7
View File
@@ -278,6 +278,13 @@ WORKER NAME | LAST IP | COUNT | ACCEPTED | REJ | 10 MINUTES
</div>
</div>
<section id="development-fund" class="container py-4" aria-labelledby="development-fund-title">
<h2 id="development-fund-title">Support Wownero development</h2>
<p>Help fund Wownero development. Donate WOW to the community development fund:</p>
<p style="overflow-wrap: anywhere; font-family: monospace;">Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHuLqHkweDxwZZU42d16v94mP</p>
<p><a class="foot-link" href="donations.html">Donate XMR, BTC, ETH or SOL and view fund activity</a></p>
</section>
<div class="p-1" style="background-color: #048ba8;">
<div class= "row">
<div class="col-md-12">
+4
View File
@@ -8,4 +8,8 @@
<loc>https://wownero.org/mining.html</loc>
<priority>0.8</priority>
</url>
<url>
<loc>https://wownero.org/donations.html</loc>
<priority>0.6</priority>
</url>
</urlset>