diff --git a/lib/paid.js b/lib/paid.js index ef4fbc2..f810118 100644 --- a/lib/paid.js +++ b/lib/paid.js @@ -41,6 +41,11 @@ const DEFAULTS = { // gas together, so a failed-and-retried send or a price spike is already covered. paidReserveBufferPct: 10, paidHouseFloatPol: 50, // POL kept back for house missions, never counted as sellable + // Priced in dollars because that is what a buyer judges; converted at the InstantAdPay + // contract's own rate at purchase (Marty, 2026-09-24). + paidBlockCents: 3000, // $30 a block + paidListingCents: 1000, // $10, once per mission, charged AT APPROVAL after the review is done + paidListingFreeBlocks: 4, // a first order this size or larger gets the listing free }; function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } const r4 = n => Math.round(Number(n) * 10000) / 10000; @@ -63,6 +68,38 @@ function quote(hunterPol, completions) { margin: r4(total - reserve), total, marginPct: pct }; } +// How many completions a block buys. The inverse of quote(): the advertiser fixes the spend and +// the payout they want hunters to get, and the completion count falls out. +// +// NOTE, because it is easy to get backwards: with a fixed-price block the house margin is the +// same whatever the payout. Raising the payout trades completions for SPEED at the same price, +// it does not earn the house more. Margin only moves with the number of blocks sold. So the +// upsell is "more blocks", and a higher payout is the reason a buyer needs more of them. +function blockFor(hunterPol, totalPol) { + const c = cfg(); + const hp = r4(Math.max(Number(c.paidBaseHunterPol), Number(hunterPol) || 0)); + const gas = Number(c.paidGasPol) || 0; + const buf = 1 + (Math.max(0, Number(c.paidReserveBufferPct) || 0) / 100); + const pct = Math.min(90, Math.max(0, Number(c.paidMarginPct) || 0)); + const reserve = Number(totalPol) * (1 - pct / 100); + const n = Math.floor(reserve / ((hp + gas) * buf)); + return Object.assign(quote(hp, n), { requestedPol: r4(Number(totalPol)) }); +} + +// What this order costs all in. The listing fee is charged ONCE per mission, at approval, because +// it pays for the review that has already happened by then. It is never charged again on that +// mission, and a first order of paidListingFreeBlocks or more waives it: a serious first buyer +// should not be taxed for the privilege. +function orderTotal(blocks, alreadyListed) { + const c = cfg(); + const n = Math.max(1, Math.round(Number(blocks) || 1)); + const blockCents = Number(c.paidBlockCents) * n; + const waived = alreadyListed || n >= Number(c.paidListingFreeBlocks); + const listingCents = waived ? 0 : Number(c.paidListingCents); + return { blocks: n, blockCents, listingCents, totalCents: blockCents + listingCents, + listingWaived: waived, listingReason: alreadyListed ? 'already listed' : (waived ? 'waived, ' + n + ' blocks' : 'first order') }; +} + // ---- reserves --------------------------------------------------------------------------- const paidMissions = () => store.read('missions', []).filter(m => m.paid); @@ -114,4 +151,4 @@ function isPaid(missionId) { return !!(m && m.paid); } -module.exports = { cfg, quote, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions }; +module.exports = { cfg, quote, blockFor, orderTotal, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions }; diff --git a/lib/polusd.js b/lib/polusd.js new file mode 100644 index 0000000..de5a3c6 --- /dev/null +++ b/lib/polusd.js @@ -0,0 +1,89 @@ +// What a dollar is worth in POL right now. +// +// Mission blocks are priced in dollars ($30 entry, $10 listing) because that is how Marty thinks +// about them and how a buyer judges them. The faucet pays in POL. So something has to convert, +// and it has to be a rate nobody can argue with. +// +// It is read from the InstantAdPay contract's own quote: the contract stores each package's price +// in cents and quotes the POL to pay right now, so priceCents / quoteWei IS the rate members are +// already paying at today. No third-party price feed, no oracle to trust, no disagreement between +// what a mission costs here and what a package costs there. +// +// Cached for a few minutes and fail-soft: a purchase is refused rather than priced off a stale or +// missing rate, because getting this wrong silently means selling delivery below cost. +'use strict'; +const https = require('https'); + +const IAP = '0xbe1eca72aff47d13d8e907e523f55eb9e2d365e0'; +const RPCS = ['https://polygon-bor-rpc.publicnode.com', 'https://1rpc.io/matic']; +const SEL_COUNT = '0xe0f6ef87'; // productCount() +const SEL_PRODUCT = '0xbf712fd6'; // products(uint32) -> priceCents is word 0, active word 3 +const SEL_QUOTE = '0x7de85694'; // quoteWei(uint32) +const TTL = 5 * 60000; + +let cache = { usdPerPol: 0, at: 0 }; + +function rpc(method, params) { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }); + return new Promise((resolve, reject) => { + let i = 0; + const attempt = () => { + const u = new URL(RPCS[i]); + const rq = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST', timeout: 12000, + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, res => { + let d = ''; res.on('data', c => d += c); + res.on('end', () => { + try { const j = JSON.parse(d); if (j.error) throw new Error(j.error.message); resolve(j.result); } + catch (e) { if (++i < RPCS.length) attempt(); else reject(e); } + }); + }); + rq.on('error', () => { if (++i < RPCS.length) attempt(); else reject(new Error('rpc unreachable')); }); + rq.on('timeout', () => { rq.destroy(); }); + rq.end(body); + }; + attempt(); + }); +} +const call = data => rpc('eth_call', [{ to: IAP, data }, 'latest']); +const enc = n => BigInt(n).toString(16).padStart(64, '0'); +const word = (r, i) => r.slice(2 + i * 64, 2 + (i + 1) * 64); + +// Walk the catalogue until a product quotes a non-zero POL cost, then take cents / POL from it. +async function fetchRate() { + const n = parseInt(word(await call(SEL_COUNT), 0), 16); + for (let id = 1; id <= Math.min(n, 12); id++) { + let wei = 0n, cents = 0; + try { wei = BigInt('0x' + word(await call(SEL_QUOTE + enc(id)), 0)); } catch (e) { continue; } + if (wei <= 0n) continue; + try { + const p = await call(SEL_PRODUCT + enc(id)); + cents = parseInt(word(p, 0), 16); // priceCents + const active = parseInt(word(p, 3), 16) === 1; + const exists = parseInt(word(p, 4), 16) === 1; + if (!active || !exists) cents = 0; + } catch (e) { /* fall through */ } + if (!cents) continue; + const pol = Number(wei / 10n ** 12n) / 1e6; + if (pol > 0) return (cents / 100) / pol; // dollars per POL + } + throw new Error('no product quoted a usable price'); +} + +// dollars per POL, e.g. 0.1019 +async function usdPerPol(force) { + if (!force && cache.usdPerPol > 0 && Date.now() - cache.at < TTL) return cache.usdPerPol; + const r = await fetchRate(); + if (!(r > 0) || !isFinite(r)) throw new Error('rate came back unusable'); + cache = { usdPerPol: r, at: Date.now() }; + return r; +} + +// POL needed for a price in cents. Throws rather than guessing: a purchase priced off a rate we +// could not read is a purchase that might sell delivery below cost. +async function polForCents(cents) { + const rate = await usdPerPol(); + const pol = (Number(cents) / 100) / rate; + return Math.round(pol * 10000) / 10000; +} + +module.exports = { usdPerPol, polForCents, _cache: () => cache };