agent-escrow 0.1.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,362 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ KINDS, TASK_STATUS, RESOLUTION_TYPES,
5
+ connectRelays, publishToRelays, signEvent, pubkeyFromSecret,
6
+ closeRelays
7
+ } = require('./nostr.cjs');
8
+ const { createTaskEvent, updateTaskEvent, parseTask, findTasks, getTask } = require('./task.cjs');
9
+ const { createBidEvent, parseBid, findBids, findMyBids } = require('./bid.cjs');
10
+ const { createDeliveryEvent, parseDelivery, verifyDelivery, findDeliveries } = require('./delivery.cjs');
11
+ const { createResolutionEvent, parseResolution, findResolutions } = require('./resolution.cjs');
12
+ const { getTrustScore, meetsTrustThreshold, publishWorkAttestation, publishDisputeAttestation } = require('./trust.cjs');
13
+ const { paymentsAvailable, createWallet, payWorker, checkBalance, createManualPaymentResult } = require('./escrow.cjs');
14
+
15
+ /**
16
+ * Create a marketplace instance
17
+ *
18
+ * @param {Object} opts
19
+ * @param {string[]} opts.relays - Relay URLs
20
+ * @param {string|Uint8Array} opts.secretKey - Nostr secret key (hex or bytes)
21
+ * @param {string} [opts.nwcUrl] - NWC URL for Lightning payments
22
+ * @param {string} [opts.lightningAddress] - Your Lightning address
23
+ * @param {number} [opts.defaultMinTrust] - Default minimum trust for tasks
24
+ * @param {number} [opts.connectTimeoutMs] - Relay connection timeout
25
+ */
26
+ function createMarketplace(opts) {
27
+ const {
28
+ relays: relayUrls = ['wss://relay.damus.io', 'wss://nos.lol'],
29
+ secretKey,
30
+ nwcUrl,
31
+ lightningAddress,
32
+ defaultMinTrust = 0,
33
+ connectTimeoutMs = 5000
34
+ } = opts;
35
+
36
+ if (!secretKey) throw new Error('secretKey is required');
37
+
38
+ const pubkey = pubkeyFromSecret(secretKey);
39
+ let relays = null;
40
+ let wallet = null;
41
+
42
+ /**
43
+ * Ensure relay connections are active
44
+ */
45
+ async function ensureConnected() {
46
+ if (!relays || relays.length === 0) {
47
+ relays = await connectRelays(relayUrls, connectTimeoutMs);
48
+ if (relays.length === 0) {
49
+ throw new Error('Failed to connect to any relay');
50
+ }
51
+ }
52
+ return relays;
53
+ }
54
+
55
+ /**
56
+ * Get or create wallet
57
+ */
58
+ function ensureWallet() {
59
+ if (!wallet && nwcUrl) {
60
+ wallet = createWallet(nwcUrl);
61
+ }
62
+ return wallet;
63
+ }
64
+
65
+ /**
66
+ * Sign and publish an event
67
+ */
68
+ async function signAndPublish(template) {
69
+ const r = await ensureConnected();
70
+ const event = signEvent(template, secretKey);
71
+ await publishToRelays(r, event);
72
+ return event;
73
+ }
74
+
75
+ // ── Task operations ───────────────────────────────────
76
+
77
+ /**
78
+ * Post a new task
79
+ */
80
+ async function postTask(taskOpts) {
81
+ const template = createTaskEvent({
82
+ ...taskOpts,
83
+ lightningAddress: taskOpts.lightningAddress || lightningAddress,
84
+ minTrust: taskOpts.minTrust ?? defaultMinTrust
85
+ });
86
+ const event = await signAndPublish(template);
87
+ return parseTask(event);
88
+ }
89
+
90
+ /**
91
+ * Cancel a task (only works if status is "open")
92
+ */
93
+ async function cancelTask(taskId) {
94
+ const r = await ensureConnected();
95
+ const task = await getTask(r, pubkey, taskId);
96
+ if (!task) throw new Error(`Task not found: ${taskId}`);
97
+ if (task.status !== TASK_STATUS.OPEN) {
98
+ throw new Error(`Cannot cancel task in status: ${task.status}`);
99
+ }
100
+ const template = updateTaskEvent(task.raw, { status: TASK_STATUS.CANCELLED });
101
+ const event = await signAndPublish(template);
102
+ return parseTask(event);
103
+ }
104
+
105
+ /**
106
+ * Find open tasks on the network
107
+ */
108
+ async function browseTasks(searchOpts = {}) {
109
+ const r = await ensureConnected();
110
+ return findTasks(r, {
111
+ status: searchOpts.status || TASK_STATUS.OPEN,
112
+ ...searchOpts,
113
+ timeoutMs: searchOpts.timeoutMs || 8000
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Get a specific task
119
+ */
120
+ async function fetchTask(posterPubkey, taskId) {
121
+ const r = await ensureConnected();
122
+ return getTask(r, posterPubkey, taskId);
123
+ }
124
+
125
+ // ── Bid operations ────────────────────────────────────
126
+
127
+ /**
128
+ * Submit a bid on a task
129
+ */
130
+ async function submitBid(bidOpts) {
131
+ const {
132
+ taskEventId,
133
+ posterPubkey,
134
+ amount,
135
+ eta,
136
+ message
137
+ } = bidOpts;
138
+
139
+ const template = createBidEvent({
140
+ taskEventId,
141
+ posterPubkey,
142
+ amount,
143
+ lightningAddress: bidOpts.lightningAddress || lightningAddress,
144
+ eta,
145
+ message
146
+ });
147
+
148
+ const event = await signAndPublish(template);
149
+ return parseBid(event);
150
+ }
151
+
152
+ /**
153
+ * Get bids for a task
154
+ */
155
+ async function getBids(taskEventId) {
156
+ const r = await ensureConnected();
157
+ return findBids(r, taskEventId);
158
+ }
159
+
160
+ /**
161
+ * Accept a bid — claims the task for the bidder
162
+ */
163
+ async function acceptBid(taskId, bid) {
164
+ const r = await ensureConnected();
165
+ const task = await getTask(r, pubkey, taskId);
166
+ if (!task) throw new Error(`Task not found: ${taskId}`);
167
+ if (task.status !== TASK_STATUS.OPEN) {
168
+ throw new Error(`Cannot accept bid — task status is: ${task.status}`);
169
+ }
170
+
171
+ // Check bidder trust if threshold is set
172
+ if (task.minTrust && task.minTrust > 0) {
173
+ const meets = await meetsTrustThreshold(bid.bidder, task.minTrust, { relays: relayUrls });
174
+ if (!meets) {
175
+ throw new Error(`Bidder does not meet trust threshold (${task.minTrust})`);
176
+ }
177
+ }
178
+
179
+ const template = updateTaskEvent(task.raw, {
180
+ status: TASK_STATUS.CLAIMED,
181
+ workerPubkey: bid.bidder
182
+ });
183
+ const event = await signAndPublish(template);
184
+ return parseTask(event);
185
+ }
186
+
187
+ // ── Delivery operations ───────────────────────────────
188
+
189
+ /**
190
+ * Submit work for a task
191
+ */
192
+ async function deliver(deliveryOpts) {
193
+ const { taskEventId, posterPubkey, result } = deliveryOpts;
194
+
195
+ const template = createDeliveryEvent({
196
+ taskEventId,
197
+ posterPubkey,
198
+ result,
199
+ includeHash: deliveryOpts.includeHash
200
+ });
201
+
202
+ const event = await signAndPublish(template);
203
+ return parseDelivery(event);
204
+ }
205
+
206
+ /**
207
+ * Get deliveries for a task
208
+ */
209
+ async function getDeliveries(taskEventId) {
210
+ const r = await ensureConnected();
211
+ return findDeliveries(r, taskEventId);
212
+ }
213
+
214
+ // ── Resolution operations ─────────────────────────────
215
+
216
+ /**
217
+ * Approve delivery and pay the worker
218
+ *
219
+ * If wallet is configured, pays automatically.
220
+ * If not, returns a manual payment object for the caller to handle.
221
+ */
222
+ async function approve(approveOpts) {
223
+ const {
224
+ taskId,
225
+ workerPubkey,
226
+ workerLightningAddress,
227
+ amount,
228
+ message
229
+ } = approveOpts;
230
+
231
+ let payment = null;
232
+
233
+ // Try automatic payment
234
+ const w = ensureWallet();
235
+ if (w && workerLightningAddress) {
236
+ try {
237
+ payment = await payWorker(w, workerLightningAddress, amount, `agent-escrow: ${taskId}`);
238
+ } catch (e) {
239
+ // Payment failed — continue without payment proof
240
+ payment = { error: e.message, amountSats: amount };
241
+ }
242
+ } else {
243
+ payment = createManualPaymentResult(null, null, amount);
244
+ }
245
+
246
+ // Get the task event ID for the resolution
247
+ const r = await ensureConnected();
248
+ const task = await getTask(r, pubkey, taskId);
249
+ if (!task) throw new Error(`Task not found: ${taskId}`);
250
+
251
+ // Publish resolution
252
+ const resTemplate = createResolutionEvent({
253
+ taskEventId: task.eventId,
254
+ workerPubkey,
255
+ type: RESOLUTION_TYPES.APPROVE,
256
+ paymentBolt11: payment.bolt11,
257
+ preimage: payment.preimage,
258
+ message
259
+ });
260
+ const resEvent = await signAndPublish(resTemplate);
261
+
262
+ // Update task status to completed
263
+ const taskTemplate = updateTaskEvent(task.raw, { status: TASK_STATUS.COMPLETED });
264
+ await signAndPublish(taskTemplate);
265
+
266
+ // Publish ai.wot attestation (non-blocking)
267
+ publishWorkAttestation({
268
+ targetPubkey: workerPubkey,
269
+ comment: `Completed task "${task.title}" — ${amount} sats via agent-escrow`,
270
+ secretKey,
271
+ relayUrls
272
+ }).catch(() => {}); // Non-fatal
273
+
274
+ return {
275
+ resolution: parseResolution(resEvent),
276
+ payment,
277
+ task: { ...task, status: TASK_STATUS.COMPLETED }
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Dispute a delivery
283
+ */
284
+ async function dispute(disputeOpts) {
285
+ const { taskId, workerPubkey, reason } = disputeOpts;
286
+
287
+ const r = await ensureConnected();
288
+ const task = await getTask(r, pubkey, taskId);
289
+ if (!task) throw new Error(`Task not found: ${taskId}`);
290
+
291
+ // Publish resolution
292
+ const resTemplate = createResolutionEvent({
293
+ taskEventId: task.eventId,
294
+ workerPubkey,
295
+ type: RESOLUTION_TYPES.DISPUTE,
296
+ message: reason
297
+ });
298
+ const resEvent = await signAndPublish(resTemplate);
299
+
300
+ // Update task status to disputed
301
+ const taskTemplate = updateTaskEvent(task.raw, { status: TASK_STATUS.DISPUTED });
302
+ await signAndPublish(taskTemplate);
303
+
304
+ // Publish negative attestation (non-blocking)
305
+ publishDisputeAttestation({
306
+ targetPubkey: workerPubkey,
307
+ comment: `Disputed task "${task.title}": ${reason}`,
308
+ secretKey,
309
+ relayUrls
310
+ }).catch(() => {}); // Non-fatal
311
+
312
+ return {
313
+ resolution: parseResolution(resEvent),
314
+ task: { ...task, status: TASK_STATUS.DISPUTED }
315
+ };
316
+ }
317
+
318
+ // ── Lifecycle ─────────────────────────────────────────
319
+
320
+ /**
321
+ * Close all connections and clean up
322
+ */
323
+ async function close() {
324
+ if (relays) {
325
+ closeRelays(relays);
326
+ relays = null;
327
+ }
328
+ if (wallet && wallet.close) {
329
+ await wallet.close();
330
+ wallet = null;
331
+ }
332
+ }
333
+
334
+ return {
335
+ // Identity
336
+ pubkey,
337
+
338
+ // Tasks
339
+ postTask,
340
+ cancelTask,
341
+ browseTasks,
342
+ fetchTask,
343
+
344
+ // Bids
345
+ submitBid,
346
+ getBids,
347
+ acceptBid,
348
+
349
+ // Delivery
350
+ deliver,
351
+ getDeliveries,
352
+
353
+ // Resolution
354
+ approve,
355
+ dispute,
356
+
357
+ // Utility
358
+ close
359
+ };
360
+ }
361
+
362
+ module.exports = { createMarketplace };
package/src/nostr.cjs ADDED
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const { finalizeEvent, getPublicKey } = require('nostr-tools/pure');
4
+ const { Relay, useWebSocketImplementation } = require('nostr-tools/relay');
5
+
6
+ // Use ws in Node.js
7
+ try {
8
+ const WebSocket = require('ws');
9
+ useWebSocketImplementation(WebSocket);
10
+ } catch (e) {
11
+ // Browser environment — native WebSocket available
12
+ }
13
+
14
+ // Event kinds
15
+ const KINDS = {
16
+ TASK: 30950, // Parameterized replaceable
17
+ BID: 950, // Regular
18
+ DELIVERY: 951, // Regular
19
+ RESOLUTION: 952 // Regular
20
+ };
21
+
22
+ const TASK_STATUS = {
23
+ OPEN: 'open',
24
+ CLAIMED: 'claimed',
25
+ DELIVERED: 'delivered',
26
+ COMPLETED: 'completed',
27
+ CANCELLED: 'cancelled',
28
+ DISPUTED: 'disputed',
29
+ EXPIRED: 'expired'
30
+ };
31
+
32
+ const RESOLUTION_TYPES = {
33
+ APPROVE: 'approve',
34
+ REJECT: 'reject',
35
+ DISPUTE: 'dispute'
36
+ };
37
+
38
+ /**
39
+ * Connect to multiple relays, return first successful connections
40
+ */
41
+ async function connectRelays(urls, timeoutMs = 5000) {
42
+ const connected = [];
43
+ const results = await Promise.allSettled(
44
+ urls.map(url =>
45
+ Promise.race([
46
+ Relay.connect(url).then(r => { connected.push(r); return r; }),
47
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs))
48
+ ])
49
+ )
50
+ );
51
+ return connected;
52
+ }
53
+
54
+ /**
55
+ * Publish event to multiple relays
56
+ */
57
+ async function publishToRelays(relays, event) {
58
+ const results = await Promise.allSettled(
59
+ relays.map(r => r.publish(event))
60
+ );
61
+ const successes = results.filter(r => r.status === 'fulfilled').length;
62
+ if (successes === 0) {
63
+ throw new Error('Failed to publish to any relay');
64
+ }
65
+ return successes;
66
+ }
67
+
68
+ /**
69
+ * Query relays and collect events
70
+ */
71
+ async function queryRelays(relays, filters, timeoutMs = 8000) {
72
+ const events = new Map(); // dedupe by id
73
+
74
+ await Promise.allSettled(
75
+ relays.map(relay =>
76
+ new Promise((resolve) => {
77
+ const timer = setTimeout(() => resolve(), timeoutMs);
78
+ relay.subscribe(Array.isArray(filters) ? filters : [filters], {
79
+ onevent(event) {
80
+ events.set(event.id, event);
81
+ },
82
+ oneose() {
83
+ clearTimeout(timer);
84
+ resolve();
85
+ }
86
+ });
87
+ })
88
+ )
89
+ );
90
+
91
+ return Array.from(events.values());
92
+ }
93
+
94
+ /**
95
+ * Sign and finalize a Nostr event
96
+ */
97
+ function signEvent(template, secretKey) {
98
+ const sk = typeof secretKey === 'string'
99
+ ? Uint8Array.from(Buffer.from(secretKey, 'hex'))
100
+ : secretKey;
101
+ return finalizeEvent(template, sk);
102
+ }
103
+
104
+ /**
105
+ * Get public key hex from secret key
106
+ */
107
+ function pubkeyFromSecret(secretKey) {
108
+ const sk = typeof secretKey === 'string'
109
+ ? Uint8Array.from(Buffer.from(secretKey, 'hex'))
110
+ : secretKey;
111
+ return getPublicKey(sk);
112
+ }
113
+
114
+ /**
115
+ * Extract tag value from event
116
+ */
117
+ function getTag(event, tagName) {
118
+ const tag = event.tags.find(t => t[0] === tagName);
119
+ return tag ? tag[1] : null;
120
+ }
121
+
122
+ /**
123
+ * Extract all values of a repeatable tag
124
+ */
125
+ function getTags(event, tagName) {
126
+ return event.tags.filter(t => t[0] === tagName).map(t => t[1]);
127
+ }
128
+
129
+ /**
130
+ * Close all relay connections
131
+ */
132
+ function closeRelays(relays) {
133
+ for (const r of relays) {
134
+ try { r.close(); } catch (e) { /* ignore */ }
135
+ }
136
+ }
137
+
138
+ module.exports = {
139
+ KINDS,
140
+ TASK_STATUS,
141
+ RESOLUTION_TYPES,
142
+ connectRelays,
143
+ publishToRelays,
144
+ queryRelays,
145
+ signEvent,
146
+ pubkeyFromSecret,
147
+ getTag,
148
+ getTags,
149
+ closeRelays
150
+ };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const { KINDS, RESOLUTION_TYPES, queryRelays, getTag } = require('./nostr.cjs');
4
+
5
+ /**
6
+ * Create a resolution event template
7
+ */
8
+ function createResolutionEvent(opts) {
9
+ const {
10
+ taskEventId,
11
+ workerPubkey,
12
+ type,
13
+ paymentBolt11,
14
+ preimage,
15
+ message
16
+ } = opts;
17
+
18
+ if (!taskEventId) throw new Error('taskEventId is required');
19
+ if (!workerPubkey) throw new Error('workerPubkey is required');
20
+ if (!type || !Object.values(RESOLUTION_TYPES).includes(type)) {
21
+ throw new Error(`type must be one of: ${Object.values(RESOLUTION_TYPES).join(', ')}`);
22
+ }
23
+
24
+ // Disputes/rejections must include a reason
25
+ if ((type === RESOLUTION_TYPES.DISPUTE || type === RESOLUTION_TYPES.REJECT) && !message) {
26
+ throw new Error('message is required for dispute/reject resolutions');
27
+ }
28
+
29
+ const tags = [
30
+ ['e', taskEventId],
31
+ ['p', workerPubkey],
32
+ ['type', type]
33
+ ];
34
+
35
+ if (paymentBolt11) tags.push(['payment', paymentBolt11]);
36
+ if (preimage) tags.push(['preimage', preimage]);
37
+
38
+ return {
39
+ kind: KINDS.RESOLUTION,
40
+ created_at: Math.floor(Date.now() / 1000),
41
+ tags,
42
+ content: message || ''
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Parse a resolution event into a structured object
48
+ */
49
+ function parseResolution(event) {
50
+ return {
51
+ eventId: event.id,
52
+ poster: event.pubkey,
53
+ taskEventId: getTag(event, 'e'),
54
+ workerPubkey: getTag(event, 'p'),
55
+ type: getTag(event, 'type'),
56
+ paymentBolt11: getTag(event, 'payment'),
57
+ preimage: getTag(event, 'preimage'),
58
+ message: event.content,
59
+ createdAt: event.created_at * 1000,
60
+ raw: event
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Find resolutions for a specific task
66
+ */
67
+ async function findResolutions(relays, taskEventId, timeoutMs = 8000) {
68
+ const filter = {
69
+ kinds: [KINDS.RESOLUTION],
70
+ '#e': [taskEventId]
71
+ };
72
+ const events = await queryRelays(relays, filter, timeoutMs);
73
+ return events.map(parseResolution);
74
+ }
75
+
76
+ module.exports = {
77
+ createResolutionEvent,
78
+ parseResolution,
79
+ findResolutions
80
+ };