joinhive 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/bin/derive-evm-key.mjs +13 -0
- package/bin/hive +47 -67
- package/bin/hive-buzz.mjs +73 -0
- package/bin/hive-core.mjs +64 -0
- package/bin/hive-join.mjs +342 -91
- package/bin/hive-key.mjs +131 -0
- package/bin/hive-net.mjs +36 -9
- package/daemon/fanout.mjs +27 -5
- package/daemon/hived.mjs +110 -10
- package/docs/cli.md +3 -1
- package/onchain/deployments.sepolia.json +12 -3
- package/onchain/src/HoneyV3.sol +97 -0
- package/onchain/src/JellyV3.sol +103 -0
- package/package.json +6 -4
- package/server/api.mjs +56 -1
- package/server/join-page.mjs +7 -5
- package/server/provision.mjs +189 -4
- package/server/reactions.mjs +186 -0
- package/server/rewarder.mjs +155 -56
- package/server/slasher.mjs +136 -0
- package/server/supervisor.mjs +27 -0
- package/server/treasury.mjs +145 -2
- package/server/x402-facilitator.mjs +52 -0
- package/server/x402-gateway.mjs +44 -0
- package/shared/core.mjs +71 -0
- package/shared/events.mjs +4 -1
- package/shared/prompt.mjs +168 -0
- package/shared/reactions.mjs +37 -0
- package/shared/rewards.json +23 -1
- package/shared/txqueue.mjs +9 -4
- package/shared/x402-client.mjs +28 -0
- package/shared/x402.mjs +72 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// server/reactions — REAL-TIME reaction → HONEY.
|
|
2
|
+
//
|
|
3
|
+
// The daily epoch (rewarder.mjs) used to be the only thing that paid out human
|
|
4
|
+
// upvotes, once a day. This worker replaces that for reactions: the instant a
|
|
5
|
+
// HUMAN reacts to a bee's result, HONEY mints on-chain (Sepolia) and a
|
|
6
|
+
// hive-mint receipt with the tx link lands on #hive-logs — exactly like a tip.
|
|
7
|
+
//
|
|
8
|
+
// Anti-gaming is identical to the epoch's R1 (shared scoreReaction): only
|
|
9
|
+
// HUMAN reactions mint; the result's author is resolved from the SIGNED result
|
|
10
|
+
// event (never a self-asserted result_by); self/owner/linked-device reactions
|
|
11
|
+
// mint 0; pair-decay + per-bee (12), per-reactor (40), and network (375) daily
|
|
12
|
+
// caps apply via running state. Minting is idempotent per feedback event id, so
|
|
13
|
+
// a restart re-scans from the cursor and never double-mints.
|
|
14
|
+
//
|
|
15
|
+
// Safety spine: minting lives HERE (server, treasury MINTER_ROLE), never in the
|
|
16
|
+
// daemon. The daemon still may not emit reactions or mint.
|
|
17
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
18
|
+
import { EV, verifiedEvent, tryJson } from '../shared/events.mjs';
|
|
19
|
+
import { REWARDS, scoreReaction, rolloverDay, foldAltkeys } from './rewarder.mjs';
|
|
20
|
+
import { normalizeEmoji } from '../shared/reactions.mjs';
|
|
21
|
+
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
22
|
+
|
|
23
|
+
export const sepoliaTxUrl = (hash) => `https://sepolia.etherscan.io/tx/${hash}`;
|
|
24
|
+
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
25
|
+
const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
|
|
26
|
+
const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
|
|
27
|
+
|
|
28
|
+
const RX = REWARDS.reactions || {};
|
|
29
|
+
const RESULTS_CACHE_MAX = 5000;
|
|
30
|
+
const PROCESSED_MAX = 5000;
|
|
31
|
+
const CURSOR_OVERLAP_SECS = 5; // re-scan a small overlap so same-second events aren't missed
|
|
32
|
+
|
|
33
|
+
// A NIP-25 kind-7 reaction's content is an emoji, a shortcode (:fire:), or +/-.
|
|
34
|
+
export const k7Emoji = (content) => {
|
|
35
|
+
const c = String(content || '').trim();
|
|
36
|
+
if (!c) return RX.default_up || '👍';
|
|
37
|
+
return normalizeEmoji(c.replace(/^:+|:+$/g, ''), RX);
|
|
38
|
+
};
|
|
39
|
+
// NIP-25: the reacted event is the LAST `e` tag.
|
|
40
|
+
export const eTagOf = (tags) => {
|
|
41
|
+
const es = (tags || []).filter((t) => t[0] === 'e' && typeof t[1] === 'string');
|
|
42
|
+
return es.length ? es[es.length - 1][1] : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// deps: { relay, logsChannelId, honeyContract, txq, parseUnits, loadRegistry,
|
|
46
|
+
// statePath, emit, selfPubkey, log, now? }
|
|
47
|
+
// loadRegistry() -> {pubkey:{is_bee,bee_of,evm,name}}
|
|
48
|
+
// emit(obj) -> publish a JSON event to #hive-logs (steward-signed)
|
|
49
|
+
// now() -> ms (injectable for tests)
|
|
50
|
+
export const createReactionWorker = (deps) => {
|
|
51
|
+
const { statePath, log = () => {}, now = () => Date.now() } = deps;
|
|
52
|
+
const nowSec = () => Math.floor(now() / 1000);
|
|
53
|
+
|
|
54
|
+
const load = () => {
|
|
55
|
+
const s = loadJson(statePath, null);
|
|
56
|
+
if (s) { s.processed = s.processed || []; s.results = s.results || {}; return s; }
|
|
57
|
+
// Cold start: begin at "now" — historical reactions were already paid by
|
|
58
|
+
// the daily epoch; we must not retroactively mint them.
|
|
59
|
+
return { cursor: nowSec(), processed: [], results: {}, altkeys: { claims: {}, acks: {} }, date: utcDay(now()) };
|
|
60
|
+
};
|
|
61
|
+
const save = (s) => writeAtomic(statePath, JSON.stringify(s, null, 2));
|
|
62
|
+
|
|
63
|
+
// Resolve the SIGNER of the reacted event — but only if that event is a
|
|
64
|
+
// REWARDABLE ANSWER: a hive-result (typed, CLI path) or a plaintext reply (the
|
|
65
|
+
// human-readable answer Buzz reacts to). Reacting to a reaction (kind 7), a
|
|
66
|
+
// receipt, an intent rebroadcast, or any other typed bee-signed event earns
|
|
67
|
+
// NOTHING — HONEY tracks delivered answers, not arbitrary bee-signed events
|
|
68
|
+
// (M1). The signer is the author who earns; isBee() in scoreReaction gates it,
|
|
69
|
+
// and provenance is the true signer (never a self-asserted result_by).
|
|
70
|
+
const rewardableSigner = async (state, eventId) => {
|
|
71
|
+
if (!eventId) return null;
|
|
72
|
+
if (state.results[eventId]) return state.results[eventId]; // cache holds only verified hive-result authors
|
|
73
|
+
try {
|
|
74
|
+
const hit = (await deps.relay.query([{ ids: [eventId], limit: 1 }]))?.[0];
|
|
75
|
+
if (!hit || !hit.pubkey || hit.kind === 7) return null; // no reacting-to-a-reaction
|
|
76
|
+
const j = tryJson(hit.content);
|
|
77
|
+
if (j && j.type !== EV.RESULT) return null; // a typed non-answer (receipt/intent/offer/…)
|
|
78
|
+
cacheResult(state, eventId, hit.pubkey);
|
|
79
|
+
return hit.pubkey;
|
|
80
|
+
} catch (e) { log('reactions: signer resolve failed:', String(e.message).slice(0, 100)); }
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
const cacheResult = (state, id, author) => {
|
|
84
|
+
state.results[id] = author;
|
|
85
|
+
const ids = Object.keys(state.results);
|
|
86
|
+
if (ids.length > RESULTS_CACHE_MAX) for (const k of ids.slice(0, ids.length - RESULTS_CACHE_MAX)) delete state.results[k];
|
|
87
|
+
};
|
|
88
|
+
const markProcessed = (state, id) => {
|
|
89
|
+
state.processed.push(id);
|
|
90
|
+
if (state.processed.length > PROCESSED_MAX) state.processed = state.processed.slice(-PROCESSED_MAX);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const poll = async () => {
|
|
94
|
+
if (!deps.honeyContract) return { minted: 0, reason: 'no-honey-contract' };
|
|
95
|
+
const state = load();
|
|
96
|
+
rolloverDay(state, utcDay(now()));
|
|
97
|
+
const registry = deps.loadRegistry();
|
|
98
|
+
if (!Object.values(registry).some((r) => r?.is_bee)) { save(state); return { minted: 0, reason: 'no-bees' }; }
|
|
99
|
+
const isBee = (pk) => !!(registry[pk] && registry[pk].is_bee);
|
|
100
|
+
|
|
101
|
+
const since = Math.max(0, (state.cursor || nowSec()) - CURSOR_OVERLAP_SECS);
|
|
102
|
+
// Two reaction sources, one scoring path: (a) CLI hive-feedback (kind 9/40002)
|
|
103
|
+
// on #hive-logs; (b) Buzz-native kind-7 reactions on the human-facing channels.
|
|
104
|
+
const filters = [{ kinds: [9, 40002], '#h': [deps.logsChannelId], since, limit: 500 }];
|
|
105
|
+
for (const cid of (deps.reactionChannelIds || [])) filters.push({ kinds: [7], '#h': [cid], since, limit: 500 });
|
|
106
|
+
const raw = await deps.relay.query(filters);
|
|
107
|
+
if (!raw) { save(state); return { minted: 0, reason: 'relay-unreachable' }; }
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const rows = raw.map(RelayClient.normalize)
|
|
110
|
+
.sort((a, b) => (a.created_at - b.created_at) || (a.id < b.id ? -1 : 1))
|
|
111
|
+
.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true))); // an event can match two filters — process it once
|
|
112
|
+
|
|
113
|
+
// Fold altkeys + refresh the result-author cache from the typed #hive-logs window.
|
|
114
|
+
foldAltkeys(state, rows.filter((m) => m.kind === 9 || m.kind === 40002)
|
|
115
|
+
.map((m) => ({ pubkey: m.pubkey, j: verifiedEvent({ content: m.content, pubkey: m.pubkey }) })).filter((e) => e.j));
|
|
116
|
+
for (const m of rows) {
|
|
117
|
+
if ((m.kind === 9 || m.kind === 40002)) {
|
|
118
|
+
const j = verifiedEvent({ content: m.content, pubkey: m.pubkey });
|
|
119
|
+
if (j && j.type === EV.RESULT) cacheResult(state, m.id, m.pubkey);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const processed = new Set(state.processed);
|
|
124
|
+
let minted = 0, maxAt = state.cursor || 0;
|
|
125
|
+
for (const m of rows) {
|
|
126
|
+
maxAt = Math.max(maxAt, m.created_at);
|
|
127
|
+
if (processed.has(m.id)) continue;
|
|
128
|
+
|
|
129
|
+
// Normalize either source into one reaction candidate.
|
|
130
|
+
let cand = null;
|
|
131
|
+
if (m.kind === 7) {
|
|
132
|
+
cand = { reactor: m.pubkey, resultRef: eTagOf(m.tags), emoji: k7Emoji(m.content), dir: undefined, via: 'buzz' };
|
|
133
|
+
} else {
|
|
134
|
+
const j = verifiedEvent({ content: m.content, pubkey: m.pubkey });
|
|
135
|
+
if (!j || j.type !== EV.FEEDBACK) continue; // another consumer's event — leave it, don't mark
|
|
136
|
+
cand = { reactor: m.pubkey, resultRef: typeof j.result === 'string' ? j.result : null, emoji: j.emoji, dir: j.dir, via: 'cli' };
|
|
137
|
+
}
|
|
138
|
+
if (!cand.resultRef) { markProcessed(state, m.id); continue; }
|
|
139
|
+
// Early skip: a bee's own reaction (incl. the "thinking" indicator) never
|
|
140
|
+
// mints — drop it before paying for a signer lookup.
|
|
141
|
+
if (isBee(cand.reactor)) { markProcessed(state, m.id); continue; }
|
|
142
|
+
|
|
143
|
+
const author = await rewardableSigner(state, cand.resultRef);
|
|
144
|
+
const score = scoreReaction(state, { reactor: cand.reactor, author, result: cand.resultRef, emoji: cand.emoji, dir: cand.dir }, registry, REWARDS);
|
|
145
|
+
if (!score.ok) { markProcessed(state, m.id); continue; } // excluded / downvote / capped — no mint, don't reprocess
|
|
146
|
+
|
|
147
|
+
const evm = registry[author]?.evm;
|
|
148
|
+
if (!evm || !/^0x[0-9a-fA-F]{40}$/.test(evm)) { markProcessed(state, m.id); log(`reactions: no wallet for ${String(author).slice(0, 12)} — skip`); continue; }
|
|
149
|
+
|
|
150
|
+
// Reserve BEFORE broadcast (the spend-ledger pattern): mark processed +
|
|
151
|
+
// persist that we are paying this reaction, THEN mint. A crash, a save
|
|
152
|
+
// error, or a failed mint can now only MISS this reaction — never pay it
|
|
153
|
+
// twice. If the reserve save itself fails, we never mint (no inflation) and
|
|
154
|
+
// retry cleanly next tick.
|
|
155
|
+
markProcessed(state, m.id);
|
|
156
|
+
try { save(state); }
|
|
157
|
+
catch (e) { log('reactions: reserve save failed — not minting:', String(e.message).slice(0, 100)); return { minted, reason: 'save-failed', retry: true }; }
|
|
158
|
+
try {
|
|
159
|
+
const receipt = await deps.txq.enqueue((o) => deps.honeyContract.mint(evm, deps.parseUnits(String(score.honey), 18), o), { gasLimit: 250000n });
|
|
160
|
+
minted++;
|
|
161
|
+
const name = registry[author]?.name || String(author).slice(0, 12);
|
|
162
|
+
log(`reactions: +${score.honey} HONEY ${score.emoji} → ${name} via ${cand.via} (${receipt.hash.slice(0, 12)})`);
|
|
163
|
+
try {
|
|
164
|
+
await deps.emit({
|
|
165
|
+
type: EV.MINT, to: author, honey: score.honey, emoji: score.emoji,
|
|
166
|
+
reactor: score.reactor, result: cand.resultRef, tx: receipt.hash,
|
|
167
|
+
url: sepoliaTxUrl(receipt.hash), source: cand.via, by: deps.selfPubkey, at: nowSec(),
|
|
168
|
+
});
|
|
169
|
+
} catch (e) { log('reactions: receipt emit failed:', String(e.message).slice(0, 100)); }
|
|
170
|
+
} catch (e) {
|
|
171
|
+
// Mint failed after we reserved — this reaction is SKIPPED (already marked
|
|
172
|
+
// processed), never retried, so it can never double-mint. Stop the tick;
|
|
173
|
+
// the rest retry next poll. Rare; the human can react again.
|
|
174
|
+
log(`reactions: mint failed (reaction skipped) for ${String(author).slice(0, 12)}: ${String(e.message).slice(0, 120)}`);
|
|
175
|
+
return { minted, reason: 'mint-failed' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Clamp the cursor to wall-clock: a client-controlled future `created_at`
|
|
179
|
+
// must not push `since` ahead of real time and stall all future minting (M2).
|
|
180
|
+
state.cursor = Math.min(maxAt, nowSec());
|
|
181
|
+
save(state);
|
|
182
|
+
return { minted };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return { poll, _load: load, _save: save };
|
|
186
|
+
};
|
package/server/rewarder.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { readFileSync } from 'node:fs';
|
|
|
22
22
|
import { join, dirname } from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { EV, tryJson } from '../shared/events.mjs';
|
|
25
|
+
import { normalizeEmoji } from '../shared/reactions.mjs';
|
|
25
26
|
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
26
27
|
|
|
27
28
|
const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -50,46 +51,138 @@ export const parseAltkeyLoose = (content) => {
|
|
|
50
51
|
};
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
// events
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const bees = Object.fromEntries(Object.entries(registry).filter(([, v]) => v && v.is_bee));
|
|
61
|
-
const isBee = (pk) => !!bees[pk];
|
|
62
|
-
const ownerOf = (pk) => bees[pk]?.bee_of || null;
|
|
63
|
-
|
|
64
|
-
// ---- alt-key linkage (durable state, folded from each day's events) --------
|
|
65
|
-
// A link exists only when BOTH directions asserted it: the member claimed
|
|
66
|
-
// the alt ({alt, by:member}) AND the alt acked the member ({owner, by:alt}).
|
|
67
|
-
// Either side revokes with {…, revoke:true}. Linked keys collapse to the
|
|
68
|
-
// member's primary for every identity-sensitive rule below — so upvoting
|
|
69
|
-
// your own bee from your desktop key mints nothing, and pair-decay cannot
|
|
70
|
-
// be reset by hopping devices.
|
|
54
|
+
// Fold hive-altkey events into durable link state and return a memberOf(pk)
|
|
55
|
+
// that collapses a linked device to its member-primary. A link exists only
|
|
56
|
+
// when BOTH directions asserted it (member claims {alt}, alt acks {owner});
|
|
57
|
+
// either side revokes with {revoke:true}. Shared by the daily epoch and the
|
|
58
|
+
// real-time reaction scorer so device-hopping can't defeat anti-gaming in
|
|
59
|
+
// either path.
|
|
60
|
+
export const foldAltkeys = (state, events) => {
|
|
71
61
|
state.altkeys = state.altkeys || { claims: {}, acks: {} };
|
|
72
62
|
for (const ev of events) {
|
|
73
63
|
const j = ev.j;
|
|
74
|
-
if (j.type !== EV.ALTKEY) continue;
|
|
64
|
+
if (!j || j.type !== EV.ALTKEY) continue;
|
|
75
65
|
if (typeof j.alt === 'string' && /^[0-9a-f]{64}$/i.test(j.alt)) {
|
|
76
|
-
// member side: signer claims j.alt as their device
|
|
77
66
|
const c = state.altkeys.claims[ev.pubkey] = state.altkeys.claims[ev.pubkey] || {};
|
|
78
67
|
if (j.revoke) delete c[j.alt]; else c[j.alt] = true;
|
|
79
68
|
} else if (typeof j.owner === 'string' && /^[0-9a-f]{64}$/i.test(j.owner)) {
|
|
80
|
-
// alt side: signer acks j.owner as their member
|
|
81
69
|
const a = state.altkeys.acks[ev.pubkey] = state.altkeys.acks[ev.pubkey] || {};
|
|
82
70
|
if (j.revoke) delete a[j.owner]; else a[j.owner] = true;
|
|
83
71
|
}
|
|
84
72
|
}
|
|
73
|
+
return memberOfFrom(state);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Build memberOf(pk) from already-folded altkey state (no event replay).
|
|
77
|
+
export const memberOfFrom = (state) => {
|
|
78
|
+
const claims = state.altkeys?.claims || {};
|
|
79
|
+
const acks = state.altkeys?.acks || {};
|
|
85
80
|
const altToMember = {};
|
|
86
|
-
for (const [member, alts] of Object.entries(
|
|
81
|
+
for (const [member, alts] of Object.entries(claims)) {
|
|
87
82
|
for (const alt of Object.keys(alts)) {
|
|
88
|
-
if (
|
|
83
|
+
if (acks[alt]?.[member]) altToMember[alt] = member;
|
|
89
84
|
}
|
|
90
85
|
}
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
return (pk) => altToMember[pk] || pk;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ---- real-time reaction scoring (shared/rewards.json → reactions) ---------------
|
|
90
|
+
// Emoji resolution is shared with the CLI (shared/reactions.mjs); re-export so
|
|
91
|
+
// callers importing it from the rewarder keep working.
|
|
92
|
+
export { normalizeEmoji };
|
|
93
|
+
|
|
94
|
+
// Zero the per-day running counters when the UTC day rolls over. Durable state
|
|
95
|
+
// (altkeys, cursor, processed ids) is preserved across the reset.
|
|
96
|
+
export const rolloverDay = (state, dayKey) => {
|
|
97
|
+
if (state.date !== dayKey) {
|
|
98
|
+
state.date = dayKey;
|
|
99
|
+
state.perBee = {};
|
|
100
|
+
state.perReactor = {};
|
|
101
|
+
state.pair = {};
|
|
102
|
+
state.seenPair = {};
|
|
103
|
+
state.network = 0;
|
|
104
|
+
}
|
|
105
|
+
return state;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Score ONE reaction against running daily state, mutating state on a real
|
|
109
|
+
// mint. Returns {ok, honey, emoji, reason}. This is the real-time analogue of
|
|
110
|
+
// computeEpoch's R1 block — same exclusions (human-only, self, owner-boost,
|
|
111
|
+
// linked-device), same pair-decay — but stateful and per-reaction so HONEY can
|
|
112
|
+
// mint on-chain the instant a human reacts. Caller must rolloverDay() and fold
|
|
113
|
+
// altkeys into state first.
|
|
114
|
+
// fb = {reactor, author, result, emoji?, dir?} (author = verified result_by)
|
|
115
|
+
export const scoreReaction = (state, fb, registry, rewards = REWARDS) => {
|
|
116
|
+
const rx = rewards.reactions || {};
|
|
117
|
+
const isBee = (pk) => !!(registry[pk] && registry[pk].is_bee);
|
|
118
|
+
const ownerOf = (pk) => (registry[pk] && registry[pk].bee_of) || null;
|
|
119
|
+
const memberOf = memberOfFrom(state);
|
|
120
|
+
const emoji = normalizeEmoji(fb.emoji || (fb.dir === 'down' ? rx.down_emoji : undefined), rx);
|
|
121
|
+
const reject = (reason) => ({ ok: false, honey: 0, emoji, reason });
|
|
122
|
+
|
|
123
|
+
const author = fb.author;
|
|
124
|
+
if (!author || !isBee(author)) return reject('author-not-bee'); // only bees earn reputation
|
|
125
|
+
if (emoji === rx.down_emoji || fb.dir === 'down') return reject('downvote'); // logged, mints 0
|
|
126
|
+
const tier = rx.tiers ? rx.tiers[emoji] : undefined;
|
|
127
|
+
if (!(tier > 0)) return reject('unknown-emoji');
|
|
128
|
+
if (isBee(fb.reactor)) return reject('reactor-is-bee'); // agent reactions mint 0
|
|
129
|
+
const reactorM = memberOf(fb.reactor);
|
|
130
|
+
if (reactorM === author || fb.reactor === author) return reject('self');
|
|
131
|
+
if (reactorM === memberOf(ownerOf(author) || '')) return reject('owner-boost');
|
|
132
|
+
|
|
133
|
+
const pairSeenKey = `${reactorM}:${fb.result}`; // one counted reaction per (member,result)
|
|
134
|
+
state.seenPair = state.seenPair || {};
|
|
135
|
+
if (state.seenPair[pairSeenKey]) return reject('already-counted');
|
|
136
|
+
|
|
137
|
+
const perReactor = (state.perReactor && state.perReactor[reactorM]) || 0;
|
|
138
|
+
if (perReactor >= (rx.per_reactor_daily_cap ?? Infinity)) return reject('reactor-daily-cap');
|
|
139
|
+
|
|
140
|
+
const decayKey = `${reactorM}:${author}`; // pair-decay by member, not device
|
|
141
|
+
const nPair = (state.pair && state.pair[decayKey]) || 0;
|
|
142
|
+
const decay = rx.pair_decay[Math.min(nPair, rx.pair_decay.length - 1)];
|
|
143
|
+
|
|
144
|
+
const perBee = (state.perBee && state.perBee[author]) || 0;
|
|
145
|
+
const net = state.network || 0;
|
|
146
|
+
let honey = tier * decay;
|
|
147
|
+
honey = Math.min(honey, Math.max(0, (rx.per_bee_daily_cap ?? Infinity) - perBee));
|
|
148
|
+
honey = Math.min(honey, Math.max(0, (rx.network_daily_cap ?? Infinity) - net));
|
|
149
|
+
honey = Math.round(honey * 100) / 100;
|
|
150
|
+
|
|
151
|
+
// Whether or not it pays out, burn the dedup + pair slot so a decayed/capped
|
|
152
|
+
// reaction can never be retried for value.
|
|
153
|
+
state.seenPair[pairSeenKey] = 1;
|
|
154
|
+
state.pair = state.pair || {};
|
|
155
|
+
state.pair[decayKey] = nPair + 1;
|
|
156
|
+
if (!(honey > 0)) return reject('capped-or-decayed');
|
|
157
|
+
|
|
158
|
+
state.perBee = state.perBee || {};
|
|
159
|
+
state.perBee[author] = Math.round((perBee + honey) * 100) / 100;
|
|
160
|
+
state.perReactor = state.perReactor || {};
|
|
161
|
+
state.perReactor[reactorM] = perReactor + 1;
|
|
162
|
+
state.network = Math.round((net + honey) * 100) / 100;
|
|
163
|
+
return { ok: true, honey, emoji, reason: 'minted', author, reactor: reactorM };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// events: [{id, pubkey, at, j}] — provenance-verified, ascending by time.
|
|
167
|
+
// registry: {pubkey: {is_bee, bee_of, evm, name}}.
|
|
168
|
+
// state: mutated — {adoption: {proto: {authors:..., users: {pubkey: count}}},
|
|
169
|
+
// r6_paid: {proto: true}, streaks: {pubkey: n},
|
|
170
|
+
// altkeys: {claims: {member:{alt:true}}, acks: {alt:{member:true}}}}
|
|
171
|
+
// opts.skipRealtimeReactions: when true, R1 (human upvotes) is NOT scored here —
|
|
172
|
+
// reactions mint in real time (server/reactions.mjs) instead, so the epoch must
|
|
173
|
+
// not double-pay them. Feedback is still indexed (R2 needs the down-react set).
|
|
174
|
+
export const computeEpoch = (events, registry, state, rewards = REWARDS, opts = {}) => {
|
|
175
|
+
const R = rewards.rules;
|
|
176
|
+
const bees = Object.fromEntries(Object.entries(registry).filter(([, v]) => v && v.is_bee));
|
|
177
|
+
const isBee = (pk) => !!bees[pk];
|
|
178
|
+
const ownerOf = (pk) => bees[pk]?.bee_of || null;
|
|
179
|
+
|
|
180
|
+
// Alt-key linkage folded into durable state; memberOf() collapses a linked
|
|
181
|
+
// device to its member-primary so device-hopping can't defeat the rules
|
|
182
|
+
// below — so upvoting your own bee from your desktop key mints nothing, and
|
|
183
|
+
// pair-decay cannot be reset by hopping devices. Shared with the real-time
|
|
184
|
+
// reaction scorer via foldAltkeys().
|
|
185
|
+
const memberOf = foldAltkeys(state, events);
|
|
93
186
|
const earned = {}; // pubkey -> {total, reasons: [{code, amount, evidence[]}]}
|
|
94
187
|
const add = (pk, code, amount, evidence) => {
|
|
95
188
|
if (amount <= 0) return;
|
|
@@ -147,35 +240,40 @@ export const computeEpoch = (events, registry, state, rewards = REWARDS) => {
|
|
|
147
240
|
}
|
|
148
241
|
|
|
149
242
|
// R1 — human upvotes, ordered by time, ladder amounts, pair decay, cap.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
243
|
+
// Skipped when reactions mint in real time (server/reactions.mjs) so the
|
|
244
|
+
// epoch never double-pays them; `feedback` is still indexed above because R2
|
|
245
|
+
// depends on the down-react set.
|
|
246
|
+
if (!opts.skipRealtimeReactions) {
|
|
247
|
+
const resultAuthor = Object.fromEntries(results.map((r) => [r.id, r.author]));
|
|
248
|
+
const upvotesByAuthor = {};
|
|
249
|
+
const pairCount = {}; // `${reactor}:${author}` -> n
|
|
250
|
+
const seenReactPair = new Set(); // one vote per (reactor, result)
|
|
251
|
+
for (const f of feedback) {
|
|
252
|
+
if (f.dir !== 'up') continue;
|
|
253
|
+
const author = resultAuthor[f.result] || f.author;
|
|
254
|
+
if (!isBee(author)) continue;
|
|
255
|
+
if (isBee(f.reactor)) continue; // agent reactions mint 0
|
|
256
|
+
const reactorM = memberOf(f.reactor); // collapse linked devices
|
|
257
|
+
if (reactorM === author || f.reactor === author) continue; // self
|
|
258
|
+
if (reactorM === memberOf(ownerOf(author) || '')) continue; // owner (any device) boosting own bee
|
|
259
|
+
const rp = `${reactorM}:${f.result}`; // one vote per MEMBER per result
|
|
260
|
+
if (seenReactPair.has(rp)) continue;
|
|
261
|
+
seenReactPair.add(rp);
|
|
262
|
+
const pk = `${reactorM}:${author}`; // pair-decay by member, not device
|
|
263
|
+
const nPair = pairCount[pk] || 0;
|
|
264
|
+
pairCount[pk] = nPair + 1;
|
|
265
|
+
const decay = R.R1.pair_decay[Math.min(nPair, R.R1.pair_decay.length - 1)];
|
|
266
|
+
const list = upvotesByAuthor[author] = upvotesByAuthor[author] || [];
|
|
267
|
+
list.push({ id: f.id, decay });
|
|
268
|
+
}
|
|
269
|
+
for (const [author, ups] of Object.entries(upvotesByAuthor)) {
|
|
270
|
+
let total = 0;
|
|
271
|
+
ups.forEach((u, i) => {
|
|
272
|
+
const base = R.R1.amounts[Math.min(i, R.R1.amounts.length - 1)] ?? R.R1.amount_tail;
|
|
273
|
+
const amt = Math.min(base * u.decay, Math.max(0, R.R1.cap - total));
|
|
274
|
+
if (amt > 0) { total += amt; add(author, 'R1', amt, [u.id]); }
|
|
275
|
+
});
|
|
276
|
+
}
|
|
179
277
|
}
|
|
180
278
|
|
|
181
279
|
// R2 — distinct intents served with no complaint (no down-react on the
|
|
@@ -351,7 +449,8 @@ export const runEpoch = async (epochDate, deps) => {
|
|
|
351
449
|
const end = Math.floor(Date.parse(`${epochDate}T00:00:00Z`) / 1000) + closeHour * 3600;
|
|
352
450
|
const start = end - 86400;
|
|
353
451
|
const events = await fetchEpochEvents(relay, logsChannelId, start, end);
|
|
354
|
-
|
|
452
|
+
// Reactions (R1) mint in real time now; keep the epoch from double-paying them.
|
|
453
|
+
const { mints, penalties } = computeEpoch(events, registry, state, REWARDS, { skipRealtimeReactions: !!REWARDS.reactions?.realtime });
|
|
355
454
|
log(`epoch ${epochDate}: ${events.length} events -> ${mints.length} mint(s), ${penalties.length} penalt(ies)`);
|
|
356
455
|
|
|
357
456
|
// Receipt FIRST (auditable intent), then the transactions, then tx receipt.
|
|
@@ -360,7 +459,7 @@ export const runEpoch = async (epochDate, deps) => {
|
|
|
360
459
|
for (const m of mints) {
|
|
361
460
|
if (!m.evm) { log(`epoch: no wallet for ${m.to.slice(0, 12)} — skipping mint`); continue; }
|
|
362
461
|
try {
|
|
363
|
-
const receipt = await txq.enqueue((o) => honeyContract.mint(m.evm, parseUnits(String(m.honey), 18), o));
|
|
462
|
+
const receipt = await txq.enqueue((o) => honeyContract.mint(m.evm, parseUnits(String(m.honey), 18), o), { gasLimit: 250000n });
|
|
364
463
|
txs.push({ to: m.evm, honey: m.honey, tx: receipt.hash });
|
|
365
464
|
log(`epoch: minted ${m.honey} HONEY -> ${registry[m.to]?.name || m.to.slice(0, 12)} (${receipt.hash.slice(0, 12)})`);
|
|
366
465
|
} catch (e) { log(`epoch mint failed for ${m.to.slice(0, 12)}: ${String(e.message).slice(0, 120)}`); }
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// server/slasher — REAL-TIME, on-chain HONEY slashing.
|
|
2
|
+
//
|
|
3
|
+
// The burn counterpart to server/reactions.mjs. HoneyV2 forbade automated burns
|
|
4
|
+
// ("automating the report pipeline turns it into a weapon"); HoneyV3 permits
|
|
5
|
+
// them under SLASHER_ROLE, with the guardrails enforced HERE so the on-chain
|
|
6
|
+
// call stays a dumb, floored primitive:
|
|
7
|
+
// - trigger = a REPORT QUORUM: >= `quorum` DISTINCT human reporters (altkey-
|
|
8
|
+
// collapsed) naming the same bee within `window_secs`.
|
|
9
|
+
// - reporters that are bees, or the bee's own owner, never count — a bee can't
|
|
10
|
+
// slash a rival and an owner can't slash their own bee.
|
|
11
|
+
// - rate cap: at most `per_bee_daily_cap` slashes per bee per UTC day.
|
|
12
|
+
// - idempotent per report event id; floored on-chain (slash burns
|
|
13
|
+
// min(amount, balance)); every slash emits a hive-slash receipt + tx link.
|
|
14
|
+
//
|
|
15
|
+
// Below the daemon's HONEY-gate thresholds a slashed bee is throttled, then
|
|
16
|
+
// de-eligible ("dies") — that gate lives in the daemon (fan-out + spend), not
|
|
17
|
+
// here. This worker only performs the burn and records it.
|
|
18
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
19
|
+
import { EV, verifiedEvent } from '../shared/events.mjs';
|
|
20
|
+
import { REWARDS, foldAltkeys, memberOfFrom } from './rewarder.mjs';
|
|
21
|
+
import { sepoliaTxUrl } from './reactions.mjs';
|
|
22
|
+
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
23
|
+
|
|
24
|
+
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
25
|
+
const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
|
|
26
|
+
const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
|
|
27
|
+
|
|
28
|
+
const PROCESSED_MAX = 5000;
|
|
29
|
+
const CURSOR_OVERLAP_SECS = 5;
|
|
30
|
+
|
|
31
|
+
// Count distinct human reporters of `bee` within the window, and whether the
|
|
32
|
+
// quorum is met. Pure — testable without a relay. `reports` = {bee:{member:at}}.
|
|
33
|
+
export const quorumMet = (reports, bee, nowSec, cfg) => {
|
|
34
|
+
const r = reports[bee] || {};
|
|
35
|
+
const fresh = Object.values(r).filter((at) => at >= nowSec - (cfg.window_secs || 86400)).length;
|
|
36
|
+
return { count: fresh, met: fresh >= (cfg.quorum || 2) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// deps: { relay, logsChannelId, honeyContract, txq, parseUnits, loadRegistry,
|
|
40
|
+
// statePath, emit, selfPubkey, log, now? }
|
|
41
|
+
// honeyContract must expose slash(address,uint256,string) (HoneyV3).
|
|
42
|
+
export const createSlasher = (deps) => {
|
|
43
|
+
const { statePath, log = () => {}, now = () => Date.now() } = deps;
|
|
44
|
+
const cfg = REWARDS.slashing || {};
|
|
45
|
+
const nowSec = () => Math.floor(now() / 1000);
|
|
46
|
+
|
|
47
|
+
const load = () => {
|
|
48
|
+
const s = loadJson(statePath, null);
|
|
49
|
+
if (s) { s.processed = s.processed || []; s.reports = s.reports || {}; s.slashedToday = s.slashedToday || []; return s; }
|
|
50
|
+
return { cursor: nowSec(), processed: [], reports: {}, slashedToday: [], altkeys: { claims: {}, acks: {} }, date: utcDay(now()) };
|
|
51
|
+
};
|
|
52
|
+
const save = (s) => writeAtomic(statePath, JSON.stringify(s, null, 2));
|
|
53
|
+
const markProcessed = (state, id) => { state.processed.push(id); if (state.processed.length > PROCESSED_MAX) state.processed = state.processed.slice(-PROCESSED_MAX); };
|
|
54
|
+
|
|
55
|
+
const poll = async () => {
|
|
56
|
+
if (!cfg.enabled || !deps.honeyContract) return { slashed: 0, reason: 'disabled' };
|
|
57
|
+
const state = load();
|
|
58
|
+
if (state.date !== utcDay(now())) { state.date = utcDay(now()); state.slashedToday = []; } // daily rate-cap reset
|
|
59
|
+
const registry = deps.loadRegistry();
|
|
60
|
+
const isBee = (pk) => !!(registry[pk] && registry[pk].is_bee);
|
|
61
|
+
const ownerOf = (pk) => (registry[pk] && registry[pk].bee_of) || null;
|
|
62
|
+
if (!Object.values(registry).some((r) => r?.is_bee)) { save(state); return { slashed: 0, reason: 'no-bees' }; }
|
|
63
|
+
|
|
64
|
+
const since = Math.max(0, (state.cursor || nowSec()) - CURSOR_OVERLAP_SECS);
|
|
65
|
+
const raw = await deps.relay.query([{ kinds: [9, 40002], '#h': [deps.logsChannelId], since, limit: 500 }]);
|
|
66
|
+
if (!raw) { save(state); return { slashed: 0, reason: 'relay-unreachable' }; }
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const rows = raw.map(RelayClient.normalize)
|
|
69
|
+
.sort((a, b) => (a.created_at - b.created_at) || (a.id < b.id ? -1 : 1))
|
|
70
|
+
.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
|
|
71
|
+
|
|
72
|
+
// Fold altkeys so a reporter's linked devices collapse to one member.
|
|
73
|
+
foldAltkeys(state, rows.map((m) => ({ pubkey: m.pubkey, j: verifiedEvent({ content: m.content, pubkey: m.pubkey }) })).filter((e) => e.j));
|
|
74
|
+
const memberOf = memberOfFrom(state);
|
|
75
|
+
|
|
76
|
+
// Ingest new reports into the durable reporter set.
|
|
77
|
+
const processed = new Set(state.processed);
|
|
78
|
+
let maxAt = state.cursor || 0;
|
|
79
|
+
for (const m of rows) {
|
|
80
|
+
maxAt = Math.max(maxAt, m.created_at);
|
|
81
|
+
if (processed.has(m.id)) continue;
|
|
82
|
+
const j = verifiedEvent({ content: m.content, pubkey: m.pubkey });
|
|
83
|
+
if (!j || j.type !== EV.REPORT || typeof j.subject !== 'string') continue; // not a report — leave for other consumers
|
|
84
|
+
markProcessed(state, m.id);
|
|
85
|
+
const bee = j.subject;
|
|
86
|
+
if (!isBee(bee)) continue; // only bees are slashable
|
|
87
|
+
const reporter = memberOf(m.pubkey);
|
|
88
|
+
// Reporter must be a REGISTERED, non-bee member. An unregistered key isn't a
|
|
89
|
+
// bee (isBee=false) but must NOT count toward quorum — otherwise two throwaway
|
|
90
|
+
// keys posting reports could slash anyone (Sybil). Altkeys collapse to the
|
|
91
|
+
// member via memberOf, so a member's devices count once.
|
|
92
|
+
if (!registry[reporter] || registry[reporter].is_bee) continue;
|
|
93
|
+
if (reporter === memberOf(ownerOf(bee) || '')) continue; // owner can't slash own bee
|
|
94
|
+
(state.reports[bee] = state.reports[bee] || {})[reporter] = m.created_at;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Prune stale reports and evaluate quorum.
|
|
98
|
+
const win = cfg.window_secs || 86400;
|
|
99
|
+
let slashed = 0;
|
|
100
|
+
for (const bee of Object.keys(state.reports)) {
|
|
101
|
+
for (const [rep, at] of Object.entries(state.reports[bee])) if (at < nowSec() - win) delete state.reports[bee][rep];
|
|
102
|
+
if (!Object.keys(state.reports[bee]).length) { delete state.reports[bee]; continue; }
|
|
103
|
+
const { count, met } = quorumMet(state.reports, bee, nowSec(), cfg);
|
|
104
|
+
if (!met) continue;
|
|
105
|
+
if (state.slashedToday.includes(bee)) continue; // per-bee daily rate cap (per_bee_daily_cap = 1)
|
|
106
|
+
const evm = registry[bee]?.evm;
|
|
107
|
+
if (!evm || !/^0x[0-9a-fA-F]{40}$/.test(evm)) { state.slashedToday.push(bee); log(`slasher: no wallet for ${bee.slice(0, 12)} — skip`); continue; }
|
|
108
|
+
const reason = `report-quorum:${count}`;
|
|
109
|
+
// Reserve today's slot BEFORE the burn (spend-ledger pattern) so a crash can
|
|
110
|
+
// never double-slash; a failed burn just defers (reports persist, today is
|
|
111
|
+
// spent). On SUCCESS, clear the reports so the SAME quorum can't re-slash on
|
|
112
|
+
// the next UTC day — the double-jeopardy that made one quorum burn twice.
|
|
113
|
+
state.slashedToday.push(bee);
|
|
114
|
+
save(state);
|
|
115
|
+
try {
|
|
116
|
+
const receipt = await deps.txq.enqueue((o) => deps.honeyContract.slash(evm, deps.parseUnits(String(cfg.amount || 10), 18), reason, o), { gasLimit: 250000n });
|
|
117
|
+
delete state.reports[bee];
|
|
118
|
+
save(state);
|
|
119
|
+
slashed++;
|
|
120
|
+
log(`slasher: −${cfg.amount} HONEY ⚔️ ${registry[bee]?.name || bee.slice(0, 12)} (${reason}, ${receipt.hash.slice(0, 12)})`);
|
|
121
|
+
try {
|
|
122
|
+
await deps.emit({ type: EV.SLASH, to: bee, amount: cfg.amount, reason, trigger: 'report-quorum', tx: receipt.hash, url: sepoliaTxUrl(receipt.hash), by: deps.selfPubkey, at: nowSec() });
|
|
123
|
+
} catch (e) { log('slasher: receipt emit failed:', String(e.message).slice(0, 100)); }
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// Slot already reserved; reports remain → retried next UTC day. No double.
|
|
126
|
+
log(`slasher: slash failed for ${bee.slice(0, 12)} (deferred to next day): ${String(e.message).slice(0, 120)}`);
|
|
127
|
+
return { slashed, reason: 'slash-failed' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
state.cursor = maxAt;
|
|
131
|
+
save(state);
|
|
132
|
+
return { slashed };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return { poll, _load: load, _save: save };
|
|
136
|
+
};
|
package/server/supervisor.mjs
CHANGED
|
@@ -216,6 +216,33 @@ createServer((req, res) => {
|
|
|
216
216
|
res.end(JSON.stringify({ respawned: true }));
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
|
+
if (url.pathname === '/restart' && req.method === 'POST') {
|
|
220
|
+
// Restart ONE bee with fresh config+secrets from disk (used by the api
|
|
221
|
+
// worker after `hive key set` rewrites them). Internal port only.
|
|
222
|
+
let body = '';
|
|
223
|
+
req.on('data', (c) => { body += c; });
|
|
224
|
+
req.on('end', () => {
|
|
225
|
+
let name = '';
|
|
226
|
+
try { name = String(JSON.parse(body || '{}').name || ''); } catch {}
|
|
227
|
+
const e = bees.get(name);
|
|
228
|
+
if (!name || (!e && !existsSync(join(BEES_DIR, name, 'config.json')))) {
|
|
229
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
230
|
+
res.end('{"error":"unknown bee"}');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (e) { e.restarts = []; e.backoffMs = 1000; }
|
|
234
|
+
if (e?.child) {
|
|
235
|
+
try { e.child.kill('SIGTERM'); } catch {} // exit handler respawns
|
|
236
|
+
} else {
|
|
237
|
+
if (e) e.state = 'stopped';
|
|
238
|
+
spawnBee(name);
|
|
239
|
+
}
|
|
240
|
+
log(`restart requested for bee ${name}`);
|
|
241
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
242
|
+
res.end(JSON.stringify({ restarting: name }));
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
219
246
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
220
247
|
res.end('{"error":"not found"}');
|
|
221
248
|
}).listen(HEALTH_PORT, () => log(`health on :${HEALTH_PORT}/healthz`));
|