NAS syndication adapter (feature-flagged off): push banner/text to Network Ad Space, reconcile delivery into unified credit pool

- nas.js: direct-MySQL writer mirroring CTB's proven payments+sponsorads path (approved:1, pid 1/2, catid 5, remaining counts DOWN)
- Create-time push for banner/text; pause/resume mirrors to NAS deactivate/topUp
- reconcileNas() 5-min loop: reads served, charges delta credits to the shared budget, stops NAS ad when budget dry
- Campaign row shows IAP + '+N net' network views (differentiated stats)
- Inert unless NAS_DB_* env is set; zero prod impact until enabled
- Also: rename Inbox sub-tab to 'Inbox Ads'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 08:11:32 -05:00
parent 860aedabf6
commit 57bba0111a
12 changed files with 153 additions and 28 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -108,6 +108,8 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN cta_label VARCHAR(40) NULL');
await alterSafe('ALTER TABLE campaigns ADD COLUMN width INT NULL'); // banner size (IAB) → also NAS width
await alterSafe('ALTER TABLE campaigns ADD COLUMN height INT NULL'); // banner size (IAB) → also NAS height
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_ad_id INT NULL'); // syndicated NAS sponsorads.ID
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await q(`CREATE TABLE IF NOT EXISTS burns (
id VARCHAR(32) PRIMARY KEY,
member_id INT NOT NULL,
+114
View File
@@ -0,0 +1,114 @@
// NAS syndication — pushes IAP campaigns out to Network Ad Space (Marty's own
// EvolutionScript platform; no source, so its MySQL DB IS the API). Mirrors the
// CTB Rewards direct-write pattern: INSERT payments then sponsorads with
// approved:1 (bypasses NAS moderation), and reads `remaining` back to reconcile
// spend into IAP's unified credit pool.
//
// KNOWN NAS TRUTHS (measured across RM Circle + CTB, 1600+ ads):
// - sponsorads.remaining counts DOWN: served = assigned - remaining.
// - pid 1 = text, pid 2 = banner ONLY; banner size lives in width/height.
// - hits = clicks (not impressions). catid 5 = Cryptocurrencies.
// - NAS drifts its own counters upward post-insert → clamp served to assigned.
//
// FEATURE-FLAGGED: inert unless NAS_DB_HOST/USER/PASSWORD/NAME are all set.
// Nothing here runs (no connection, no writes) when the flag is off.
const crypto = require('crypto');
let pool = null;
function enabled() {
return !!(process.env.NAS_DB_HOST && process.env.NAS_DB_USER
&& process.env.NAS_DB_PASSWORD && process.env.NAS_DB_NAME);
}
function nasPool() {
if (pool) return pool;
const mysql = require('mysql2/promise');
pool = mysql.createPool({
host: process.env.NAS_DB_HOST,
port: Number(process.env.NAS_DB_PORT || 3306),
user: process.env.NAS_DB_USER,
password: process.env.NAS_DB_PASSWORD,
database: process.env.NAS_DB_NAME,
charset: 'latin1', // EvolutionScript is latin1
connectionLimit: 3,
connectTimeout: 10000
});
return pool;
}
async function q(sql, args) { const [r] = await nasPool().query(sql, args); return r; }
const CATID_CRYPTO = Number(process.env.NAS_CATID || 5);
// how many NAS impressions one IAP credit buys, per format. IAP charges credits
// on its own surfaces at these same rates, so NAS delivery draws the same pool.
function impressionsPerCredit(type) {
return type === 'banner' ? 5 : type === 'text' ? 10 : type === 'video' ? 0 : 0;
}
function nasKind(type) {
if (type === 'banner') return { pid: 2, adtype: 'banner' };
if (type === 'text') return { pid: 1, adtype: 'text' };
return null; // only banner/text syndicate to NAS in v1 (login/solo/video are IAP-native)
}
// push one IAP campaign into NAS. `c` is a pubC-shaped campaign. Returns
// { nasAdId, assigned } or { skipped } / throws on a real DB error.
async function pushCampaign(c, opts = {}) {
if (!enabled()) return { skipped: 'flag-off' };
const kind = nasKind(c.type);
if (!kind) return { skipped: 'type' };
const budgetLeft = c.budget - (c.spent || 0);
const assigned = Math.max(1, Math.floor(budgetLeft * impressionsPerCredit(c.type)));
const token = 'iap_' + crypto.randomBytes(8).toString('hex'); // manage token = Username
const now = new Date();
const days = Number(opts.days || 30);
const edate = new Date(now.getTime() + days * 86400000);
const payref = 'iap_campaign:' + c.id;
// payments first (MyISAM, no txn) — hand-rollback the row if sponsorads fails
const pay = await q(
'INSERT INTO payments (Username, Amount, Currency_code, status, Date, pay_address) VALUES (?,?,?,1,?,?)',
[token, 0, 'IAP_CREDIT', now, payref]);
try {
const ad = await q(
`INSERT INTO sponsorads
(Username, Subject, Body, WebsiteURL, assigned, remaining, hits, approved, Date, adtype,
Name, Email, PaymentDetails, EDate, sp, width, height, BannerURL, pid, ref_by, catid)
VALUES (?,?,?,?,?,?,0,1,?,?,?,?,?,?,'',?,?,?,?,0,?)`,
[token, c.title || null, c.type === 'text' ? (c.body || null) : null, c.targetUrl,
assigned, assigned, now, kind.adtype,
opts.name || 'InstantAdPay member', opts.email || 'ads@instantadpay.com',
'InstantAdPay campaign #' + c.id, edate,
c.width || '', c.height || '', c.type === 'banner' ? c.imageUrl : null, kind.pid, CATID_CRYPTO]);
return { nasAdId: ad.insertId, manageToken: token, assigned };
} catch (e) {
try { await q('DELETE FROM payments WHERE ID=?', [pay.insertId]); } catch (e2) {}
throw e;
}
}
// read served count for a syndicated ad (assigned - remaining, clamped ≥0 and
// ≤ assigned because NAS drifts counters upward post-insert).
async function readServed(nasAdId) {
if (!enabled()) return null;
const rows = await q('SELECT assigned, remaining, hits FROM sponsorads WHERE ID=?', [Number(nasAdId)]);
if (!rows.length) return null;
const assigned = Number(rows[0].assigned) || 0;
const remaining = Number(rows[0].remaining) || 0;
const served = Math.max(0, Math.min(assigned, assigned - remaining));
return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 };
}
// stop a syndicated ad (budget spent, paused, or expired) — remaining:0 halts serving.
async function deactivate(nasAdId) {
if (!enabled()) return { skipped: 'flag-off' };
await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]);
return { ok: true };
}
// top up a syndicated ad with more impressions (buy-more-views / reactivate).
async function topUp(nasAdId, addImpressions, days) {
if (!enabled()) return { skipped: 'flag-off' };
await q(`UPDATE sponsorads SET assigned=assigned+?, remaining=remaining+?, approved=1,
EDate=DATE_ADD(NOW(), INTERVAL ? DAY) WHERE ID=?`,
[Number(addImpressions), Number(addImpressions), Number(days || 30), Number(nasAdId)]);
return { ok: true };
}
module.exports = { enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind };
+3 -1
View File
@@ -342,7 +342,9 @@
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td>'
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + '</td>'
+ '<td class="num">' + c.imps.toLocaleString() + '</td><td class="num">' + c.clicks + '</td>'
+ '<td class="num">' + c.imps.toLocaleString()
+ (c.impsNas ? ' <span class="muted small" title="views across the network">+' + c.impsNas.toLocaleString() + ' net</span>' : '')
+ '</td><td class="num">' + c.clicks + '</td>'
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>'
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
+4 -4
View File
@@ -5,7 +5,7 @@
<title>The contract | InstantAdPay</title>
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
</head>
<body>
<div class="wrap">
@@ -129,8 +129,8 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/contract.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
<script src="/assets/common.js?v=20260905y"></script>
<script src="/assets/contract.js?v=20260905y"></script>
<script src="/assets/chat.js?v=20260905y"></script>
</body>
</html>
+5 -5
View File
@@ -5,7 +5,7 @@
<title>InstantAdPay: advertise and earn, locked in code</title>
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
</head>
<body>
@@ -410,9 +410,9 @@
</div>
</section>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/wallet.js?v=20260905x"></script>
<script src="/assets/home.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
<script src="/assets/common.js?v=20260905y"></script>
<script src="/assets/wallet.js?v=20260905y"></script>
<script src="/assets/home.js?v=20260905y"></script>
<script src="/assets/chat.js?v=20260905y"></script>
</body>
</html>
+4 -4
View File
@@ -5,7 +5,7 @@
<title>Live ledger | InstantAdPay</title>
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
</head>
<body>
<div class="wrap">
@@ -25,8 +25,8 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/ledger.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
<script src="/assets/common.js?v=20260905y"></script>
<script src="/assets/ledger.js?v=20260905y"></script>
<script src="/assets/chat.js?v=20260905y"></script>
</body>
</html>
+6 -6
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
</head>
<body class="bo-body">
@@ -307,7 +307,7 @@
<div class="subtabs" role="tablist">
<button class="subtab on" data-earn="watch" type="button">Watch ads</button>
<button class="subtab" data-earn="videos" type="button">Watch videos</button>
<button class="subtab" data-earn="inbox" type="button">Inbox<span class="pill" id="inboxBadge2" hidden></span></button>
<button class="subtab" data-earn="inbox" type="button">Inbox Ads<span class="pill" id="inboxBadge2" hidden></span></button>
</div>
<div class="earn-sub" id="earn-watch">
@@ -484,9 +484,9 @@
</div>
</div>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/wallet.js?v=20260905x"></script>
<script src="/assets/my.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
<script src="/assets/common.js?v=20260905y"></script>
<script src="/assets/wallet.js?v=20260905y"></script>
<script src="/assets/my.js?v=20260905y"></script>
<script src="/assets/chat.js?v=20260905y"></script>
</body>
</html>
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Transaction | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
</head>
<body>
<div id="nav"></div>
@@ -33,7 +33,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div>
</section>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/tx.js?v=20260905x"></script>
<script src="/assets/common.js?v=20260905y"></script>
<script src="/assets/tx.js?v=20260905y"></script>
</body>
</html>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewing ad — InstantAdPay</title>
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
<style>
html,body{height:100%;margin:0;overflow:hidden}
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
@@ -43,6 +43,6 @@
</div>
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
</div>
<script src="/assets/view.js?v=20260905x"></script>
<script src="/assets/view.js?v=20260905y"></script>
</body>
</html>
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Banner wall | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<link rel="stylesheet" href="/assets/site.css?v=20260905y">
</head>
<body>
<div id="nav"></div>
@@ -26,7 +26,7 @@
</div>
</div>
</section>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/wall.js?v=20260905x"></script>
<script src="/assets/common.js?v=20260905y"></script>
<script src="/assets/wall.js?v=20260905y"></script>
</body>
</html>
+7
View File
@@ -123,6 +123,13 @@ async function boot() {
chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
// NAS reconcile: pull syndicated delivery into the unified credit pool
// (inert unless NAS_DB_* is set). Every 5 min after a short warm-up.
if (ads.nasEnabled()) {
console.log('NAS syndication enabled');
setTimeout(() => ads.reconcileNas().catch(e => console.error('nas reconcile', e.message)), 90 * 1000);
setInterval(() => ads.reconcileNas().catch(e => console.error('nas reconcile', e.message)), 5 * 60 * 1000);
}
}
function siteConfig() {