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
package/server/rewarder.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { readFileSync } from 'node:fs';
|
|
|
22
22
|
import { join, dirname } from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { EV, tryJson } from '../shared/events.mjs';
|
|
25
|
+
import { normalizeEmoji } from '../shared/reactions.mjs';
|
|
25
26
|
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
26
27
|
|
|
27
28
|
const PACK_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -50,46 +51,138 @@ export const parseAltkeyLoose = (content) => {
|
|
|
50
51
|
};
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
// events
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const bees = Object.fromEntries(Object.entries(registry).filter(([, v]) => v && v.is_bee));
|
|
61
|
-
const isBee = (pk) => !!bees[pk];
|
|
62
|
-
const ownerOf = (pk) => bees[pk]?.bee_of || null;
|
|
63
|
-
|
|
64
|
-
// ---- alt-key linkage (durable state, folded from each day's events) --------
|
|
65
|
-
// A link exists only when BOTH directions asserted it: the member claimed
|
|
66
|
-
// the alt ({alt, by:member}) AND the alt acked the member ({owner, by:alt}).
|
|
67
|
-
// Either side revokes with {…, revoke:true}. Linked keys collapse to the
|
|
68
|
-
// member's primary for every identity-sensitive rule below — so upvoting
|
|
69
|
-
// your own bee from your desktop key mints nothing, and pair-decay cannot
|
|
70
|
-
// be reset by hopping devices.
|
|
54
|
+
// Fold hive-altkey events into durable link state and return a memberOf(pk)
|
|
55
|
+
// that collapses a linked device to its member-primary. A link exists only
|
|
56
|
+
// when BOTH directions asserted it (member claims {alt}, alt acks {owner});
|
|
57
|
+
// either side revokes with {revoke:true}. Shared by the daily epoch and the
|
|
58
|
+
// real-time reaction scorer so device-hopping can't defeat anti-gaming in
|
|
59
|
+
// either path.
|
|
60
|
+
export const foldAltkeys = (state, events) => {
|
|
71
61
|
state.altkeys = state.altkeys || { claims: {}, acks: {} };
|
|
72
62
|
for (const ev of events) {
|
|
73
63
|
const j = ev.j;
|
|
74
|
-
if (j.type !== EV.ALTKEY) continue;
|
|
64
|
+
if (!j || j.type !== EV.ALTKEY) continue;
|
|
75
65
|
if (typeof j.alt === 'string' && /^[0-9a-f]{64}$/i.test(j.alt)) {
|
|
76
|
-
// member side: signer claims j.alt as their device
|
|
77
66
|
const c = state.altkeys.claims[ev.pubkey] = state.altkeys.claims[ev.pubkey] || {};
|
|
78
67
|
if (j.revoke) delete c[j.alt]; else c[j.alt] = true;
|
|
79
68
|
} else if (typeof j.owner === 'string' && /^[0-9a-f]{64}$/i.test(j.owner)) {
|
|
80
|
-
// alt side: signer acks j.owner as their member
|
|
81
69
|
const a = state.altkeys.acks[ev.pubkey] = state.altkeys.acks[ev.pubkey] || {};
|
|
82
70
|
if (j.revoke) delete a[j.owner]; else a[j.owner] = true;
|
|
83
71
|
}
|
|
84
72
|
}
|
|
73
|
+
return memberOfFrom(state);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Build memberOf(pk) from already-folded altkey state (no event replay).
|
|
77
|
+
export const memberOfFrom = (state) => {
|
|
78
|
+
const claims = state.altkeys?.claims || {};
|
|
79
|
+
const acks = state.altkeys?.acks || {};
|
|
85
80
|
const altToMember = {};
|
|
86
|
-
for (const [member, alts] of Object.entries(
|
|
81
|
+
for (const [member, alts] of Object.entries(claims)) {
|
|
87
82
|
for (const alt of Object.keys(alts)) {
|
|
88
|
-
if (
|
|
83
|
+
if (acks[alt]?.[member]) altToMember[alt] = member;
|
|
89
84
|
}
|
|
90
85
|
}
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
return (pk) => altToMember[pk] || pk;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ---- real-time reaction scoring (shared/rewards.json → reactions) ---------------
|
|
90
|
+
// Emoji resolution is shared with the CLI (shared/reactions.mjs); re-export so
|
|
91
|
+
// callers importing it from the rewarder keep working.
|
|
92
|
+
export { normalizeEmoji };
|
|
93
|
+
|
|
94
|
+
// Zero the per-day running counters when the UTC day rolls over. Durable state
|
|
95
|
+
// (altkeys, cursor, processed ids) is preserved across the reset.
|
|
96
|
+
export const rolloverDay = (state, dayKey) => {
|
|
97
|
+
if (state.date !== dayKey) {
|
|
98
|
+
state.date = dayKey;
|
|
99
|
+
state.perBee = {};
|
|
100
|
+
state.perReactor = {};
|
|
101
|
+
state.pair = {};
|
|
102
|
+
state.seenPair = {};
|
|
103
|
+
state.network = 0;
|
|
104
|
+
}
|
|
105
|
+
return state;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Score ONE reaction against running daily state, mutating state on a real
|
|
109
|
+
// mint. Returns {ok, honey, emoji, reason}. This is the real-time analogue of
|
|
110
|
+
// computeEpoch's R1 block — same exclusions (human-only, self, owner-boost,
|
|
111
|
+
// linked-device), same pair-decay — but stateful and per-reaction so HONEY can
|
|
112
|
+
// mint on-chain the instant a human reacts. Caller must rolloverDay() and fold
|
|
113
|
+
// altkeys into state first.
|
|
114
|
+
// fb = {reactor, author, result, emoji?, dir?} (author = verified result_by)
|
|
115
|
+
export const scoreReaction = (state, fb, registry, rewards = REWARDS) => {
|
|
116
|
+
const rx = rewards.reactions || {};
|
|
117
|
+
const isBee = (pk) => !!(registry[pk] && registry[pk].is_bee);
|
|
118
|
+
const ownerOf = (pk) => (registry[pk] && registry[pk].bee_of) || null;
|
|
119
|
+
const memberOf = memberOfFrom(state);
|
|
120
|
+
const emoji = normalizeEmoji(fb.emoji || (fb.dir === 'down' ? rx.down_emoji : undefined), rx);
|
|
121
|
+
const reject = (reason) => ({ ok: false, honey: 0, emoji, reason });
|
|
122
|
+
|
|
123
|
+
const author = fb.author;
|
|
124
|
+
if (!author || !isBee(author)) return reject('author-not-bee'); // only bees earn reputation
|
|
125
|
+
if (emoji === rx.down_emoji || fb.dir === 'down') return reject('downvote'); // logged, mints 0
|
|
126
|
+
const tier = rx.tiers ? rx.tiers[emoji] : undefined;
|
|
127
|
+
if (!(tier > 0)) return reject('unknown-emoji');
|
|
128
|
+
if (isBee(fb.reactor)) return reject('reactor-is-bee'); // agent reactions mint 0
|
|
129
|
+
const reactorM = memberOf(fb.reactor);
|
|
130
|
+
if (reactorM === author || fb.reactor === author) return reject('self');
|
|
131
|
+
if (reactorM === memberOf(ownerOf(author) || '')) return reject('owner-boost');
|
|
132
|
+
|
|
133
|
+
const pairSeenKey = `${reactorM}:${fb.result}`; // one counted reaction per (member,result)
|
|
134
|
+
state.seenPair = state.seenPair || {};
|
|
135
|
+
if (state.seenPair[pairSeenKey]) return reject('already-counted');
|
|
136
|
+
|
|
137
|
+
const perReactor = (state.perReactor && state.perReactor[reactorM]) || 0;
|
|
138
|
+
if (perReactor >= (rx.per_reactor_daily_cap ?? Infinity)) return reject('reactor-daily-cap');
|
|
139
|
+
|
|
140
|
+
const decayKey = `${reactorM}:${author}`; // pair-decay by member, not device
|
|
141
|
+
const nPair = (state.pair && state.pair[decayKey]) || 0;
|
|
142
|
+
const decay = rx.pair_decay[Math.min(nPair, rx.pair_decay.length - 1)];
|
|
143
|
+
|
|
144
|
+
const perBee = (state.perBee && state.perBee[author]) || 0;
|
|
145
|
+
const net = state.network || 0;
|
|
146
|
+
let honey = tier * decay;
|
|
147
|
+
honey = Math.min(honey, Math.max(0, (rx.per_bee_daily_cap ?? Infinity) - perBee));
|
|
148
|
+
honey = Math.min(honey, Math.max(0, (rx.network_daily_cap ?? Infinity) - net));
|
|
149
|
+
honey = Math.round(honey * 100) / 100;
|
|
150
|
+
|
|
151
|
+
// Whether or not it pays out, burn the dedup + pair slot so a decayed/capped
|
|
152
|
+
// reaction can never be retried for value.
|
|
153
|
+
state.seenPair[pairSeenKey] = 1;
|
|
154
|
+
state.pair = state.pair || {};
|
|
155
|
+
state.pair[decayKey] = nPair + 1;
|
|
156
|
+
if (!(honey > 0)) return reject('capped-or-decayed');
|
|
157
|
+
|
|
158
|
+
state.perBee = state.perBee || {};
|
|
159
|
+
state.perBee[author] = Math.round((perBee + honey) * 100) / 100;
|
|
160
|
+
state.perReactor = state.perReactor || {};
|
|
161
|
+
state.perReactor[reactorM] = perReactor + 1;
|
|
162
|
+
state.network = Math.round((net + honey) * 100) / 100;
|
|
163
|
+
return { ok: true, honey, emoji, reason: 'minted', author, reactor: reactorM };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// events: [{id, pubkey, at, j}] — provenance-verified, ascending by time.
|
|
167
|
+
// registry: {pubkey: {is_bee, bee_of, evm, name}}.
|
|
168
|
+
// state: mutated — {adoption: {proto: {authors:..., users: {pubkey: count}}},
|
|
169
|
+
// r6_paid: {proto: true}, streaks: {pubkey: n},
|
|
170
|
+
// altkeys: {claims: {member:{alt:true}}, acks: {alt:{member:true}}}}
|
|
171
|
+
// opts.skipRealtimeReactions: when true, R1 (human upvotes) is NOT scored here —
|
|
172
|
+
// reactions mint in real time (server/reactions.mjs) instead, so the epoch must
|
|
173
|
+
// not double-pay them. Feedback is still indexed (R2 needs the down-react set).
|
|
174
|
+
export const computeEpoch = (events, registry, state, rewards = REWARDS, opts = {}) => {
|
|
175
|
+
const R = rewards.rules;
|
|
176
|
+
const bees = Object.fromEntries(Object.entries(registry).filter(([, v]) => v && v.is_bee));
|
|
177
|
+
const isBee = (pk) => !!bees[pk];
|
|
178
|
+
const ownerOf = (pk) => bees[pk]?.bee_of || null;
|
|
179
|
+
|
|
180
|
+
// Alt-key linkage folded into durable state; memberOf() collapses a linked
|
|
181
|
+
// device to its member-primary so device-hopping can't defeat the rules
|
|
182
|
+
// below — so upvoting your own bee from your desktop key mints nothing, and
|
|
183
|
+
// pair-decay cannot be reset by hopping devices. Shared with the real-time
|
|
184
|
+
// reaction scorer via foldAltkeys().
|
|
185
|
+
const memberOf = foldAltkeys(state, events);
|
|
93
186
|
const earned = {}; // pubkey -> {total, reasons: [{code, amount, evidence[]}]}
|
|
94
187
|
const add = (pk, code, amount, evidence) => {
|
|
95
188
|
if (amount <= 0) return;
|
|
@@ -147,35 +240,40 @@ export const computeEpoch = (events, registry, state, rewards = REWARDS) => {
|
|
|
147
240
|
}
|
|
148
241
|
|
|
149
242
|
// R1 — human upvotes, ordered by time, ladder amounts, pair decay, cap.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
243
|
+
// Skipped when reactions mint in real time (server/reactions.mjs) so the
|
|
244
|
+
// epoch never double-pays them; `feedback` is still indexed above because R2
|
|
245
|
+
// depends on the down-react set.
|
|
246
|
+
if (!opts.skipRealtimeReactions) {
|
|
247
|
+
const resultAuthor = Object.fromEntries(results.map((r) => [r.id, r.author]));
|
|
248
|
+
const upvotesByAuthor = {};
|
|
249
|
+
const pairCount = {}; // `${reactor}:${author}` -> n
|
|
250
|
+
const seenReactPair = new Set(); // one vote per (reactor, result)
|
|
251
|
+
for (const f of feedback) {
|
|
252
|
+
if (f.dir !== 'up') continue;
|
|
253
|
+
const author = resultAuthor[f.result] || f.author;
|
|
254
|
+
if (!isBee(author)) continue;
|
|
255
|
+
if (isBee(f.reactor)) continue; // agent reactions mint 0
|
|
256
|
+
const reactorM = memberOf(f.reactor); // collapse linked devices
|
|
257
|
+
if (reactorM === author || f.reactor === author) continue; // self
|
|
258
|
+
if (reactorM === memberOf(ownerOf(author) || '')) continue; // owner (any device) boosting own bee
|
|
259
|
+
const rp = `${reactorM}:${f.result}`; // one vote per MEMBER per result
|
|
260
|
+
if (seenReactPair.has(rp)) continue;
|
|
261
|
+
seenReactPair.add(rp);
|
|
262
|
+
const pk = `${reactorM}:${author}`; // pair-decay by member, not device
|
|
263
|
+
const nPair = pairCount[pk] || 0;
|
|
264
|
+
pairCount[pk] = nPair + 1;
|
|
265
|
+
const decay = R.R1.pair_decay[Math.min(nPair, R.R1.pair_decay.length - 1)];
|
|
266
|
+
const list = upvotesByAuthor[author] = upvotesByAuthor[author] || [];
|
|
267
|
+
list.push({ id: f.id, decay });
|
|
268
|
+
}
|
|
269
|
+
for (const [author, ups] of Object.entries(upvotesByAuthor)) {
|
|
270
|
+
let total = 0;
|
|
271
|
+
ups.forEach((u, i) => {
|
|
272
|
+
const base = R.R1.amounts[Math.min(i, R.R1.amounts.length - 1)] ?? R.R1.amount_tail;
|
|
273
|
+
const amt = Math.min(base * u.decay, Math.max(0, R.R1.cap - total));
|
|
274
|
+
if (amt > 0) { total += amt; add(author, 'R1', amt, [u.id]); }
|
|
275
|
+
});
|
|
276
|
+
}
|
|
179
277
|
}
|
|
180
278
|
|
|
181
279
|
// R2 — distinct intents served with no complaint (no down-react on the
|
|
@@ -351,7 +449,8 @@ export const runEpoch = async (epochDate, deps) => {
|
|
|
351
449
|
const end = Math.floor(Date.parse(`${epochDate}T00:00:00Z`) / 1000) + closeHour * 3600;
|
|
352
450
|
const start = end - 86400;
|
|
353
451
|
const events = await fetchEpochEvents(relay, logsChannelId, start, end);
|
|
354
|
-
|
|
452
|
+
// Reactions (R1) mint in real time now; keep the epoch from double-paying them.
|
|
453
|
+
const { mints, penalties } = computeEpoch(events, registry, state, REWARDS, { skipRealtimeReactions: !!REWARDS.reactions?.realtime });
|
|
355
454
|
log(`epoch ${epochDate}: ${events.length} events -> ${mints.length} mint(s), ${penalties.length} penalt(ies)`);
|
|
356
455
|
|
|
357
456
|
// Receipt FIRST (auditable intent), then the transactions, then tx receipt.
|
|
@@ -360,7 +459,7 @@ export const runEpoch = async (epochDate, deps) => {
|
|
|
360
459
|
for (const m of mints) {
|
|
361
460
|
if (!m.evm) { log(`epoch: no wallet for ${m.to.slice(0, 12)} — skipping mint`); continue; }
|
|
362
461
|
try {
|
|
363
|
-
const receipt = await txq.enqueue((o) => honeyContract.mint(m.evm, parseUnits(String(m.honey), 18), o));
|
|
462
|
+
const receipt = await txq.enqueue((o) => honeyContract.mint(m.evm, parseUnits(String(m.honey), 18), o), { gasLimit: 250000n });
|
|
364
463
|
txs.push({ to: m.evm, honey: m.honey, tx: receipt.hash });
|
|
365
464
|
log(`epoch: minted ${m.honey} HONEY -> ${registry[m.to]?.name || m.to.slice(0, 12)} (${receipt.hash.slice(0, 12)})`);
|
|
366
465
|
} catch (e) { log(`epoch mint failed for ${m.to.slice(0, 12)}: ${String(e.message).slice(0, 120)}`); }
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// server/slasher — REAL-TIME, on-chain HONEY slashing.
|
|
2
|
+
//
|
|
3
|
+
// The burn counterpart to server/reactions.mjs. HoneyV2 forbade automated burns
|
|
4
|
+
// ("automating the report pipeline turns it into a weapon"); HoneyV3 permits
|
|
5
|
+
// them under SLASHER_ROLE, with the guardrails enforced HERE so the on-chain
|
|
6
|
+
// call stays a dumb, floored primitive:
|
|
7
|
+
// - trigger = a REPORT QUORUM: >= `quorum` DISTINCT human reporters (altkey-
|
|
8
|
+
// collapsed) naming the same bee within `window_secs`.
|
|
9
|
+
// - reporters that are bees, or the bee's own owner, never count — a bee can't
|
|
10
|
+
// slash a rival and an owner can't slash their own bee.
|
|
11
|
+
// - rate cap: at most `per_bee_daily_cap` slashes per bee per UTC day.
|
|
12
|
+
// - idempotent per report event id; floored on-chain (slash burns
|
|
13
|
+
// min(amount, balance)); every slash emits a hive-slash receipt + tx link.
|
|
14
|
+
//
|
|
15
|
+
// Below the daemon's HONEY-gate thresholds a slashed bee is throttled, then
|
|
16
|
+
// de-eligible ("dies") — that gate lives in the daemon (fan-out + spend), not
|
|
17
|
+
// here. This worker only performs the burn and records it.
|
|
18
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
19
|
+
import { EV, verifiedEvent } from '../shared/events.mjs';
|
|
20
|
+
import { REWARDS, foldAltkeys, memberOfFrom } from './rewarder.mjs';
|
|
21
|
+
import { sepoliaTxUrl } from './reactions.mjs';
|
|
22
|
+
import { RelayClient } from '../daemon/relay/client.mjs';
|
|
23
|
+
|
|
24
|
+
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
25
|
+
const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
|
|
26
|
+
const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
|
|
27
|
+
|
|
28
|
+
const PROCESSED_MAX = 5000;
|
|
29
|
+
const CURSOR_OVERLAP_SECS = 5;
|
|
30
|
+
|
|
31
|
+
// Count distinct human reporters of `bee` within the window, and whether the
|
|
32
|
+
// quorum is met. Pure — testable without a relay. `reports` = {bee:{member:at}}.
|
|
33
|
+
export const quorumMet = (reports, bee, nowSec, cfg) => {
|
|
34
|
+
const r = reports[bee] || {};
|
|
35
|
+
const fresh = Object.values(r).filter((at) => at >= nowSec - (cfg.window_secs || 86400)).length;
|
|
36
|
+
return { count: fresh, met: fresh >= (cfg.quorum || 2) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// deps: { relay, logsChannelId, honeyContract, txq, parseUnits, loadRegistry,
|
|
40
|
+
// statePath, emit, selfPubkey, log, now? }
|
|
41
|
+
// honeyContract must expose slash(address,uint256,string) (HoneyV3).
|
|
42
|
+
export const createSlasher = (deps) => {
|
|
43
|
+
const { statePath, log = () => {}, now = () => Date.now() } = deps;
|
|
44
|
+
const cfg = REWARDS.slashing || {};
|
|
45
|
+
const nowSec = () => Math.floor(now() / 1000);
|
|
46
|
+
|
|
47
|
+
const load = () => {
|
|
48
|
+
const s = loadJson(statePath, null);
|
|
49
|
+
if (s) { s.processed = s.processed || []; s.reports = s.reports || {}; s.slashedToday = s.slashedToday || []; return s; }
|
|
50
|
+
return { cursor: nowSec(), processed: [], reports: {}, slashedToday: [], altkeys: { claims: {}, acks: {} }, date: utcDay(now()) };
|
|
51
|
+
};
|
|
52
|
+
const save = (s) => writeAtomic(statePath, JSON.stringify(s, null, 2));
|
|
53
|
+
const markProcessed = (state, id) => { state.processed.push(id); if (state.processed.length > PROCESSED_MAX) state.processed = state.processed.slice(-PROCESSED_MAX); };
|
|
54
|
+
|
|
55
|
+
const poll = async () => {
|
|
56
|
+
if (!cfg.enabled || !deps.honeyContract) return { slashed: 0, reason: 'disabled' };
|
|
57
|
+
const state = load();
|
|
58
|
+
if (state.date !== utcDay(now())) { state.date = utcDay(now()); state.slashedToday = []; } // daily rate-cap reset
|
|
59
|
+
const registry = deps.loadRegistry();
|
|
60
|
+
const isBee = (pk) => !!(registry[pk] && registry[pk].is_bee);
|
|
61
|
+
const ownerOf = (pk) => (registry[pk] && registry[pk].bee_of) || null;
|
|
62
|
+
if (!Object.values(registry).some((r) => r?.is_bee)) { save(state); return { slashed: 0, reason: 'no-bees' }; }
|
|
63
|
+
|
|
64
|
+
const since = Math.max(0, (state.cursor || nowSec()) - CURSOR_OVERLAP_SECS);
|
|
65
|
+
const raw = await deps.relay.query([{ kinds: [9, 40002], '#h': [deps.logsChannelId], since, limit: 500 }]);
|
|
66
|
+
if (!raw) { save(state); return { slashed: 0, reason: 'relay-unreachable' }; }
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const rows = raw.map(RelayClient.normalize)
|
|
69
|
+
.sort((a, b) => (a.created_at - b.created_at) || (a.id < b.id ? -1 : 1))
|
|
70
|
+
.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
|
|
71
|
+
|
|
72
|
+
// Fold altkeys so a reporter's linked devices collapse to one member.
|
|
73
|
+
foldAltkeys(state, rows.map((m) => ({ pubkey: m.pubkey, j: verifiedEvent({ content: m.content, pubkey: m.pubkey }) })).filter((e) => e.j));
|
|
74
|
+
const memberOf = memberOfFrom(state);
|
|
75
|
+
|
|
76
|
+
// Ingest new reports into the durable reporter set.
|
|
77
|
+
const processed = new Set(state.processed);
|
|
78
|
+
let maxAt = state.cursor || 0;
|
|
79
|
+
for (const m of rows) {
|
|
80
|
+
maxAt = Math.max(maxAt, m.created_at);
|
|
81
|
+
if (processed.has(m.id)) continue;
|
|
82
|
+
const j = verifiedEvent({ content: m.content, pubkey: m.pubkey });
|
|
83
|
+
if (!j || j.type !== EV.REPORT || typeof j.subject !== 'string') continue; // not a report — leave for other consumers
|
|
84
|
+
markProcessed(state, m.id);
|
|
85
|
+
const bee = j.subject;
|
|
86
|
+
if (!isBee(bee)) continue; // only bees are slashable
|
|
87
|
+
const reporter = memberOf(m.pubkey);
|
|
88
|
+
// Reporter must be a REGISTERED, non-bee member. An unregistered key isn't a
|
|
89
|
+
// bee (isBee=false) but must NOT count toward quorum — otherwise two throwaway
|
|
90
|
+
// keys posting reports could slash anyone (Sybil). Altkeys collapse to the
|
|
91
|
+
// member via memberOf, so a member's devices count once.
|
|
92
|
+
if (!registry[reporter] || registry[reporter].is_bee) continue;
|
|
93
|
+
if (reporter === memberOf(ownerOf(bee) || '')) continue; // owner can't slash own bee
|
|
94
|
+
(state.reports[bee] = state.reports[bee] || {})[reporter] = m.created_at;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Prune stale reports and evaluate quorum.
|
|
98
|
+
const win = cfg.window_secs || 86400;
|
|
99
|
+
let slashed = 0;
|
|
100
|
+
for (const bee of Object.keys(state.reports)) {
|
|
101
|
+
for (const [rep, at] of Object.entries(state.reports[bee])) if (at < nowSec() - win) delete state.reports[bee][rep];
|
|
102
|
+
if (!Object.keys(state.reports[bee]).length) { delete state.reports[bee]; continue; }
|
|
103
|
+
const { count, met } = quorumMet(state.reports, bee, nowSec(), cfg);
|
|
104
|
+
if (!met) continue;
|
|
105
|
+
if (state.slashedToday.includes(bee)) continue; // per-bee daily rate cap (per_bee_daily_cap = 1)
|
|
106
|
+
const evm = registry[bee]?.evm;
|
|
107
|
+
if (!evm || !/^0x[0-9a-fA-F]{40}$/.test(evm)) { state.slashedToday.push(bee); log(`slasher: no wallet for ${bee.slice(0, 12)} — skip`); continue; }
|
|
108
|
+
const reason = `report-quorum:${count}`;
|
|
109
|
+
// Reserve today's slot BEFORE the burn (spend-ledger pattern) so a crash can
|
|
110
|
+
// never double-slash; a failed burn just defers (reports persist, today is
|
|
111
|
+
// spent). On SUCCESS, clear the reports so the SAME quorum can't re-slash on
|
|
112
|
+
// the next UTC day — the double-jeopardy that made one quorum burn twice.
|
|
113
|
+
state.slashedToday.push(bee);
|
|
114
|
+
save(state);
|
|
115
|
+
try {
|
|
116
|
+
const receipt = await deps.txq.enqueue((o) => deps.honeyContract.slash(evm, deps.parseUnits(String(cfg.amount || 10), 18), reason, o), { gasLimit: 250000n });
|
|
117
|
+
delete state.reports[bee];
|
|
118
|
+
save(state);
|
|
119
|
+
slashed++;
|
|
120
|
+
log(`slasher: −${cfg.amount} HONEY ⚔️ ${registry[bee]?.name || bee.slice(0, 12)} (${reason}, ${receipt.hash.slice(0, 12)})`);
|
|
121
|
+
try {
|
|
122
|
+
await deps.emit({ type: EV.SLASH, to: bee, amount: cfg.amount, reason, trigger: 'report-quorum', tx: receipt.hash, url: sepoliaTxUrl(receipt.hash), by: deps.selfPubkey, at: nowSec() });
|
|
123
|
+
} catch (e) { log('slasher: receipt emit failed:', String(e.message).slice(0, 100)); }
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// Slot already reserved; reports remain → retried next UTC day. No double.
|
|
126
|
+
log(`slasher: slash failed for ${bee.slice(0, 12)} (deferred to next day): ${String(e.message).slice(0, 120)}`);
|
|
127
|
+
return { slashed, reason: 'slash-failed' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
state.cursor = maxAt;
|
|
131
|
+
save(state);
|
|
132
|
+
return { slashed };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return { poll, _load: load, _save: save };
|
|
136
|
+
};
|
package/server/treasury.mjs
CHANGED
|
@@ -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
|
|
138
|
-
|
|
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
|
+
};
|