diff --git a/ads.js b/ads.js index 51e6cef..48c95c0 100644 Binary files a/ads.js and b/ads.js differ diff --git a/db.js b/db.js index ba823bb..6938ec1 100644 --- a/db.js +++ b/db.js @@ -120,6 +120,7 @@ async function bootstrap() { 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 alterSafe('ALTER TABLE campaigns ADD COLUMN drip_id INT NULL'); // syndicated DripOffers campaigns.id (2026-09-18) await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day); any type: scheduled start await q(`CREATE TABLE IF NOT EXISTS camp_hours ( diff --git a/dripoffers.js b/dripoffers.js new file mode 100644 index 0000000..d2443c2 --- /dev/null +++ b/dripoffers.js @@ -0,0 +1,161 @@ +// Syndicate member campaigns to DripOffers as paid-per-click offers. +// +// Third syndication rail, and the one that behaves least like the other two. Network Ad Space +// and AdRevLinks both serve impressions into a rotation. DripOffers serves *clicks*: a real +// person picks the offer off an offerwall, visits the target URL, and has to stay the pack's +// dwell time before anybody gets paid. So a DripOffers click is worth far more than an +// impression, and there are far fewer of them. +// +// The DripOffers database is not reachable from this server, so we talk to a narrow +// authenticated endpoint that runs there (public_html/iap-bridge.php) and does the insert +// locally. Six actions, no general query surface. +// +// FEATURE-FLAGGED: inert unless DRIPOFFERS_BRIDGE_URL and DRIPOFFERS_BRIDGE_KEY are set. +// +// Three things carried over deliberately from the NAS and AdRevLinks work: +// +// 1. DELIVERY IS READ, NEVER DERIVED. `status` counts rows in that platform's permanent +// per-click ledger. We never compute delivery from "ordered minus remaining", because +// remaining is mutable and deriving from it is what charged members for impressions that +// never ran on NAS. +// +// 2. EVERY WRITE IS IDEMPOTENT. Each campaign carries ref "iap:" behind a UNIQUE index +// on the far side, so a retry after a timeout cannot book a second campaign. +// +// 3. A SYNDICATION HICCUP NEVER BLOCKS THE MEMBER. Callers swallow errors; the IAP campaign +// goes live either way. +// +// Capacity note (2026-09-18): the offerwall is a menu, not a rotator - every eligible campaign +// is listed at once, ordered by reward, and the member chooses. So adding campaigns does not +// starve the others the way the AdRevLinks rotator does; it lengthens the menu. What is finite +// is total clicks, running around 2,700 a day across the whole platform. Hence MAX_CLICKS +// below: one campaign booking 50,000 clicks would sit in the list for weeks. Cost is not the +// reason for the caps - Marty owns the platform and treats these placements as free. +'use strict'; +const https = require('https'); +const { URL } = require('url'); + +const MIN_CREDITS = Number(process.env.DRIPOFFERS_MIN_CREDITS || 1000); // below this, not worth a listing +const MAX_CLICKS = Number(process.env.DRIPOFFERS_MAX_CLICKS || 10000); +const MIN_CLICKS = 500; // the platform's own floor +const PACK_ID = Number(process.env.DRIPOFFERS_PACK_ID || 2); // 2 = a 10-second dwell +const USER_ID = Number(process.env.DRIPOFFERS_USER_ID || 1); // the funding account + +// Which IAP formats make sense as a click offer: the ones whose whole point is sending a +// person to the target URL. Video and solo are a different product, and featured is a slot +// buy on our own site rather than a destination. +const KINDS = ['banner', 'text', 'visits']; + +function enabled() { + return !!(process.env.DRIPOFFERS_BRIDGE_URL && process.env.DRIPOFFERS_BRIDGE_KEY); +} + +function call(action, payload, timeoutMs) { + return new Promise((resolve, reject) => { + if (!enabled()) return reject(new Error('dripoffers-disabled')); + const u = new URL(process.env.DRIPOFFERS_BRIDGE_URL); + const body = JSON.stringify(Object.assign({ action }, payload || {})); + const req = https.request({ + hostname: u.hostname, path: u.pathname + u.search, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), + 'X-Bridge-Key': process.env.DRIPOFFERS_BRIDGE_KEY }, + timeout: timeoutMs || 25000 + }, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + let j = null; + try { j = JSON.parse(d); } catch (e) {} + if (!j) return reject(new Error('dripoffers ' + res.statusCode + ': ' + d.slice(0, 120))); + if (j.error) return reject(new Error(j.error)); + resolve(j); + }); + }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('dripoffers timeout'))); + req.end(body); + }); +} + +const refFor = campaignId => 'iap:' + Number(campaignId); +const kindOk = type => KINDS.includes(String(type || '')); + +// How many clicks a campaign's remaining credits are worth, capped so one campaign cannot +// park itself at the top of the offerwall for a month. +function clicksFor(credits) { + const v = Math.max(0, Math.floor(Number(credits) || 0)); + return Math.min(v, MAX_CLICKS); +} + +// Country targeting. Unlike the AdRevLinks popup rail - which only has prices for five +// countries and so cannot carry a targeted campaign at all - this platform filters on an +// explicit country list, so geo-restricted campaigns CAN syndicate here. That is the point of +// including geo support: those campaigns currently reach no external rail at all. +// +// Tier 3 is the exception. It is defined as "everywhere the other two tiers are not", which an +// inclusion list cannot express, so a tier-3-only campaign is skipped rather than sent +// worldwide - sending it worldwide would quietly deliver the Tier 1 traffic its owner chose +// not to target. +function countriesFor(geo, tiers) { + const g = String(geo || '').trim(); + if (!g) return '*'; + const want = g.split(',').map(s => s.trim()).filter(Boolean); + if (want.length === 3) return '*'; + const out = []; + if (want.includes('1')) out.push(...(tiers && tiers.t1 ? tiers.t1 : [])); + if (want.includes('2')) out.push(...(tiers && tiers.t2 ? tiers.t2 : [])); + if (!out.length) return null; // tier 3 only: not expressible, skip + return [...new Set(out.map(c => String(c).toUpperCase()))].join(','); +} + +// Push a campaign. Returns null when it is not worth syndicating (or cannot be targeted +// faithfully) rather than throwing, so the caller treats "too small", "wrong format" and +// "disabled" the same quiet way. +async function push(campaign, opts) { + if (!enabled()) return null; + if (!kindOk(campaign.type)) return null; + const credits = Number((campaign.budget || 0) - (campaign.spent || 0) - (campaign.accrued || 0)); + if (credits < MIN_CREDITS) return null; + const clicks = clicksFor(credits); + if (clicks < MIN_CLICKS) return null; + + const url = String(campaign.targetUrl || campaign.target_url || '').trim(); + if (!/^https?:\/\//i.test(url)) return null; + + const countries = countriesFor(campaign.geo, (opts && opts.tiers) || null); + if (countries === null) return null; + + const title = String(campaign.name || campaign.title || 'InstantAdPay').slice(0, 250); + // The offerwall shows this line under the title, so it has to read like an offer rather + // than like an internal campaign record. + const description = String(campaign.title || campaign.body || campaign.name || title).slice(0, 250); + + return await call('create_ptc', { + title, description, url, clicks, countries, + device: 0, + pack_id: (opts && opts.packId) || PACK_ID, + user_id: USER_ID, + ref: refFor(campaign.id), + activate: !(opts && opts.paused) + }); +} + +// Real delivery, counted from the platform's own click ledger. Never derived. +async function readServed(campaignId) { + if (!enabled()) return null; + try { return await call('status', { ref: refFor(campaignId) }); } + catch (e) { if (/not found/i.test(e.message)) return null; throw e; } +} + +async function pause(campaignId) { return enabled() ? call('pause', { ref: refFor(campaignId) }) : null; } +async function resume(campaignId) { return enabled() ? call('resume', { ref: refFor(campaignId) }) : null; } +async function remove(campaignId) { + if (!enabled()) return null; + try { return await call('delete', { ref: refFor(campaignId) }); } + catch (e) { if (/not found/i.test(e.message)) return null; throw e; } +} +async function packs() { return enabled() ? call('packs', {}) : null; } + +module.exports = { enabled, push, readServed, pause, resume, remove, packs, + clicksFor, countriesFor, kindOk, refFor, + MIN_CREDITS, MAX_CLICKS, MIN_CLICKS, PACK_ID, KINDS }; diff --git a/qa/dripoffers-bridge.mjs b/qa/dripoffers-bridge.mjs new file mode 100644 index 0000000..ea5bbad --- /dev/null +++ b/qa/dripoffers-bridge.mjs @@ -0,0 +1,91 @@ +// DripOffers paid-per-click syndication, driven against the REAL bridge on dripoffers.com. +// +// Creates a paused test campaign, reads its delivery back, pauses/resumes it, proves a retry +// cannot double-book, then deletes it. Nothing is left behind and nothing is ever activated, +// so no publisher is ever paid for a test click. +// +// DRIPOFFERS_BRIDGE_URL=... DRIPOFFERS_BRIDGE_KEY=... node qa/dripoffers-bridge.mjs +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const drip = require('../dripoffers.js'); + +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; + +// ---- pure logic first: these run with or without a configured bridge ------ +const tiers = { t1: new Set(['US', 'CA', 'GB']), t2: new Set(['BR', 'PL']) }; +t('no targeting means worldwide', drip.countriesFor('', tiers) === '*', drip.countriesFor('', tiers)); +t('all three tiers collapse to worldwide', drip.countriesFor('1,2,3', tiers) === '*', drip.countriesFor('1,2,3', tiers)); +t('tier 1 becomes a country list', drip.countriesFor('1', tiers) === 'US,CA,GB', drip.countriesFor('1', tiers)); +t('tier 1+2 merges both lists', drip.countriesFor('1,2', tiers) === 'US,CA,GB,BR,PL', drip.countriesFor('1,2', tiers)); +// tier 3 is "everywhere the others are not" — an inclusion list cannot say that, and sending it +// worldwide would deliver the Tier 1 traffic its owner deliberately excluded +t('tier 3 alone is skipped, not silently widened', drip.countriesFor('3', tiers) === null, String(drip.countriesFor('3', tiers))); + +t('credits convert to clicks one for one', drip.clicksFor(4000) === 4000, String(drip.clicksFor(4000))); +t('one campaign cannot swallow the platform', drip.clicksFor(500000) === drip.MAX_CLICKS, String(drip.clicksFor(500000))); +t('a click format is accepted', drip.kindOk('banner') && drip.kindOk('visits') && drip.kindOk('text')); +t('video and solo are not click formats', !drip.kindOk('video') && !drip.kindOk('solo') && !drip.kindOk('featured')); + +if (!drip.enabled()) { + console.log('PASS ' + ok.length + ' (pure logic only)'); + for (const b of bad) console.log('FAIL ' + b); + console.log('SKIP: DRIPOFFERS_BRIDGE_URL / _KEY not set — live bridge checks not run'); + process.exit(bad.length ? 1 : 0); +} + +// ---- live bridge --------------------------------------------------------- +const ID = 999000 + Math.floor(Math.random() * 900); +const camp = { id: ID, name: 'QA bridge campaign', title: 'A test offer that is never activated', + targetUrl: 'https://instantadpay.com/', budget: 12000, spent: 0, accrued: 0, type: 'banner' }; + +// a campaign below the credit floor is skipped quietly rather than taking a listing +const small = await drip.push({ ...camp, id: ID + 1, budget: 200 }, { paused: true, tiers }); +t('a campaign under the credit floor is not syndicated', small === null, JSON.stringify(small)); + +// a format that is not a click offer never goes out +const wrongKind = await drip.push({ ...camp, id: ID + 2, type: 'video' }, { paused: true, tiers }); +t('a video campaign is not syndicated as a click offer', wrongKind === null, JSON.stringify(wrongKind)); + +// the real push +const r = await drip.push(camp, { paused: true, tiers }); +t('campaign syndicated', !!(r && r.campaign_id), JSON.stringify(r)); +t('clicks are capped at the per-campaign ceiling', r && r.clicks_booked === drip.MAX_CLICKS, r && String(r.clicks_booked)); +t('untargeted campaign goes worldwide', r && r.countries === '*', r && String(r.countries)); +t('created paused, so no publisher is paid by the test', r && r.status === 0, r && String(r.status)); +t('the funding account was actually debited', r && r.charged === true, r && String(r.charged)); + +// idempotency: the guard that stops a retry double-booking +const again = await drip.push(camp, { paused: true, tiers }); +t('a retry returns the SAME campaign, never a second', again && again.campaign_id === r.campaign_id && again.duplicate === true, + JSON.stringify(again)); + +// delivery is counted from the click ledger, never derived from remaining_visits +const st = await drip.readServed(ID); +t('delivery reads back from the platform', !!(st && st.ok), JSON.stringify(st).slice(0, 140)); +t('ordered clicks match what was booked', st && st.ordered_clicks === drip.MAX_CLICKS, st && String(st.ordered_clicks)); +t('delivered starts at zero and is a counted figure', st && st.delivered_clicks === 0, st && String(st.delivered_clicks)); + +// pause / resume mirror — and the reason this matters: a pause must NOT read back as delivered +await drip.resume(ID); +let s2 = await drip.readServed(ID); +t('resume activates it', s2 && s2.status === 1, s2 && String(s2.status)); +await drip.pause(ID); +s2 = await drip.readServed(ID); +t('pause deactivates it', s2 && s2.status === 0, s2 && String(s2.status)); +t('pausing does not fake delivery', s2 && s2.delivered_clicks === 0 && s2.remaining_visits === drip.MAX_CLICKS, + s2 && (s2.delivered_clicks + '/' + s2.remaining_visits)); + +// cleanup must actually work, or tests litter a production platform +const del = await drip.remove(ID); +t('the test campaign is deleted', !!(del && del.ok), JSON.stringify(del)); +const gone = await drip.readServed(ID); +t('and it is really gone', gone === null, JSON.stringify(gone)); + +// an unknown campaign reads as null rather than throwing +const none = await drip.readServed(ID + 12345); +t('an unknown campaign reads as null', none === null, JSON.stringify(none)); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +process.exit(bad.length ? 1 : 0); diff --git a/server.js b/server.js index 44b32c9..0ce53f6 100644 --- a/server.js +++ b/server.js @@ -386,7 +386,7 @@ async function boot() { chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message)); } }); auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' }); accounts.init({ dataDir: DATA_DIR }); - ads.init({ dataDir: DATA_DIR, chain }); + ads.init({ dataDir: DATA_DIR, chain, tiers: () => geo.tierLists(siteConfig()) }); mailer.init({ dataDir: DATA_DIR }); messages.init({ dataDir: DATA_DIR }); reports.init({ dataDir: DATA_DIR });