| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138 |
- const crypto = require("crypto");
- const fs = require("fs");
- const path = require("path");
- const pull = require("../server/node_modules/pull-stream");
- const { getConfig } = require("../configs/config-manager.js");
- const { config } = require("../server/SSB_server.js");
- const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
- const MAX_PENDING_EPOCHS = 12;
- const DEFAULT_RULES = {
- epochKind: "MONTHLY",
- alpha: 0.2,
- reserveMin: 500,
- capPerEpoch: 2000,
- caps: { M_max: 3, T_max: 1.5, P_max: 2, cap_user_epoch: 50, w_min: 0.2, w_max: 6 },
- coeffs: { a1: 0.6, a2: 0.4, a3: 0.3, a4: 0.5, b1: 0.5, b2: 1.0 },
- graceDays: 30
- };
- const STORAGE_DIR = path.join(__dirname, "..", "configs");
- const EPOCHS_PATH = path.join(STORAGE_DIR, "banking-epochs.json");
- const TRANSFERS_PATH = path.join(STORAGE_DIR, "banking-allocations.json");
- const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
- function ensureStoreFiles() {
- if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
- if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]");
- if (!fs.existsSync(TRANSFERS_PATH)) fs.writeFileSync(TRANSFERS_PATH, "[]");
- if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
- }
- function epochIdNow() {
- const d = new Date();
- const yyyy = d.getUTCFullYear();
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
- return `${yyyy}-${mm}`;
- }
- async function getAnyWalletAddress() {
- const tryOne = async (method, params = []) => {
- const r = await rpcCall(method, params, "user");
- if (!r) return null;
- if (typeof r === "string" && isValidEcoinAddress(r)) return r;
- if (Array.isArray(r) && r.length && isValidEcoinAddress(r[0])) return r[0];
- if (r && typeof r === "object") {
- const keys = Object.keys(r);
- if (keys.length && isValidEcoinAddress(keys[0])) return keys[0];
- if (r.address && isValidEcoinAddress(r.address)) return r.address;
- }
- return null;
- };
- return await tryOne("getnewaddress")
- || await tryOne("getaddress")
- || await tryOne("getaccountaddress", [""])
- || await tryOne("getaddressesbyaccount", [""])
- || await tryOne("getaddressesbylabel", [""])
- || await tryOne("getaddressesbylabel", ["default"]);
- }
- async function ensureSelfAddressPublished() {
- const me = config.keys.id;
- const local = readAddrMap();
- const current = typeof local[me] === "string" ? local[me] : (local[me] && local[me].address) || null;
- if (current && isValidEcoinAddress(current)) return { status: "present", address: current };
- const cfg = getWalletCfg("user") || {};
- if (!cfg.url) return { status: "skipped" };
- const addr = await getAnyWalletAddress();
- if (addr && isValidEcoinAddress(addr)) {
- const m = readAddrMap();
- m[me] = addr;
- writeAddrMap(m);
- let ssb = null;
- try {
- if (services?.cooler?.open) ssb = await services.cooler.open();
- else if (global.ssb) ssb = global.ssb;
- else {
- try {
- const srv = require("../server/SSB_server.js");
- ssb = srv?.ssb || srv?.server || srv?.default || null;
- } catch (_) {}
- }
- } catch (_) {}
- if (ssb && ssb.publish) {
- await new Promise((resolve, reject) =>
- ssb.publish(
- { type: "wallet", coin: "ECO", address: addr, timestamp: Date.now(), updatedAt: new Date().toISOString() },
- (err) => err ? reject(err) : resolve()
- )
- );
- }
- return { status: "published", address: addr };
- }
- return { status: "error" };
- }
- function readJson(p, d) {
- try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return d; }
- }
- function writeJson(p, v) {
- fs.writeFileSync(p, JSON.stringify(v, null, 2));
- }
- async function rpcCall(method, params, kind = "user") {
- const cfg = getWalletCfg(kind);
- if (!cfg?.url) {
- return null;
- }
- const headers = {
- "Content-Type": "application/json",
- };
- if (cfg.user || cfg.pass) {
- headers.authorization = "Basic " + Buffer.from(`${cfg.user}:${cfg.pass}`).toString("base64");
- }
- try {
- const res = await fetch(cfg.url, {
- method: "POST",
- headers: headers,
- body: JSON.stringify({
- jsonrpc: "1.0",
- id: "oasis",
- method: method,
- params: params,
- }),
- });
- if (!res.ok) {
- return null;
- }
- const data = await res.json();
- if (data.error) {
- return null;
- }
- return data.result;
- } catch (err) {
- return null;
- }
- }
- async function safeGetBalance(kind = "user") {
- try {
- const r = await rpcCall("getbalance", [], kind);
- return Number(r) || 0;
- } catch {
- return 0;
- }
- }
- function readAddrMap() {
- ensureStoreFiles();
- const raw = readJson(ADDR_PATH, {});
- return raw && typeof raw === "object" ? raw : {};
- }
- function writeAddrMap(m) {
- ensureStoreFiles();
- writeJson(ADDR_PATH, m || {});
- }
- function getLogLimit() {
- return getConfig().ssbLogStream?.limit || 1000;
- }
- function isValidEcoinAddress(addr) {
- return typeof addr === "string" && /^[A-Za-z0-9]{20,64}$/.test(addr);
- }
- function getWalletCfg(kind) {
- const cfg = getConfig() || {};
- if (kind === "pub") {
- if (!isPubNode()) return null;
- return cfg.wallet || null;
- }
- return cfg.wallet || null;
- }
- function isPubNode() {
- const pubId = (getConfig() || {}).walletPub?.pubId || "";
- const myId = config?.keys?.id || "";
- return !!pubId && !!myId && pubId === myId;
- }
- function getConfiguredPubId() {
- return (getConfig() || {}).walletPub?.pubId || "";
- }
- function resolveUserId(maybeId) {
- const s = String(maybeId || "").trim();
- if (s) return s;
- return config?.keys?.id || "";
- }
- let FEED_SRC = "none";
- module.exports = ({ services } = {}) => {
- const transfersRepo = {
- listAll: async () => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []); },
- listByTag: async (tag) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).filter(t => (t.tags || []).includes(tag)); },
- findById: async (id) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).find(t => t.id === id) || null; },
- create: async (t) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); all.push(t); writeJson(TRANSFERS_PATH, all); },
- markClosed: async (id, txid) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); const i = all.findIndex(x => x.id === id); if (i >= 0) { all[i].status = "CLOSED"; all[i].txid = txid; writeJson(TRANSFERS_PATH, all); } }
- };
- const epochsRepo = {
- list: async () => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []); },
- save: async (epoch) => { ensureStoreFiles(); const all = readJson(EPOCHS_PATH, []); const i = all.findIndex(e => e.id === epoch.id); if (i >= 0) all[i] = epoch; else all.push(epoch); writeJson(EPOCHS_PATH, all); },
- get: async (id) => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []).find(e => e.id === id) || null; }
- };
- let ssbInstance;
- async function openSsb() {
- if (ssbInstance) return ssbInstance;
- if (services?.cooler?.open) ssbInstance = await services.cooler.open();
- else if (cooler?.open) ssbInstance = await cooler.open();
- else if (global.ssb) ssbInstance = global.ssb;
- else {
- try {
- const srv = require("../server/SSB_server.js");
- ssbInstance = srv?.ssb || srv?.server || srv?.default || null;
- } catch (_) {
- ssbInstance = null;
- }
- }
- return ssbInstance;
- }
- async function scanLogStream() {
- const ssb = await openSsb();
- if (!ssb) return [];
- return new Promise((resolve, reject) =>
- pull(
- ssb.createLogStream({ limit: getLogLimit(), reverse: true }),
- pull.collect((err, arr) => err ? reject(err) : resolve(arr))
- )
- );
- }
- async function getWalletFromSSB(userId) {
- const msgs = await scanLogStream();
- for (const m of msgs) {
- const v = m.value || {};
- const c = v.content || {};
- if (v.author === userId && c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
- return c.address;
- }
- }
- return null;
- }
- async function scanAllWalletsSSB() {
- const latest = {};
- const msgs = await scanLogStream();
- for (const m of msgs) {
- const v = m.value || {};
- const c = v.content || {};
- if (c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
- if (!latest[v.author]) latest[v.author] = c.address;
- }
- }
- return latest;
- }
- async function publishSelfAddress(address) {
- const ssb = await openSsb();
- if (!ssb) return false;
- const msg = { type: "wallet", coin: "ECO", address, updatedAt: new Date().toISOString() };
- await new Promise((resolve, reject) => ssb.publish(msg, (err, val) => err ? reject(err) : resolve(val)));
- return true;
- }
- async function listUsers() {
- const addrLocal = readAddrMap();
- const ids = Object.keys(addrLocal);
- if (ids.length > 0) return ids.map(id => ({ id }));
- return [{ id: config.keys.id }];
- }
- async function getUserAddress(userId) {
- const v = readAddrMap()[userId];
- if (v === "__removed__") return null;
- const local = typeof v === "string" ? v : (v && v.address) || null;
- if (local) return local;
- const ssbAddr = await getWalletFromSSB(userId);
- return ssbAddr;
- }
- async function setUserAddress(userId, address, publishIfSelf) {
- const m = readAddrMap();
- m[userId] = address;
- writeAddrMap(m);
- if (publishIfSelf && idsEqual(userId, config.keys.id)) await publishSelfAddress(address);
- return true;
- }
- async function addAddress({ userId, address }) {
- if (!userId || !address || !isValidEcoinAddress(address)) return { status: "invalid" };
- const m = readAddrMap();
- const prev = m[userId];
- m[userId] = address;
- writeAddrMap(m);
- if (idsEqual(userId, config.keys.id)) await publishSelfAddress(address);
- return { status: prev ? (prev === address || (prev && prev.address === address) ? "exists" : "updated") : "added" };
- }
- async function removeAddress({ userId }) {
- if (!userId) return { status: "invalid" };
- const m = readAddrMap();
- if (m[userId]) {
- delete m[userId];
- writeAddrMap(m);
- return { status: "deleted" };
- }
- const ssbAll = await scanAllWalletsSSB();
- if (!ssbAll[userId]) return { status: "not_found" };
- m[userId] = "__removed__";
- writeAddrMap(m);
- return { status: "deleted" };
- }
- async function listAddressesMerged() {
- const local = readAddrMap();
- const ssbAll = await scanAllWalletsSSB();
- const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]);
- const out = [];
- for (const id of keys) {
- if (local[id] === "__removed__") continue;
- if (local[id]) out.push({ id, address: typeof local[id] === "string" ? local[id] : local[id].address, source: "local" });
- else if (ssbAll[id]) out.push({ id, address: ssbAll[id], source: "ssb" });
- }
- return out;
- }
- function idsEqual(a, b) {
- if (!a || !b) return false;
- const A = String(a).trim();
- const B = String(b).trim();
- if (A === B) return true;
- const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, "");
- return strip(A) === strip(B);
- }
- function inferType(c = {}) {
- if (c.vote) return "vote";
- if (c.votes) return "votes";
- if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
- if (c.type === "ubiClaimResult" && c.txid && c.epochId) return "ubiClaimResult";
- if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
- if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
- if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
- if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe";
- if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event";
- if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task";
- if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report";
- if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job";
- if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio";
- if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video";
- if (typeof c.url !== "undefined" && c.title && c.key) return "document";
- if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed";
- if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post";
- if (typeof c.contact !== "undefined") return "contact";
- if (typeof c.about !== "undefined") return "about";
- if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer";
- return "";
- }
- function normalizeType(a) {
- const t = a.type || a.content?.type || inferType(a.content) || "";
- return String(t).toLowerCase();
- }
- function priorityBump(p) {
- const s = String(p || "").toUpperCase();
- if (s === "HIGH") return 3;
- if (s === "MEDIUM") return 1;
- return 0;
- }
- function severityBump(s) {
- const x = String(s || "").toUpperCase();
- if (x === "CRITICAL") return 6;
- if (x === "HIGH") return 4;
- if (x === "MEDIUM") return 2;
- return 0;
- }
- function scoreMarket(c) {
- const st = String(c.status || "").toUpperCase();
- let s = 5;
- if (st === "SOLD") s += 8;
- else if (st === "ACTIVE") s += 3;
- const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
- s += Math.min(10, bids);
- return s;
- }
- function scoreProject(c) {
- const st = String(c.status || "ACTIVE").toUpperCase();
- const prog = Number(c.progress || 0);
- let s = 8 + Math.min(10, prog / 10);
- if (st === "FUNDED") s += 10;
- return s;
- }
- function calculateOpinionScore(content) {
- const cats = content?.opinions || {};
- let s = 0;
- for (const k in cats) {
- if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
- if (k === "interesting" || k === "inspiring") s += 5;
- else if (k === "boring" || k === "spam" || k === "propaganda") s -= 3;
- else s += 1;
- }
- return s;
- }
- async function listAllActions() {
- if (services?.feed?.listAll) {
- const arr = await services.feed.listAll();
- FEED_SRC = "services.feed.listAll";
- return normalizeFeedArray(arr);
- }
- if (services?.activity?.list) {
- const arr = await services.activity.list();
- FEED_SRC = "services.activity.list";
- return normalizeFeedArray(arr);
- }
- if (typeof global.listFeed === "function") {
- const arr = await global.listFeed("all");
- FEED_SRC = "global.listFeed('all')";
- return normalizeFeedArray(arr);
- }
- const ssb = await openSsb();
- if (!ssb || !ssb.createLogStream) {
- FEED_SRC = "none";
- return [];
- }
- const msgs = await scanLogStream();
- FEED_SRC = "ssb.createLogStream";
- return msgs.map(m => {
- const v = m.value || {};
- const c = v.content || {};
- return {
- id: v.key || m.key,
- author: v.author,
- type: (c.type || "").toLowerCase(),
- value: v,
- content: c
- };
- });
- }
- function normalizeFeedArray(arr) {
- if (!Array.isArray(arr)) return [];
- return arr.map(x => {
- const value = x.value || {};
- const content = x.content || value.content || {};
- const author = x.author || value.author || content.author || null;
- const type = (content.type || "").toLowerCase();
- return { id: x.id || value.key || x.key, author, type, value, content };
- });
- }
- async function publishKarmaScore(userId, karmaScore) {
- const ssb = await openSsb();
- if (!ssb || !ssb.publish) return false;
- const timestamp = new Date().toISOString();
- const content = { type: "karmaScore", karmaScore, userId, timestamp };
- return new Promise((resolve, reject) => {
- ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
- });
- }
- async function fetchUserActions(userId) {
- const me = resolveUserId(userId);
- const actions = await listAllActions();
- const authored = actions.filter(a =>
- (a.author && a.author === me) || (a.value?.author && a.value.author === me)
- );
- if (authored.length) return authored;
- return actions.filter(a => {
- const c = a.content || {};
- const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
- return fields.some(f => f && f === me);
- });
- }
- function scoreFromActions(actions) {
- let score = 0;
- const nowMs = Date.now();
- for (const action of actions) {
- const t = normalizeType(action);
- const c = action.content || {};
- const rawType = String(c.type || "").toLowerCase();
- const ts = action.value?.timestamp;
- const ageDays = ts ? (nowMs - ts) / 86400000 : Infinity;
- const decay = ageDays <= 30 ? 1.0 : ageDays <= 90 ? 0.5 : 0.25;
- if (t === "post") score += 10 * decay;
- else if (t === "comment") score += 5 * decay;
- else if (t === "like") score += 2 * decay;
- else if (t === "image") score += 8 * decay;
- else if (t === "video") score += 12 * decay;
- else if (t === "audio") score += 8 * decay;
- else if (t === "document") score += 6 * decay;
- else if (t === "bookmark") score += 2 * decay;
- else if (t === "feed") score += 6 * decay;
- else if (t === "forum") score += (c.root ? 5 : 10) * decay;
- else if (t === "vote") score += (3 + calculateOpinionScore(c)) * decay;
- else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0)) * decay;
- else if (t === "market") score += scoreMarket(c) * decay;
- else if (t === "project") score += scoreProject(c) * decay;
- else if (t === "tribe") score += (6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0)) * decay;
- else if (t === "event") score += (4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0)) * decay;
- else if (t === "task") score += (3 + priorityBump(c.priority)) * decay;
- else if (t === "report") score += (4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity)) * decay;
- else if (t === "curriculum") score += 5 * decay;
- else if (t === "aiexchange") score += (Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0) * decay;
- else if (t === "job") score += (4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0)) * decay;
- else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5) * decay;
- else if (t === "bankwallet") score += 2 * decay;
- else if (t === "transfer") score += 1 * decay;
- else if (t === "about") score += 1 * decay;
- else if (t === "contact") score += 1 * decay;
- else if (t === "pub") score += 1 * decay;
- else if (t === "parliamentcandidature" || rawType === "parliamentcandidature") score += 12 * decay;
- else if (t === "parliamentterm" || rawType === "parliamentterm") score += 25 * decay;
- else if (t === "parliamentproposal" || rawType === "parliamentproposal") score += 8 * decay;
- else if (t === "parliamentlaw" || rawType === "parliamentlaw") score += 16 * decay;
- else if (t === "parliamentrevocation" || rawType === "parliamentrevocation") score += 10 * decay;
- else if (t === "courts_case" || t === "courtscase" || rawType === "courts_case") score += 4 * decay;
- else if (t === "courts_evidence" || t === "courtsevidence" || rawType === "courts_evidence") score += 3 * decay;
- else if (t === "courts_answer" || t === "courtsanswer" || rawType === "courts_answer") score += 4 * decay;
- else if (t === "courts_verdict" || t === "courtsverdict" || rawType === "courts_verdict") score += 10 * decay;
- else if (t === "courts_settlement" || t === "courtssettlement" || rawType === "courts_settlement") score += 8 * decay;
- else if (t === "courts_nomination" || t === "courtsnomination" || rawType === "courts_nomination") score += 6 * decay;
- else if (t === "courts_nom_vote" || t === "courtsnomvote" || rawType === "courts_nom_vote") score += 3 * decay;
- else if (t === "courts_public_pref" || t === "courtspublicpref" || rawType === "courts_public_pref") score += 1 * decay;
- else if (t === "courts_mediators" || t === "courtsmediators" || rawType === "courts_mediators") score += 6 * decay;
- else if (t === "courts_open_support" || t === "courtsopensupport" || rawType === "courts_open_support") score += 2 * decay;
- else if (t === "courts_verdict_vote" || t === "courtsverdictvote" || rawType === "courts_verdict_vote") score += 3 * decay;
- else if (t === "courts_judge_assign" || t === "courtsjudgeassign" || rawType === "courts_judge_assign") score += 5 * decay;
- }
- return Math.max(0, Math.round(score));
- }
- async function getUserEngagementScore(userId) {
- const ssb = await openSsb();
- const uid = resolveUserId(userId);
- const actions = await fetchUserActions(uid);
- const karmaScore = scoreFromActions(actions);
- const prev = await getLastKarmaScore(uid);
- const lastPublishedTimestamp = await getLastPublishedTimestamp(uid);
- const isSelf = idsEqual(uid, ssb.id);
- const hasSSB = !!(ssb && ssb.publish);
- const changed = (prev === null) || (karmaScore !== prev);
- const nowMs = Date.now();
- const lastMs = lastPublishedTimestamp ? new Date(lastPublishedTimestamp).getTime() : 0;
- const cooldownOk = (nowMs - lastMs) >= 24 * 60 * 60 * 1000;
- if (isSelf && hasSSB && changed && cooldownOk) {
- await publishKarmaScore(uid, karmaScore);
- }
- return karmaScore;
- }
- async function getLastKarmaScore(userId) {
- const ssb = await openSsb();
- if (!ssb) return null;
- return new Promise((resolve) => {
- const source = ssb.messagesByType
- ? ssb.messagesByType({ type: "karmaScore", reverse: true })
- : ssb.createLogStream && ssb.createLogStream({ reverse: true });
- if (!source) return resolve(null);
- pull(
- source,
- pull.filter(msg => {
- const v = msg.value || msg;
- const c = v.content || {};
- return c && c.type === "karmaScore" && c.userId === userId;
- }),
- pull.take(1),
- pull.collect((err, arr) => {
- if (err || !arr || !arr.length) return resolve(null);
- const v = arr[0].value || arr[0];
- const c = v.content || {};
- resolve(Number(c.karmaScore) || 0);
- })
- );
- });
- }
- async function getLastPublishedTimestamp(userId) {
- const ssb = await openSsb();
- if (!ssb) return new Date(0).toISOString();
- const fallback = new Date(0).toISOString();
- return new Promise((resolve) => {
- const source = ssb.messagesByType
- ? ssb.messagesByType({ type: "karmaScore", reverse: true })
- : ssb.createLogStream && ssb.createLogStream({ reverse: true });
- if (!source) return resolve(fallback);
- pull(
- source,
- pull.filter(msg => {
- const v = msg.value || msg;
- const c = v.content || {};
- return c && c.type === "karmaScore" && c.userId === userId;
- }),
- pull.take(1),
- pull.collect((err, arr) => {
- if (err || !arr || !arr.length) return resolve(fallback);
- const v = arr[0].value || arr[0];
- const c = v.content || {};
- resolve(c.timestamp || fallback);
- })
- );
- });
- }
- function computePoolVars(pubBal, rules) {
- const alphaCap = (rules.alpha || DEFAULT_RULES.alpha) * pubBal;
- const available = Math.max(0, pubBal - (rules.reserveMin || DEFAULT_RULES.reserveMin));
- const rawMin = Math.min(available, (rules.capPerEpoch || DEFAULT_RULES.capPerEpoch), alphaCap);
- const pool = clamp(rawMin, 0, Number.MAX_SAFE_INTEGER);
- return { pubBal, alphaCap, available, rawMin, pool };
- }
- async function computeEpoch({ epochId, userId, rules = DEFAULT_RULES }) {
- const pubBal = await safeGetBalance("pub");
- const pv = computePoolVars(pubBal, rules);
- const addresses = await listAddressesMerged();
- const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
- const capUser = (rules.caps && rules.caps.cap_user_epoch) || DEFAULT_RULES.caps.cap_user_epoch;
- const wMin = (rules.caps && rules.caps.w_min) || DEFAULT_RULES.caps.w_min;
- const wMax = (rules.caps && rules.caps.w_max) || DEFAULT_RULES.caps.w_max;
- const weights = [];
- for (const entry of eligible) {
- const score = await getUserEngagementScore(entry.id);
- weights.push({ user: entry.id, w: clamp(1 + score / 100, wMin, wMax) });
- }
- if (!weights.length && userId) {
- const score = await getUserEngagementScore(userId);
- weights.push({ user: userId, w: clamp(1 + score / 100, wMin, wMax) });
- }
- const W = weights.reduce((acc, x) => acc + x.w, 0) || 1;
- const floorUbi = 1;
- const allocations = weights.map(({ user, w }) => {
- const amount = Math.max(floorUbi, Math.min(pv.pool * w / W, capUser));
- return {
- id: `alloc:${epochId}:${user}`,
- epoch: epochId,
- user,
- weight: Number(w.toFixed(6)),
- amount: Number(amount.toFixed(6))
- };
- });
- const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2);
- const hash = crypto.createHash("sha256").update(snapshot).digest("hex");
- return { epoch: { id: epochId, pool: Number(pv.pool.toFixed(6)), weightsSum: Number(W.toFixed(6)), rules, hash }, allocations };
- }
- async function executeEpoch({ epochId, rules = DEFAULT_RULES } = {}) {
- const eid = epochId || epochIdNow();
- await expireOldAllocations();
- const existing = await epochsRepo.get(eid);
- if (existing) return { epoch: existing, allocations: await transfersRepo.listByTag(`epoch:${eid}`) };
- const { epoch, allocations } = await computeEpoch({ epochId: eid, userId: config.keys.id, rules });
- await epochsRepo.save(epoch);
- for (const a of allocations) {
- if (a.amount <= 0) continue;
- const record = {
- id: a.id,
- from: config.keys.id,
- to: a.user,
- amount: a.amount,
- concept: `UBI ${eid}`,
- status: "UNCLAIMED",
- createdAt: new Date().toISOString(),
- deadline: new Date(Date.now() + (rules.graceDays || DEFAULT_RULES.graceDays) * 86400000).toISOString(),
- tags: ["UBI", `epoch:${eid}`],
- opinions: {}
- };
- await transfersRepo.create(record);
- try { await publishUbiAllocation(record); } catch (_) {}
- }
- return { epoch, allocations };
- }
- async function publishBankClaim({ amount, epochId, allocationId, txid }) {
- const ssbClient = await openSsb();
- const content = { type: "bankClaim", amount, epochId, allocationId, txid, timestamp: Date.now() };
- return new Promise((resolve, reject) => ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)));
- }
- async function claimAllocation({ transferId, claimerId, forcePub = false }) {
- const allocation = await transfersRepo.findById(transferId);
- if (!allocation || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) throw new Error("Invalid allocation or already claimed.");
- if (claimerId && allocation.to !== claimerId) throw new Error("This allocation is not for you.");
- const addr = await getUserAddress(allocation.to);
- if (!addr || !isValidEcoinAddress(addr)) throw new Error("No valid ECOin address registered.");
- const txid = await rpcCall("sendtoaddress", [addr, allocation.amount, `UBI ${allocation.concept || "claim"}`], "pub");
- if (!txid) throw new Error("RPC sendtoaddress failed. Check PUB wallet configuration.");
- await transfersRepo.markClosed(transferId, txid);
- return { txid };
- }
- async function claimUBI(userId) {
- const uid = resolveUserId(userId);
- const epochId = epochIdNow();
- const pubId = getConfiguredPubId();
- if (!pubId) throw new Error("no_pub_configured");
- const alreadyClaimed = await hasClaimedThisMonth(uid);
- if (alreadyClaimed) throw new Error("already_claimed");
- const karmaScore = await getUserEngagementScore(uid);
- const wMin = DEFAULT_RULES.caps.w_min;
- const wMax = DEFAULT_RULES.caps.w_max;
- const capUser = DEFAULT_RULES.caps.cap_user_epoch;
- const userW = clamp(1 + karmaScore / 100, wMin, wMax);
- const amount = Number(Math.max(1, Math.min(capUser * (userW / wMax), capUser)).toFixed(6));
- const ssb = await openSsb();
- if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
- const now = new Date().toISOString();
- const transferContent = {
- type: "transfer",
- from: pubId,
- to: uid,
- concept: `UBI ${epochId} ${uid}`,
- amount: String(amount),
- createdAt: now,
- updatedAt: now,
- deadline: null,
- confirmedBy: [pubId],
- status: "UNCONFIRMED",
- tags: ["UBI", "PENDING"],
- opinions: {},
- opinions_inhabitants: []
- };
- const transferRes = await new Promise((resolve, reject) => ssb.publish(transferContent, (err, res) => err ? reject(err) : resolve(res)));
- const transferId = transferRes?.key || "";
- const claimContent = { type: "ubiClaim", pubId, amount, epochId, claimedAt: now, transferId };
- await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res)));
- return { status: "claimed_pending", amount, epochId };
- }
- async function updateAllocationStatus(allocationId, status, txid) {
- if (status === "CLOSED") {
- await transfersRepo.markClosed(allocationId, txid);
- return;
- }
- ensureStoreFiles();
- const all = readJson(TRANSFERS_PATH, []);
- const idx = all.findIndex(t => t.id === allocationId);
- if (idx >= 0) {
- all[idx].status = status;
- if (txid) all[idx].txid = txid;
- writeJson(TRANSFERS_PATH, all);
- }
- }
- async function hasClaimedThisMonth(userId) {
- const epochId = epochIdNow();
- const msgs = await scanLogStream();
- for (const m of msgs) {
- const v = m.value || {};
- const c = v.content || {};
- if (c.type === "ubiClaimResult" && c.userId === userId && c.epochId === epochId) return true;
- if (c.type === "ubiClaim" && v.author === userId && c.epochId === epochId) return true;
- }
- return false;
- }
- async function getUbiClaimHistory(userId) {
- const msgs = await scanLogStream();
- let lastClaimedDate = null;
- let totalClaimed = 0;
- let claimCount = 0;
- for (const m of msgs) {
- const v = m.value || {};
- const c = v.content || {};
- if (c.type === "ubiClaimResult" && c.userId === userId) {
- totalClaimed += Number(c.amount) || 0;
- claimCount += 1;
- const d = c.processedAt || null;
- if (d && (!lastClaimedDate || d > lastClaimedDate)) lastClaimedDate = d;
- }
- }
- return { lastClaimedDate, totalClaimed: Number(totalClaimed.toFixed(6)), claimCount };
- }
- async function getUbiAllocationsFromSSB() {
- const pubId = getConfiguredPubId();
- if (!pubId) return [];
- const msgs = await scanLogStream();
- const out = [];
- for (const m of msgs) {
- const v = m.value || {};
- const c = v.content || {};
- if (v.author === pubId && c && c.type === "ubiAllocation") {
- out.push({
- id: c.allocationId,
- from: pubId,
- to: c.to,
- amount: c.amount,
- concept: c.concept,
- epochId: c.epochId,
- status: c.status || "UNCLAIMED",
- createdAt: c.createdAt || new Date().toISOString()
- });
- }
- }
- return out;
- }
- async function publishPubAvailability() {
- if (!isPubNode()) return;
- const balance = await safeGetBalance("pub");
- const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user || 1);
- const available = Number(balance) >= floor;
- const ssb = await openSsb();
- if (!ssb || !ssb.publish) return;
- const content = { type: "pubAvailability", available, coin: "ECO", timestamp: Date.now() };
- await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
- return available;
- }
- async function getPubAvailabilityFromSSB() {
- const pubId = getConfiguredPubId();
- if (!pubId) return false;
- const msgs = await scanLogStream();
- let latest = null;
- for (const m of msgs) {
- const v = m.value || {};
- const c = v.content || {};
- if (v.author === pubId && c && c.type === "pubAvailability" && c.coin === "ECO") {
- if (!latest || (Number(c.timestamp) || 0) > (Number(latest.timestamp) || 0)) latest = c;
- }
- }
- return !!(latest && latest.available);
- }
- async function listBanking(filter = "overview", userId) {
- const uid = resolveUserId(userId);
- const epochId = epochIdNow();
- let pubBalance = 0;
- let ubiAvailable = false;
- let allocations;
- if (isPubNode()) {
- pubBalance = await safeGetBalance("pub");
- const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user || 1);
- ubiAvailable = Number(pubBalance) >= floor;
- try { await publishPubAvailability(); } catch (_) {}
- const all = await transfersRepo.listByTag("UBI");
- allocations = all.map(t => ({
- id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status,
- createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid
- }));
- } else {
- ubiAvailable = await getPubAvailabilityFromSSB();
- allocations = await getUbiAllocationsFromSSB();
- }
- const userBalance = await safeGetBalance("user");
- const epochs = await epochsRepo.list();
- let computed = null;
- try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
- const pv = computePoolVars(pubBalance, DEFAULT_RULES);
- const actions = await fetchUserActions(uid);
- const engagementScore = scoreFromActions(actions);
- const poolForEpoch = computed?.epoch?.pool || pv.pool || 0;
- const futureUBI = Number(((engagementScore / 100) * poolForEpoch).toFixed(6));
- const addresses = await listAddressesMerged();
- const alreadyClaimed = await hasClaimedThisMonth(uid);
- const pubId = getConfiguredPubId();
- const userAddress = await getUserAddress(uid);
- const userWalletCfg = getWalletCfg("user") || {};
- const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url);
- const summary = {
- userBalance,
- epochId,
- pool: poolForEpoch,
- weightsSum: computed?.epoch?.weightsSum || 0,
- userEngagementScore: engagementScore,
- futureUBI,
- alreadyClaimed,
- pubId,
- hasValidWallet,
- ubiAvailability: ubiAvailable ? "OK" : "NO_FUNDS"
- };
- const exchange = await calculateEcoinValue();
- return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange };
- }
- async function getAllocationById(id) {
- const t = await transfersRepo.findById(id);
- if (!t) return null;
- return { id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid };
- }
- async function getEpochById(id) {
- const existing = await epochsRepo.get(id);
- if (existing) return existing;
- const all = await transfersRepo.listAll();
- const filtered = all.filter(t => (t.tags || []).includes(`epoch:${id}`));
- const pool = filtered.reduce((s, t) => s + Number(t.amount || 0), 0);
- return { id, pool, weightsSum: 0, rules: DEFAULT_RULES, hash: "-" };
- }
- async function listEpochAllocations(id) {
- const all = await transfersRepo.listAll();
- return all.filter(t => (t.tags || []).includes(`epoch:${id}`)).map(t => ({
- id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid
- }));
- }
- let genesisTimeCache = null;
- async function getAvgBlockSeconds(blocks) {
- if (!blocks || blocks < 2) return 0;
- try {
- if (!genesisTimeCache) {
- const h1 = await rpcCall("getblockhash", [1]);
- if (!h1) return 0;
- const b1 = await rpcCall("getblock", [h1]);
- genesisTimeCache = b1?.time || null;
- if (!genesisTimeCache) return 0;
- }
- const hCur = await rpcCall("getblockhash", [blocks]);
- if (!hCur) return 0;
- const bCur = await rpcCall("getblock", [hCur]);
- const curTime = bCur?.time || 0;
- if (!curTime) return 0;
- const elapsed = curTime - genesisTimeCache;
- return elapsed > 0 ? elapsed / (blocks - 1) : 0;
- } catch (_) { return 0; }
- }
- async function calculateEcoinValue() {
- const totalSupply = 25500000;
- let circulatingSupply = 0;
- let blocks = 0;
- let blockValueEco = 0;
- let isSynced = false;
- try {
- const info = await rpcCall("getinfo", []);
- circulatingSupply = info?.moneysupply || 0;
- blocks = info?.blocks || 0;
- isSynced = circulatingSupply > 0;
- const mining = await rpcCall("getmininginfo", []);
- blockValueEco = (mining?.blockvalue || 0) / 1e8;
- } catch (_) {}
- const avgSec = await getAvgBlockSeconds(blocks);
- const ecoValuePerHour = avgSec > 0 ? (3600 / avgSec) * blockValueEco : 0;
- const maturity = totalSupply > 0 ? circulatingSupply / totalSupply : 0;
- const ecoTimeMs = maturity * 3600 * 1000;
- const annualIssuance = ecoValuePerHour * 24 * 365;
- const inflationFactor = circulatingSupply > 0 ? (annualIssuance / circulatingSupply) * 100 : 0;
- const inflationMonthly = inflationFactor / 12;
- return {
- ecoValue: Number(ecoValuePerHour.toFixed(6)),
- ecoTimeMs: Number(ecoTimeMs.toFixed(3)),
- totalSupply,
- inflationFactor: Number(inflationFactor.toFixed(2)),
- inflationMonthly: Number(inflationMonthly.toFixed(2)),
- currentSupply: circulatingSupply,
- isSynced
- };
- }
- async function getBankingData(userId) {
- const ecoValue = await calculateEcoinValue();
- const karmaScore = await getUserEngagementScore(userId);
- let estimatedUBI = 0;
- try {
- const pubBal = isPubNode() ? await safeGetBalance("pub") : 0;
- const pv = computePoolVars(pubBal, DEFAULT_RULES);
- const pool = pv.pool || 0;
- const addresses = await listAddressesMerged();
- const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
- const totalW = eligible.length > 0 ? eligible.length + eligible.length * (karmaScore / 100) : 1;
- const userW = 1 + karmaScore / 100;
- const cap = DEFAULT_RULES.caps?.cap_user_epoch ?? 50;
- estimatedUBI = Math.min(pool * (userW / Math.max(1, totalW)), cap);
- } catch (_) {}
- const claimHistory = await getUbiClaimHistory(userId).catch(() => ({ lastClaimedDate: null, totalClaimed: 0 }));
- return {
- ecoValue,
- karmaScore,
- estimatedUBI,
- lastClaimedDate: claimHistory.lastClaimedDate,
- totalClaimed: claimHistory.totalClaimed
- };
- }
- async function expireOldAllocations() {
- const cutoffMs = MAX_PENDING_EPOCHS * 30 * 86400000;
- const now = Date.now();
- const allocs = await transfersRepo.listAll();
- for (const a of allocs) {
- if ((a.status === "UNCLAIMED" || a.status === "UNCONFIRMED") &&
- (now - new Date(a.createdAt).getTime()) > cutoffMs) {
- await updateAllocationStatus(a.id, "EXPIRED");
- }
- }
- }
- async function publishUbiAllocation(allocation) {
- const ssb = await openSsb();
- if (!ssb) return;
- const epochTag = (allocation.tags || []).find(t => t.startsWith("epoch:"));
- const content = {
- type: "ubiAllocation",
- allocationId: allocation.id,
- to: allocation.to,
- amount: allocation.amount,
- concept: allocation.concept,
- epochId: epochTag ? epochTag.slice(6) : "",
- status: "UNCLAIMED",
- createdAt: allocation.createdAt
- };
- return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
- }
- async function publishUbiClaim(allocationId, epochId) {
- const ssb = await openSsb();
- if (!ssb) return;
- const content = { type: "ubiClaim", allocationId, epochId, claimedAt: new Date().toISOString() };
- return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
- }
- async function publishUbiClaimResult(allocationId, epochId, txid, userId, amount) {
- const ssb = await openSsb();
- if (!ssb) return;
- const content = { type: "ubiClaimResult", allocationId, epochId, txid, userId, amount, processedAt: new Date().toISOString() };
- return new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
- }
- async function processPendingClaims() {
- if (!isPubNode()) return;
- const ssb = await openSsb();
- if (!ssb) return;
- const claims = [];
- const results = [];
- await new Promise((resolve, reject) => {
- pull(ssb.messagesByType({ type: "ubiClaim", reverse: false }),
- pull.drain(msg => {
- if (msg.value?.content?.type === "ubiClaim") {
- claims.push({ ...msg.value.content, _author: msg.value.author });
- }
- },
- err => err ? reject(err) : resolve()));
- });
- await new Promise((resolve, reject) => {
- pull(ssb.messagesByType({ type: "ubiClaimResult", reverse: false }),
- pull.drain(msg => { if (msg.value?.content?.type === "ubiClaimResult") results.push(msg.value.content); },
- err => err ? reject(err) : resolve()));
- });
- const processedEpochUser = new Set(results.map(r => `${r.epochId}:${r.userId}`));
- const epochId = epochIdNow();
- for (const claim of claims) {
- const claimantId = claim._author;
- if (!claimantId) continue;
- const claimEpoch = claim.epochId || epochId;
- if (processedEpochUser.has(`${claimEpoch}:${claimantId}`)) continue;
- try {
- const addr = await getUserAddress(claimantId);
- if (!addr || !isValidEcoinAddress(addr)) continue;
- const pubBal = await safeGetBalance("pub");
- if (pubBal <= 0) continue;
- const pv = computePoolVars(pubBal, DEFAULT_RULES);
- const addresses = await listAddressesMerged();
- const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
- const karmaScore = await getUserEngagementScore(claimantId);
- const wMin = DEFAULT_RULES.caps.w_min;
- const wMax = DEFAULT_RULES.caps.w_max;
- const capUser = DEFAULT_RULES.caps.cap_user_epoch;
- const userW = clamp(1 + karmaScore / 100, wMin, wMax);
- const totalW = eligible.reduce((acc) => acc + clamp(1, wMin, wMax), 0) || 1;
- const amount = Number(Math.max(1, Math.min(pv.pool * userW / totalW, capUser)).toFixed(6));
- const txid = await rpcCall("sendtoaddress", [addr, amount, `UBI ${claimEpoch}`], "pub");
- if (!txid) continue;
- await publishUbiClaimResult(claim.allocationId || `claim:${claimEpoch}:${claimantId}`, claimEpoch, txid, claimantId, amount);
- await publishBankClaim({ amount, epochId: claimEpoch, allocationId: claim.allocationId || `claim:${claimEpoch}:${claimantId}`, txid });
- const now = new Date().toISOString();
- await new Promise((resolve, reject) => ssb.publish({
- type: "transfer",
- from: config.keys.id,
- to: claimantId,
- concept: `UBI ${claimEpoch} ${claimantId}`,
- amount: String(amount),
- createdAt: now,
- updatedAt: now,
- deadline: null,
- confirmedBy: [config.keys.id],
- status: "UNCONFIRMED",
- tags: ["UBI"],
- opinions: {},
- opinions_inhabitants: [],
- txid
- }, (err, msg) => err ? reject(err) : resolve(msg)));
- } catch (_) {}
- }
- }
- return {
- DEFAULT_RULES,
- isPubNode,
- getConfiguredPubId,
- computeEpoch,
- executeEpoch,
- getUserEngagementScore,
- publishBankClaim,
- publishUbiAllocation,
- publishUbiClaim,
- publishUbiClaimResult,
- publishPubAvailability,
- getPubAvailabilityFromSSB,
- hasClaimedThisMonth,
- getUbiClaimHistory,
- claimUBI,
- processPendingClaims,
- expireOldAllocations,
- claimAllocation,
- listBanking,
- getAllocationById,
- getEpochById,
- listEpochAllocations,
- addAddress,
- removeAddress,
- ensureSelfAddressPublished,
- getUserAddress,
- setUserAddress,
- listAddressesMerged,
- calculateEcoinValue,
- getBankingData
- };
- };
|