joinhive 2.1.0 → 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.
@@ -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
+ };
@@ -18,7 +18,13 @@ import { getPublicKey } from 'nostr-tools/pure';
18
18
  import { TxQueue } from '../shared/txqueue.mjs';
19
19
  import { RelayClient } from '../daemon/relay/client.mjs';
20
20
  import { EV } from '../shared/events.mjs';
21
+ import { createServer } from 'node:http';
21
22
  import { runEpoch, REWARDS } from './rewarder.mjs';
23
+ import { createReactionWorker } from './reactions.mjs';
24
+ import { createSlasher } from './slasher.mjs';
25
+ import { createFacilitator } from './x402-facilitator.mjs';
26
+ import { createGateway } from './x402-gateway.mjs';
27
+ import { tryJson } from '../shared/events.mjs';
22
28
 
23
29
  const DATA_DIR = process.env.HIVE_DATA || '/data';
24
30
  const RPC = process.env.SEPOLIA_RPC_URL || 'https://ethereum-sepolia-rpc.publicnode.com';
@@ -31,6 +37,8 @@ const TOPUP_BELOW_ETH = 0.01;
31
37
  const TOPUP_AMOUNT_ETH = 0.03;
32
38
  const ALERT_BELOW_ETH = 0.2;
33
39
  const TICK_MS = 60_000;
40
+ const REACTION_TICK_MS = 6_000; // real-time reactions: near-instant, not per-epoch
41
+ const SLASH_TICK_MS = 15_000; // real-time slashing: responsive, but rarer + heavier
34
42
  const TOPUP_EVERY_MS = 60 * 60_000;
35
43
 
36
44
  const log = (...a) => console.log(`[treasury ${new Date().toISOString()}]`, ...a);
@@ -128,14 +136,19 @@ const topUps = async () => {
128
136
  // One run per date, ledgered; a crash mid-run resumes losslessly because the
129
137
  // receipt precedes the mints and TxQueue awaits receipts.
130
138
  const honey = deployments.honey ? new Contract(deployments.honey, ['function mint(address,uint256)'], wallet) : null;
139
+ // slash() exists only on HoneyV3+ — stays null (slasher dormant) until the
140
+ // slashable contract is deployed and deployments.version is bumped to >= 3.
141
+ const honeySlash = (deployments.honey && deployments.version >= 3)
142
+ ? new Contract(deployments.honey, ['function slash(address,uint256,string)'], wallet) : null;
131
143
  const rewarderStatePath = join(DATA_DIR, 'rewarder-state.json');
132
144
  const closedEpochDate = () => {
133
145
  const nowMs = Date.now() - REWARDS.epoch_close_utc_hour * 3600_000;
134
146
  return new Date(nowMs - 86400_000 * 0).toISOString().slice(0, 10); // most recent day whose close has passed
135
147
  };
136
148
  const maybeRunEpoch = async () => {
137
- // v2 contracts only — the treasury key has no mint rights on v1 (Ownable).
138
- if (!honey || !relay || deployments.version !== 2) return;
149
+ // v2+ contracts — the treasury key holds MINTER_ROLE on HoneyV2/V3 (not v1's
150
+ // Ownable). R2-R8 keep paying via the epoch until they're migrated to real-time.
151
+ if (!honey || !relay || deployments.version < 2) return;
139
152
  const date = closedEpochDate();
140
153
  const key = `epoch:${date}`;
141
154
  if (ledger[key]?.done) return;
@@ -156,9 +169,139 @@ const maybeRunEpoch = async () => {
156
169
  if (mints.length) await alert(`epoch ${date}: ${txs.length}/${mints.length} HONEY mints executed — hive leaderboard to see the ranks.`);
157
170
  };
158
171
 
172
+ // ---- real-time reactions (instant HONEY, not per-epoch) --------------------------
173
+ // A HUMAN reaction to a bee's result mints HONEY on-chain within ~seconds and
174
+ // posts a hive-mint receipt with the Sepolia tx link. Same anti-gaming as the
175
+ // epoch's R1 (shared scoreReaction), enforced with running per-day state.
176
+ const reactionStatePath = join(DATA_DIR, 'reactions-state.json');
177
+ let reactionWorker = null;
178
+ let logsChannelId = null;
179
+ let intentsChannelId = null;
180
+ let reacting = false;
181
+ const reactionTick = async () => {
182
+ if (reacting) return; // a poll may still be awaiting tx.wait (longer than the tick) — overlapping polls read stale state and DOUBLE-MINT
183
+ reacting = true;
184
+ try {
185
+ if (!reactionWorker) {
186
+ // needs a MINTER contract (v2+), the steward relay, AND real-time enabled
187
+ // (same flag the epoch checks to skip R1 — so they can never both pay).
188
+ if (!honey || !relay || deployments.version < 2 || !REWARDS.reactions?.realtime) return;
189
+ logsChannelId = logsChannelId || await relay.ensureChannel('hive-logs');
190
+ intentsChannelId = intentsChannelId || await relay.ensureChannel('hive-intents');
191
+ reactionWorker = createReactionWorker({
192
+ relay, logsChannelId,
193
+ // where Buzz-native kind-7 reactions land: on the human-facing answers
194
+ // (#hive-intents) and the typed results (#hive-logs).
195
+ reactionChannelIds: [intentsChannelId, logsChannelId],
196
+ honeyContract: honey, txq, parseUnits,
197
+ loadRegistry: () => loadJson(join(DATA_DIR, 'registry.json'), {}),
198
+ statePath: reactionStatePath, log,
199
+ selfPubkey: getPublicKey(Uint8Array.from(Buffer.from(STEWARD, 'hex'))),
200
+ emit: (obj) => relay.sendMessage(logsChannelId, JSON.stringify(obj)),
201
+ });
202
+ log('real-time reactions worker armed');
203
+ }
204
+ await reactionWorker.poll();
205
+ } catch (e) { log('reaction tick error:', String(e.message).slice(0, 160)); }
206
+ finally { reacting = false; }
207
+ };
208
+
209
+ // ---- automated on-chain slashing (dormant until HoneyV3 is deployed) -------------
210
+ const slasherStatePath = join(DATA_DIR, 'slasher-state.json');
211
+ let slasher = null;
212
+ let slashing = false;
213
+ const slasherTick = async () => {
214
+ if (slashing) return; // never overlap: a slash tx can exceed the tick, and concurrent polls would double-slash
215
+ slashing = true;
216
+ try {
217
+ if (!honeySlash || !relay || !REWARDS.slashing?.enabled) return; // needs the v3 slash path + steward
218
+ if (!slasher) {
219
+ logsChannelId = logsChannelId || await relay.ensureChannel('hive-logs');
220
+ slasher = createSlasher({
221
+ relay, logsChannelId, honeyContract: honeySlash, txq, parseUnits,
222
+ loadRegistry: () => loadJson(join(DATA_DIR, 'registry.json'), {}),
223
+ statePath: slasherStatePath, log,
224
+ selfPubkey: getPublicKey(Uint8Array.from(Buffer.from(STEWARD, 'hex'))),
225
+ emit: (obj) => relay.sendMessage(logsChannelId, JSON.stringify(obj)),
226
+ });
227
+ log('on-chain slasher armed (HoneyV3 slash path)');
228
+ }
229
+ await slasher.poll();
230
+ } catch (e) { log('slasher tick error:', String(e.message).slice(0, 160)); }
231
+ finally { slashing = false; }
232
+ };
233
+
234
+ // ---- x402 A2A gateway (inert unless X402_GATEWAY_PORT is set) ---------------------
235
+ // Prices a call to a worker bee, settles the payer's EIP-3009 authorization with
236
+ // the treasury wallet (which pays only gas — funds move payer→worker), then
237
+ // dispatches the task to the worker over the bus and returns its answer.
238
+ const X402_PORT = Number(process.env.X402_GATEWAY_PORT) || 0;
239
+ const X402_ASSET = process.env.X402_ASSET || deployments.jelly_x402 || '';
240
+ const X402_PRICE = process.env.X402_A2A_PRICE || String(parseUnits('1', 18)); // 1 JELLY default
241
+ const X402_ASSET_NAME = process.env.X402_ASSET_NAME || 'Jelly';
242
+ const X402_ASSET_VERSION = process.env.X402_ASSET_VERSION || '1';
243
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
244
+ const findBee = (reg, name) => Object.entries(reg).find(([, v]) => v?.is_bee && (v.name === name || v.name === `${name}.bee`));
245
+
246
+ const a2aServe = async (beeName, body, { payer }) => {
247
+ const reg = loadJson(join(DATA_DIR, 'registry.json'), {});
248
+ const hit = findBee(reg, beeName);
249
+ if (!hit) throw new Error(`unknown worker bee "${beeName}"`);
250
+ const [workerPubkey, workerRec] = hit;
251
+ const task = String(body?.task || body?.intent || '').slice(0, 500).trim();
252
+ if (!task) throw new Error('body.task is required');
253
+ const logsId = await relay.ensureChannel('hive-logs');
254
+ const since = Math.floor(Date.now() / 1000) - 2;
255
+ const stewardPub = getPublicKey(Uint8Array.from(Buffer.from(STEWARD, 'hex')));
256
+ const taskId = `a2a-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
257
+ // Direct the paid task straight to the worker (bypasses fan-out); it answers
258
+ // because we sign as its configured steward. Match the reply by task_id.
259
+ await relay.sendMessage(logsId, JSON.stringify({ type: EV.TASK, task, task_id: taskId, for_bee: workerPubkey, by: stewardPub }));
260
+ log(`a2a: directed task ${taskId} → ${beeName} (payer ${String(payer).slice(0, 10)})`);
261
+ const deadline = Date.now() + 30_000;
262
+ while (Date.now() < deadline) {
263
+ await sleep(3000);
264
+ const rows = (await relay.query([{ kinds: [9, 40002], '#h': [logsId], since, limit: 200 }])) || [];
265
+ for (const m of rows.map(RelayClient.normalize)) {
266
+ const j = tryJson(m.content);
267
+ if (j && j.type === EV.RESULT && j.task_id === taskId && m.pubkey === workerPubkey) {
268
+ return { bee: workerRec.name, task, answer: j.result, engine: j.engine, task_id: taskId };
269
+ }
270
+ }
271
+ }
272
+ throw new Error(`worker ${beeName} did not answer task ${taskId} within 30s`);
273
+ };
274
+
275
+ const startX402Gateway = () => {
276
+ if (!X402_PORT) return;
277
+ if (!X402_ASSET || !STEWARD) { log('x402 gateway NOT started: set X402_ASSET (or deploy JellyV3) + a steward key'); return; }
278
+ const facilitator = createFacilitator({ signer: wallet, txq });
279
+ const gateway = createGateway({
280
+ facilitator, log, serve: a2aServe,
281
+ requirementsFor: (req) => {
282
+ const reg = loadJson(join(DATA_DIR, 'registry.json'), {});
283
+ const payTo = findBee(reg, req.bee)?.[1]?.evm || wallet.address; // pay the worker
284
+ return { accepts: [{ scheme: 'exact', network: 'sepolia', asset: X402_ASSET, amount: X402_PRICE, payTo, name: X402_ASSET_NAME, version: X402_ASSET_VERSION, chainId: 11155111, description: `invoke ${req.bee}` }] };
285
+ },
286
+ });
287
+ createServer(async (req, res) => {
288
+ const send = (status, headers, obj) => { res.writeHead(status, { 'content-type': 'application/json', ...(headers || {}) }); res.end(JSON.stringify(obj)); };
289
+ try {
290
+ const mt = req.url.match(/^\/a2a\/([a-z0-9.-]+)\/invoke$/i);
291
+ if (req.method !== 'POST' || !mt) return send(404, {}, { error: 'POST /a2a/<bee>/invoke' });
292
+ let raw = ''; for await (const c of req) raw += c;
293
+ const out = await gateway({ getHeader: (n) => req.headers[n], bee: mt[1].replace(/\.bee$/, ''), body: tryJson(raw) || {} });
294
+ return send(out.status, out.headers, out.body);
295
+ } catch (e) { log('x402 gateway error:', String(e.message).slice(0, 140)); send(500, {}, { error: 'gateway error' }); }
296
+ }).listen(X402_PORT, () => log(`x402 A2A gateway on :${X402_PORT} (asset ${X402_ASSET.slice(0, 10)}…, price ${X402_PRICE})`));
297
+ };
298
+
159
299
  const main = async () => {
160
300
  const bal = await provider.getBalance(wallet.address).catch(() => null);
161
301
  log(`treasury up: ${wallet.address}, float ${bal === null ? '?' : formatEther(bal)} ETH, jelly ${deployments.jelly}, honey ${deployments.honey || '(v1 — no rewarder)'}${deployments.version === 2 ? ' [v2]' : ''}`);
302
+ setInterval(reactionTick, REACTION_TICK_MS); // real-time reaction minting, concurrent with the 60s tick (TxQueue serializes nonces)
303
+ setInterval(slasherTick, SLASH_TICK_MS); // real-time on-chain slashing (dormant until HoneyV3)
304
+ startX402Gateway(); // x402 A2A payment gateway (inert unless X402_GATEWAY_PORT set)
162
305
  for (;;) {
163
306
  try {
164
307
  await processQueue();
@@ -0,0 +1,52 @@
1
+ // server/x402-facilitator — the verify + settle half of x402.
2
+ //
3
+ // verify: the off-chain checks (signature, amount, recipient, window from
4
+ // shared/x402.verifyExact) PLUS the on-chain checks it can't do — the nonce is
5
+ // unused and the payer actually has the balance.
6
+ // settle: submit the EIP-3009 transferWithAuthorization on-chain (serialized
7
+ // through the TxQueue) and return the tx hash.
8
+ //
9
+ // Used by the bee-host gateway to price A2A work: a paying bee's authorization
10
+ // is verified, the resource is served, and the payment is settled — all against
11
+ // JellyV3 (or any EIP-3009 token, e.g. test-USDC).
12
+ import { Contract } from 'ethers';
13
+ import { verifyExact } from '../shared/x402.mjs';
14
+
15
+ export const EIP3009_ABI = [
16
+ 'function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,bytes signature)',
17
+ 'function authorizationState(address authorizer,bytes32 nonce) view returns (bool)',
18
+ 'function balanceOf(address) view returns (uint256)',
19
+ ];
20
+
21
+ // deps: { signer?, txq?, contractFor? }
22
+ // contractFor(asset) -> an ethers Contract exposing EIP3009_ABI (injectable
23
+ // for tests). Defaults to new Contract(asset, EIP3009_ABI, signer).
24
+ export const createFacilitator = ({ signer, txq, contractFor } = {}) => {
25
+ const forAsset = contractFor || ((asset) => new Contract(asset, EIP3009_ABI, signer));
26
+
27
+ const verify = async (payload, req, opts) => {
28
+ const off = verifyExact(payload, req, opts);
29
+ if (!off.valid) return off;
30
+ try {
31
+ const c = forAsset(payload.asset);
32
+ const a = payload.authorization;
33
+ if (await c.authorizationState(a.from, a.nonce)) return { valid: false, reason: 'nonce-used' };
34
+ if ((await c.balanceOf(a.from)) < BigInt(a.value)) return { valid: false, reason: 'insufficient-balance' };
35
+ return { valid: true, from: off.from };
36
+ } catch (e) { return { valid: false, reason: `chain: ${String(e.message).slice(0, 60)}` }; }
37
+ };
38
+
39
+ const settle = async (payload, req, opts) => {
40
+ const v = await verify(payload, req, opts);
41
+ if (!v.valid) return { success: false, reason: v.reason };
42
+ const a = payload.authorization;
43
+ const c = forAsset(payload.asset);
44
+ const send = (o = {}) => c.transferWithAuthorization(a.from, a.to, a.value, a.validAfter, a.validBefore, a.nonce, payload.signature, o);
45
+ try {
46
+ const receipt = txq ? await txq.enqueue(send) : await (await send()).wait(1);
47
+ return { success: true, txHash: receipt.hash, payer: v.from };
48
+ } catch (e) { return { success: false, reason: `settle: ${String(e.message).slice(0, 80)}` }; }
49
+ };
50
+
51
+ return { verify, settle };
52
+ };