signet-protocol 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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/anomaly.d.ts +42 -0
  4. package/dist/anomaly.d.ts.map +1 -0
  5. package/dist/anomaly.js +209 -0
  6. package/dist/anomaly.js.map +1 -0
  7. package/dist/badge.d.ts +56 -0
  8. package/dist/badge.d.ts.map +1 -0
  9. package/dist/badge.js +171 -0
  10. package/dist/badge.js.map +1 -0
  11. package/dist/bonds.d.ts +39 -0
  12. package/dist/bonds.d.ts.map +1 -0
  13. package/dist/bonds.js +149 -0
  14. package/dist/bonds.js.map +1 -0
  15. package/dist/challenges.d.ts +18 -0
  16. package/dist/challenges.d.ts.map +1 -0
  17. package/dist/challenges.js +145 -0
  18. package/dist/challenges.js.map +1 -0
  19. package/dist/cold-call.d.ts +74 -0
  20. package/dist/cold-call.d.ts.map +1 -0
  21. package/dist/cold-call.js +176 -0
  22. package/dist/cold-call.js.map +1 -0
  23. package/dist/compliance.d.ts +82 -0
  24. package/dist/compliance.d.ts.map +1 -0
  25. package/dist/compliance.js +478 -0
  26. package/dist/compliance.js.map +1 -0
  27. package/dist/connections.d.ts +63 -0
  28. package/dist/connections.d.ts.map +1 -0
  29. package/dist/connections.js +170 -0
  30. package/dist/connections.js.map +1 -0
  31. package/dist/constants.d.ts +86 -0
  32. package/dist/constants.d.ts.map +1 -0
  33. package/dist/constants.js +124 -0
  34. package/dist/constants.js.map +1 -0
  35. package/dist/credentials.d.ts +190 -0
  36. package/dist/credentials.d.ts.map +1 -0
  37. package/dist/credentials.js +686 -0
  38. package/dist/credentials.js.map +1 -0
  39. package/dist/crypto.d.ts +27 -0
  40. package/dist/crypto.d.ts.map +1 -0
  41. package/dist/crypto.js +75 -0
  42. package/dist/crypto.js.map +1 -0
  43. package/dist/errors.d.ts +17 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +29 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/i18n.d.ts +98 -0
  48. package/dist/i18n.d.ts.map +1 -0
  49. package/dist/i18n.js +1118 -0
  50. package/dist/i18n.js.map +1 -0
  51. package/dist/identity-bridge.d.ts +52 -0
  52. package/dist/identity-bridge.d.ts.map +1 -0
  53. package/dist/identity-bridge.js +228 -0
  54. package/dist/identity-bridge.js.map +1 -0
  55. package/dist/identity-tree.d.ts +47 -0
  56. package/dist/identity-tree.d.ts.map +1 -0
  57. package/dist/identity-tree.js +69 -0
  58. package/dist/identity-tree.js.map +1 -0
  59. package/dist/index.d.ts +55 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +86 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/key-derivation.d.ts +43 -0
  64. package/dist/key-derivation.d.ts.map +1 -0
  65. package/dist/key-derivation.js +212 -0
  66. package/dist/key-derivation.js.map +1 -0
  67. package/dist/lsag.d.ts +23 -0
  68. package/dist/lsag.d.ts.map +1 -0
  69. package/dist/lsag.js +35 -0
  70. package/dist/lsag.js.map +1 -0
  71. package/dist/merkle.d.ts +19 -0
  72. package/dist/merkle.d.ts.map +1 -0
  73. package/dist/merkle.js +155 -0
  74. package/dist/merkle.js.map +1 -0
  75. package/dist/policies.d.ts +22 -0
  76. package/dist/policies.d.ts.map +1 -0
  77. package/dist/policies.js +123 -0
  78. package/dist/policies.js.map +1 -0
  79. package/dist/range-proof.d.ts +6 -0
  80. package/dist/range-proof.d.ts.map +1 -0
  81. package/dist/range-proof.js +45 -0
  82. package/dist/range-proof.js.map +1 -0
  83. package/dist/relay.d.ts +106 -0
  84. package/dist/relay.d.ts.map +1 -0
  85. package/dist/relay.js +336 -0
  86. package/dist/relay.js.map +1 -0
  87. package/dist/ring-signature.d.ts +35 -0
  88. package/dist/ring-signature.d.ts.map +1 -0
  89. package/dist/ring-signature.js +56 -0
  90. package/dist/ring-signature.js.map +1 -0
  91. package/dist/shamir.d.ts +55 -0
  92. package/dist/shamir.d.ts.map +1 -0
  93. package/dist/shamir.js +253 -0
  94. package/dist/shamir.js.map +1 -0
  95. package/dist/signet-words.d.ts +42 -0
  96. package/dist/signet-words.d.ts.map +1 -0
  97. package/dist/signet-words.js +82 -0
  98. package/dist/signet-words.js.map +1 -0
  99. package/dist/store.d.ts +65 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +290 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/trust-score.d.ts +9 -0
  104. package/dist/trust-score.d.ts.map +1 -0
  105. package/dist/trust-score.js +186 -0
  106. package/dist/trust-score.js.map +1 -0
  107. package/dist/types.d.ts +358 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +15 -0
  110. package/dist/types.js.map +1 -0
  111. package/dist/utils.d.ts +11 -0
  112. package/dist/utils.d.ts.map +1 -0
  113. package/dist/utils.js +21 -0
  114. package/dist/utils.js.map +1 -0
  115. package/dist/validation.d.ts +33 -0
  116. package/dist/validation.d.ts.map +1 -0
  117. package/dist/validation.js +312 -0
  118. package/dist/validation.js.map +1 -0
  119. package/dist/verifiers.d.ts +18 -0
  120. package/dist/verifiers.d.ts.map +1 -0
  121. package/dist/verifiers.js +118 -0
  122. package/dist/verifiers.js.map +1 -0
  123. package/dist/vouches.d.ts +14 -0
  124. package/dist/vouches.d.ts.map +1 -0
  125. package/dist/vouches.js +103 -0
  126. package/dist/vouches.js.map +1 -0
  127. package/package.json +76 -0
  128. package/src/anomaly.ts +307 -0
  129. package/src/badge.ts +208 -0
  130. package/src/bonds.ts +203 -0
  131. package/src/challenges.ts +187 -0
  132. package/src/cold-call.ts +238 -0
  133. package/src/compliance.ts +612 -0
  134. package/src/connections.ts +216 -0
  135. package/src/constants.ts +146 -0
  136. package/src/credentials.ts +908 -0
  137. package/src/crypto.ts +85 -0
  138. package/src/errors.ts +31 -0
  139. package/src/i18n.ts +1347 -0
  140. package/src/identity-bridge.ts +262 -0
  141. package/src/identity-tree.ts +90 -0
  142. package/src/index.ts +452 -0
  143. package/src/lsag.ts +53 -0
  144. package/src/merkle.ts +176 -0
  145. package/src/policies.ts +154 -0
  146. package/src/range-proof.ts +66 -0
  147. package/src/relay.ts +433 -0
  148. package/src/ring-signature.ts +76 -0
  149. package/src/signet-words.ts +122 -0
  150. package/src/store.ts +336 -0
  151. package/src/trust-score.ts +208 -0
  152. package/src/types.ts +482 -0
  153. package/src/utils.ts +20 -0
  154. package/src/validation.ts +391 -0
  155. package/src/verifiers.ts +156 -0
  156. package/src/vouches.ts +141 -0
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "signet-protocol",
3
+ "version": "0.1.0",
4
+ "description": "Decentralised identity verification protocol for Nostr",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "typecheck": "tsc --noEmit",
23
+ "clean": "rm -rf dist",
24
+ "prepare": "npm run build"
25
+ },
26
+ "keywords": [
27
+ "nostr",
28
+ "identity",
29
+ "verification",
30
+ "zkp",
31
+ "signet",
32
+ "web-of-trust",
33
+ "age-verification",
34
+ "zero-knowledge-proofs",
35
+ "child-safety",
36
+ "privacy",
37
+ "decentralised-identity",
38
+ "ring-signatures",
39
+ "pedersen-range-proofs",
40
+ "secp256k1"
41
+ ],
42
+ "sideEffects": false,
43
+ "license": "MIT",
44
+ "engines": {
45
+ "node": ">=22"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/forgesworn/signet-protocol.git"
50
+ },
51
+ "dependencies": {
52
+ "@forgesworn/range-proof": "^2.0.0",
53
+ "@forgesworn/ring-sig": "^3.0.0",
54
+ "@forgesworn/shamir-words": "^1.0.0",
55
+ "@noble/curves": "^2.0.1",
56
+ "@noble/hashes": "^2.0.1",
57
+ "@scure/bip39": "^2.0.1",
58
+ "jurisdiction-kit": "^1.0.0",
59
+ "nostr-attestations": "^2.3.1",
60
+ "nsec-tree": "^1.4.1",
61
+ "spoken-token": "^2.0.3"
62
+ },
63
+ "devDependencies": {
64
+ "@semantic-release/changelog": "^6.0.3",
65
+ "@semantic-release/git": "^10.0.1",
66
+ "@types/node": "^25.3.3",
67
+ "semantic-release": "^25.0.3",
68
+ "tsx": "^4.21.0",
69
+ "typescript": "^5.7.0",
70
+ "vitest": "^3.0.0"
71
+ },
72
+ "funding": {
73
+ "type": "lightning",
74
+ "url": "lightning:thedonkey@strike.me"
75
+ }
76
+ }
package/src/anomaly.ts ADDED
@@ -0,0 +1,307 @@
1
+ // Verifier Anomaly Detection
2
+ // Statistical analysis of verifier issuance patterns
3
+ // Layer 3 of the anti-corruption framework
4
+
5
+ import { ATTESTATION_KIND, ATTESTATION_TYPES } from './constants.js';
6
+ import { getTagValue } from './validation.js';
7
+ import type { NostrEvent } from './types.js';
8
+
9
+ /** Types of anomaly flags */
10
+ export type AnomalyType = 'volume' | 'temporal' | 'geographic' | 'pattern';
11
+
12
+ /** A detected anomaly */
13
+ export interface AnomalyFlag {
14
+ type: AnomalyType;
15
+ verifierPubkey: string;
16
+ severity: 'low' | 'medium' | 'high';
17
+ description: string;
18
+ evidence: {
19
+ metric: string;
20
+ value: number;
21
+ threshold: number;
22
+ };
23
+ }
24
+
25
+ /** Anomaly detection configuration */
26
+ export interface AnomalyConfig {
27
+ /** Max credentials per week before flagging (default: 20) */
28
+ maxWeeklyVolume: number;
29
+ /** Max credentials per hour before flagging (default: 5) */
30
+ maxHourlyVolume: number;
31
+ /** Max percentage of credentials in a single foreign jurisdiction (default: 30) */
32
+ maxForeignJurisdictionPercent: number;
33
+ /** Min time between consecutive credentials in seconds (default: 300 = 5 min) */
34
+ minTimeBetweenCredentials: number;
35
+ /** Volume multiplier vs network average to flag (default: 5) */
36
+ volumeMultiplierThreshold: number;
37
+ }
38
+
39
+ const DEFAULT_CONFIG: AnomalyConfig = {
40
+ maxWeeklyVolume: 20,
41
+ maxHourlyVolume: 5,
42
+ maxForeignJurisdictionPercent: 30,
43
+ minTimeBetweenCredentials: 300,
44
+ volumeMultiplierThreshold: 5,
45
+ };
46
+
47
+ /**
48
+ * Analyze a verifier's issuance patterns for anomalies.
49
+ *
50
+ * @param verifierPubkey - The verifier to analyze
51
+ * @param allCredentials - All credential events (kind 31000, type: credential) in the store
52
+ * @param verifierCredential - The verifier's own credential (kind 31000, type: verifier)
53
+ * @param config - Detection thresholds
54
+ */
55
+ export function detectAnomalies(
56
+ verifierPubkey: string,
57
+ allCredentials: NostrEvent[],
58
+ verifierCredential?: NostrEvent,
59
+ config: Partial<AnomalyConfig> = {}
60
+ ): AnomalyFlag[] {
61
+ const cfg = { ...DEFAULT_CONFIG, ...config };
62
+ const flags: AnomalyFlag[] = [];
63
+
64
+ // Get this verifier's issued credentials
65
+ const issued = allCredentials.filter(
66
+ (e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL && e.pubkey === verifierPubkey
67
+ );
68
+
69
+ if (issued.length === 0) return flags;
70
+
71
+ // Sort by time
72
+ const sorted = [...issued].sort((a, b) => a.created_at - b.created_at);
73
+
74
+ // --- Volume Analysis ---
75
+ flags.push(...analyzeVolume(verifierPubkey, sorted, allCredentials, cfg));
76
+
77
+ // --- Temporal Analysis ---
78
+ flags.push(...analyzeTemporal(verifierPubkey, sorted, cfg));
79
+
80
+ // --- Geographic Analysis ---
81
+ if (verifierCredential) {
82
+ flags.push(...analyzeGeographic(verifierPubkey, sorted, verifierCredential, cfg));
83
+ }
84
+
85
+ // --- Pattern Analysis ---
86
+ flags.push(...analyzePatterns(verifierPubkey, sorted));
87
+
88
+ return flags;
89
+ }
90
+
91
+ /** Volume anomaly detection */
92
+ function analyzeVolume(
93
+ verifierPubkey: string,
94
+ issued: NostrEvent[],
95
+ allCredentials: NostrEvent[],
96
+ cfg: AnomalyConfig
97
+ ): AnomalyFlag[] {
98
+ const flags: AnomalyFlag[] = [];
99
+ const now = Math.floor(Date.now() / 1000);
100
+ const oneWeek = 7 * 24 * 60 * 60;
101
+ const oneHour = 60 * 60;
102
+
103
+ // Weekly volume
104
+ const weeklyCount = issued.filter((e) => e.created_at > now - oneWeek).length;
105
+ if (weeklyCount > cfg.maxWeeklyVolume) {
106
+ flags.push({
107
+ type: 'volume',
108
+ verifierPubkey,
109
+ severity: weeklyCount > cfg.maxWeeklyVolume * 3 ? 'high' : 'medium',
110
+ description: `Issued ${weeklyCount} credentials this week (threshold: ${cfg.maxWeeklyVolume})`,
111
+ evidence: { metric: 'weekly_volume', value: weeklyCount, threshold: cfg.maxWeeklyVolume },
112
+ });
113
+ }
114
+
115
+ // Hourly volume (burst detection)
116
+ const hourlyCount = issued.filter((e) => e.created_at > now - oneHour).length;
117
+ if (hourlyCount > cfg.maxHourlyVolume) {
118
+ flags.push({
119
+ type: 'volume',
120
+ verifierPubkey,
121
+ severity: 'high',
122
+ description: `Issued ${hourlyCount} credentials in the last hour (threshold: ${cfg.maxHourlyVolume})`,
123
+ evidence: { metric: 'hourly_volume', value: hourlyCount, threshold: cfg.maxHourlyVolume },
124
+ });
125
+ }
126
+
127
+ // Compare to network average
128
+ const allVerifiers = new Set(
129
+ allCredentials.filter((e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL).map((e) => e.pubkey)
130
+ );
131
+ if (allVerifiers.size > 1) {
132
+ const totalCredentials = allCredentials.filter(
133
+ (e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL && e.created_at > now - oneWeek
134
+ ).length;
135
+ const networkAvg = totalCredentials / allVerifiers.size;
136
+
137
+ if (networkAvg > 0 && weeklyCount > networkAvg * cfg.volumeMultiplierThreshold) {
138
+ flags.push({
139
+ type: 'volume',
140
+ verifierPubkey,
141
+ severity: 'high',
142
+ description: `Weekly volume ${weeklyCount} is ${(weeklyCount / networkAvg).toFixed(1)}x the network average of ${networkAvg.toFixed(1)}`,
143
+ evidence: {
144
+ metric: 'volume_vs_average',
145
+ value: weeklyCount / networkAvg,
146
+ threshold: cfg.volumeMultiplierThreshold,
147
+ },
148
+ });
149
+ }
150
+ }
151
+
152
+ return flags;
153
+ }
154
+
155
+ /** Temporal anomaly detection */
156
+ function analyzeTemporal(
157
+ verifierPubkey: string,
158
+ sorted: NostrEvent[],
159
+ cfg: AnomalyConfig
160
+ ): AnomalyFlag[] {
161
+ const flags: AnomalyFlag[] = [];
162
+
163
+ if (sorted.length < 2) return flags;
164
+
165
+ // Check minimum time between consecutive credentials
166
+ let rapidCount = 0;
167
+ let minGap = Infinity;
168
+
169
+ for (let i = 1; i < sorted.length; i++) {
170
+ const gap = sorted[i].created_at - sorted[i - 1].created_at;
171
+ if (gap < cfg.minTimeBetweenCredentials) {
172
+ rapidCount++;
173
+ }
174
+ if (gap < minGap) minGap = gap;
175
+ }
176
+
177
+ if (rapidCount > 0) {
178
+ flags.push({
179
+ type: 'temporal',
180
+ verifierPubkey,
181
+ severity: rapidCount > 5 ? 'high' : 'medium',
182
+ description: `${rapidCount} credential pairs issued less than ${cfg.minTimeBetweenCredentials}s apart (min gap: ${minGap}s). Suggests rubber-stamping, not in-person verification.`,
183
+ evidence: { metric: 'rapid_issuance_count', value: rapidCount, threshold: 0 },
184
+ });
185
+ }
186
+
187
+ return flags;
188
+ }
189
+
190
+ /** Geographic anomaly detection */
191
+ function analyzeGeographic(
192
+ verifierPubkey: string,
193
+ issued: NostrEvent[],
194
+ verifierCredential: NostrEvent,
195
+ cfg: AnomalyConfig
196
+ ): AnomalyFlag[] {
197
+ const flags: AnomalyFlag[] = [];
198
+
199
+ const verifierJurisdiction = getTagValue(verifierCredential, 'jurisdiction');
200
+ if (!verifierJurisdiction) return flags;
201
+
202
+ // Count jurisdictions in issued credentials
203
+ const jurisdictionCounts = new Map<string, number>();
204
+ for (const cred of issued) {
205
+ const j = getTagValue(cred, 'jurisdiction') || 'unknown';
206
+ jurisdictionCounts.set(j, (jurisdictionCounts.get(j) || 0) + 1);
207
+ }
208
+
209
+ // Check for high foreign jurisdiction percentage
210
+ for (const [jurisdiction, count] of jurisdictionCounts) {
211
+ if (jurisdiction === verifierJurisdiction) continue;
212
+
213
+ const percent = (count / issued.length) * 100;
214
+ if (percent > cfg.maxForeignJurisdictionPercent) {
215
+ flags.push({
216
+ type: 'geographic',
217
+ verifierPubkey,
218
+ severity: percent > 60 ? 'high' : 'medium',
219
+ description: `${percent.toFixed(0)}% of credentials (${count}/${issued.length}) issued in ${jurisdiction}, but verifier is registered in ${verifierJurisdiction}`,
220
+ evidence: {
221
+ metric: 'foreign_jurisdiction_percent',
222
+ value: percent,
223
+ threshold: cfg.maxForeignJurisdictionPercent,
224
+ },
225
+ });
226
+ }
227
+ }
228
+
229
+ return flags;
230
+ }
231
+
232
+ /** Pattern anomaly detection */
233
+ function analyzePatterns(
234
+ verifierPubkey: string,
235
+ sorted: NostrEvent[]
236
+ ): AnomalyFlag[] {
237
+ const flags: AnomalyFlag[] = [];
238
+
239
+ if (sorted.length < 5) return flags;
240
+
241
+ // Check for repeated subjects (same person verified multiple times)
242
+ const subjectCounts = new Map<string, number>();
243
+ for (const cred of sorted) {
244
+ const subject = getTagValue(cred, 'd') || '';
245
+ subjectCounts.set(subject, (subjectCounts.get(subject) || 0) + 1);
246
+ }
247
+
248
+ const duplicates = Array.from(subjectCounts.entries()).filter(([, count]) => count > 1);
249
+ if (duplicates.length > 0) {
250
+ const totalDupes = duplicates.reduce((sum, [, count]) => sum + count - 1, 0);
251
+ flags.push({
252
+ type: 'pattern',
253
+ verifierPubkey,
254
+ severity: 'low',
255
+ description: `${duplicates.length} subjects verified multiple times (${totalDupes} extra verifications)`,
256
+ evidence: { metric: 'duplicate_subjects', value: duplicates.length, threshold: 0 },
257
+ });
258
+ }
259
+
260
+ // Check for disproportionate tier 4 issuance
261
+ let tier4Count = 0;
262
+ for (const cred of sorted) {
263
+ if (getTagValue(cred, 'tier') === '4') tier4Count++;
264
+ }
265
+
266
+ const tier4Percent = (tier4Count / sorted.length) * 100;
267
+ if (tier4Count > 3 && tier4Percent > 80) {
268
+ flags.push({
269
+ type: 'pattern',
270
+ verifierPubkey,
271
+ severity: 'medium',
272
+ description: `${tier4Percent.toFixed(0)}% of credentials are Tier 4 (${tier4Count}/${sorted.length}). Unusually high child verification rate.`,
273
+ evidence: { metric: 'tier4_percent', value: tier4Percent, threshold: 80 },
274
+ });
275
+ }
276
+
277
+ return flags;
278
+ }
279
+
280
+ /**
281
+ * Get a summary of all flagged verifiers from a set of credentials.
282
+ */
283
+ export function scanForAnomalies(
284
+ allCredentials: NostrEvent[],
285
+ verifierCredentials: NostrEvent[],
286
+ config?: Partial<AnomalyConfig>
287
+ ): Map<string, AnomalyFlag[]> {
288
+ const results = new Map<string, AnomalyFlag[]>();
289
+
290
+ // Get unique verifier pubkeys from credentials
291
+ const verifiers = new Set(
292
+ allCredentials
293
+ .filter((e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL)
294
+ .map((e) => e.pubkey)
295
+ );
296
+
297
+ for (const pubkey of verifiers) {
298
+ const verifierCred = verifierCredentials.find((c) => c.pubkey === pubkey);
299
+ const flags = detectAnomalies(pubkey, allCredentials, verifierCred, config);
300
+
301
+ if (flags.length > 0) {
302
+ results.set(pubkey, flags);
303
+ }
304
+ }
305
+
306
+ return results;
307
+ }
package/src/badge.ts ADDED
@@ -0,0 +1,208 @@
1
+ // Level 1 Badge Display Library
2
+ // Minimal drop-in module for Nostr clients to display Signet trust badges.
3
+ // Requires only kind 31000 (type: credential and type: vouch) and Schnorr verification.
4
+
5
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, TRUST_WEIGHTS, MAX_TRUST_SCORE } from './constants.js';
6
+ import { verifyEvent } from './crypto.js';
7
+ import { getTagValue } from './validation.js';
8
+ import type { NostrEvent, SignetTier } from './types.js';
9
+
10
+ // --- Types ---
11
+
12
+ export interface BadgeInfo {
13
+ /** The pubkey this badge describes */
14
+ pubkey: string;
15
+ /** Highest verification tier (1-4) */
16
+ tier: SignetTier;
17
+ /** Human-readable tier label */
18
+ tierLabel: string;
19
+ /** Signet Score (0-200) */
20
+ score: number;
21
+ /** Whether the user has any valid credentials */
22
+ isVerified: boolean;
23
+ /** Short display string, e.g. "Verified (Tier 3)" */
24
+ displayLabel: string;
25
+ /** Number of valid credentials */
26
+ credentialCount: number;
27
+ /** Number of valid vouches */
28
+ vouchCount: number;
29
+ }
30
+
31
+ export type TrustLevel = 'unverified' | 'self-declared' | 'vouched' | 'professional' | 'professional-child';
32
+
33
+ const TIER_LABELS: Record<SignetTier, string> = {
34
+ 1: 'Self-declared',
35
+ 2: 'Web-of-trust',
36
+ 3: 'Verified',
37
+ 4: 'Verified (Child Safety)',
38
+ };
39
+
40
+ const TIER_TO_TRUST_LEVEL: Record<SignetTier, TrustLevel> = {
41
+ 1: 'self-declared',
42
+ 2: 'vouched',
43
+ 3: 'professional',
44
+ 4: 'professional-child',
45
+ };
46
+
47
+ // --- Core Functions ---
48
+
49
+ /**
50
+ * Compute badge info for a pubkey from their credentials and vouches.
51
+ * This is the main entry point for Level 1 integration.
52
+ *
53
+ * @param pubkey - The Nostr pubkey to compute a badge for
54
+ * @param events - All relevant kind 31000 (`type: credential` and `type: vouch`) events
55
+ * @param options - Optional configuration
56
+ * @returns Badge info for display
57
+ */
58
+ export async function computeBadge(
59
+ pubkey: string,
60
+ events: NostrEvent[],
61
+ options?: { verifySignatures?: boolean; now?: number }
62
+ ): Promise<BadgeInfo> {
63
+ const now = options?.now ?? Math.floor(Date.now() / 1000);
64
+ const verify = options?.verifySignatures ?? false;
65
+
66
+ let highestTier: SignetTier = 1;
67
+ let rawScore = 0;
68
+ let credentialCount = 0;
69
+ let hasAnyCredential = false;
70
+
71
+ // Process credentials
72
+ for (const event of events) {
73
+ if (event.kind !== ATTESTATION_KIND) continue;
74
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.CREDENTIAL) continue;
75
+ const dTag = getTagValue(event, 'd') || '';
76
+ const pTag = getTagValue(event, 'p');
77
+ let subject: string;
78
+ if (dTag.startsWith('assertion:') && pTag) {
79
+ subject = pTag;
80
+ } else {
81
+ subject = dTag.startsWith('credential:') ? dTag.slice('credential:'.length) : dTag;
82
+ }
83
+ if (subject !== pubkey) continue;
84
+
85
+ // Check expiry — NaN must be treated as expired (not perpetually valid)
86
+ const expires = getTagValue(event, 'expiration');
87
+ if (expires) {
88
+ const exp = parseInt(expires, 10);
89
+ if (isNaN(exp) || exp < now) continue;
90
+ }
91
+
92
+ // Optional signature verification
93
+ if (verify && !await verifyEvent(event)) continue;
94
+
95
+ hasAnyCredential = true;
96
+ credentialCount++;
97
+
98
+ const rawTier = parseInt(getTagValue(event, 'tier') || '1', 10);
99
+ const tier = (rawTier >= 1 && rawTier <= 4 ? rawTier : 1) as SignetTier;
100
+ if (tier > highestTier) highestTier = tier;
101
+
102
+ const verificationType = getTagValue(event, 'verification-type');
103
+ if (verificationType === 'professional') {
104
+ rawScore += TRUST_WEIGHTS.PROFESSIONAL_VERIFICATION;
105
+ }
106
+ }
107
+
108
+ // Process vouches
109
+ const vouchersSeen = new Set<string>();
110
+ let vouchCount = 0;
111
+
112
+ for (const event of events) {
113
+ if (event.kind !== ATTESTATION_KIND) continue;
114
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.VOUCH) continue;
115
+ const dTag = getTagValue(event, 'd') || '';
116
+ const subject = dTag.startsWith('vouch:') ? dTag.slice('vouch:'.length) : dTag;
117
+ if (subject !== pubkey) continue;
118
+
119
+ // One vouch per voucher
120
+ if (vouchersSeen.has(event.pubkey)) continue;
121
+ vouchersSeen.add(event.pubkey);
122
+
123
+ if (verify && !await verifyEvent(event)) continue;
124
+
125
+ vouchCount++;
126
+
127
+ const method = getTagValue(event, 'method');
128
+ const rawVoucherScore = parseInt(getTagValue(event, 'voucher-score') || '50', 10);
129
+ const voucherScore = isNaN(rawVoucherScore) ? 50 : Math.max(0, Math.min(rawVoucherScore, MAX_TRUST_SCORE));
130
+ const multiplier = voucherScore / MAX_TRUST_SCORE;
131
+
132
+ if (method === 'in-person') {
133
+ rawScore += TRUST_WEIGHTS.IN_PERSON_VOUCH * multiplier;
134
+ } else {
135
+ rawScore += TRUST_WEIGHTS.ONLINE_VOUCH * multiplier;
136
+ }
137
+ }
138
+
139
+ const score = Math.min(Math.round(rawScore), MAX_TRUST_SCORE);
140
+ const tierLabel = TIER_LABELS[highestTier];
141
+
142
+ let displayLabel: string;
143
+ if (!hasAnyCredential && vouchCount === 0) {
144
+ displayLabel = 'Unverified';
145
+ } else {
146
+ displayLabel = `${tierLabel} (Tier ${highestTier})`;
147
+ }
148
+
149
+ return {
150
+ pubkey,
151
+ tier: highestTier,
152
+ tierLabel,
153
+ score,
154
+ isVerified: hasAnyCredential,
155
+ displayLabel,
156
+ credentialCount,
157
+ vouchCount,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Get the trust level classification for a badge.
163
+ */
164
+ export function getTrustLevel(badge: BadgeInfo): TrustLevel {
165
+ if (!badge.isVerified && badge.vouchCount === 0) return 'unverified';
166
+ return TIER_TO_TRUST_LEVEL[badge.tier];
167
+ }
168
+
169
+ /**
170
+ * Check if a pubkey has at least the required tier.
171
+ * Useful for gating access to features or communities.
172
+ */
173
+ export function meetsMinimumTier(badge: BadgeInfo, minTier: SignetTier): boolean {
174
+ return badge.isVerified && badge.tier >= minTier;
175
+ }
176
+
177
+ /**
178
+ * Filter a set of events to only those relevant for a given pubkey.
179
+ * Useful when you have a mixed bag of events from a relay query.
180
+ */
181
+ export function filterEventsForPubkey(pubkey: string, events: NostrEvent[]): NostrEvent[] {
182
+ return events.filter(event => {
183
+ if (event.kind !== ATTESTATION_KIND) return false;
184
+ const eventType = getTagValue(event, 'type');
185
+ if (eventType !== ATTESTATION_TYPES.CREDENTIAL && eventType !== ATTESTATION_TYPES.VOUCH) {
186
+ return false;
187
+ }
188
+ const dTag = getTagValue(event, 'd') || '';
189
+ // Strip type prefix from d-tag to get subject
190
+ const subject = dTag.includes(':') ? dTag.slice(dTag.indexOf(':') + 1) : dTag;
191
+ return subject === pubkey;
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Build a Nostr filter for fetching badge-relevant events for one or more pubkeys.
197
+ * Returns filters suitable for use with REQ messages.
198
+ */
199
+ export function buildBadgeFilters(pubkeys: string[]): Array<{ kinds: number[]; '#d': string[] }> {
200
+ // With the generic attestation kind, d-tags are now prefixed with the type
201
+ const dTags = pubkeys.flatMap(pk => [`credential:${pk}`, `vouch:${pk}`]);
202
+ return [
203
+ {
204
+ kinds: [ATTESTATION_KIND],
205
+ '#d': dTags,
206
+ },
207
+ ];
208
+ }