@tjamescouch/agentchat 0.10.0 → 0.12.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,237 @@
1
+ /**
2
+ * EscrowHooks - Event system for external escrow integration
3
+ *
4
+ * Allows external systems (blockchain, multi-sig, compliance) to hook into
5
+ * escrow lifecycle events without modifying core AgentChat code.
6
+ *
7
+ * Events:
8
+ * escrow:created - Escrow created when proposal accepted with stakes
9
+ * escrow:released - Escrow released (expired, cancelled)
10
+ * settlement:completion - Proposal completed, stakes returned
11
+ * settlement:dispute - Proposal disputed, stakes transferred/burned
12
+ */
13
+
14
+ export const EscrowEvent = {
15
+ CREATED: 'escrow:created',
16
+ RELEASED: 'escrow:released',
17
+ COMPLETION_SETTLED: 'settlement:completion',
18
+ DISPUTE_SETTLED: 'settlement:dispute'
19
+ };
20
+
21
+ export class EscrowHooks {
22
+ constructor(options = {}) {
23
+ this.handlers = new Map(); // event -> Set of handlers
24
+ this.logger = options.logger || console;
25
+ this.continueOnError = options.continueOnError !== false; // default true
26
+
27
+ // Initialize event handler sets
28
+ for (const event of Object.values(EscrowEvent)) {
29
+ this.handlers.set(event, new Set());
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Register a handler for an escrow event
35
+ * @param {string} event - Event name from EscrowEvent
36
+ * @param {Function} handler - Async function(payload) to call
37
+ * @returns {Function} Unsubscribe function
38
+ */
39
+ on(event, handler) {
40
+ if (!this.handlers.has(event)) {
41
+ throw new Error(`Unknown escrow event: ${event}`);
42
+ }
43
+
44
+ if (typeof handler !== 'function') {
45
+ throw new Error('Handler must be a function');
46
+ }
47
+
48
+ this.handlers.get(event).add(handler);
49
+
50
+ // Return unsubscribe function
51
+ return () => this.off(event, handler);
52
+ }
53
+
54
+ /**
55
+ * Remove a handler for an escrow event
56
+ * @param {string} event - Event name
57
+ * @param {Function} handler - Handler to remove
58
+ */
59
+ off(event, handler) {
60
+ if (this.handlers.has(event)) {
61
+ this.handlers.get(event).delete(handler);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Remove all handlers for an event (or all events)
67
+ * @param {string} [event] - Optional event name
68
+ */
69
+ clear(event) {
70
+ if (event) {
71
+ if (this.handlers.has(event)) {
72
+ this.handlers.get(event).clear();
73
+ }
74
+ } else {
75
+ for (const handlers of this.handlers.values()) {
76
+ handlers.clear();
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Emit an escrow event to all registered handlers
83
+ * @param {string} event - Event name
84
+ * @param {Object} payload - Event payload
85
+ * @returns {Promise<Object>} Results from all handlers
86
+ */
87
+ async emit(event, payload) {
88
+ if (!this.handlers.has(event)) {
89
+ throw new Error(`Unknown escrow event: ${event}`);
90
+ }
91
+
92
+ const handlers = this.handlers.get(event);
93
+ if (handlers.size === 0) {
94
+ return { event, handled: false, results: [] };
95
+ }
96
+
97
+ const results = [];
98
+ const errors = [];
99
+
100
+ for (const handler of handlers) {
101
+ try {
102
+ const result = await handler(payload);
103
+ results.push({ success: true, result });
104
+ } catch (err) {
105
+ const errorInfo = {
106
+ success: false,
107
+ error: err.message,
108
+ stack: err.stack
109
+ };
110
+ errors.push(errorInfo);
111
+ results.push(errorInfo);
112
+
113
+ this.logger.error?.(`[EscrowHooks] Error in ${event} handler:`, err.message);
114
+
115
+ if (!this.continueOnError) {
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ return {
122
+ event,
123
+ handled: true,
124
+ results,
125
+ errors: errors.length > 0 ? errors : undefined
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Check if any handlers are registered for an event
131
+ * @param {string} event - Event name
132
+ * @returns {boolean}
133
+ */
134
+ hasHandlers(event) {
135
+ return this.handlers.has(event) && this.handlers.get(event).size > 0;
136
+ }
137
+
138
+ /**
139
+ * Get count of handlers for an event
140
+ * @param {string} event - Event name
141
+ * @returns {number}
142
+ */
143
+ handlerCount(event) {
144
+ return this.handlers.has(event) ? this.handlers.get(event).size : 0;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Create payload for escrow:created event
150
+ */
151
+ export function createEscrowCreatedPayload(proposal, escrowResult) {
152
+ return {
153
+ event: EscrowEvent.CREATED,
154
+ timestamp: Date.now(),
155
+ proposal_id: proposal.id,
156
+ from_agent: proposal.from,
157
+ to_agent: proposal.to,
158
+ proposer_stake: proposal.proposer_stake || 0,
159
+ acceptor_stake: proposal.acceptor_stake || 0,
160
+ total_stake: (proposal.proposer_stake || 0) + (proposal.acceptor_stake || 0),
161
+ task: proposal.task,
162
+ amount: proposal.amount,
163
+ currency: proposal.currency,
164
+ expires: proposal.expires,
165
+ escrow_id: escrowResult.escrow?.proposal_id || proposal.id
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Create payload for settlement:completion event
171
+ */
172
+ export function createCompletionPayload(proposal, ratingChanges) {
173
+ const escrowInfo = ratingChanges?._escrow || {};
174
+ return {
175
+ event: EscrowEvent.COMPLETION_SETTLED,
176
+ timestamp: Date.now(),
177
+ proposal_id: proposal.id,
178
+ from_agent: proposal.from,
179
+ to_agent: proposal.to,
180
+ completed_by: proposal.completed_by,
181
+ completion_proof: proposal.completion_proof,
182
+ settlement: 'returned',
183
+ stakes_returned: {
184
+ proposer: escrowInfo.proposer_stake || 0,
185
+ acceptor: escrowInfo.acceptor_stake || 0
186
+ },
187
+ rating_changes: {
188
+ [proposal.from]: ratingChanges?.[proposal.from],
189
+ [proposal.to]: ratingChanges?.[proposal.to]
190
+ }
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Create payload for settlement:dispute event
196
+ */
197
+ export function createDisputePayload(proposal, ratingChanges) {
198
+ const escrowInfo = ratingChanges?._escrow || {};
199
+ return {
200
+ event: EscrowEvent.DISPUTE_SETTLED,
201
+ timestamp: Date.now(),
202
+ proposal_id: proposal.id,
203
+ from_agent: proposal.from,
204
+ to_agent: proposal.to,
205
+ disputed_by: proposal.disputed_by,
206
+ dispute_reason: proposal.dispute_reason,
207
+ settlement: escrowInfo.settlement || 'settled',
208
+ settlement_reason: escrowInfo.settlement_reason,
209
+ fault_determination: escrowInfo.fault_party,
210
+ stakes_transferred: escrowInfo.transferred,
211
+ stakes_burned: escrowInfo.burned,
212
+ rating_changes: {
213
+ [proposal.from]: ratingChanges?.[proposal.from],
214
+ [proposal.to]: ratingChanges?.[proposal.to]
215
+ }
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Create payload for escrow:released event
221
+ */
222
+ export function createEscrowReleasedPayload(proposalId, escrow, reason) {
223
+ return {
224
+ event: EscrowEvent.RELEASED,
225
+ timestamp: Date.now(),
226
+ proposal_id: proposalId,
227
+ from_agent: escrow.from?.agent_id,
228
+ to_agent: escrow.to?.agent_id,
229
+ stakes_released: {
230
+ proposer: escrow.from?.stake || 0,
231
+ acceptor: escrow.to?.stake || 0
232
+ },
233
+ reason: reason || 'expired'
234
+ };
235
+ }
236
+
237
+ export default EscrowHooks;
package/lib/identity.js CHANGED
@@ -44,6 +44,7 @@ export class Identity {
44
44
  this.pubkey = data.pubkey; // PEM format
45
45
  this.privkey = data.privkey; // PEM format (null if loaded from export)
46
46
  this.created = data.created;
47
+ this.rotations = data.rotations || []; // Array of rotation records
47
48
 
48
49
  // Lazy-load crypto key objects
49
50
  this._publicKey = null;
@@ -85,7 +86,8 @@ export class Identity {
85
86
  name: this.name,
86
87
  pubkey: this.pubkey,
87
88
  privkey: this.privkey,
88
- created: this.created
89
+ created: this.created,
90
+ rotations: this.rotations
89
91
  };
90
92
 
91
93
  await fs.writeFile(filePath, JSON.stringify(data, null, 2), {
@@ -160,7 +162,137 @@ export class Identity {
160
162
  return {
161
163
  name: this.name,
162
164
  pubkey: this.pubkey,
163
- created: this.created
165
+ created: this.created,
166
+ rotations: this.rotations
164
167
  };
165
168
  }
169
+
170
+ /**
171
+ * Rotate to a new keypair
172
+ * Signs the new public key with the old private key for chain of custody
173
+ * @returns {object} Rotation record with old_pubkey, new_pubkey, signature, timestamp
174
+ */
175
+ rotate() {
176
+ if (!this.privkey) {
177
+ throw new Error('Private key not available - cannot rotate');
178
+ }
179
+
180
+ // Generate new keypair
181
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
182
+ const newPubkey = publicKey.export({ type: 'spki', format: 'pem' });
183
+ const newPrivkey = privateKey.export({ type: 'pkcs8', format: 'pem' });
184
+
185
+ // Use same timestamp for both signing and record
186
+ const timestamp = new Date().toISOString();
187
+
188
+ // Create rotation record content to sign
189
+ const rotationContent = JSON.stringify({
190
+ old_pubkey: this.pubkey,
191
+ new_pubkey: newPubkey,
192
+ timestamp
193
+ });
194
+
195
+ // Sign with old private key
196
+ const signature = this.sign(rotationContent);
197
+
198
+ // Create rotation record
199
+ const rotationRecord = {
200
+ old_pubkey: this.pubkey,
201
+ old_agent_id: this.getAgentId(),
202
+ new_pubkey: newPubkey,
203
+ new_agent_id: pubkeyToAgentId(newPubkey),
204
+ signature,
205
+ timestamp
206
+ };
207
+
208
+ // Update identity with new keys
209
+ this.rotations.push(rotationRecord);
210
+ this.pubkey = newPubkey;
211
+ this.privkey = newPrivkey;
212
+ this._publicKey = null;
213
+ this._privateKey = null;
214
+
215
+ return rotationRecord;
216
+ }
217
+
218
+ /**
219
+ * Verify a rotation record
220
+ * Checks that the signature is valid using the old public key
221
+ * @param {object} record - Rotation record to verify
222
+ * @returns {boolean} True if signature is valid
223
+ */
224
+ static verifyRotation(record) {
225
+ try {
226
+ const rotationContent = JSON.stringify({
227
+ old_pubkey: record.old_pubkey,
228
+ new_pubkey: record.new_pubkey,
229
+ timestamp: record.timestamp
230
+ });
231
+
232
+ return Identity.verify(rotationContent, record.signature, record.old_pubkey);
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Verify the entire rotation chain
240
+ * @returns {object} { valid: boolean, errors: string[] }
241
+ */
242
+ verifyRotationChain() {
243
+ const errors = [];
244
+
245
+ if (this.rotations.length === 0) {
246
+ return { valid: true, errors: [] };
247
+ }
248
+
249
+ // Verify each rotation in sequence
250
+ for (let i = 0; i < this.rotations.length; i++) {
251
+ const record = this.rotations[i];
252
+
253
+ // Verify signature
254
+ if (!Identity.verifyRotation(record)) {
255
+ errors.push(`Rotation ${i + 1}: Invalid signature`);
256
+ continue;
257
+ }
258
+
259
+ // Verify chain continuity (each new_pubkey should match next old_pubkey)
260
+ if (i < this.rotations.length - 1) {
261
+ const nextRecord = this.rotations[i + 1];
262
+ if (record.new_pubkey !== nextRecord.old_pubkey) {
263
+ errors.push(`Rotation ${i + 1}: Chain break - new_pubkey doesn't match next old_pubkey`);
264
+ }
265
+ }
266
+ }
267
+
268
+ // Verify final pubkey matches current identity
269
+ const lastRotation = this.rotations[this.rotations.length - 1];
270
+ if (lastRotation.new_pubkey !== this.pubkey) {
271
+ errors.push('Final rotation new_pubkey does not match current identity pubkey');
272
+ }
273
+
274
+ return {
275
+ valid: errors.length === 0,
276
+ errors
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Get the original (genesis) public key before any rotations
282
+ * @returns {string} Original public key in PEM format
283
+ */
284
+ getOriginalPubkey() {
285
+ if (this.rotations.length === 0) {
286
+ return this.pubkey;
287
+ }
288
+ return this.rotations[0].old_pubkey;
289
+ }
290
+
291
+ /**
292
+ * Get the original (genesis) agent ID
293
+ * @returns {string} Original agent ID
294
+ */
295
+ getOriginalAgentId() {
296
+ return pubkeyToAgentId(this.getOriginalPubkey());
297
+ }
166
298
  }
package/lib/proposals.js CHANGED
@@ -45,6 +45,10 @@ export class ProposalStore {
45
45
  created_at: now,
46
46
  updated_at: now,
47
47
  sig: proposal.sig,
48
+ // ELO staking
49
+ proposer_stake: proposal.elo_stake || null,
50
+ acceptor_stake: null,
51
+ stakes_escrowed: false,
48
52
  // Response tracking
49
53
  response_sig: null,
50
54
  response_payment_code: null,
@@ -82,8 +86,13 @@ export class ProposalStore {
82
86
 
83
87
  /**
84
88
  * Accept a proposal
89
+ * @param {string} id - Proposal ID
90
+ * @param {string} acceptorId - Agent accepting the proposal
91
+ * @param {string} sig - Signature of acceptance
92
+ * @param {string} payment_code - Optional payment code
93
+ * @param {number} acceptor_stake - Optional ELO stake from acceptor
85
94
  */
86
- accept(id, acceptorId, sig, payment_code = null) {
95
+ accept(id, acceptorId, sig, payment_code = null, acceptor_stake = null) {
87
96
  const proposal = this.get(id);
88
97
  if (!proposal) {
89
98
  return { error: 'PROPOSAL_NOT_FOUND' };
@@ -106,6 +115,7 @@ export class ProposalStore {
106
115
  proposal.status = ProposalStatus.ACCEPTED;
107
116
  proposal.response_sig = sig;
108
117
  proposal.response_payment_code = payment_code;
118
+ proposal.acceptor_stake = acceptor_stake;
109
119
  proposal.updated_at = Date.now();
110
120
 
111
121
  return { proposal };
@@ -296,7 +306,8 @@ export function formatProposal(proposal) {
296
306
  expires: proposal.expires,
297
307
  status: proposal.status,
298
308
  created_at: proposal.created_at,
299
- sig: proposal.sig
309
+ sig: proposal.sig,
310
+ elo_stake: proposal.proposer_stake
300
311
  };
301
312
  }
302
313
 
@@ -317,7 +328,9 @@ export function formatProposalResponse(proposal, responseType) {
317
328
  from: proposal.from,
318
329
  to: proposal.to,
319
330
  payment_code: proposal.response_payment_code,
320
- sig: proposal.response_sig
331
+ sig: proposal.response_sig,
332
+ proposer_stake: proposal.proposer_stake,
333
+ acceptor_stake: proposal.acceptor_stake
321
334
  };
322
335
 
323
336
  case 'reject':
@@ -337,7 +350,11 @@ export function formatProposalResponse(proposal, responseType) {
337
350
  completed_by: proposal.completed_by,
338
351
  completed_at: proposal.completed_at,
339
352
  proof: proposal.completion_proof,
340
- sig: proposal.completion_sig
353
+ sig: proposal.completion_sig,
354
+ elo_stakes: {
355
+ proposer: proposal.proposer_stake || 0,
356
+ acceptor: proposal.acceptor_stake || 0
357
+ }
341
358
  };
342
359
 
343
360
  case 'dispute':
@@ -348,7 +365,11 @@ export function formatProposalResponse(proposal, responseType) {
348
365
  disputed_by: proposal.disputed_by,
349
366
  disputed_at: proposal.disputed_at,
350
367
  reason: proposal.dispute_reason,
351
- sig: proposal.dispute_sig
368
+ sig: proposal.dispute_sig,
369
+ elo_stakes: {
370
+ proposer: proposal.proposer_stake || 0,
371
+ acceptor: proposal.acceptor_stake || 0
372
+ }
352
373
  };
353
374
 
354
375
  default:
@@ -367,16 +388,20 @@ export function getProposalSigningContent(proposal) {
367
388
  proposal.amount || '',
368
389
  proposal.currency || '',
369
390
  proposal.payment_code || '',
370
- proposal.expires || ''
391
+ proposal.expires || '',
392
+ proposal.elo_stake || ''
371
393
  ];
372
394
  return fields.join('|');
373
395
  }
374
396
 
375
397
  /**
376
398
  * Create accept content string for signing
399
+ * @param {string} proposalId - The proposal being accepted
400
+ * @param {string} payment_code - Optional payment code
401
+ * @param {number} elo_stake - Optional ELO stake from acceptor
377
402
  */
378
- export function getAcceptSigningContent(proposalId, payment_code = '') {
379
- return `ACCEPT|${proposalId}|${payment_code}`;
403
+ export function getAcceptSigningContent(proposalId, payment_code = '', elo_stake = '') {
404
+ return `ACCEPT|${proposalId}|${payment_code}|${elo_stake}`;
380
405
  }
381
406
 
382
407
  /**
package/lib/protocol.js CHANGED
@@ -24,7 +24,12 @@ export const ClientMessageType = {
24
24
  DISPUTE: 'DISPUTE',
25
25
  // Skill discovery message types
26
26
  REGISTER_SKILLS: 'REGISTER_SKILLS',
27
- SEARCH_SKILLS: 'SEARCH_SKILLS'
27
+ SEARCH_SKILLS: 'SEARCH_SKILLS',
28
+ // Presence message types
29
+ SET_PRESENCE: 'SET_PRESENCE',
30
+ // Identity verification message types
31
+ VERIFY_REQUEST: 'VERIFY_REQUEST',
32
+ VERIFY_RESPONSE: 'VERIFY_RESPONSE'
28
33
  };
29
34
 
30
35
  // Server -> Client message types
@@ -47,7 +52,14 @@ export const ServerMessageType = {
47
52
  DISPUTE: 'DISPUTE',
48
53
  // Skill discovery message types
49
54
  SKILLS_REGISTERED: 'SKILLS_REGISTERED',
50
- SEARCH_RESULTS: 'SEARCH_RESULTS'
55
+ SEARCH_RESULTS: 'SEARCH_RESULTS',
56
+ // Presence message types
57
+ PRESENCE_CHANGED: 'PRESENCE_CHANGED',
58
+ // Identity verification message types
59
+ VERIFY_REQUEST: 'VERIFY_REQUEST',
60
+ VERIFY_RESPONSE: 'VERIFY_RESPONSE',
61
+ VERIFY_SUCCESS: 'VERIFY_SUCCESS',
62
+ VERIFY_FAILED: 'VERIFY_FAILED'
51
63
  };
52
64
 
53
65
  // Error codes
@@ -65,7 +77,22 @@ export const ErrorCode = {
65
77
  PROPOSAL_EXPIRED: 'PROPOSAL_EXPIRED',
66
78
  INVALID_PROPOSAL: 'INVALID_PROPOSAL',
67
79
  SIGNATURE_REQUIRED: 'SIGNATURE_REQUIRED',
68
- NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY'
80
+ NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY',
81
+ // Staking errors
82
+ INSUFFICIENT_REPUTATION: 'INSUFFICIENT_REPUTATION',
83
+ INVALID_STAKE: 'INVALID_STAKE',
84
+ // Verification errors
85
+ VERIFICATION_FAILED: 'VERIFICATION_FAILED',
86
+ VERIFICATION_EXPIRED: 'VERIFICATION_EXPIRED',
87
+ NO_PUBKEY: 'NO_PUBKEY'
88
+ };
89
+
90
+ // Presence status
91
+ export const PresenceStatus = {
92
+ ONLINE: 'online',
93
+ AWAY: 'away',
94
+ BUSY: 'busy',
95
+ OFFLINE: 'offline'
69
96
  };
70
97
 
71
98
  // Proposal status
@@ -250,20 +277,40 @@ export function validateClientMessage(raw) {
250
277
  if (!msg.sig) {
251
278
  return { valid: false, error: 'Proposals must be signed' };
252
279
  }
253
- // Optional fields: amount, currency, payment_code, expires, terms
280
+ // Optional fields: amount, currency, payment_code, expires, terms, elo_stake
254
281
  if (msg.expires !== undefined && typeof msg.expires !== 'number') {
255
282
  return { valid: false, error: 'expires must be a number (seconds)' };
256
283
  }
284
+ if (msg.elo_stake !== undefined) {
285
+ if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
286
+ return { valid: false, error: 'elo_stake must be a non-negative integer' };
287
+ }
288
+ }
257
289
  break;
258
290
 
259
291
  case ClientMessageType.ACCEPT:
292
+ // Accept requires: proposal_id and signature
293
+ if (!msg.proposal_id) {
294
+ return { valid: false, error: 'Missing proposal_id' };
295
+ }
296
+ if (!msg.sig) {
297
+ return { valid: false, error: 'Accept must be signed' };
298
+ }
299
+ // Optional: elo_stake for acceptor's stake
300
+ if (msg.elo_stake !== undefined) {
301
+ if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
302
+ return { valid: false, error: 'elo_stake must be a non-negative integer' };
303
+ }
304
+ }
305
+ break;
306
+
260
307
  case ClientMessageType.REJECT:
261
- // Accept/Reject require: proposal_id and signature
308
+ // Reject requires: proposal_id and signature
262
309
  if (!msg.proposal_id) {
263
310
  return { valid: false, error: 'Missing proposal_id' };
264
311
  }
265
312
  if (!msg.sig) {
266
- return { valid: false, error: 'Accept/Reject must be signed' };
313
+ return { valid: false, error: 'Reject must be signed' };
267
314
  }
268
315
  break;
269
316
 
@@ -317,6 +364,50 @@ export function validateClientMessage(raw) {
317
364
  // query_id is optional but useful for tracking responses
318
365
  break;
319
366
 
367
+ case ClientMessageType.SET_PRESENCE:
368
+ // Set presence requires: status (online, away, busy, offline)
369
+ const validStatuses = ['online', 'away', 'busy', 'offline'];
370
+ if (!msg.status || !validStatuses.includes(msg.status)) {
371
+ return { valid: false, error: `Invalid presence status. Must be one of: ${validStatuses.join(', ')}` };
372
+ }
373
+ // Optional: status_text for custom message
374
+ if (msg.status_text !== undefined && typeof msg.status_text !== 'string') {
375
+ return { valid: false, error: 'status_text must be a string' };
376
+ }
377
+ if (msg.status_text && msg.status_text.length > 100) {
378
+ return { valid: false, error: 'status_text too long (max 100 chars)' };
379
+ }
380
+ break;
381
+
382
+ case ClientMessageType.VERIFY_REQUEST:
383
+ // Verify request requires: target agent and nonce
384
+ if (!msg.target) {
385
+ return { valid: false, error: 'Missing target agent' };
386
+ }
387
+ if (!isAgent(msg.target)) {
388
+ return { valid: false, error: 'Target must be an agent (@id)' };
389
+ }
390
+ if (!msg.nonce || typeof msg.nonce !== 'string') {
391
+ return { valid: false, error: 'Missing or invalid nonce' };
392
+ }
393
+ if (msg.nonce.length < 16 || msg.nonce.length > 128) {
394
+ return { valid: false, error: 'Nonce must be 16-128 characters' };
395
+ }
396
+ break;
397
+
398
+ case ClientMessageType.VERIFY_RESPONSE:
399
+ // Verify response requires: request_id, nonce, and signature
400
+ if (!msg.request_id) {
401
+ return { valid: false, error: 'Missing request_id' };
402
+ }
403
+ if (!msg.nonce || typeof msg.nonce !== 'string') {
404
+ return { valid: false, error: 'Missing or invalid nonce' };
405
+ }
406
+ if (!msg.sig || typeof msg.sig !== 'string') {
407
+ return { valid: false, error: 'Missing or invalid signature' };
408
+ }
409
+ break;
410
+
320
411
  default:
321
412
  return { valid: false, error: `Unknown message type: ${msg.type}` };
322
413
  }
@@ -372,3 +463,21 @@ export function isProposalMessage(type) {
372
463
  ClientMessageType.DISPUTE
373
464
  ].includes(type);
374
465
  }
466
+
467
+ /**
468
+ * Generate a unique verification request ID
469
+ * Format: verify_<timestamp>_<random>
470
+ */
471
+ export function generateVerifyId() {
472
+ const timestamp = Date.now().toString(36);
473
+ const random = crypto.randomBytes(4).toString('hex');
474
+ return `verify_${timestamp}_${random}`;
475
+ }
476
+
477
+ /**
478
+ * Generate a random nonce for identity verification
479
+ * Returns a 32-character hex string
480
+ */
481
+ export function generateNonce() {
482
+ return crypto.randomBytes(16).toString('hex');
483
+ }