@tjamescouch/agentchat 0.9.0 → 0.11.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/agentchat.js +20 -4
- package/lib/client.js +6 -3
- package/lib/proposals.js +33 -8
- package/lib/protocol.js +27 -4
- package/lib/reputation.js +214 -14
- package/lib/server.js +126 -11
- package/package.json +1 -1
package/bin/agentchat.js
CHANGED
|
@@ -95,7 +95,11 @@ program
|
|
|
95
95
|
console.log('Message sent');
|
|
96
96
|
process.exit(0);
|
|
97
97
|
} catch (err) {
|
|
98
|
-
|
|
98
|
+
if (err.code === 'ECONNREFUSED') {
|
|
99
|
+
console.error('Error: Connection refused. Is the server running?');
|
|
100
|
+
} else {
|
|
101
|
+
console.error('Error:', err.message || err.code || err);
|
|
102
|
+
}
|
|
99
103
|
process.exit(1);
|
|
100
104
|
}
|
|
101
105
|
});
|
|
@@ -142,7 +146,12 @@ program
|
|
|
142
146
|
});
|
|
143
147
|
|
|
144
148
|
} catch (err) {
|
|
145
|
-
|
|
149
|
+
if (err.code === 'ECONNREFUSED') {
|
|
150
|
+
console.error('Error: Connection refused. Is the server running?');
|
|
151
|
+
console.error(` Try: agentchat serve --port 8080`);
|
|
152
|
+
} else {
|
|
153
|
+
console.error('Error:', err.message || err.code || err);
|
|
154
|
+
}
|
|
146
155
|
process.exit(1);
|
|
147
156
|
}
|
|
148
157
|
});
|
|
@@ -358,6 +367,7 @@ program
|
|
|
358
367
|
.option('-p, --payment-code <code>', 'Your payment code (BIP47, address)')
|
|
359
368
|
.option('-e, --expires <seconds>', 'Expiration time in seconds', '300')
|
|
360
369
|
.option('-t, --terms <terms>', 'Additional terms')
|
|
370
|
+
.option('-s, --elo-stake <n>', 'ELO points to stake on this proposal')
|
|
361
371
|
.action(async (server, agent, task, options) => {
|
|
362
372
|
try {
|
|
363
373
|
const client = new AgentChatClient({ server, identity: options.identity });
|
|
@@ -369,7 +379,8 @@ program
|
|
|
369
379
|
currency: options.currency,
|
|
370
380
|
payment_code: options.paymentCode,
|
|
371
381
|
terms: options.terms,
|
|
372
|
-
expires: parseInt(options.expires)
|
|
382
|
+
expires: parseInt(options.expires),
|
|
383
|
+
elo_stake: options.eloStake ? parseInt(options.eloStake) : undefined
|
|
373
384
|
});
|
|
374
385
|
|
|
375
386
|
console.log('Proposal sent:');
|
|
@@ -377,6 +388,7 @@ program
|
|
|
377
388
|
console.log(` To: ${proposal.to}`);
|
|
378
389
|
console.log(` Task: ${proposal.task}`);
|
|
379
390
|
if (proposal.amount) console.log(` Amount: ${proposal.amount} ${proposal.currency || ''}`);
|
|
391
|
+
if (proposal.elo_stake) console.log(` ELO Stake: ${proposal.elo_stake}`);
|
|
380
392
|
if (proposal.expires) console.log(` Expires: ${new Date(proposal.expires).toISOString()}`);
|
|
381
393
|
console.log(`\nUse this ID to track responses.`);
|
|
382
394
|
|
|
@@ -394,16 +406,20 @@ program
|
|
|
394
406
|
.description('Accept a proposal')
|
|
395
407
|
.option('-i, --identity <file>', 'Path to identity file (required)', DEFAULT_IDENTITY_PATH)
|
|
396
408
|
.option('-p, --payment-code <code>', 'Your payment code for receiving payment')
|
|
409
|
+
.option('-s, --elo-stake <n>', 'ELO points to stake (as acceptor)')
|
|
397
410
|
.action(async (server, proposalId, options) => {
|
|
398
411
|
try {
|
|
399
412
|
const client = new AgentChatClient({ server, identity: options.identity });
|
|
400
413
|
await client.connect();
|
|
401
414
|
|
|
402
|
-
const
|
|
415
|
+
const eloStake = options.eloStake ? parseInt(options.eloStake) : undefined;
|
|
416
|
+
const response = await client.accept(proposalId, options.paymentCode, eloStake);
|
|
403
417
|
|
|
404
418
|
console.log('Proposal accepted:');
|
|
405
419
|
console.log(` Proposal ID: ${response.proposal_id}`);
|
|
406
420
|
console.log(` Status: ${response.status}`);
|
|
421
|
+
if (response.proposer_stake) console.log(` Proposer Stake: ${response.proposer_stake} ELO`);
|
|
422
|
+
if (response.acceptor_stake) console.log(` Your Stake: ${response.acceptor_stake} ELO`);
|
|
407
423
|
|
|
408
424
|
client.disconnect();
|
|
409
425
|
process.exit(0);
|
package/lib/client.js
CHANGED
|
@@ -297,7 +297,8 @@ export class AgentChatClient extends EventEmitter {
|
|
|
297
297
|
currency: proposal.currency,
|
|
298
298
|
payment_code: proposal.payment_code,
|
|
299
299
|
terms: proposal.terms,
|
|
300
|
-
expires: proposal.expires
|
|
300
|
+
expires: proposal.expires,
|
|
301
|
+
elo_stake: proposal.elo_stake
|
|
301
302
|
};
|
|
302
303
|
|
|
303
304
|
// Sign the proposal
|
|
@@ -337,19 +338,21 @@ export class AgentChatClient extends EventEmitter {
|
|
|
337
338
|
* Accept a proposal
|
|
338
339
|
* @param {string} proposalId - The proposal ID to accept
|
|
339
340
|
* @param {string} [payment_code] - Your payment code for receiving payment
|
|
341
|
+
* @param {number} [elo_stake] - ELO points to stake as acceptor
|
|
340
342
|
*/
|
|
341
|
-
async accept(proposalId, payment_code = null) {
|
|
343
|
+
async accept(proposalId, payment_code = null, elo_stake = null) {
|
|
342
344
|
if (!this._identity || !this._identity.privkey) {
|
|
343
345
|
throw new Error('Accepting proposals requires persistent identity.');
|
|
344
346
|
}
|
|
345
347
|
|
|
346
|
-
const sigContent = getAcceptSigningContent(proposalId, payment_code || '');
|
|
348
|
+
const sigContent = getAcceptSigningContent(proposalId, payment_code || '', elo_stake || '');
|
|
347
349
|
const sig = this._identity.sign(sigContent);
|
|
348
350
|
|
|
349
351
|
const msg = {
|
|
350
352
|
type: ClientMessageType.ACCEPT,
|
|
351
353
|
proposal_id: proposalId,
|
|
352
354
|
payment_code,
|
|
355
|
+
elo_stake,
|
|
353
356
|
sig
|
|
354
357
|
};
|
|
355
358
|
|
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
|
@@ -65,7 +65,10 @@ export const ErrorCode = {
|
|
|
65
65
|
PROPOSAL_EXPIRED: 'PROPOSAL_EXPIRED',
|
|
66
66
|
INVALID_PROPOSAL: 'INVALID_PROPOSAL',
|
|
67
67
|
SIGNATURE_REQUIRED: 'SIGNATURE_REQUIRED',
|
|
68
|
-
NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY'
|
|
68
|
+
NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY',
|
|
69
|
+
// Staking errors
|
|
70
|
+
INSUFFICIENT_REPUTATION: 'INSUFFICIENT_REPUTATION',
|
|
71
|
+
INVALID_STAKE: 'INVALID_STAKE'
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
// Proposal status
|
|
@@ -250,20 +253,40 @@ export function validateClientMessage(raw) {
|
|
|
250
253
|
if (!msg.sig) {
|
|
251
254
|
return { valid: false, error: 'Proposals must be signed' };
|
|
252
255
|
}
|
|
253
|
-
// Optional fields: amount, currency, payment_code, expires, terms
|
|
256
|
+
// Optional fields: amount, currency, payment_code, expires, terms, elo_stake
|
|
254
257
|
if (msg.expires !== undefined && typeof msg.expires !== 'number') {
|
|
255
258
|
return { valid: false, error: 'expires must be a number (seconds)' };
|
|
256
259
|
}
|
|
260
|
+
if (msg.elo_stake !== undefined) {
|
|
261
|
+
if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
|
|
262
|
+
return { valid: false, error: 'elo_stake must be a non-negative integer' };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
257
265
|
break;
|
|
258
266
|
|
|
259
267
|
case ClientMessageType.ACCEPT:
|
|
268
|
+
// Accept requires: proposal_id and signature
|
|
269
|
+
if (!msg.proposal_id) {
|
|
270
|
+
return { valid: false, error: 'Missing proposal_id' };
|
|
271
|
+
}
|
|
272
|
+
if (!msg.sig) {
|
|
273
|
+
return { valid: false, error: 'Accept must be signed' };
|
|
274
|
+
}
|
|
275
|
+
// Optional: elo_stake for acceptor's stake
|
|
276
|
+
if (msg.elo_stake !== undefined) {
|
|
277
|
+
if (typeof msg.elo_stake !== 'number' || msg.elo_stake < 0 || !Number.isInteger(msg.elo_stake)) {
|
|
278
|
+
return { valid: false, error: 'elo_stake must be a non-negative integer' };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
|
|
260
283
|
case ClientMessageType.REJECT:
|
|
261
|
-
//
|
|
284
|
+
// Reject requires: proposal_id and signature
|
|
262
285
|
if (!msg.proposal_id) {
|
|
263
286
|
return { valid: false, error: 'Missing proposal_id' };
|
|
264
287
|
}
|
|
265
288
|
if (!msg.sig) {
|
|
266
|
-
return { valid: false, error: '
|
|
289
|
+
return { valid: false, error: 'Reject must be signed' };
|
|
267
290
|
}
|
|
268
291
|
break;
|
|
269
292
|
|
package/lib/reputation.js
CHANGED
|
@@ -21,6 +21,7 @@ export const DEFAULT_RATINGS_PATH = path.join(AGENTCHAT_DIR, 'ratings.json');
|
|
|
21
21
|
// ELO constants
|
|
22
22
|
export const DEFAULT_RATING = 1200;
|
|
23
23
|
export const ELO_DIVISOR = 400; // Standard ELO divisor
|
|
24
|
+
export const MINIMUM_RATING = 100; // Floor - can't drop below this
|
|
24
25
|
|
|
25
26
|
// K-factor thresholds
|
|
26
27
|
const K_FACTOR_NEW = 32; // < 30 transactions
|
|
@@ -127,6 +128,7 @@ export class ReputationStore {
|
|
|
127
128
|
constructor(ratingsPath = DEFAULT_RATINGS_PATH) {
|
|
128
129
|
this.ratingsPath = ratingsPath;
|
|
129
130
|
this._ratings = null; // Lazy load
|
|
131
|
+
this._escrows = new Map(); // proposalId → escrow record
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
/**
|
|
@@ -210,6 +212,125 @@ export class ReputationStore {
|
|
|
210
212
|
return getKFactor(record.transactions);
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Get total escrowed ELO for an agent
|
|
217
|
+
*/
|
|
218
|
+
getEscrowedAmount(agentId) {
|
|
219
|
+
const id = this._normalizeId(agentId);
|
|
220
|
+
let total = 0;
|
|
221
|
+
for (const escrow of this._escrows.values()) {
|
|
222
|
+
if (escrow.status === 'active') {
|
|
223
|
+
if (escrow.from.agent_id === id) {
|
|
224
|
+
total += escrow.from.stake;
|
|
225
|
+
}
|
|
226
|
+
if (escrow.to.agent_id === id) {
|
|
227
|
+
total += escrow.to.stake;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return total;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get available rating for staking (rating - escrowed - minimum floor)
|
|
236
|
+
*/
|
|
237
|
+
async getAvailableRating(agentId) {
|
|
238
|
+
const record = await this.getRating(agentId);
|
|
239
|
+
const escrowed = this.getEscrowedAmount(agentId);
|
|
240
|
+
const available = record.rating - escrowed - MINIMUM_RATING;
|
|
241
|
+
return Math.max(0, available);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if agent can stake the requested amount
|
|
246
|
+
*/
|
|
247
|
+
async canStake(agentId, amount) {
|
|
248
|
+
if (!amount || amount <= 0) {
|
|
249
|
+
return { canStake: true, available: await this.getAvailableRating(agentId) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const available = await this.getAvailableRating(agentId);
|
|
253
|
+
|
|
254
|
+
if (amount > available) {
|
|
255
|
+
return {
|
|
256
|
+
canStake: false,
|
|
257
|
+
available,
|
|
258
|
+
reason: `Insufficient ELO. Available: ${available}, Requested: ${amount}`
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { canStake: true, available };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Create escrow for a proposal
|
|
267
|
+
* Called when proposal is accepted with stakes
|
|
268
|
+
*/
|
|
269
|
+
async createEscrow(proposalId, fromStake, toStake, expiresAt = null) {
|
|
270
|
+
// Validate both parties can stake
|
|
271
|
+
if (fromStake.stake > 0) {
|
|
272
|
+
const canFrom = await this.canStake(fromStake.agent_id, fromStake.stake);
|
|
273
|
+
if (!canFrom.canStake) {
|
|
274
|
+
return { success: false, error: `Proposer: ${canFrom.reason}` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (toStake.stake > 0) {
|
|
279
|
+
const canTo = await this.canStake(toStake.agent_id, toStake.stake);
|
|
280
|
+
if (!canTo.canStake) {
|
|
281
|
+
return { success: false, error: `Acceptor: ${canTo.reason}` };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const escrow = {
|
|
286
|
+
proposal_id: proposalId,
|
|
287
|
+
created_at: Date.now(),
|
|
288
|
+
from: {
|
|
289
|
+
agent_id: this._normalizeId(fromStake.agent_id),
|
|
290
|
+
stake: fromStake.stake || 0
|
|
291
|
+
},
|
|
292
|
+
to: {
|
|
293
|
+
agent_id: this._normalizeId(toStake.agent_id),
|
|
294
|
+
stake: toStake.stake || 0
|
|
295
|
+
},
|
|
296
|
+
status: 'active',
|
|
297
|
+
expires_at: expiresAt,
|
|
298
|
+
settled_at: null,
|
|
299
|
+
settlement_reason: null
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
this._escrows.set(proposalId, escrow);
|
|
303
|
+
return { success: true, escrow };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get escrow for a proposal
|
|
308
|
+
*/
|
|
309
|
+
getEscrow(proposalId) {
|
|
310
|
+
return this._escrows.get(proposalId) || null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Release escrow (return stakes to both parties, no rating change)
|
|
315
|
+
* Used for proposal expiration
|
|
316
|
+
*/
|
|
317
|
+
releaseEscrow(proposalId) {
|
|
318
|
+
const escrow = this._escrows.get(proposalId);
|
|
319
|
+
if (!escrow) {
|
|
320
|
+
return { released: false, error: 'Escrow not found' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (escrow.status !== 'active') {
|
|
324
|
+
return { released: false, error: `Escrow already ${escrow.status}` };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
escrow.status = 'released';
|
|
328
|
+
escrow.settled_at = Date.now();
|
|
329
|
+
escrow.settlement_reason = 'expired';
|
|
330
|
+
|
|
331
|
+
return { released: true, escrow };
|
|
332
|
+
}
|
|
333
|
+
|
|
213
334
|
/**
|
|
214
335
|
* Update rating for an agent
|
|
215
336
|
*/
|
|
@@ -233,7 +354,7 @@ export class ReputationStore {
|
|
|
233
354
|
}
|
|
234
355
|
|
|
235
356
|
/**
|
|
236
|
-
* Process a COMPLETE receipt - both parties gain
|
|
357
|
+
* Process a COMPLETE receipt - both parties gain (halved gains with staking)
|
|
237
358
|
*
|
|
238
359
|
* @param {object} receipt - The COMPLETE receipt
|
|
239
360
|
* @returns {object} Rating changes for both parties
|
|
@@ -243,6 +364,7 @@ export class ReputationStore {
|
|
|
243
364
|
const party1 = receipt.proposal?.from || receipt.from;
|
|
244
365
|
const party2 = receipt.proposal?.to || receipt.to;
|
|
245
366
|
const amount = receipt.proposal?.amount || receipt.amount || 0;
|
|
367
|
+
const proposalId = receipt.proposal_id;
|
|
246
368
|
|
|
247
369
|
if (!party1 || !party2) {
|
|
248
370
|
throw new Error('Receipt missing party information');
|
|
@@ -252,12 +374,32 @@ export class ReputationStore {
|
|
|
252
374
|
const rating1 = await this.getRating(party1);
|
|
253
375
|
const rating2 = await this.getRating(party2);
|
|
254
376
|
|
|
255
|
-
// Calculate gains
|
|
377
|
+
// Calculate gains (halved for staking model)
|
|
256
378
|
const k1 = getKFactor(rating1.transactions);
|
|
257
379
|
const k2 = getKFactor(rating2.transactions);
|
|
258
380
|
|
|
259
|
-
const
|
|
260
|
-
const
|
|
381
|
+
const fullGain1 = calculateCompletionGain(rating1.rating, rating2.rating, k1, amount);
|
|
382
|
+
const fullGain2 = calculateCompletionGain(rating2.rating, rating1.rating, k2, amount);
|
|
383
|
+
|
|
384
|
+
// Half the gains (staking model: split gains to balance inflation)
|
|
385
|
+
const gain1 = Math.max(1, Math.round(fullGain1 / 2));
|
|
386
|
+
const gain2 = Math.max(1, Math.round(fullGain2 / 2));
|
|
387
|
+
|
|
388
|
+
// Settle escrow if exists (return stakes)
|
|
389
|
+
let escrowSettlement = null;
|
|
390
|
+
if (proposalId) {
|
|
391
|
+
const escrow = this._escrows.get(proposalId);
|
|
392
|
+
if (escrow && escrow.status === 'active') {
|
|
393
|
+
escrow.status = 'settled';
|
|
394
|
+
escrow.settled_at = Date.now();
|
|
395
|
+
escrow.settlement_reason = 'completed';
|
|
396
|
+
escrowSettlement = {
|
|
397
|
+
proposer_stake: escrow.from.stake,
|
|
398
|
+
acceptor_stake: escrow.to.stake,
|
|
399
|
+
settlement: 'returned'
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
261
403
|
|
|
262
404
|
// Apply updates
|
|
263
405
|
const updated1 = await this._updateAgent(party1, gain1);
|
|
@@ -266,7 +408,7 @@ export class ReputationStore {
|
|
|
266
408
|
// Save
|
|
267
409
|
await this.save();
|
|
268
410
|
|
|
269
|
-
|
|
411
|
+
const result = {
|
|
270
412
|
[party1]: {
|
|
271
413
|
oldRating: rating1.rating,
|
|
272
414
|
newRating: updated1.rating,
|
|
@@ -280,12 +422,19 @@ export class ReputationStore {
|
|
|
280
422
|
transactions: updated2.transactions
|
|
281
423
|
}
|
|
282
424
|
};
|
|
425
|
+
|
|
426
|
+
if (escrowSettlement) {
|
|
427
|
+
result._escrow = escrowSettlement;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return result;
|
|
283
431
|
}
|
|
284
432
|
|
|
285
433
|
/**
|
|
286
434
|
* Process a DISPUTE receipt
|
|
287
435
|
* If disputed_by is set, they are the "winner" (counterparty is at fault)
|
|
288
436
|
* Otherwise, both parties lose (mutual fault)
|
|
437
|
+
* Stakes are transferred to winner or burned on mutual fault
|
|
289
438
|
*
|
|
290
439
|
* @param {object} receipt - The DISPUTE receipt
|
|
291
440
|
* @returns {object} Rating changes for both parties
|
|
@@ -295,6 +444,7 @@ export class ReputationStore {
|
|
|
295
444
|
const party2 = receipt.proposal?.to || receipt.to;
|
|
296
445
|
const disputedBy = receipt.disputed_by;
|
|
297
446
|
const amount = receipt.proposal?.amount || receipt.amount || 0;
|
|
447
|
+
const proposalId = receipt.proposal_id;
|
|
298
448
|
|
|
299
449
|
if (!party1 || !party2) {
|
|
300
450
|
throw new Error('Receipt missing party information');
|
|
@@ -306,24 +456,68 @@ export class ReputationStore {
|
|
|
306
456
|
const k1 = getKFactor(rating1.transactions);
|
|
307
457
|
const k2 = getKFactor(rating2.transactions);
|
|
308
458
|
|
|
459
|
+
// Get escrow info for stake calculations
|
|
460
|
+
let escrow = null;
|
|
461
|
+
let stake1 = 0, stake2 = 0;
|
|
462
|
+
if (proposalId) {
|
|
463
|
+
escrow = this._escrows.get(proposalId);
|
|
464
|
+
if (escrow && escrow.status === 'active') {
|
|
465
|
+
stake1 = escrow.from.agent_id === this._normalizeId(party1)
|
|
466
|
+
? escrow.from.stake
|
|
467
|
+
: escrow.to.stake;
|
|
468
|
+
stake2 = escrow.from.agent_id === this._normalizeId(party2)
|
|
469
|
+
? escrow.from.stake
|
|
470
|
+
: escrow.to.stake;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
309
474
|
let change1, change2;
|
|
475
|
+
let escrowSettlement = null;
|
|
310
476
|
|
|
311
477
|
if (disputedBy) {
|
|
312
478
|
// The disputer is the "winner", counterparty at fault
|
|
313
479
|
const atFault = disputedBy === party1 ? party2 : party1;
|
|
314
|
-
const winner = disputedBy === party1 ? party1 : party2;
|
|
315
480
|
|
|
316
481
|
if (atFault === party1) {
|
|
317
|
-
|
|
318
|
-
|
|
482
|
+
// Party1 at fault: loses ELO + loses stake to party2
|
|
483
|
+
change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount) - stake1;
|
|
484
|
+
change2 = Math.round(Math.abs(calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount)) * 0.5) + stake1;
|
|
485
|
+
escrowSettlement = {
|
|
486
|
+
proposer_stake: escrow?.from.stake || 0,
|
|
487
|
+
acceptor_stake: escrow?.to.stake || 0,
|
|
488
|
+
settlement: 'transferred',
|
|
489
|
+
transferred_to: party2,
|
|
490
|
+
transferred_amount: stake1
|
|
491
|
+
};
|
|
319
492
|
} else {
|
|
320
|
-
|
|
321
|
-
|
|
493
|
+
// Party2 at fault: loses ELO + loses stake to party1
|
|
494
|
+
change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount) - stake2;
|
|
495
|
+
change1 = Math.round(Math.abs(calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount)) * 0.5) + stake2;
|
|
496
|
+
escrowSettlement = {
|
|
497
|
+
proposer_stake: escrow?.from.stake || 0,
|
|
498
|
+
acceptor_stake: escrow?.to.stake || 0,
|
|
499
|
+
settlement: 'transferred',
|
|
500
|
+
transferred_to: party1,
|
|
501
|
+
transferred_amount: stake2
|
|
502
|
+
};
|
|
322
503
|
}
|
|
323
504
|
} else {
|
|
324
|
-
// Mutual fault - both lose
|
|
325
|
-
change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount);
|
|
326
|
-
change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount);
|
|
505
|
+
// Mutual fault - both lose ELO + both stakes burned
|
|
506
|
+
change1 = calculateDisputeLoss(rating1.rating, rating2.rating, k1, amount) - stake1;
|
|
507
|
+
change2 = calculateDisputeLoss(rating2.rating, rating1.rating, k2, amount) - stake2;
|
|
508
|
+
escrowSettlement = {
|
|
509
|
+
proposer_stake: escrow?.from.stake || 0,
|
|
510
|
+
acceptor_stake: escrow?.to.stake || 0,
|
|
511
|
+
settlement: 'burned',
|
|
512
|
+
burned_amount: stake1 + stake2
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Settle escrow
|
|
517
|
+
if (escrow && escrow.status === 'active') {
|
|
518
|
+
escrow.status = 'settled';
|
|
519
|
+
escrow.settled_at = Date.now();
|
|
520
|
+
escrow.settlement_reason = 'disputed';
|
|
327
521
|
}
|
|
328
522
|
|
|
329
523
|
const updated1 = await this._updateAgent(party1, change1);
|
|
@@ -331,7 +525,7 @@ export class ReputationStore {
|
|
|
331
525
|
|
|
332
526
|
await this.save();
|
|
333
527
|
|
|
334
|
-
|
|
528
|
+
const result = {
|
|
335
529
|
[party1]: {
|
|
336
530
|
oldRating: rating1.rating,
|
|
337
531
|
newRating: updated1.rating,
|
|
@@ -345,6 +539,12 @@ export class ReputationStore {
|
|
|
345
539
|
transactions: updated2.transactions
|
|
346
540
|
}
|
|
347
541
|
};
|
|
542
|
+
|
|
543
|
+
if (escrowSettlement) {
|
|
544
|
+
result._escrow = escrowSettlement;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return result;
|
|
348
548
|
}
|
|
349
549
|
|
|
350
550
|
/**
|
package/lib/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
isProposalMessage
|
|
24
24
|
} from './protocol.js';
|
|
25
25
|
import { ProposalStore, formatProposal, formatProposalResponse } from './proposals.js';
|
|
26
|
+
import { ReputationStore } from './reputation.js';
|
|
26
27
|
|
|
27
28
|
export class AgentChatServer {
|
|
28
29
|
constructor(options = {}) {
|
|
@@ -75,6 +76,9 @@ export class AgentChatServer {
|
|
|
75
76
|
// Skills registry: agentId -> { skills: [], registered_at, sig }
|
|
76
77
|
this.skillsRegistry = new Map();
|
|
77
78
|
|
|
79
|
+
// Reputation store for ELO ratings
|
|
80
|
+
this.reputationStore = new ReputationStore();
|
|
81
|
+
|
|
78
82
|
this.wss = null;
|
|
79
83
|
this.httpServer = null; // For TLS mode
|
|
80
84
|
}
|
|
@@ -681,7 +685,8 @@ export class AgentChatServer {
|
|
|
681
685
|
payment_code: msg.payment_code,
|
|
682
686
|
terms: msg.terms,
|
|
683
687
|
expires: msg.expires,
|
|
684
|
-
sig: msg.sig
|
|
688
|
+
sig: msg.sig,
|
|
689
|
+
elo_stake: msg.elo_stake || null
|
|
685
690
|
});
|
|
686
691
|
|
|
687
692
|
this._log('proposal', { id: proposal.id, from: agent.id, to: targetId });
|
|
@@ -696,7 +701,7 @@ export class AgentChatServer {
|
|
|
696
701
|
this._send(ws, outMsg);
|
|
697
702
|
}
|
|
698
703
|
|
|
699
|
-
_handleAccept(ws, msg) {
|
|
704
|
+
async _handleAccept(ws, msg) {
|
|
700
705
|
const agent = this.agents.get(ws);
|
|
701
706
|
if (!agent) {
|
|
702
707
|
this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
|
|
@@ -708,11 +713,40 @@ export class AgentChatServer {
|
|
|
708
713
|
return;
|
|
709
714
|
}
|
|
710
715
|
|
|
716
|
+
// Get proposal first to check stakes
|
|
717
|
+
const existingProposal = this.proposals.get(msg.proposal_id);
|
|
718
|
+
if (!existingProposal) {
|
|
719
|
+
this._send(ws, createError(ErrorCode.PROPOSAL_NOT_FOUND, 'Proposal not found'));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const proposerStake = existingProposal.proposer_stake || 0;
|
|
724
|
+
const acceptorStake = msg.elo_stake || 0;
|
|
725
|
+
|
|
726
|
+
// Validate proposer can stake (if they declared a stake)
|
|
727
|
+
if (proposerStake > 0) {
|
|
728
|
+
const canProposerStake = await this.reputationStore.canStake(existingProposal.from, proposerStake);
|
|
729
|
+
if (!canProposerStake.canStake) {
|
|
730
|
+
this._send(ws, createError(ErrorCode.INSUFFICIENT_REPUTATION, `Proposer: ${canProposerStake.reason}`));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Validate acceptor can stake (if they declared a stake)
|
|
736
|
+
if (acceptorStake > 0) {
|
|
737
|
+
const canAcceptorStake = await this.reputationStore.canStake(`@${agent.id}`, acceptorStake);
|
|
738
|
+
if (!canAcceptorStake.canStake) {
|
|
739
|
+
this._send(ws, createError(ErrorCode.INSUFFICIENT_REPUTATION, canAcceptorStake.reason));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
711
744
|
const result = this.proposals.accept(
|
|
712
745
|
msg.proposal_id,
|
|
713
746
|
`@${agent.id}`,
|
|
714
747
|
msg.sig,
|
|
715
|
-
msg.payment_code
|
|
748
|
+
msg.payment_code,
|
|
749
|
+
acceptorStake
|
|
716
750
|
);
|
|
717
751
|
|
|
718
752
|
if (result.error) {
|
|
@@ -721,7 +755,29 @@ export class AgentChatServer {
|
|
|
721
755
|
}
|
|
722
756
|
|
|
723
757
|
const proposal = result.proposal;
|
|
724
|
-
|
|
758
|
+
|
|
759
|
+
// Create escrow if either party has a stake
|
|
760
|
+
if (proposerStake > 0 || acceptorStake > 0) {
|
|
761
|
+
const escrowResult = await this.reputationStore.createEscrow(
|
|
762
|
+
proposal.id,
|
|
763
|
+
{ agent_id: proposal.from, stake: proposerStake },
|
|
764
|
+
{ agent_id: proposal.to, stake: acceptorStake },
|
|
765
|
+
proposal.expires
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
if (escrowResult.success) {
|
|
769
|
+
proposal.stakes_escrowed = true;
|
|
770
|
+
this._log('escrow_created', {
|
|
771
|
+
proposal_id: proposal.id,
|
|
772
|
+
proposer_stake: proposerStake,
|
|
773
|
+
acceptor_stake: acceptorStake
|
|
774
|
+
});
|
|
775
|
+
} else {
|
|
776
|
+
this._log('escrow_error', { proposal_id: proposal.id, error: escrowResult.error });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
this._log('accept', { id: proposal.id, by: agent.id, proposer_stake: proposerStake, acceptor_stake: acceptorStake });
|
|
725
781
|
|
|
726
782
|
// Notify the proposal creator
|
|
727
783
|
const creatorId = proposal.from.slice(1);
|
|
@@ -780,7 +836,7 @@ export class AgentChatServer {
|
|
|
780
836
|
this._send(ws, outMsg);
|
|
781
837
|
}
|
|
782
838
|
|
|
783
|
-
_handleComplete(ws, msg) {
|
|
839
|
+
async _handleComplete(ws, msg) {
|
|
784
840
|
const agent = this.agents.get(ws);
|
|
785
841
|
if (!agent) {
|
|
786
842
|
this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
|
|
@@ -807,9 +863,29 @@ export class AgentChatServer {
|
|
|
807
863
|
const proposal = result.proposal;
|
|
808
864
|
this._log('complete', { id: proposal.id, by: agent.id });
|
|
809
865
|
|
|
866
|
+
// Update reputation ratings (includes escrow settlement)
|
|
867
|
+
let ratingChanges = null;
|
|
868
|
+
try {
|
|
869
|
+
ratingChanges = await this.reputationStore.processCompletion({
|
|
870
|
+
type: 'COMPLETE',
|
|
871
|
+
proposal_id: proposal.id,
|
|
872
|
+
from: proposal.from,
|
|
873
|
+
to: proposal.to,
|
|
874
|
+
amount: proposal.amount
|
|
875
|
+
});
|
|
876
|
+
this._log('reputation_updated', {
|
|
877
|
+
proposal_id: proposal.id,
|
|
878
|
+
changes: ratingChanges,
|
|
879
|
+
escrow: ratingChanges?._escrow
|
|
880
|
+
});
|
|
881
|
+
} catch (err) {
|
|
882
|
+
this._log('reputation_error', { error: err.message });
|
|
883
|
+
}
|
|
884
|
+
|
|
810
885
|
// Notify both parties
|
|
811
886
|
const outMsg = createMessage(ServerMessageType.COMPLETE, {
|
|
812
|
-
...formatProposalResponse(proposal, 'complete')
|
|
887
|
+
...formatProposalResponse(proposal, 'complete'),
|
|
888
|
+
rating_changes: ratingChanges
|
|
813
889
|
});
|
|
814
890
|
|
|
815
891
|
// Notify the other party
|
|
@@ -823,7 +899,7 @@ export class AgentChatServer {
|
|
|
823
899
|
this._send(ws, outMsg);
|
|
824
900
|
}
|
|
825
901
|
|
|
826
|
-
_handleDispute(ws, msg) {
|
|
902
|
+
async _handleDispute(ws, msg) {
|
|
827
903
|
const agent = this.agents.get(ws);
|
|
828
904
|
if (!agent) {
|
|
829
905
|
this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
|
|
@@ -850,9 +926,30 @@ export class AgentChatServer {
|
|
|
850
926
|
const proposal = result.proposal;
|
|
851
927
|
this._log('dispute', { id: proposal.id, by: agent.id, reason: msg.reason });
|
|
852
928
|
|
|
929
|
+
// Update reputation ratings (includes escrow settlement)
|
|
930
|
+
let ratingChanges = null;
|
|
931
|
+
try {
|
|
932
|
+
ratingChanges = await this.reputationStore.processDispute({
|
|
933
|
+
type: 'DISPUTE',
|
|
934
|
+
proposal_id: proposal.id,
|
|
935
|
+
from: proposal.from,
|
|
936
|
+
to: proposal.to,
|
|
937
|
+
amount: proposal.amount,
|
|
938
|
+
disputed_by: `@${agent.id}`
|
|
939
|
+
});
|
|
940
|
+
this._log('reputation_updated', {
|
|
941
|
+
proposal_id: proposal.id,
|
|
942
|
+
changes: ratingChanges,
|
|
943
|
+
escrow: ratingChanges?._escrow
|
|
944
|
+
});
|
|
945
|
+
} catch (err) {
|
|
946
|
+
this._log('reputation_error', { error: err.message });
|
|
947
|
+
}
|
|
948
|
+
|
|
853
949
|
// Notify both parties
|
|
854
950
|
const outMsg = createMessage(ServerMessageType.DISPUTE, {
|
|
855
|
-
...formatProposalResponse(proposal, 'dispute')
|
|
951
|
+
...formatProposalResponse(proposal, 'dispute'),
|
|
952
|
+
rating_changes: ratingChanges
|
|
856
953
|
});
|
|
857
954
|
|
|
858
955
|
// Notify the other party
|
|
@@ -907,7 +1004,7 @@ export class AgentChatServer {
|
|
|
907
1004
|
}
|
|
908
1005
|
}
|
|
909
1006
|
|
|
910
|
-
_handleSearchSkills(ws, msg) {
|
|
1007
|
+
async _handleSearchSkills(ws, msg) {
|
|
911
1008
|
const agent = this.agents.get(ws);
|
|
912
1009
|
if (!agent) {
|
|
913
1010
|
this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
|
|
@@ -955,8 +1052,26 @@ export class AgentChatServer {
|
|
|
955
1052
|
}
|
|
956
1053
|
}
|
|
957
1054
|
|
|
958
|
-
//
|
|
959
|
-
results.
|
|
1055
|
+
// Enrich results with reputation data
|
|
1056
|
+
const uniqueAgentIds = [...new Set(results.map(r => r.agent_id))];
|
|
1057
|
+
const ratingCache = new Map();
|
|
1058
|
+
for (const agentId of uniqueAgentIds) {
|
|
1059
|
+
const ratingInfo = await this.reputationStore.getRating(agentId);
|
|
1060
|
+
ratingCache.set(agentId, ratingInfo);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Add rating info to each result
|
|
1064
|
+
for (const result of results) {
|
|
1065
|
+
const ratingInfo = ratingCache.get(result.agent_id);
|
|
1066
|
+
result.rating = ratingInfo.rating;
|
|
1067
|
+
result.transactions = ratingInfo.transactions;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Sort by rating (highest first), then by registration time
|
|
1071
|
+
results.sort((a, b) => {
|
|
1072
|
+
if (b.rating !== a.rating) return b.rating - a.rating;
|
|
1073
|
+
return b.registered_at - a.registered_at;
|
|
1074
|
+
});
|
|
960
1075
|
|
|
961
1076
|
// Limit results
|
|
962
1077
|
const limit = query.limit || 50;
|