Skip to content

Commit d86865c

Browse files
authored
feat: show what is actually at each payout address (dv6 tier 2) (#275)
Tier 1 answered "where does my money go?". This answers "and what is there?". /api/payout-balances is a SEPARATE endpoint from /api/payout-registry on purpose. The registry is a local join and answers instantly; these are network reads against public RPCs that can take seconds or time out. Folding them together would make the whole payout table hostage to the slowest chain, so the page renders the table first and fills the column in afterwards. Only addresses we actually hold are queried. An internal, minted or unknown service has no address, and putting a public RPC to work on nothing is both pointless and rude to infrastructure we do not pay for. THE RULE, one level down from the address column: "could not check" must never render like "the balance is zero". Three distinct looks -- known the amount and its symbol, 0 included, because a real zero IS a fact unreachable "could not check", italic, with the reason on hover nothing asked an em dash, because there was no address to ask about Decimal is serialised as a STRING. A float at this last step would undo exactly the precision the reader uses Decimal to preserve. The harness now runs balanceCell for real and was mutation-tested: making unreachable render as 0 fails 2 checks and exits 1. The endpoint's address filter was mutation-tested too -- removing the supported-chain condition fails 2 tests. 4159 passed, 95.56%, all six render harnesses green.
1 parent d617d8e commit d86865c

5 files changed

Lines changed: 326 additions & 3 deletions

File tree

app/main.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
metrics,
4848
net_activity,
4949
notify,
50+
onchain,
5051
payout_registry,
5152
payouts,
5253
power,
@@ -3072,6 +3073,49 @@ async def api_payout_registry(request: Request) -> dict[str, Any]:
30723073
return await payout_registry.registry()
30733074

30743075

3076+
@app.get("/api/payout-balances")
3077+
async def api_payout_balances(request: Request) -> dict[str, Any]:
3078+
"""What is actually AT each payout address (CashPilot-dv6, tier 2).
3079+
3080+
OWNER-ONLY, same reasoning as the registry it builds on.
3081+
3082+
A SEPARATE endpoint from /api/payout-registry on purpose. The registry is a
3083+
local join and answers instantly; these are network reads against public
3084+
RPCs that can take seconds or time out. Folding them together would make the
3085+
whole payout table hostage to the slowest chain, so the page renders the
3086+
table first and fills these in afterwards.
3087+
3088+
Only addresses we actually hold are queried -- an `internal`, `minted` or
3089+
`unknown` service has no address, and asking a public RPC about nothing is
3090+
both pointless and rude.
3091+
"""
3092+
_require_owner(request)
3093+
registry = await payout_registry.registry()
3094+
3095+
wanted = [
3096+
row
3097+
for row in registry["entries"]
3098+
if row.get("model") == "external" and row.get("address") and row.get("chain") in onchain.CHAINS
3099+
]
3100+
results = await onchain.balances([(row["chain"], row["address"]) for row in wanted])
3101+
3102+
balances: dict[str, Any] = {}
3103+
for row, result in zip(wanted, results, strict=True):
3104+
# Decimal does not survive JSON. str() keeps every digit, which is the
3105+
# whole reason the reader uses Decimal in the first place -- a float here
3106+
# would undo it at the last step.
3107+
amount = result.get("amount")
3108+
balances[row["slug"]] = {**result, "amount": None if amount is None else str(amount)}
3109+
3110+
return {
3111+
"balances": balances,
3112+
# Named so the UI can say "3 of 5 addresses could not be checked"
3113+
# instead of quietly showing fewer rows than it did a moment ago.
3114+
"checked": len(wanted),
3115+
"unreadable": sum(1 for r in results if r.get("state") != onchain.KNOWN),
3116+
}
3117+
3118+
30753119
@app.get("/api/update-status")
30763120
async def api_update_status(request: Request) -> dict[str, Any]:
30773121
"""Whether a newer CashPilot has been released (CashPilot-w0ss).

app/static/css/style.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2134,3 +2134,21 @@ img { max-width: 100%; }
21342134
.payout-row-actionable {
21352135
background: var(--warning-soft, rgba(245, 158, 11, 0.08));
21362136
}
2137+
2138+
/* On-chain balance column (CashPilot-dv6 tier 2).
2139+
"could not check" must not read as a balance, and must not read as the
2140+
em dash used for "there was nothing to ask". Three distinct looks. */
2141+
.payout-balance {
2142+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
2143+
font-size: 0.82rem;
2144+
white-space: nowrap;
2145+
color: var(--text-primary);
2146+
}
2147+
/* Not an error and not a zero: we simply do not know right now. */
2148+
.payout-unchecked {
2149+
color: var(--text-muted);
2150+
font-size: 0.8rem;
2151+
font-style: italic;
2152+
border-bottom: 1px dotted var(--border-color);
2153+
cursor: help;
2154+
}

app/templates/payouts.html

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,33 @@ <h3 class="card-title">All services</h3>
102102
return '<span class="payout-na">Not classified yet</span>';
103103
}
104104

105+
// What is actually AT the address (CashPilot-dv6 tier 2).
106+
//
107+
// The same rule as the address column, one level down: "we could not check"
108+
// must NEVER render like "the balance is zero". Zero is a fact about an
109+
// address; unreachable is a fact about us, and a public RPC rate-limiting us
110+
// is not news about the user's money.
111+
//
112+
// A row we never queried (internal, minted, unknown, or no address) gets an
113+
// em dash, because there was nothing to ask.
114+
function balanceCell(result) {
115+
if (!result) return '<span class="payout-na">&mdash;</span>';
116+
if (result.state === 'known') {
117+
return '<span class="payout-balance">' + esc(result.amount)
118+
+ (result.symbol ? ' ' + esc(result.symbol) : '') + '</span>';
119+
}
120+
if (result.state === 'unreachable') {
121+
return '<span class="payout-unchecked" title="' + esc(result.detail || '')
122+
+ '">could not check</span>';
123+
}
124+
if (result.state === 'invalid') {
125+
return '<span class="payout-missing">address not valid</span>';
126+
}
127+
// unsupported: we have no keyless endpoint for that chain. Not the user's
128+
// problem and not an error about their money.
129+
return '<span class="payout-na">&mdash;</span>';
130+
}
131+
105132
function payoutRow(entry) {
106133
var chain = entry.chain ? esc(entry.chain) : '<span class="payout-na">&mdash;</span>';
107134
var deployed = entry.deployed
@@ -116,6 +143,8 @@ <h3 class="card-title">All services</h3>
116143
+ '<td><span class="badge badge-category">' + esc(entry.model || 'unknown') + '</span></td>'
117144
+ '<td>' + chain + '</td>'
118145
+ '<td>' + payoutAddressCell(entry) + '</td>'
146+
+ '<td class="payout-balance-cell" data-slug="' + esc(entry.slug) + '">'
147+
+ balanceCell(_balances[entry.slug]) + '</td>'
119148
+ '</tr>';
120149
}
121150

@@ -125,12 +154,14 @@ <h3 class="card-title">All services</h3>
125154
}
126155
return '<div style="overflow-x:auto;"><table class="breakdown-table"><thead><tr>'
127156
+ '<th>Service</th><th>Status</th><th>Payout model</th><th>Chain</th><th>Address</th>'
157+
+ '<th>On-chain</th>'
128158
+ '</tr></thead><tbody>'
129159
+ entries.map(payoutRow).join('')
130160
+ '</tbody></table></div>';
131161
}
132162

133163
var _entries = [];
164+
var _balances = {};
134165

135166
function applyFilter() {
136167
var q = (document.getElementById('payout-filter').value || '').trim().toLowerCase();
@@ -163,6 +194,7 @@ <h3 class="card-title">All services</h3>
163194
}
164195

165196
applyFilter();
197+
loadBalances();
166198
} catch (err) {
167199
// Say the registry could not be read. Never render an empty table, which
168200
// would look like "no services pay you anything".
@@ -171,6 +203,23 @@ <h3 class="card-title">All services</h3>
171203
}
172204
}
173205

206+
// Deliberately fired after the table exists. These are network reads against
207+
// public RPCs; making the page wait on them would mean a rate-limited chain
208+
// shows the user a blank screen instead of their addresses.
209+
async function loadBalances() {
210+
try {
211+
var data = await CP.api('/api/payout-balances');
212+
_balances = data.balances || {};
213+
} catch (err) {
214+
// Leave every cell as it was. Inventing zeroes here would be the exact
215+
// failure this column is built to avoid.
216+
return;
217+
}
218+
document.querySelectorAll('.payout-balance-cell').forEach(function (cell) {
219+
cell.innerHTML = balanceCell(_balances[cell.dataset.slug]);
220+
});
221+
}
222+
174223
document.addEventListener('DOMContentLoaded', function () {
175224
if (document.body.dataset.page !== 'payouts') return;
176225
document.getElementById('payout-filter').addEventListener('input', applyFilter);

scripts/payout_registry_check.mjs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ function extract(name) {
4040
// Evaluate the real esc/payoutAddressCell/payoutRow together, since they call
4141
// one another. A stub for esc() here would let escaping break while this stayed
4242
// green — exactly the failure these harnesses exist to catch.
43-
const src = [extract('esc'), extract('payoutAddressCell'), extract('payoutRow')].join('\n');
44-
const {payoutAddressCell, payoutRow} = new Function(
45-
`${src}; return {payoutAddressCell, payoutRow};`
43+
const src = [extract('esc'), extract('payoutAddressCell'), extract('balanceCell'), extract('payoutRow')].join('\n');
44+
// payoutRow reads the module-level _balances map, so give it an empty one --
45+
// the balance cell is exercised directly below.
46+
const {payoutAddressCell, payoutRow, balanceCell} = new Function(
47+
`const _balances = {}; ${src}; return {payoutAddressCell, payoutRow, balanceCell};`
4648
)();
4749

4850
let failures = 0;
@@ -135,6 +137,43 @@ check(
135137
payoutAddressCell({})
136138
);
137139

140+
// ---------------------------------------------------------------------------
141+
// The on-chain column (dv6 tier 2). Same rule, one level down.
142+
// ---------------------------------------------------------------------------
143+
const bKnown = balanceCell({state: 'known', amount: '1.5', symbol: 'ETH'});
144+
const bZero = balanceCell({state: 'known', amount: '0', symbol: 'ETH'});
145+
const bUnreachable = balanceCell({state: 'unreachable', detail: 'timeout'});
146+
const bUnsupported = balanceCell({state: 'unsupported'});
147+
const bInvalid = balanceCell({state: 'invalid'});
148+
const bNone = balanceCell(undefined);
149+
150+
check('a known balance is shown with its symbol', bKnown.includes('1.5') && bKnown.includes('ETH'), bKnown);
151+
check('a real zero balance IS shown as 0', bZero.includes('0'), bZero);
152+
153+
check(
154+
'THE ONE THAT MATTERS: unreachable does not render as a number',
155+
!/\d/.test(bUnreachable.replace(/[^>]*>/g, '')) && /could not check/i.test(bUnreachable),
156+
bUnreachable
157+
);
158+
check(
159+
'unreachable and a zero balance look different',
160+
bUnreachable !== bZero && !bUnreachable.includes('payout-balance"'),
161+
`unreachable=${bUnreachable}\n zero=${bZero}`
162+
);
163+
check(
164+
'a row that was never queried is an em dash, not "could not check"',
165+
bNone.includes('&mdash;') && !/could not check/i.test(bNone),
166+
bNone
167+
);
168+
check('unsupported is also an em dash, not an error', bUnsupported.includes('&mdash;'), bUnsupported);
169+
check('an invalid address says so', /not valid/i.test(bInvalid), bInvalid);
170+
171+
check(
172+
'CONTROL: a hostile amount is escaped in the balance cell',
173+
!balanceCell({state: 'known', amount: '<script>x</script>', symbol: 'E'}).includes('<script>'),
174+
balanceCell({state: 'known', amount: '<script>x</script>', symbol: 'E'})
175+
);
176+
138177
if (failures) {
139178
console.error(`\n${failures} payout-registry render check(s) failed`);
140179
process.exit(1);

0 commit comments

Comments
 (0)