mirror of
https://github.com/wownero/wownero.org-website.git
synced 2026-10-17 15:21:36 +02:00
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.
This commit is contained in:
+1
-1
@@ -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; }
|
||||
|
||||
+21
-18
@@ -3,7 +3,7 @@
|
||||
<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.">
|
||||
<meta name="description" content="Support the Wownero development fund and follow its live balances.">
|
||||
<title>Development fund | Wownero</title>
|
||||
<link rel="icon" href="img/Wownero_Logo_ico.ico">
|
||||
<link rel="stylesheet" href="css/donations.css">
|
||||
@@ -15,44 +15,47 @@
|
||||
<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">
|
||||
<section data-coin="WOW" 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>
|
||||
<p class="balance">Balance not loaded.</p>
|
||||
<p class="status" role="status"></p>
|
||||
<p>View key, public so anyone can verify incoming donations:</p>
|
||||
<code class="viewkey">e62e40bfd5ca7e3a7f199602a3c97df511780489e1c1861884b00c28abaea406</code>
|
||||
<a href="https://explore.wownero.com" rel="noreferrer">Wownero explorer →</a>
|
||||
</section>
|
||||
<section aria-labelledby="xmr-title">
|
||||
<section data-coin="XMR" 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>
|
||||
<p class="balance">Balance not loaded.</p>
|
||||
<p class="status" role="status"></p>
|
||||
<p>The view key will be published here so anyone can verify incoming donations.</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>
|
||||
<h2>Live balances</h2>
|
||||
<p>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.</p>
|
||||
<button id="refresh" type="button" hidden>Refresh balances</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">
|
||||
<noscript>Enable JavaScript to show balances here, or read <a href="/donations/balances.json">balances.json</a> directly.</noscript>
|
||||
<section data-coin="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>
|
||||
<a href="https://mempool.space/address/bc1qcw9zglp3fxyl25zswemw7jczlqryms2lsmu464" rel="noreferrer">Bitcoin activity →</a>
|
||||
</section>
|
||||
<section data-chain="eth" aria-labelledby="eth-title">
|
||||
<section data-coin="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>
|
||||
<a href="https://eth.blockscout.com/address/0xcBc9DC45AE61E5e69eE9D0b33625e4E52662a563" rel="noreferrer">Ethereum activity →</a>
|
||||
</section>
|
||||
<section data-chain="sol" aria-labelledby="sol-title">
|
||||
<section data-coin="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>
|
||||
<a href="https://explorer.solana.com/address/2PHUHnsK6YXGD87onFjqrQTrPd5yv1E7Wgum1FHdqFuB" rel="noreferrer">Solana activity →</a>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+90
-123
@@ -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 };
|
||||
|
||||
+25
-46
@@ -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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user