mirror of
https://github.com/wownero/wownero.org-website.git
synced 2026-10-17 07:11:34 +02:00
feat(donations): publish development fund monitor
Add the tested public donation page and development fund sections.
This commit is contained in:
@@ -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/).
|
||||
|
||||
@@ -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; }
|
||||
@@ -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">← 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 →</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 →</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 →</a>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user