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.
- package/bin/hive +21 -61
- package/bin/hive-core.mjs +64 -0
- package/bin/hive-net.mjs +23 -9
- package/daemon/fanout.mjs +23 -4
- package/daemon/hived.mjs +90 -9
- package/onchain/deployments.sepolia.json +12 -3
- package/onchain/src/HoneyV3.sol +97 -0
- package/onchain/src/JellyV3.sol +103 -0
- package/package.json +4 -3
- package/server/api.mjs +15 -0
- package/server/provision.mjs +49 -0
- package/server/reactions.mjs +186 -0
- package/server/rewarder.mjs +155 -56
- package/server/slasher.mjs +136 -0
- package/server/treasury.mjs +145 -2
- package/server/x402-facilitator.mjs +52 -0
- package/server/x402-gateway.mjs +44 -0
- package/shared/core.mjs +71 -0
- package/shared/events.mjs +4 -1
- package/shared/reactions.mjs +37 -0
- package/shared/rewards.json +23 -1
- package/shared/txqueue.mjs +9 -4
- package/shared/x402-client.mjs +28 -0
- package/shared/x402.mjs +72 -0
|
@@ -0,0 +1,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.
|
|
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
|
package/server/provision.mjs
CHANGED
|
@@ -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
|
+
};
|