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