hd-wallet-ui 1.0.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,699 @@
1
+ /**
2
+ * Blockchain Trust Transactions
3
+ *
4
+ * KeySpace-inspired trust model: all trust relationships are published
5
+ * as on-chain transactions using OP_RETURN (Bitcoin), memo fields (Solana),
6
+ * or transaction data (Ethereum).
7
+ *
8
+ * Binary encoding format (v2):
9
+ * Trust: [0x54][0x01][level][timestamp:4][pubkey:32-33] = 40-41 bytes
10
+ * Revocation: [0x52][0x01][timestamp:4][txhash:32] = 38 bytes
11
+ */
12
+
13
+ // =============================================================================
14
+ // Trust Levels (PGP-style)
15
+ // =============================================================================
16
+
17
+ export const TrustLevel = {
18
+ NEVER: 1, // Blocklist / Do not trust
19
+ UNKNOWN: 2, // Default / No opinion
20
+ MARGINAL: 3, // Some trust
21
+ FULL: 4, // Full trust / Can sign other keys
22
+ ULTIMATE: 5, // Own keys
23
+ };
24
+
25
+ export const TrustLevelNames = {
26
+ 1: 'Never',
27
+ 2: 'Unknown',
28
+ 3: 'Marginal',
29
+ 4: 'Full',
30
+ 5: 'Ultimate',
31
+ };
32
+
33
+ // =============================================================================
34
+ // Binary Constants
35
+ // =============================================================================
36
+
37
+ const MAGIC_TRUST = 0x54; // 'T'
38
+ const MAGIC_REVOKE = 0x52; // 'R'
39
+ const VERSION = 0x01;
40
+
41
+ // Legacy ASCII prefixes
42
+ const LEGACY_TRUST_PREFIX = 'TRUST';
43
+ const LEGACY_REVOKE_PREFIX = 'REVOKE';
44
+
45
+ // =============================================================================
46
+ // Binary Encoding Helpers
47
+ // =============================================================================
48
+
49
+ function writeUint32(buf, offset, value) {
50
+ buf[offset] = (value >>> 24) & 0xff;
51
+ buf[offset + 1] = (value >>> 16) & 0xff;
52
+ buf[offset + 2] = (value >>> 8) & 0xff;
53
+ buf[offset + 3] = value & 0xff;
54
+ }
55
+
56
+ function readUint32(buf, offset) {
57
+ return (
58
+ ((buf[offset] << 24) >>> 0) +
59
+ (buf[offset + 1] << 16) +
60
+ (buf[offset + 2] << 8) +
61
+ buf[offset + 3]
62
+ ) >>> 0;
63
+ }
64
+
65
+ function hexToBytes(hex) {
66
+ const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
67
+ const bytes = new Uint8Array(clean.length / 2);
68
+ for (let i = 0; i < bytes.length; i++) {
69
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
70
+ }
71
+ return bytes;
72
+ }
73
+
74
+ function bytesToHex(bytes) {
75
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
76
+ }
77
+
78
+ function bytesToBase64(bytes) {
79
+ if (typeof btoa === 'function') {
80
+ return btoa(String.fromCharCode(...bytes));
81
+ }
82
+ return Buffer.from(bytes).toString('base64');
83
+ }
84
+
85
+ function base64ToBytes(b64) {
86
+ if (typeof atob === 'function') {
87
+ const bin = atob(b64);
88
+ const bytes = new Uint8Array(bin.length);
89
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
90
+ return bytes;
91
+ }
92
+ return new Uint8Array(Buffer.from(b64, 'base64'));
93
+ }
94
+
95
+ // =============================================================================
96
+ // Binary Trust Encoding (v2)
97
+ // =============================================================================
98
+
99
+ /**
100
+ * Encode trust metadata as compact binary Uint8Array.
101
+ *
102
+ * Format:
103
+ * Byte [0]: Magic 0x54 ('T')
104
+ * Byte [1]: Version 0x01
105
+ * Byte [2]: Trust level (0x01-0x05)
106
+ * Bytes [3-6]: Timestamp as uint32 (seconds since epoch)
107
+ * Bytes [7-N]: Recipient pubkey bytes (33 for secp256k1, 32 for ed25519)
108
+ *
109
+ * @param {number} level - Trust level (1-5)
110
+ * @param {string} recipientPubkey - Hex-encoded public key
111
+ * @param {number} [timestamp] - Unix timestamp in milliseconds (default: now)
112
+ * @returns {Uint8Array} Binary encoded trust metadata
113
+ */
114
+ export function encodeTrustMetadata(level, recipientPubkey, timestamp = Date.now()) {
115
+ if (level < 1 || level > 5) {
116
+ throw new Error(`Invalid trust level: ${level}`);
117
+ }
118
+
119
+ const pubkeyBytes = hexToBytes(recipientPubkey);
120
+ const timeSec = Math.floor(timestamp / 1000);
121
+ const buf = new Uint8Array(7 + pubkeyBytes.length);
122
+
123
+ buf[0] = MAGIC_TRUST;
124
+ buf[1] = VERSION;
125
+ buf[2] = level;
126
+ writeUint32(buf, 3, timeSec);
127
+ buf.set(pubkeyBytes, 7);
128
+
129
+ return buf;
130
+ }
131
+
132
+ /**
133
+ * Encode revocation metadata as compact binary Uint8Array.
134
+ *
135
+ * Format:
136
+ * Byte [0]: Magic 0x52 ('R')
137
+ * Byte [1]: Version 0x01
138
+ * Bytes [2-5]: Timestamp as uint32 (seconds since epoch)
139
+ * Bytes [6-37]: Original tx hash (32 bytes)
140
+ *
141
+ * @param {string} originalTxHash - Hex-encoded transaction hash
142
+ * @param {number} [timestamp] - Unix timestamp in milliseconds (default: now)
143
+ * @returns {Uint8Array} Binary encoded revocation metadata
144
+ */
145
+ export function encodeRevocationMetadata(originalTxHash, timestamp = Date.now()) {
146
+ const hashBytes = hexToBytes(originalTxHash);
147
+ if (hashBytes.length !== 32) {
148
+ throw new Error(`Expected 32-byte tx hash, got ${hashBytes.length}`);
149
+ }
150
+
151
+ const timeSec = Math.floor(timestamp / 1000);
152
+ const buf = new Uint8Array(38);
153
+
154
+ buf[0] = MAGIC_REVOKE;
155
+ buf[1] = VERSION;
156
+ writeUint32(buf, 2, timeSec);
157
+ buf.set(hashBytes, 6);
158
+
159
+ return buf;
160
+ }
161
+
162
+ /**
163
+ * Legacy ASCII encoder for backwards compatibility.
164
+ * Format: TRUST:<version>:<level>:<timestamp>:<recipientPubkey>
165
+ */
166
+ export function encodeTrustMetadataLegacy(level, recipientPubkey, timestamp = Date.now()) {
167
+ if (level < 1 || level > 5) {
168
+ throw new Error(`Invalid trust level: ${level}`);
169
+ }
170
+ return `${LEGACY_TRUST_PREFIX}:1:${level}:${timestamp}:${recipientPubkey}`;
171
+ }
172
+
173
+ // =============================================================================
174
+ // Parsing (binary + legacy ASCII)
175
+ // =============================================================================
176
+
177
+ /**
178
+ * Parse trust metadata from either binary (Uint8Array) or legacy ASCII string.
179
+ * Detects format by checking the first byte: 0x54 = binary trust, 0x52 = binary revoke.
180
+ *
181
+ * @param {Uint8Array|string} metadata - Binary buffer or ASCII string
182
+ * @returns {object|null} Parsed trust/revocation object, or null
183
+ */
184
+ export function parseTrustMetadata(metadata) {
185
+ // Binary path
186
+ if (metadata instanceof Uint8Array || metadata instanceof ArrayBuffer) {
187
+ const buf = metadata instanceof ArrayBuffer ? new Uint8Array(metadata) : metadata;
188
+ return parseBinaryMetadata(buf);
189
+ }
190
+
191
+ // If it's a string, check if the first char signals binary
192
+ if (typeof metadata === 'string') {
193
+ // Could be base64-encoded binary; try legacy ASCII first
194
+ const legacy = parseLegacyMetadata(metadata);
195
+ if (legacy) return legacy;
196
+
197
+ // Try base64 decode
198
+ try {
199
+ const bytes = base64ToBytes(metadata);
200
+ if (bytes.length >= 38 && (bytes[0] === MAGIC_TRUST || bytes[0] === MAGIC_REVOKE)) {
201
+ return parseBinaryMetadata(bytes);
202
+ }
203
+ } catch (_) {
204
+ // not base64
205
+ }
206
+ }
207
+
208
+ return null;
209
+ }
210
+
211
+ function parseBinaryMetadata(buf) {
212
+ if (!buf || buf.length < 38) return null;
213
+
214
+ if (buf[0] === MAGIC_TRUST && buf[1] === VERSION && buf.length >= 39) {
215
+ const level = buf[2];
216
+ if (level < 1 || level > 5) return null;
217
+ const timestamp = readUint32(buf, 3) * 1000;
218
+ const recipientPubkey = bytesToHex(buf.slice(7));
219
+
220
+ return {
221
+ type: 'trust',
222
+ version: String(buf[1]),
223
+ level,
224
+ timestamp,
225
+ recipientPubkey,
226
+ };
227
+ }
228
+
229
+ if (buf[0] === MAGIC_REVOKE && buf[1] === VERSION && buf.length >= 38) {
230
+ const timestamp = readUint32(buf, 2) * 1000;
231
+ const originalTxHash = bytesToHex(buf.slice(6, 38));
232
+
233
+ return {
234
+ type: 'revocation',
235
+ version: String(buf[1]),
236
+ originalTxHash,
237
+ timestamp,
238
+ };
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ function parseLegacyMetadata(str) {
245
+ const parts = str.split(':');
246
+
247
+ if (parts[0] === LEGACY_TRUST_PREFIX && parts.length >= 5) {
248
+ return {
249
+ type: 'trust',
250
+ version: parts[1],
251
+ level: parseInt(parts[2], 10),
252
+ timestamp: parseInt(parts[3], 10),
253
+ recipientPubkey: parts[4],
254
+ };
255
+ }
256
+
257
+ if (parts[0] === LEGACY_REVOKE_PREFIX && parts.length >= 4) {
258
+ return {
259
+ type: 'revocation',
260
+ version: parts[1],
261
+ originalTxHash: parts[2],
262
+ timestamp: parseInt(parts[3], 10),
263
+ };
264
+ }
265
+
266
+ return null;
267
+ }
268
+
269
+ // =============================================================================
270
+ // Bitcoin OP_RETURN Trust Transactions
271
+ // =============================================================================
272
+
273
+ /**
274
+ * Build Bitcoin OP_RETURN output data for trust transaction.
275
+ * Uses compact binary encoding; total payload is 40-41 bytes (well within 80-byte limit).
276
+ */
277
+ export function buildBitcoinTrustOpReturn(level, recipientPubkey) {
278
+ const bytes = encodeTrustMetadata(level, recipientPubkey);
279
+
280
+ if (bytes.length > 80) {
281
+ throw new Error('Trust metadata exceeds OP_RETURN size limit (80 bytes)');
282
+ }
283
+
284
+ // OP_RETURN format: 0x6a (OP_RETURN) + length + data
285
+ return {
286
+ scriptPubKey: `6a${bytes.length.toString(16).padStart(2, '0')}${bytesToHex(bytes)}`,
287
+ metadata: bytes,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Parse Bitcoin OP_RETURN data from transaction.
293
+ * Handles both binary and legacy ASCII payloads.
294
+ */
295
+ export function parseBitcoinOpReturn(scriptPubKey) {
296
+ if (!scriptPubKey.startsWith('6a')) return null;
297
+
298
+ const dataHex = scriptPubKey.slice(4);
299
+ const bytes = hexToBytes(dataHex);
300
+
301
+ // Try binary first
302
+ if (bytes.length >= 38 && (bytes[0] === MAGIC_TRUST || bytes[0] === MAGIC_REVOKE)) {
303
+ return parseBinaryMetadata(bytes);
304
+ }
305
+
306
+ // Fall back to legacy ASCII
307
+ const text = new TextDecoder().decode(bytes);
308
+ return parseLegacyMetadata(text);
309
+ }
310
+
311
+ // =============================================================================
312
+ // Solana Memo Trust Transactions
313
+ // =============================================================================
314
+
315
+ /**
316
+ * Build Solana memo instruction for trust transaction.
317
+ * Returns base64-encoded binary for the memo field.
318
+ */
319
+ export function buildSolanaTrustMemo(level, recipientPubkey) {
320
+ const bytes = encodeTrustMetadata(level, recipientPubkey);
321
+ return bytesToBase64(bytes);
322
+ }
323
+
324
+ /**
325
+ * Parse Solana memo from transaction.
326
+ * Handles both base64-encoded binary and legacy ASCII memos.
327
+ */
328
+ export function parseSolanaMemo(memo) {
329
+ if (!memo) return null;
330
+
331
+ // Try base64 decode for binary format
332
+ try {
333
+ const bytes = base64ToBytes(memo);
334
+ if (bytes.length >= 38 && (bytes[0] === MAGIC_TRUST || bytes[0] === MAGIC_REVOKE)) {
335
+ return parseBinaryMetadata(bytes);
336
+ }
337
+ } catch (_) {
338
+ // not valid base64
339
+ }
340
+
341
+ // Fall back to legacy ASCII
342
+ return parseLegacyMetadata(memo);
343
+ }
344
+
345
+ // =============================================================================
346
+ // Ethereum Data Field Trust Transactions
347
+ // =============================================================================
348
+
349
+ /**
350
+ * Build Ethereum transaction data field for trust transaction.
351
+ * Returns hex-encoded binary with 0x prefix.
352
+ */
353
+ export function buildEthereumTrustData(level, recipientPubkey) {
354
+ const bytes = encodeTrustMetadata(level, recipientPubkey);
355
+ return '0x' + bytesToHex(bytes);
356
+ }
357
+
358
+ /**
359
+ * Parse Ethereum transaction data field.
360
+ * Handles both binary and legacy ASCII payloads.
361
+ */
362
+ export function parseEthereumData(dataHex) {
363
+ if (!dataHex || dataHex === '0x') return null;
364
+
365
+ const bytes = hexToBytes(dataHex);
366
+
367
+ // Try binary first
368
+ if (bytes.length >= 38 && (bytes[0] === MAGIC_TRUST || bytes[0] === MAGIC_REVOKE)) {
369
+ return parseBinaryMetadata(bytes);
370
+ }
371
+
372
+ // Fall back to legacy ASCII
373
+ const text = new TextDecoder().decode(bytes);
374
+ return parseLegacyMetadata(text);
375
+ }
376
+
377
+ // =============================================================================
378
+ // Trust Transaction Scanners
379
+ // =============================================================================
380
+
381
+ /**
382
+ * Scan Bitcoin blockchain for trust transactions.
383
+ * Uses block explorer API to query OP_RETURN transactions.
384
+ */
385
+ export async function scanBitcoinTrustTransactions(address) {
386
+ try {
387
+ const response = await fetch(`https://blockstream.info/api/address/${address}/txs`);
388
+ if (!response.ok) throw new Error('Failed to fetch Bitcoin transactions');
389
+
390
+ const txs = await response.json();
391
+ const trustTxs = [];
392
+
393
+ for (const tx of txs) {
394
+ for (const output of tx.vout) {
395
+ if (output.scriptpubkey_type === 'op_return') {
396
+ const parsed = parseBitcoinOpReturn(output.scriptpubkey);
397
+ if (parsed) {
398
+ trustTxs.push({
399
+ txHash: tx.txid,
400
+ blockHeight: tx.status.block_height,
401
+ timestamp: tx.status.block_time * 1000,
402
+ from: address,
403
+ chain: 'bitcoin',
404
+ ...parsed,
405
+ });
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ return trustTxs;
412
+ } catch (err) {
413
+ console.error('Bitcoin trust scan failed:', err);
414
+ return [];
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Scan Solana blockchain for trust transactions.
420
+ * Uses RPC to get transactions with memo instructions.
421
+ */
422
+ export async function scanSolanaTrustTransactions(address) {
423
+ try {
424
+ const response = await fetch('https://api.mainnet-beta.solana.com', {
425
+ method: 'POST',
426
+ headers: { 'Content-Type': 'application/json' },
427
+ body: JSON.stringify({
428
+ jsonrpc: '2.0',
429
+ id: 1,
430
+ method: 'getSignaturesForAddress',
431
+ params: [address, { limit: 100 }],
432
+ }),
433
+ });
434
+
435
+ if (!response.ok) throw new Error('Failed to fetch Solana signatures');
436
+ const data = await response.json();
437
+ const signatures = data.result || [];
438
+
439
+ const trustTxs = [];
440
+
441
+ for (const sig of signatures) {
442
+ const txResponse = await fetch('https://api.mainnet-beta.solana.com', {
443
+ method: 'POST',
444
+ headers: { 'Content-Type': 'application/json' },
445
+ body: JSON.stringify({
446
+ jsonrpc: '2.0',
447
+ id: 1,
448
+ method: 'getTransaction',
449
+ params: [sig.signature, { encoding: 'jsonParsed' }],
450
+ }),
451
+ });
452
+
453
+ if (!txResponse.ok) continue;
454
+ const txData = await txResponse.json();
455
+ const tx = txData.result;
456
+
457
+ if (!tx || !tx.meta) continue;
458
+
459
+ const memos = tx.meta.logMessages?.filter(m => m.startsWith('Program log: Memo')) || [];
460
+ for (const memoLog of memos) {
461
+ const memo = memoLog.replace('Program log: Memo (len ', '').split('): "')[1]?.replace('"', '');
462
+ if (memo) {
463
+ const parsed = parseSolanaMemo(memo);
464
+ if (parsed) {
465
+ trustTxs.push({
466
+ txHash: sig.signature,
467
+ slot: tx.slot,
468
+ timestamp: (tx.blockTime || 0) * 1000,
469
+ from: address,
470
+ chain: 'solana',
471
+ ...parsed,
472
+ });
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ return trustTxs;
479
+ } catch (err) {
480
+ console.error('Solana trust scan failed:', err);
481
+ return [];
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Scan Ethereum blockchain for trust transactions.
487
+ * Uses Etherscan API to query 0-value transactions with data.
488
+ */
489
+ export async function scanEthereumTrustTransactions(address) {
490
+ try {
491
+ console.warn('Ethereum trust scanning requires Etherscan API key');
492
+ return [];
493
+ } catch (err) {
494
+ console.error('Ethereum trust scan failed:', err);
495
+ return [];
496
+ }
497
+ }
498
+
499
+ // =============================================================================
500
+ // Trust Relationship Analyzer
501
+ // =============================================================================
502
+
503
+ /**
504
+ * Analyze trust relationships from a set of transactions relative to own addresses.
505
+ *
506
+ * Groups transactions by counterparty address and determines direction:
507
+ * - 'outbound': we sent trust to them
508
+ * - 'inbound': they sent trust to us
509
+ * - 'mutual': both directions exist
510
+ *
511
+ * @param {string[]} ownAddresses - Array of addresses belonging to the user
512
+ * @param {object[]} transactions - Array of trust transaction objects (from scanners)
513
+ * @returns {object[]} Array of relationship summaries
514
+ */
515
+ export function analyzeTrustRelationships(ownAddresses, transactions) {
516
+ const addrs = Array.isArray(ownAddresses) ? ownAddresses : Object.values(ownAddresses || {}).filter(Boolean);
517
+ const ownSet = new Set(addrs.map(a => a.toLowerCase()));
518
+ const counterparties = new Map(); // address -> { outbound: [], inbound: [] }
519
+
520
+ for (const tx of transactions) {
521
+ if (tx.type !== 'trust') continue;
522
+
523
+ const fromAddr = (tx.from || '').toLowerCase();
524
+ const toAddr = (tx.recipientPubkey || '').toLowerCase();
525
+ const isFromUs = ownSet.has(fromAddr);
526
+ const isToUs = ownSet.has(toAddr);
527
+
528
+ const txRecord = {
529
+ txHash: tx.txHash,
530
+ timestamp: tx.timestamp,
531
+ level: tx.level,
532
+ type: tx.type,
533
+ chain: tx.chain || 'unknown',
534
+ };
535
+
536
+ if (isFromUs && !isToUs) {
537
+ // Outbound: we sent trust to them
538
+ const key = tx.recipientPubkey;
539
+ if (!counterparties.has(key)) {
540
+ counterparties.set(key, { outbound: [], inbound: [] });
541
+ }
542
+ counterparties.get(key).outbound.push({ ...txRecord, direction: 'outbound' });
543
+ } else if (!isFromUs && isToUs) {
544
+ // Inbound: they sent trust to us
545
+ const key = tx.from;
546
+ if (!counterparties.has(key)) {
547
+ counterparties.set(key, { outbound: [], inbound: [] });
548
+ }
549
+ counterparties.get(key).inbound.push({ ...txRecord, direction: 'inbound' });
550
+ }
551
+ }
552
+
553
+ const results = [];
554
+
555
+ for (const [address, data] of counterparties) {
556
+ const allTxs = [...data.outbound, ...data.inbound];
557
+ allTxs.sort((a, b) => b.timestamp - a.timestamp);
558
+
559
+ let direction;
560
+ if (data.outbound.length > 0 && data.inbound.length > 0) {
561
+ direction = 'mutual';
562
+ } else if (data.outbound.length > 0) {
563
+ direction = 'outbound';
564
+ } else {
565
+ direction = 'inbound';
566
+ }
567
+
568
+ // Use the most recent trust level
569
+ const latest = allTxs[0];
570
+
571
+ results.push({
572
+ address,
573
+ chain: latest.chain,
574
+ level: latest.level,
575
+ direction,
576
+ txCount: allTxs.length,
577
+ lastSeen: latest.timestamp,
578
+ transactions: allTxs,
579
+ });
580
+ }
581
+
582
+ // Sort by most recently seen
583
+ results.sort((a, b) => b.lastSeen - a.lastSeen);
584
+
585
+ return results;
586
+ }
587
+
588
+ // =============================================================================
589
+ // Trust Graph Builder
590
+ // =============================================================================
591
+
592
+ /**
593
+ * Build trust graph from scanned transactions.
594
+ * Returns nodes (pubkeys) and edges (trust relationships).
595
+ */
596
+ export function buildTrustGraph(trustTxs) {
597
+ const nodes = new Map();
598
+ const edges = [];
599
+ const revocations = new Map();
600
+
601
+ // First pass: collect revocations
602
+ for (const tx of trustTxs) {
603
+ if (tx.type === 'revocation') {
604
+ revocations.set(tx.originalTxHash, tx.timestamp);
605
+ }
606
+ }
607
+
608
+ // Second pass: build graph
609
+ for (const tx of trustTxs) {
610
+ if (tx.type === 'trust') {
611
+ if (!nodes.has(tx.from)) {
612
+ nodes.set(tx.from, {
613
+ id: tx.from,
614
+ label: truncatePubkey(tx.from),
615
+ ownKey: false,
616
+ });
617
+ }
618
+
619
+ if (!nodes.has(tx.recipientPubkey)) {
620
+ nodes.set(tx.recipientPubkey, {
621
+ id: tx.recipientPubkey,
622
+ label: truncatePubkey(tx.recipientPubkey),
623
+ ownKey: false,
624
+ });
625
+ }
626
+
627
+ const revoked = revocations.has(tx.txHash);
628
+ edges.push({
629
+ from: tx.from,
630
+ to: tx.recipientPubkey,
631
+ level: tx.level,
632
+ txHash: tx.txHash,
633
+ timestamp: tx.timestamp,
634
+ revoked,
635
+ revokedAt: revoked ? revocations.get(tx.txHash) : null,
636
+ });
637
+ }
638
+ }
639
+
640
+ return {
641
+ nodes: Array.from(nodes.values()),
642
+ edges,
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Calculate trust score for a pubkey based on graph.
648
+ * Implements weighted transitive trust (web of trust).
649
+ */
650
+ export function calculateTrustScore(graph, targetPubkey, ownPubkeys = []) {
651
+ let score = 0;
652
+
653
+ // Direct trust from own keys
654
+ const directEdges = graph.edges.filter(
655
+ e => ownPubkeys.includes(e.from) && e.to === targetPubkey && !e.revoked
656
+ );
657
+
658
+ for (const edge of directEdges) {
659
+ score += edge.level * 20; // Max 100 for ULTIMATE (5 * 20)
660
+ }
661
+
662
+ // Transitive trust (2nd degree)
663
+ const secondDegreeNodes = graph.edges
664
+ .filter(e => ownPubkeys.includes(e.from) && !e.revoked && e.level >= TrustLevel.FULL)
665
+ .map(e => e.to);
666
+
667
+ for (const intermediateNode of secondDegreeNodes) {
668
+ const transitiveEdges = graph.edges.filter(
669
+ e => e.from === intermediateNode && e.to === targetPubkey && !e.revoked
670
+ );
671
+
672
+ for (const edge of transitiveEdges) {
673
+ score += edge.level * 20 * 0.5;
674
+ }
675
+ }
676
+
677
+ // Boomerang trust bonus (bidirectional)
678
+ const outgoing = graph.edges.filter(e => e.from === targetPubkey && !e.revoked);
679
+ const incoming = graph.edges.filter(e => e.to === targetPubkey && !e.revoked);
680
+
681
+ const bidirectional = outgoing.filter(out =>
682
+ incoming.some(inc => inc.from === out.to)
683
+ );
684
+
685
+ if (bidirectional.length > 0) {
686
+ score += 10;
687
+ }
688
+
689
+ return Math.min(Math.round(score), 100);
690
+ }
691
+
692
+ // =============================================================================
693
+ // Helpers
694
+ // =============================================================================
695
+
696
+ function truncatePubkey(pubkey, prefixLen = 8, suffixLen = 6) {
697
+ if (pubkey.length <= prefixLen + suffixLen + 3) return pubkey;
698
+ return `${pubkey.slice(0, prefixLen)}...${pubkey.slice(-suffixLen)}`;
699
+ }