joinhive 2.1.0 → 2.2.1

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.
@@ -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
+ };
@@ -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: [{id, pubkey, at, j}] provenance-verified, ascending by time.
54
- // registry: {pubkey: {is_bee, bee_of, evm, name}}.
55
- // state: mutated {adoption: {proto: {authors:..., users: {pubkey: count}}},
56
- // r6_paid: {proto: true}, streaks: {pubkey: n},
57
- // altkeys: {claims: {member:{alt:true}}, acks: {alt:{member:true}}}}
58
- export const computeEpoch = (events, registry, state, rewards = REWARDS) => {
59
- const R = rewards.rules;
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(state.altkeys.claims)) {
81
+ for (const [member, alts] of Object.entries(claims)) {
87
82
  for (const alt of Object.keys(alts)) {
88
- if (state.altkeys.acks[alt]?.[member]) altToMember[alt] = member;
83
+ if (acks[alt]?.[member]) altToMember[alt] = member;
89
84
  }
90
85
  }
91
- // Collapse any pubkey to its member-primary identity.
92
- const memberOf = (pk) => altToMember[pk] || pk;
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
- const resultAuthor = Object.fromEntries(results.map((r) => [r.id, r.author]));
151
- const upvotesByAuthor = {};
152
- const pairCount = {}; // `${reactor}:${author}` -> n
153
- const seenReactPair = new Set(); // one vote per (reactor, result)
154
- for (const f of feedback) {
155
- if (f.dir !== 'up') continue;
156
- const author = resultAuthor[f.result] || f.author;
157
- if (!isBee(author)) continue;
158
- if (isBee(f.reactor)) continue; // agent reactions mint 0
159
- const reactorM = memberOf(f.reactor); // collapse linked devices
160
- if (reactorM === author || f.reactor === author) continue; // self
161
- if (reactorM === memberOf(ownerOf(author) || '')) continue; // owner (any device) boosting own bee
162
- const rp = `${reactorM}:${f.result}`; // one vote per MEMBER per result
163
- if (seenReactPair.has(rp)) continue;
164
- seenReactPair.add(rp);
165
- const pk = `${reactorM}:${author}`; // pair-decay by member, not device
166
- const nPair = pairCount[pk] || 0;
167
- pairCount[pk] = nPair + 1;
168
- const decay = R.R1.pair_decay[Math.min(nPair, R.R1.pair_decay.length - 1)];
169
- const list = upvotesByAuthor[author] = upvotesByAuthor[author] || [];
170
- list.push({ id: f.id, decay });
171
- }
172
- for (const [author, ups] of Object.entries(upvotesByAuthor)) {
173
- let total = 0;
174
- ups.forEach((u, i) => {
175
- const base = R.R1.amounts[Math.min(i, R.R1.amounts.length - 1)] ?? R.R1.amount_tail;
176
- const amt = Math.min(base * u.decay, Math.max(0, R.R1.cap - total));
177
- if (amt > 0) { total += amt; add(author, 'R1', amt, [u.id]); }
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
- const { mints, penalties } = computeEpoch(events, registry, state);
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
+ };
@@ -26,6 +26,7 @@ import { join, dirname } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { openSecrets } from '../shared/sealed.mjs';
28
28
  import { validateConfig } from '../shared/config-schema.mjs';
29
+ import { getPublicKey } from 'nostr-tools/pure';
29
30
 
30
31
  const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
31
32
  const DATA_DIR = process.env.HIVE_DATA || '/data';
@@ -95,11 +96,31 @@ const rebuildRegistry = () => {
95
96
  return reg;
96
97
  };
97
98
 
99
+ // Backfill steward_pubkey for bees provisioned before directed A2A tasks existed.
100
+ // provision.mjs now writes it, but older bees predate that field, so they would
101
+ // silently ignore every paid hive-task (hived.mjs: `j.by !== cfg.steward_pubkey`).
102
+ // Derive it from the SAME host steward key the x402 gateway signs tasks with, so
103
+ // `by` matches exactly. Idempotent: no-op once the field is present. Runs before
104
+ // each spawn, so the freshly-read daemon config already carries it.
105
+ const ensureStewardPubkey = (home) => {
106
+ const sk = process.env.HIVE_STEWARD_KEY || '';
107
+ if (!sk) return;
108
+ const p = join(home, 'config.json');
109
+ const cfg = loadJson(p, null);
110
+ if (!cfg || cfg.steward_pubkey) return;
111
+ try {
112
+ cfg.steward_pubkey = getPublicKey(Uint8Array.from(Buffer.from(sk, 'hex')));
113
+ writeAtomic(p, JSON.stringify(cfg, null, 2));
114
+ log(`bee ${cfg.bee_name || home.split('/').pop()} backfilled steward_pubkey (directed A2A tasks enabled)`);
115
+ } catch (e) { log(`steward_pubkey backfill skipped: ${String(e.message).slice(0, 80)}`); }
116
+ };
117
+
98
118
  const spawnBee = (name) => {
99
119
  if (shuttingDown) return;
100
120
  const home = join(BEES_DIR, name);
101
121
  const entry = bees.get(name) || { name, home, restarts: [], backoffMs: 1000, state: 'starting' };
102
122
  bees.set(name, entry);
123
+ ensureStewardPubkey(home); // enable directed paid A2A tasks on pre-existing bees
103
124
 
104
125
  // Validate before burning a process slot — misconfig is `degraded`, not a
105
126
  // crash loop.