openvibers/openvibe.live Commits

Live: OpenCoins come only from the Network wallet; nothing reads users.openvibe_coins_balance (#80)

OP OpenVibers committed 10 Oct 2026 · 7dd72bfce7 · 9 files, +58 −38

* Live: OpenCoins come only from the Network wallet; nothing reads users.openvibe_coins_balance

Plan T4: delete "the `users` columns WS-B left" from Live, convert-verify-delete; this PR is the convert half for `users.openvibe_coins_balance`. OpenCoins are each person's OpenVibe.Network wallet (`wallet-client.js`): every earn and spend already went there, and nothing wrote the column. But three things still **read** it, so they showed a frozen number as if it were the balance.

**What changes:**
- `opencoins.getGold()` (`GET /api/coins/balance`) no longer falls back to the frozen column when the wallet cannot answer. It returns `null`, and the navbar chip, the user menu and the dashboard show "—" instead of a stale number or a made-up 0.
- `/api/auth`'s own-user record no longer carries `openvibe_coins_balance`.
- The chat profile card loses its coins stat: it showed the frozen column (or 0), and no one else's wallet is readable by Live anyway. `getUserProfile` stops selecting the column, and Chat's context route comment says so.
- The dead `db.addOpenCoins`/`db.deductOpenCoins` (no callers) are deleted.
- `test/coins-column-unread.test.js` fails if anything under `server/`, `scripts/` or `public/js/` names the column again. The public serializer's never-expose list is the one allowed mention, until the column is gone.

**Next:** a contract migration drops the column once this has been deployed through the N-1 window.

`ov test`: 100 files reached, green.

* /api/auth/me keeps openvibe_coins_balance as null for one release (the N-1 pages expect the field)

Changes

ddiff --git a/public/js/app.js b/public/js/app.js
iindex 9d3b9e25..77495cbb 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -768,12 +768,13 @@ async function loadBalance() {
// Navbar OpenCoins = the GLOBAL currency (game / cosmetics / media wallet).
try {
const coinData = await api('/coins/balance');
-- const coins = coinData.balance || 0;
++ // null: the Network wallet did not answer. Say so ("—") rather than show 0 as the balance.
++ const coins = coinData.balance == null ? '—' : Number(coinData.balance).toLocaleString();
const coinEl = document.getElementById('nav-coins-amount');
-- if (coinEl) coinEl.textContent = coins.toLocaleString();
-- if (window.LiveNav) window.LiveNav.setChip('coins', coins.toLocaleString());
++ if (coinEl) coinEl.textContent = coins;
++ if (window.LiveNav) window.LiveNav.setChip('coins', coins);
const udC = document.getElementById('ud-coins');
-- if (udC) udC.textContent = coins.toLocaleString();
++ if (udC) udC.textContent = coins;
} catch { /* silent */ }
}
ddiff --git a/public/js/chat-shared.js b/public/js/chat-shared.js
iindex 504622b5..d9a0888e 100644
---- a/public/js/chat-shared.js
++++ b/public/js/chat-shared.js
@@@ -461,7 +461,6 @@
</div>
</div>`;
}
-- const coins = p.openvibe_coins_balance || 0;
// Chat answers the message count; a cold cache leaves it null and the card omits the stat
// rather than showing a stale mirror number as real.
const msgs = p.messageCount == null ? null : Number(p.messageCount);
@@@ -479,7 +478,6 @@
</div>
</div>
<div class="ctx-stats">
-- <div class="ctx-stat"><i class="fa-solid fa-coins"></i> ${numf(coins)}</div>
${msgs == null ? '' : `<div class="ctx-stat"><i class="fa-solid fa-message"></i> ${numf(msgs)}</div>`}
<div class="ctx-stat"><i class="fa-solid fa-heart"></i> ${numf(p.followerCount || 0)}</div>
</div>
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 230f7621..2fe65550 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -1163,8 +1163,7 @@ async function loadDashCoins() {
if (!currentUser) return;
try {
const data = await api('/coins/balance');
-- const bal = data.balance || 0;
-- document.getElementById('dash-coins-amount').textContent = bal.toLocaleString();
++ document.getElementById('dash-coins-amount').textContent = data.balance == null ? '—' : Number(data.balance).toLocaleString();
} catch { /* silent */ }
}
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 57b8a5a3..c221378e 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -654,7 +654,9 @@ async function sanitizeUser(user, publicOnly = false) {
// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never
// shown as live); the balance comes from /api/funds/balance.
if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;
-- safe.openvibe_coins_balance = user.openvibe_coins_balance;
++ // Kept as null for one release: the previous release's pages expect the field in /api/auth/me (test/n-1.test.js).
++ // OpenCoins come from /api/coins/balance (the Network wallet); the frozen column is never read. Goes with the column.
++ safe.openvibe_coins_balance = null;
safe.stream_key = user.stream_key;
}
return safe;
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 55fb8893..557ba6dc 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -115,7 +115,7 @@ contextRouter.get('/users/profile', async (req, res) => {
if (!user) return fail(res, 404, 'User not found');
const profile = await db.getUserProfile(user.id);
if (!profile) return fail(res, 404, 'Profile not found');
-- // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.
++ // Vibes (real money) and presence stay with the user. No OpenCoins: they are each person's Network wallet.
const viewerId = int(req.query.viewer_id, 0);
if (!viewerId || viewerId !== user.id) {
delete profile.openvibe_bucks_balance;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 4b835efb..6faf352a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -2162,7 +2162,7 @@ async function countEasterEggSolves(eggDate) {
async function getUserProfile(userId) {
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
-- openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seen
++ openvibe_bucks_balance, created_at, last_seen
FROM users WHERE id = ?`, [userId]);
if (!user) return null;
// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
@@@ -2972,19 +2972,6 @@ async function deleteChannelAiBot(id) {
// ── OpenCoins helpers ───────────────────────────────────────
--async function addOpenCoins(userId, amount) {
-- return await run(`UPDATE users SET openvibe_coins_balance = openvibe_coins_balance + ? WHERE id = ?`,
-- [amount, userId]);
--}
--
--async function deductOpenCoins(userId, amount) {
-- const result = await run(
-- `UPDATE users SET openvibe_coins_balance = openvibe_coins_balance - ? WHERE id = ? AND openvibe_coins_balance >= ?`,
-- [amount, userId, amount]
-- );
-- return result.changes > 0;
--}
--
async function createCoinTransaction({ user_id, stream_id, amount, type, reward_id, message }) {
return await run(
`INSERT INTO coin_transactions (user_id, stream_id, amount, type, reward_id, message)
@@@ -3930,7 +3917,7 @@ module.exports = {
// Transactions (Vibes)
createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,
// OpenCoins
-- addOpenCoins, deductOpenCoins, createCoinTransaction, getCoinTransactions,
++ createCoinTransaction, getCoinTransactions,
// Coin Rewards
createCoinReward, getCoinRewardsByStreamer, getCoinRewardById, updateCoinReward, deleteCoinReward,
// Coin Redemptions
ddiff --git a/server/monetization/opencoins.js b/server/monetization/opencoins.js
iindex 1d979309..749507f7 100644
---- a/server/monetization/opencoins.js
++++ b/server/monetization/opencoins.js
@@@ -9,8 +9,8 @@
*
* 2. OPENCOINS (network-wide wallet) — the OpenVibe.Network-owned balance shared by
* Live/Games/Tools. All reads and earn/spend go through the Network wallet API
-- * (see wallet-client.js); the legacy users.openvibe_coins_balance column is
-- * frozen for the migration script and is never written anymore.
++ * (see wallet-client.js). Live keeps no copy: the legacy users.openvibe_coins_balance column is
++ * read and written by nothing and goes in a contract migration.
*
* Every channel-points debit and credit carries a deterministic per-event key (ADR-012 rule 5,
* db.applyChannelPoints): watch = the watch_time row + minute, chat = the user's minute, follow =
@@@ -286,15 +286,12 @@ class OpenCoins {
}
/**
-- * The network-wide OpenCoins wallet balance (OpenVibe.Network-owned).
-- * Reads via the wallet API using the caller's Network JWT; falls back to the
-- * frozen legacy column only when the wallet is unreachable/unlinked.
++ * The network-wide OpenCoins wallet balance (OpenVibe.Network-owned), read with the caller's Network JWT.
++ * null when the wallet cannot answer (unreachable, or no Network account): the page says so instead of
++ * showing a number that is not the balance.
*/
-- async getGold(userId, userToken = null) {
-- const balance = await wallet.balanceForToken(userToken);
-- if (balance !== null) return balance;
-- const user = await db.getUserById(userId);
-- return user ? (user.openvibe_coins_balance || 0) : 0;
++ async getGold(_userId, userToken = null) {
++ return await wallet.balanceForToken(userToken);
}
/** Server-side earn/spend passthroughs (idempotency keys: `live:<event>:<id>`). */
ddiff --git a/server/monetization/wallet-client.js b/server/monetization/wallet-client.js
iindex a392fd57..26000055 100644
---- a/server/monetization/wallet-client.js
++++ b/server/monetization/wallet-client.js
@@@ -2,9 +2,9 @@
* OpenVibe.Live — OpenCoins wallet client (OpenVibe.Network-owned)
*
* The network-wide OpenCoins wallet lives in OpenVibe.Network (see CONTRACTS.md,
-- * "OpenCoins wallet"). Live's legacy local balance column
-- * (users.openvibe_coins_balance) is frozen for the migration script; every earn/
-- * spend goes through this client now.
++ * "OpenCoins wallet"). Live keeps no balance of its own: every read, earn and spend
++ * goes through this client (the legacy users.openvibe_coins_balance column is read by
++ * nothing; test/coins-column-unread.test.js keeps it that way until it is dropped).
*
* POST /internal/coins/credit|debit|transfer (Live's service token, server-to-server)
* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)
ddiff --git a/test/coins-column-unread.test.js b/test/coins-column-unread.test.js
nnew file mode 100644
iindex 00000000..25590021
---- /dev/null
++++ b/test/coins-column-unread.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++
++// OpenCoins are each person's OpenVibe.Network wallet (server/monetization/wallet-client.js). Live's legacy
++// users.openvibe_coins_balance column is read and written by nothing under server/, scripts/ or public/ (no stale
++// fallback, no copy sent to a page), so the contract migration that drops it breaks nothing. The one mention allowed is
++// the public serializer's list of fields that must never leave the server.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const NAME = /\bopenvibe_coins_balance\b/;
++const ALLOWED = new Set(['server/web/serializers.js']);
++// The one write the previous release needs: /api/auth/me keeps the field, as null, for one release (test/n-1.test.js).
++const NULL_SHAPE = /\bopenvibe_coins_balance\s*=\s*null\s*;/;
++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
++
++function walk(dir, out = []) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'vendor') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);
++ }
++ return out;
++}
++
++const offenders = [];
++for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts')), ...walk(path.join(ROOT, 'public', 'js'))]) {
++ const rel = path.relative(ROOT, file);
++ if (ALLOWED.has(rel)) continue;
++ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
++ if (!isCommentLine(line) && NAME.test(line) && !NULL_SHAPE.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ });
++}
++assert.deepStrictEqual(offenders, [], 'OpenCoins come from the Network wallet (wallet-client.js / opencoins.getGold), never users.openvibe_coins_balance');
++console.log('coins column: nothing reads users.openvibe_coins_balance');