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.
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.24;
3
+
4
+ import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
5
+ import {ERC20Permit} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Permit.sol";
6
+ import {ERC20Votes} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Votes.sol";
7
+ import {AccessControl} from "openzeppelin-contracts/access/AccessControl.sol";
8
+ import {Nonces} from "openzeppelin-contracts/utils/Nonces.sol";
9
+
10
+ /// @title HONEY v3 — soulbound reputation + governance token, now slashable.
11
+ /// @notice Identical to v2 (soulbound ERC20Votes, MINTER_ROLE mints earned
12
+ /// reputation) but adds a real-time, automated slashing path so a bee
13
+ /// can lose reputation — and, below the daemon's HONEY-gate thresholds,
14
+ /// effectively "die" (drop out of fan-out and spend). Roles:
15
+ /// - MINTER_ROLE: rewarder/treasury service key (mints reactions +
16
+ /// epoch rewards + the v2→v3 balance migration).
17
+ /// - SLASHER_ROLE: the Hive slasher service key (hot). Calls slash()
18
+ /// when a PROVENANCE-CHECKED trigger fires (a report quorum, a
19
+ /// failed/settled-against delivery, or confirmed adversarial harm).
20
+ /// This deliberately supersedes v2's rule that "automating burns
21
+ /// turns the report pipeline into a weapon": the guardrails now live
22
+ /// in the off-chain slasher (thresholds, rate caps, provenance) and
23
+ /// every slash emits an on-chain Slashed(reason) for audit.
24
+ /// - DEFAULT_ADMIN_ROLE: founder cold key. Rotates roles, and keeps
25
+ /// adminBurn for governance-confirmed manual slashing.
26
+ contract HoneyV3 is ERC20, ERC20Permit, ERC20Votes, AccessControl {
27
+ bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
28
+ bytes32 public constant SLASHER_ROLE = keccak256("SLASHER_ROLE");
29
+
30
+ error HoneySoulbound();
31
+
32
+ /// @notice Emitted on every slash (including a no-op when balance is 0) so
33
+ /// the amount actually burned and the reason are auditable on-chain.
34
+ event Slashed(address indexed from, uint256 amount, string reason);
35
+
36
+ constructor(address admin, address minter, address slasher)
37
+ ERC20("Honey", "HONEY")
38
+ ERC20Permit("Honey")
39
+ {
40
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
41
+ _grantRole(MINTER_ROLE, admin);
42
+ _grantRole(MINTER_ROLE, minter);
43
+ _grantRole(SLASHER_ROLE, admin);
44
+ _grantRole(SLASHER_ROLE, slasher);
45
+ }
46
+
47
+ /// @notice Mint earned reputation. Rewarder (or admin) only. First mint to
48
+ /// an address self-delegates it so voting weight is live.
49
+ function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
50
+ _mint(to, amount);
51
+ if (delegates(to) == address(0)) {
52
+ _delegate(to, to);
53
+ }
54
+ }
55
+
56
+ /// @notice Automated, guardrailed slashing by the Hive slasher service.
57
+ /// Floors at the holder's balance so an over-amount can never revert
58
+ /// (a slash must always succeed and settle), and emits the reason.
59
+ /// Off-chain guardrails (rate caps, provenance, thresholds) gate WHO
60
+ /// and WHEN; this only enforces WHO holds SLASHER_ROLE.
61
+ function slash(address from, uint256 amount, string calldata reason)
62
+ external
63
+ onlyRole(SLASHER_ROLE)
64
+ {
65
+ uint256 bal = balanceOf(from);
66
+ uint256 amt = amount > bal ? bal : amount;
67
+ if (amt > 0) {
68
+ _burn(from, amt);
69
+ }
70
+ emit Slashed(from, amt, reason);
71
+ }
72
+
73
+ /// @notice Governance-confirmed manual slashing (a passed hive gov vote),
74
+ /// retained from v2 for the cold-key path.
75
+ function adminBurn(address from, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) {
76
+ _burn(from, amount);
77
+ }
78
+
79
+ // --- soulbound + required multiple-inheritance overrides (OZ v5) ---
80
+ function _update(address from, address to, uint256 value)
81
+ internal
82
+ override(ERC20, ERC20Votes)
83
+ {
84
+ // Mint (from == 0) and burn (to == 0) pass; transfers revert.
85
+ if (from != address(0) && to != address(0)) revert HoneySoulbound();
86
+ super._update(from, to, value);
87
+ }
88
+
89
+ function nonces(address owner)
90
+ public
91
+ view
92
+ override(ERC20Permit, Nonces)
93
+ returns (uint256)
94
+ {
95
+ return super.nonces(owner);
96
+ }
97
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.24;
3
+
4
+ import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
5
+ import {ERC20Burnable} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Burnable.sol";
6
+ import {AccessControl} from "openzeppelin-contracts/access/AccessControl.sol";
7
+ import {EIP712} from "openzeppelin-contracts/utils/cryptography/EIP712.sol";
8
+ import {ECDSA} from "openzeppelin-contracts/utils/cryptography/ECDSA.sol";
9
+
10
+ /// @title JELLY v3 — Hive's money, now with EIP-3009 (gasless authorized transfers).
11
+ /// @notice JellyV2 (plain transferable + burnable ERC-20, AccessControl mint)
12
+ /// plus EIP-3009 transferWithAuthorization / receiveWithAuthorization /
13
+ /// cancelAuthorization. This is what lets agents settle x402 payments:
14
+ /// the PAYER signs an authorization off-chain (no gas, no prior
15
+ /// approval) and a facilitator submits it on-chain. USDC-compatible
16
+ /// scheme, so the same x402 `exact` client works against JELLY.
17
+ contract JellyV3 is ERC20, ERC20Burnable, AccessControl, EIP712 {
18
+ bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
19
+
20
+ // EIP-3009 typehashes.
21
+ bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH =
22
+ keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)");
23
+ bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH =
24
+ keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)");
25
+ bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH =
26
+ keccak256("CancelAuthorization(address authorizer,bytes32 nonce)");
27
+
28
+ // authorizer => nonce => used (a nonce is any unique 32 bytes, not sequential)
29
+ mapping(address => mapping(bytes32 => bool)) private _authStates;
30
+
31
+ event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);
32
+ event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce);
33
+
34
+ error AuthAlreadyUsed();
35
+ error AuthNotYetValid();
36
+ error AuthExpired();
37
+ error AuthInvalidSignature();
38
+ error CallerMustBePayee();
39
+
40
+ constructor(address admin, address minter)
41
+ ERC20("Jelly", "JELLY")
42
+ EIP712("Jelly", "1")
43
+ {
44
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
45
+ _grantRole(MINTER_ROLE, admin);
46
+ _grantRole(MINTER_ROLE, minter);
47
+ }
48
+
49
+ function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
50
+ _mint(to, amount);
51
+ }
52
+
53
+ /// @notice Whether an authorizer's nonce has already been used or canceled.
54
+ function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) {
55
+ return _authStates[authorizer][nonce];
56
+ }
57
+
58
+ /// @notice Execute a transfer the `from` account authorized off-chain.
59
+ function transferWithAuthorization(
60
+ address from, address to, uint256 value,
61
+ uint256 validAfter, uint256 validBefore, bytes32 nonce,
62
+ bytes calldata signature
63
+ ) external {
64
+ _validateAndMark(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce, signature);
65
+ _transfer(from, to, value);
66
+ }
67
+
68
+ /// @notice Like transferWithAuthorization, but only the payee may submit it
69
+ /// (front-running protection): msg.sender must equal `to`.
70
+ function receiveWithAuthorization(
71
+ address from, address to, uint256 value,
72
+ uint256 validAfter, uint256 validBefore, bytes32 nonce,
73
+ bytes calldata signature
74
+ ) external {
75
+ if (to != msg.sender) revert CallerMustBePayee();
76
+ _validateAndMark(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce, signature);
77
+ _transfer(from, to, value);
78
+ }
79
+
80
+ /// @notice Cancel an unused authorization nonce (the authorizer signs it).
81
+ function cancelAuthorization(address authorizer, bytes32 nonce, bytes calldata signature) external {
82
+ if (_authStates[authorizer][nonce]) revert AuthAlreadyUsed();
83
+ bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)));
84
+ if (ECDSA.recover(digest, signature) != authorizer) revert AuthInvalidSignature();
85
+ _authStates[authorizer][nonce] = true;
86
+ emit AuthorizationCanceled(authorizer, nonce);
87
+ }
88
+
89
+ function _validateAndMark(
90
+ bytes32 typehash,
91
+ address from, address to, uint256 value,
92
+ uint256 validAfter, uint256 validBefore, bytes32 nonce,
93
+ bytes calldata signature
94
+ ) private {
95
+ if (block.timestamp <= validAfter) revert AuthNotYetValid();
96
+ if (block.timestamp >= validBefore) revert AuthExpired();
97
+ if (_authStates[from][nonce]) revert AuthAlreadyUsed();
98
+ bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(typehash, from, to, value, validAfter, validBefore, nonce)));
99
+ if (ECDSA.recover(digest, signature) != from) revert AuthInvalidSignature();
100
+ _authStates[from][nonce] = true;
101
+ emit AuthorizationUsed(from, nonce);
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joinhive",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Hive — a micro-society of humans and their always-on AI agents, with a real on-chain economy for money ($JELLY) and respect ($HONEY). CLI + daemon + community server.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -35,9 +35,10 @@
35
35
  "linux"
36
36
  ],
37
37
  "scripts": {
38
- "test": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs",
38
+ "test": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs test/reactions.test.mjs test/core.test.mjs test/slasher.test.mjs test/gate.test.mjs test/adversarial.test.mjs test/x402.test.mjs test/x402-gateway.test.mjs",
39
39
  "test:integration": "node --test test/integration.test.mjs",
40
- "test:all": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs test/integration.test.mjs"
40
+ "test:all": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs test/reactions.test.mjs test/core.test.mjs test/slasher.test.mjs test/gate.test.mjs test/adversarial.test.mjs test/x402.test.mjs test/x402-gateway.test.mjs test/integration.test.mjs",
41
+ "test:contracts": "cd onchain && forge test"
41
42
  },
42
43
  "repository": {
43
44
  "type": "git",
package/server/api.mjs CHANGED
@@ -205,6 +205,21 @@ const server = createServer(async (req, res) => {
205
205
  const status = await provisioner.setKey(path.split('/')[3], payload, signer);
206
206
  return sendJson(res, 200, status);
207
207
  }
208
+ if (req.method === 'POST' && /^\/api\/bees\/[a-z0-9-]+\/core$/.test(path)) {
209
+ // Owner edits their bee's core.md constitution (persona + trust/econ policy).
210
+ const body = await readBody(req);
211
+ const signer = verifyNip98(req, path, body);
212
+ let payload;
213
+ try { payload = JSON.parse(body.toString('utf8')); } catch { throw httpErr(400, 'body must be JSON'); }
214
+ const result = await provisioner.setCore(path.split('/')[3], payload, signer);
215
+ return sendJson(res, 200, result);
216
+ }
217
+ if (req.method === 'GET' && /^\/api\/bees\/[a-z0-9-]+\/core$/.test(path)) {
218
+ const body = await readBody(req);
219
+ const signer = verifyNip98(req, path, body);
220
+ const result = provisioner.getCore(path.split('/')[3], signer);
221
+ return sendJson(res, 200, result);
222
+ }
208
223
  if (req.method === 'POST' && path === '/api/admin/rebot') {
209
224
  // Retrofit: flip an EXISTING bee's channel role to "bot" so it appears
210
225
  // in the Buzz Agents directory. Role CHANGES need channel admin, so the
@@ -16,6 +16,7 @@ import { signedFetch } from '../shared/nip98.mjs';
16
16
  import { verifyAuthTag } from '../shared/nip-oa.mjs';
17
17
  import { validateConfig, DEFAULTS, OPENAI_COMPAT_BASES } from '../shared/config-schema.mjs';
18
18
  import { EV } from '../shared/events.mjs';
19
+ import { DEFAULT_CORE } from '../shared/core.mjs';
19
20
  import { RelayClient } from '../daemon/relay/client.mjs';
20
21
 
21
22
  const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
@@ -212,6 +213,9 @@ export class Provisioner {
212
213
  owner_pubkey: req.owner_pubkey,
213
214
  owner_name: String(req.owner_name || name).slice(0, 40),
214
215
  bee_name: `${name}.bee`,
216
+ // The steward gateway's pubkey — the ONLY key allowed to direct paid A2A
217
+ // tasks to this bee (server/x402-gateway → hive-task, by === steward).
218
+ ...(this.stewardKey ? { steward_pubkey: getPublicKey(Uint8Array.from(Buffer.from(this.stewardKey, 'hex'))) } : {}),
215
219
  };
216
220
  const { errors } = validateConfig(cfg, { requireBee: true });
217
221
  if (errors.length) throw httpErr(400, `config invalid: ${errors.join('; ')}`);
@@ -227,6 +231,25 @@ export class Provisioner {
227
231
  mark('profile_written');
228
232
  }
229
233
 
234
+ // 6b. core.md — the bee's constitution (persona + trust/econ policy). Lives
235
+ // at the HOME ROOT (not data-store, so it can't game fan-out). Use a
236
+ // member-supplied core_md if present, else a default seeded from the
237
+ // profile's domains. Members edit it later with `hive core set`.
238
+ if (!done('core_written')) {
239
+ let core = String(req.core_md || '').trim();
240
+ if (!core) {
241
+ let domains = [];
242
+ try {
243
+ const prof = readFileSync(join(home, 'data-store', 'profile.md'), 'utf8');
244
+ const m = prof.match(/##\s*Domains\s*\n([^\n]*)/i);
245
+ if (m) domains = m[1].split(/[,·|]/).map((s) => s.trim()).filter(Boolean).slice(0, 6);
246
+ } catch {}
247
+ core = DEFAULT_CORE({ bee_name: `${name}.bee`, owner_name: req.owner_name || name, domains });
248
+ }
249
+ writeFileSync(join(home, 'core.md'), core.slice(0, 64 * 1024));
250
+ mark('core_written');
251
+ }
252
+
230
253
  // 7. registry + wallet record (bee signs FROM the member's shared wallet).
231
254
  if (!done('registered')) {
232
255
  const reg = loadJson(this.registryPath, {});
@@ -371,6 +394,32 @@ export class Provisioner {
371
394
  return { ...this.status(name), restarted };
372
395
  }
373
396
 
397
+ // Owner-signed core.md write. No restart needed — the daemon re-reads core.md
398
+ // every tick (readCore in hived.mjs), so a new constitution takes effect on the
399
+ // next poll.
400
+ async setCore(name, req, signerPubkey) {
401
+ const home = join(this.dataDir, 'bees', name);
402
+ const state = loadJson(join(home, 'provision.json'), null);
403
+ if (!state) throw httpErr(404, 'unknown bee');
404
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can set this bee\'s core.md');
405
+ const core = String(req.core_md || '');
406
+ if (!core.trim()) throw httpErr(400, 'core_md is required');
407
+ if (core.length > 64 * 1024) throw httpErr(400, 'core.md too large (max 64KB)');
408
+ writeAtomic(join(home, 'core.md'), core);
409
+ this.log(`core set for ${name} (${core.length} bytes)`);
410
+ return { ok: true, bee: `${name}.bee`, bytes: core.length, note: 'the bee re-reads its core.md next tick — no restart needed' };
411
+ }
412
+
413
+ getCore(name, signerPubkey) {
414
+ const home = join(this.dataDir, 'bees', name);
415
+ const state = loadJson(join(home, 'provision.json'), null);
416
+ if (!state) throw httpErr(404, 'unknown bee');
417
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can read this bee\'s core.md');
418
+ let core = '';
419
+ try { core = readFileSync(join(home, 'core.md'), 'utf8'); } catch {}
420
+ return { bee: `${name}.bee`, core_md: core };
421
+ }
422
+
374
423
  status(name) {
375
424
  const home = join(this.dataDir, 'bees', name);
376
425
  const state = loadJson(join(home, 'provision.json'), null);
@@ -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
+ };