consensus.go raw
1 package find
2
3 import (
4 "fmt"
5 "time"
6
7 "next.orly.dev/pkg/lol/chk"
8 "next.orly.dev/pkg/lol/errorf"
9 "next.orly.dev/pkg/database"
10 "next.orly.dev/pkg/nostr/encoders/hex"
11 )
12
13 // ConsensusEngine handles the consensus algorithm for name registrations
14 type ConsensusEngine struct {
15 db database.Database
16 trustGraph *TrustGraph
17 threshold float64 // Consensus threshold (e.g., 0.51 for 51%)
18 minCoverage float64 // Minimum trust graph coverage required
19 conflictMargin float64 // Margin for declaring conflicts (e.g., 0.05 for 5%)
20 }
21
22 // NewConsensusEngine creates a new consensus engine
23 func NewConsensusEngine(db database.Database, trustGraph *TrustGraph) *ConsensusEngine {
24 return &ConsensusEngine{
25 db: db,
26 trustGraph: trustGraph,
27 threshold: 0.51, // 51% threshold
28 minCoverage: 0.30, // 30% minimum coverage
29 conflictMargin: 0.05, // 5% conflict margin
30 }
31 }
32
33 // ProposalScore holds scoring information for a proposal
34 type ProposalScore struct {
35 Proposal *RegistrationProposal
36 Score float64
37 Attestations []*Attestation
38 Weights map[string]float64 // Attester pubkey -> weighted score
39 }
40
41 // ConsensusResult represents the result of consensus computation
42 type ConsensusResult struct {
43 Winner *RegistrationProposal
44 Score float64
45 Confidence float64 // 0.0 to 1.0
46 Attestations int
47 Conflicted bool
48 Reason string
49 }
50
51 // ComputeConsensus computes consensus for a set of competing proposals
52 func (ce *ConsensusEngine) ComputeConsensus(proposals []*RegistrationProposal, attestations []*Attestation) (*ConsensusResult, error) {
53 if len(proposals) == 0 {
54 return nil, errorf.E("no proposals to evaluate")
55 }
56
57 // Group attestations by proposal ID
58 attestationMap := make(map[string][]*Attestation)
59 for _, att := range attestations {
60 if att.Decision == DecisionApprove {
61 attestationMap[att.ProposalID] = append(attestationMap[att.ProposalID], att)
62 }
63 }
64
65 // Score each proposal
66 scores := make([]*ProposalScore, 0, len(proposals))
67 totalWeight := 0.0
68
69 for _, proposal := range proposals {
70 proposalAtts := attestationMap[hex.Enc(proposal.Event.ID)]
71 score, weights := ce.ScoreProposal(proposal, proposalAtts)
72
73 scores = append(scores, &ProposalScore{
74 Proposal: proposal,
75 Score: score,
76 Attestations: proposalAtts,
77 Weights: weights,
78 })
79
80 totalWeight += score
81 }
82
83 // Check if we have sufficient coverage
84 if totalWeight < ce.minCoverage {
85 return &ConsensusResult{
86 Conflicted: true,
87 Reason: fmt.Sprintf("insufficient attestations: %.2f%% < %.2f%%", totalWeight*100, ce.minCoverage*100),
88 }, nil
89 }
90
91 // Find highest scoring proposal
92 var winner *ProposalScore
93 for _, ps := range scores {
94 if winner == nil || ps.Score > winner.Score {
95 winner = ps
96 }
97 }
98
99 // Calculate relative score
100 relativeScore := winner.Score / totalWeight
101
102 // Check for conflicts (multiple proposals within margin)
103 conflicted := false
104 for _, ps := range scores {
105 if hex.Enc(ps.Proposal.Event.ID) != hex.Enc(winner.Proposal.Event.ID) {
106 otherRelative := ps.Score / totalWeight
107 if (relativeScore - otherRelative) < ce.conflictMargin {
108 conflicted = true
109 break
110 }
111 }
112 }
113
114 // Check if winner meets threshold
115 if relativeScore < ce.threshold {
116 return &ConsensusResult{
117 Winner: winner.Proposal,
118 Score: winner.Score,
119 Confidence: relativeScore,
120 Attestations: len(winner.Attestations),
121 Conflicted: true,
122 Reason: fmt.Sprintf("score %.2f%% below threshold %.2f%%", relativeScore*100, ce.threshold*100),
123 }, nil
124 }
125
126 // Check for conflicts
127 if conflicted {
128 return &ConsensusResult{
129 Winner: winner.Proposal,
130 Score: winner.Score,
131 Confidence: relativeScore,
132 Attestations: len(winner.Attestations),
133 Conflicted: true,
134 Reason: "competing proposals within conflict margin",
135 }, nil
136 }
137
138 // Success!
139 return &ConsensusResult{
140 Winner: winner.Proposal,
141 Score: winner.Score,
142 Confidence: relativeScore,
143 Attestations: len(winner.Attestations),
144 Conflicted: false,
145 Reason: "consensus reached",
146 }, nil
147 }
148
149 // ScoreProposal computes the trust-weighted score for a proposal
150 func (ce *ConsensusEngine) ScoreProposal(proposal *RegistrationProposal, attestations []*Attestation) (float64, map[string]float64) {
151 totalScore := 0.0
152 weights := make(map[string]float64)
153
154 for _, att := range attestations {
155 if att.Decision != DecisionApprove {
156 continue
157 }
158
159 // Get attestation weight (default 100)
160 attWeight := float64(att.Weight)
161 if attWeight <= 0 {
162 attWeight = 100
163 }
164
165 // Get trust level for this attester
166 trustLevel := ce.trustGraph.GetTrustLevel(att.Event.Pubkey)
167
168 // Calculate weighted score
169 // Score = attestation_weight * trust_level / 100
170 score := (attWeight / 100.0) * trustLevel
171
172 weights[hex.Enc(att.Event.Pubkey)] = score
173 totalScore += score
174 }
175
176 return totalScore, weights
177 }
178
179 // ValidateProposal validates a registration proposal against current state
180 func (ce *ConsensusEngine) ValidateProposal(proposal *RegistrationProposal) error {
181 // Validate name format
182 if err := ValidateName(proposal.Name); err != nil {
183 return errorf.E("invalid name format: %w", err)
184 }
185
186 // Check if proposal is expired
187 if !proposal.Expiration.IsZero() && time.Now().After(proposal.Expiration) {
188 return errorf.E("proposal expired at %v", proposal.Expiration)
189 }
190
191 // Validate subdomain authority (if applicable)
192 if !IsTLD(proposal.Name) {
193 parent := GetParentDomain(proposal.Name)
194 if parent == "" {
195 return errorf.E("invalid subdomain structure")
196 }
197
198 // Query parent domain ownership
199 parentState, err := ce.QueryNameState(parent)
200 if err != nil {
201 return errorf.E("failed to query parent domain: %w", err)
202 }
203
204 if parentState == nil {
205 return errorf.E("parent domain %s not registered", parent)
206 }
207
208 // Verify proposer owns parent domain
209 proposerPubkey := hex.Enc(proposal.Event.Pubkey)
210 if parentState.Owner != proposerPubkey {
211 return errorf.E("proposer does not own parent domain %s", parent)
212 }
213 }
214
215 // Validate against current name state
216 nameState, err := ce.QueryNameState(proposal.Name)
217 if err != nil {
218 return errorf.E("failed to query name state: %w", err)
219 }
220
221 now := time.Now()
222
223 // Name is not registered - anyone can register
224 if nameState == nil {
225 return nil
226 }
227
228 // Name is expired - anyone can register
229 if !nameState.Expiration.IsZero() && now.After(nameState.Expiration) {
230 return nil
231 }
232
233 // Calculate renewal window start (30 days before expiration)
234 renewalStart := nameState.Expiration.Add(-PreferentialRenewalDays * 24 * time.Hour)
235
236 // Before renewal window - reject all proposals
237 if now.Before(renewalStart) {
238 return errorf.E("name is currently owned and not in renewal window")
239 }
240
241 // During renewal window - only current owner can register
242 if now.Before(nameState.Expiration) {
243 proposerPubkey := hex.Enc(proposal.Event.Pubkey)
244 if proposerPubkey != nameState.Owner {
245 return errorf.E("only current owner can renew during preferential renewal window")
246 }
247 return nil
248 }
249
250 // Should not reach here, but allow registration if we do
251 return nil
252 }
253
254 // ValidateTransfer validates a transfer proposal
255 func (ce *ConsensusEngine) ValidateTransfer(proposal *RegistrationProposal) error {
256 if proposal.Action != ActionTransfer {
257 return errorf.E("not a transfer proposal")
258 }
259
260 // Must have previous owner and signature
261 if proposal.PrevOwner == "" {
262 return errorf.E("missing previous owner")
263 }
264 if proposal.PrevSig == "" {
265 return errorf.E("missing previous owner signature")
266 }
267
268 // Query current name state
269 nameState, err := ce.QueryNameState(proposal.Name)
270 if err != nil {
271 return errorf.E("failed to query name state: %w", err)
272 }
273
274 if nameState == nil {
275 return errorf.E("name not registered")
276 }
277
278 // Verify previous owner matches current owner
279 if nameState.Owner != proposal.PrevOwner {
280 return errorf.E("previous owner mismatch")
281 }
282
283 // Verify name is not expired
284 if !nameState.Expiration.IsZero() && time.Now().After(nameState.Expiration) {
285 return errorf.E("name expired")
286 }
287
288 // TODO: Verify signature over transfer message
289 // Message format: "transfer:<name>:<new_owner_pubkey>:<timestamp>"
290
291 return nil
292 }
293
294 // QueryNameState queries the current name state from the database
295 func (ce *ConsensusEngine) QueryNameState(name string) (*NameState, error) {
296 // Query kind 30102 events with d tag = name
297 filter := &struct {
298 Kinds []uint16
299 DTags []string
300 Limit int
301 }{
302 Kinds: []uint16{KindNameState},
303 DTags: []string{name},
304 Limit: 10,
305 }
306
307 // Note: This would use the actual database query method
308 // For now, return nil to indicate not found
309 // TODO: Implement actual database query
310 _ = filter
311 return nil, nil
312 }
313
314 // CreateNameState creates a name state event from consensus result
315 func (ce *ConsensusEngine) CreateNameState(result *ConsensusResult, registryPubkey []byte) (*NameState, error) {
316 if result.Winner == nil {
317 return nil, errorf.E("no winner in consensus result")
318 }
319
320 proposal := result.Winner
321
322 return &NameState{
323 Name: proposal.Name,
324 Owner: hex.Enc(proposal.Event.Pubkey),
325 RegisteredAt: time.Now(),
326 ProposalID: hex.Enc(proposal.Event.ID),
327 Attestations: result.Attestations,
328 Confidence: result.Confidence,
329 Expiration: time.Now().Add(NameRegistrationPeriod),
330 }, nil
331 }
332
333 // ProcessProposalBatch processes a batch of proposals and returns consensus results
334 func (ce *ConsensusEngine) ProcessProposalBatch(proposals []*RegistrationProposal, attestations []*Attestation) ([]*ConsensusResult, error) {
335 // Group proposals by name
336 proposalsByName := make(map[string][]*RegistrationProposal)
337 for _, proposal := range proposals {
338 proposalsByName[proposal.Name] = append(proposalsByName[proposal.Name], proposal)
339 }
340
341 results := make([]*ConsensusResult, 0)
342
343 // Process each name's proposals independently
344 for name, nameProposals := range proposalsByName {
345 // Filter attestations for this name's proposals
346 proposalIDs := make(map[string]bool)
347 for _, p := range nameProposals {
348 proposalIDs[hex.Enc(p.Event.ID)] = true
349 }
350
351 nameAttestations := make([]*Attestation, 0)
352 for _, att := range attestations {
353 if proposalIDs[att.ProposalID] {
354 nameAttestations = append(nameAttestations, att)
355 }
356 }
357
358 // Compute consensus for this name
359 result, err := ce.ComputeConsensus(nameProposals, nameAttestations)
360 if chk.E(err) {
361 // Log error but continue processing other names
362 result = &ConsensusResult{
363 Conflicted: true,
364 Reason: fmt.Sprintf("error: %v", err),
365 }
366 }
367
368 // Add name to result for tracking
369 if result.Winner != nil {
370 result.Winner.Name = name
371 }
372
373 results = append(results, result)
374 }
375
376 return results, nil
377 }
378