You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
377 lines
10 KiB
377 lines
10 KiB
package find |
|
|
|
import ( |
|
"fmt" |
|
"time" |
|
|
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/errorf" |
|
"next.orly.dev/pkg/database" |
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
) |
|
|
|
// ConsensusEngine handles the consensus algorithm for name registrations |
|
type ConsensusEngine struct { |
|
db database.Database |
|
trustGraph *TrustGraph |
|
threshold float64 // Consensus threshold (e.g., 0.51 for 51%) |
|
minCoverage float64 // Minimum trust graph coverage required |
|
conflictMargin float64 // Margin for declaring conflicts (e.g., 0.05 for 5%) |
|
} |
|
|
|
// NewConsensusEngine creates a new consensus engine |
|
func NewConsensusEngine(db database.Database, trustGraph *TrustGraph) *ConsensusEngine { |
|
return &ConsensusEngine{ |
|
db: db, |
|
trustGraph: trustGraph, |
|
threshold: 0.51, // 51% threshold |
|
minCoverage: 0.30, // 30% minimum coverage |
|
conflictMargin: 0.05, // 5% conflict margin |
|
} |
|
} |
|
|
|
// ProposalScore holds scoring information for a proposal |
|
type ProposalScore struct { |
|
Proposal *RegistrationProposal |
|
Score float64 |
|
Attestations []*Attestation |
|
Weights map[string]float64 // Attester pubkey -> weighted score |
|
} |
|
|
|
// ConsensusResult represents the result of consensus computation |
|
type ConsensusResult struct { |
|
Winner *RegistrationProposal |
|
Score float64 |
|
Confidence float64 // 0.0 to 1.0 |
|
Attestations int |
|
Conflicted bool |
|
Reason string |
|
} |
|
|
|
// ComputeConsensus computes consensus for a set of competing proposals |
|
func (ce *ConsensusEngine) ComputeConsensus(proposals []*RegistrationProposal, attestations []*Attestation) (*ConsensusResult, error) { |
|
if len(proposals) == 0 { |
|
return nil, errorf.E("no proposals to evaluate") |
|
} |
|
|
|
// Group attestations by proposal ID |
|
attestationMap := make(map[string][]*Attestation) |
|
for _, att := range attestations { |
|
if att.Decision == DecisionApprove { |
|
attestationMap[att.ProposalID] = append(attestationMap[att.ProposalID], att) |
|
} |
|
} |
|
|
|
// Score each proposal |
|
scores := make([]*ProposalScore, 0, len(proposals)) |
|
totalWeight := 0.0 |
|
|
|
for _, proposal := range proposals { |
|
proposalAtts := attestationMap[hex.Enc(proposal.Event.ID)] |
|
score, weights := ce.ScoreProposal(proposal, proposalAtts) |
|
|
|
scores = append(scores, &ProposalScore{ |
|
Proposal: proposal, |
|
Score: score, |
|
Attestations: proposalAtts, |
|
Weights: weights, |
|
}) |
|
|
|
totalWeight += score |
|
} |
|
|
|
// Check if we have sufficient coverage |
|
if totalWeight < ce.minCoverage { |
|
return &ConsensusResult{ |
|
Conflicted: true, |
|
Reason: fmt.Sprintf("insufficient attestations: %.2f%% < %.2f%%", totalWeight*100, ce.minCoverage*100), |
|
}, nil |
|
} |
|
|
|
// Find highest scoring proposal |
|
var winner *ProposalScore |
|
for _, ps := range scores { |
|
if winner == nil || ps.Score > winner.Score { |
|
winner = ps |
|
} |
|
} |
|
|
|
// Calculate relative score |
|
relativeScore := winner.Score / totalWeight |
|
|
|
// Check for conflicts (multiple proposals within margin) |
|
conflicted := false |
|
for _, ps := range scores { |
|
if hex.Enc(ps.Proposal.Event.ID) != hex.Enc(winner.Proposal.Event.ID) { |
|
otherRelative := ps.Score / totalWeight |
|
if (relativeScore - otherRelative) < ce.conflictMargin { |
|
conflicted = true |
|
break |
|
} |
|
} |
|
} |
|
|
|
// Check if winner meets threshold |
|
if relativeScore < ce.threshold { |
|
return &ConsensusResult{ |
|
Winner: winner.Proposal, |
|
Score: winner.Score, |
|
Confidence: relativeScore, |
|
Attestations: len(winner.Attestations), |
|
Conflicted: true, |
|
Reason: fmt.Sprintf("score %.2f%% below threshold %.2f%%", relativeScore*100, ce.threshold*100), |
|
}, nil |
|
} |
|
|
|
// Check for conflicts |
|
if conflicted { |
|
return &ConsensusResult{ |
|
Winner: winner.Proposal, |
|
Score: winner.Score, |
|
Confidence: relativeScore, |
|
Attestations: len(winner.Attestations), |
|
Conflicted: true, |
|
Reason: "competing proposals within conflict margin", |
|
}, nil |
|
} |
|
|
|
// Success! |
|
return &ConsensusResult{ |
|
Winner: winner.Proposal, |
|
Score: winner.Score, |
|
Confidence: relativeScore, |
|
Attestations: len(winner.Attestations), |
|
Conflicted: false, |
|
Reason: "consensus reached", |
|
}, nil |
|
} |
|
|
|
// ScoreProposal computes the trust-weighted score for a proposal |
|
func (ce *ConsensusEngine) ScoreProposal(proposal *RegistrationProposal, attestations []*Attestation) (float64, map[string]float64) { |
|
totalScore := 0.0 |
|
weights := make(map[string]float64) |
|
|
|
for _, att := range attestations { |
|
if att.Decision != DecisionApprove { |
|
continue |
|
} |
|
|
|
// Get attestation weight (default 100) |
|
attWeight := float64(att.Weight) |
|
if attWeight <= 0 { |
|
attWeight = 100 |
|
} |
|
|
|
// Get trust level for this attester |
|
trustLevel := ce.trustGraph.GetTrustLevel(att.Event.Pubkey) |
|
|
|
// Calculate weighted score |
|
// Score = attestation_weight * trust_level / 100 |
|
score := (attWeight / 100.0) * trustLevel |
|
|
|
weights[hex.Enc(att.Event.Pubkey)] = score |
|
totalScore += score |
|
} |
|
|
|
return totalScore, weights |
|
} |
|
|
|
// ValidateProposal validates a registration proposal against current state |
|
func (ce *ConsensusEngine) ValidateProposal(proposal *RegistrationProposal) error { |
|
// Validate name format |
|
if err := ValidateName(proposal.Name); err != nil { |
|
return errorf.E("invalid name format: %w", err) |
|
} |
|
|
|
// Check if proposal is expired |
|
if !proposal.Expiration.IsZero() && time.Now().After(proposal.Expiration) { |
|
return errorf.E("proposal expired at %v", proposal.Expiration) |
|
} |
|
|
|
// Validate subdomain authority (if applicable) |
|
if !IsTLD(proposal.Name) { |
|
parent := GetParentDomain(proposal.Name) |
|
if parent == "" { |
|
return errorf.E("invalid subdomain structure") |
|
} |
|
|
|
// Query parent domain ownership |
|
parentState, err := ce.QueryNameState(parent) |
|
if err != nil { |
|
return errorf.E("failed to query parent domain: %w", err) |
|
} |
|
|
|
if parentState == nil { |
|
return errorf.E("parent domain %s not registered", parent) |
|
} |
|
|
|
// Verify proposer owns parent domain |
|
proposerPubkey := hex.Enc(proposal.Event.Pubkey) |
|
if parentState.Owner != proposerPubkey { |
|
return errorf.E("proposer does not own parent domain %s", parent) |
|
} |
|
} |
|
|
|
// Validate against current name state |
|
nameState, err := ce.QueryNameState(proposal.Name) |
|
if err != nil { |
|
return errorf.E("failed to query name state: %w", err) |
|
} |
|
|
|
now := time.Now() |
|
|
|
// Name is not registered - anyone can register |
|
if nameState == nil { |
|
return nil |
|
} |
|
|
|
// Name is expired - anyone can register |
|
if !nameState.Expiration.IsZero() && now.After(nameState.Expiration) { |
|
return nil |
|
} |
|
|
|
// Calculate renewal window start (30 days before expiration) |
|
renewalStart := nameState.Expiration.Add(-PreferentialRenewalDays * 24 * time.Hour) |
|
|
|
// Before renewal window - reject all proposals |
|
if now.Before(renewalStart) { |
|
return errorf.E("name is currently owned and not in renewal window") |
|
} |
|
|
|
// During renewal window - only current owner can register |
|
if now.Before(nameState.Expiration) { |
|
proposerPubkey := hex.Enc(proposal.Event.Pubkey) |
|
if proposerPubkey != nameState.Owner { |
|
return errorf.E("only current owner can renew during preferential renewal window") |
|
} |
|
return nil |
|
} |
|
|
|
// Should not reach here, but allow registration if we do |
|
return nil |
|
} |
|
|
|
// ValidateTransfer validates a transfer proposal |
|
func (ce *ConsensusEngine) ValidateTransfer(proposal *RegistrationProposal) error { |
|
if proposal.Action != ActionTransfer { |
|
return errorf.E("not a transfer proposal") |
|
} |
|
|
|
// Must have previous owner and signature |
|
if proposal.PrevOwner == "" { |
|
return errorf.E("missing previous owner") |
|
} |
|
if proposal.PrevSig == "" { |
|
return errorf.E("missing previous owner signature") |
|
} |
|
|
|
// Query current name state |
|
nameState, err := ce.QueryNameState(proposal.Name) |
|
if err != nil { |
|
return errorf.E("failed to query name state: %w", err) |
|
} |
|
|
|
if nameState == nil { |
|
return errorf.E("name not registered") |
|
} |
|
|
|
// Verify previous owner matches current owner |
|
if nameState.Owner != proposal.PrevOwner { |
|
return errorf.E("previous owner mismatch") |
|
} |
|
|
|
// Verify name is not expired |
|
if !nameState.Expiration.IsZero() && time.Now().After(nameState.Expiration) { |
|
return errorf.E("name expired") |
|
} |
|
|
|
// TODO: Verify signature over transfer message |
|
// Message format: "transfer:<name>:<new_owner_pubkey>:<timestamp>" |
|
|
|
return nil |
|
} |
|
|
|
// QueryNameState queries the current name state from the database |
|
func (ce *ConsensusEngine) QueryNameState(name string) (*NameState, error) { |
|
// Query kind 30102 events with d tag = name |
|
filter := &struct { |
|
Kinds []uint16 |
|
DTags []string |
|
Limit int |
|
}{ |
|
Kinds: []uint16{KindNameState}, |
|
DTags: []string{name}, |
|
Limit: 10, |
|
} |
|
|
|
// Note: This would use the actual database query method |
|
// For now, return nil to indicate not found |
|
// TODO: Implement actual database query |
|
_ = filter |
|
return nil, nil |
|
} |
|
|
|
// CreateNameState creates a name state event from consensus result |
|
func (ce *ConsensusEngine) CreateNameState(result *ConsensusResult, registryPubkey []byte) (*NameState, error) { |
|
if result.Winner == nil { |
|
return nil, errorf.E("no winner in consensus result") |
|
} |
|
|
|
proposal := result.Winner |
|
|
|
return &NameState{ |
|
Name: proposal.Name, |
|
Owner: hex.Enc(proposal.Event.Pubkey), |
|
RegisteredAt: time.Now(), |
|
ProposalID: hex.Enc(proposal.Event.ID), |
|
Attestations: result.Attestations, |
|
Confidence: result.Confidence, |
|
Expiration: time.Now().Add(NameRegistrationPeriod), |
|
}, nil |
|
} |
|
|
|
// ProcessProposalBatch processes a batch of proposals and returns consensus results |
|
func (ce *ConsensusEngine) ProcessProposalBatch(proposals []*RegistrationProposal, attestations []*Attestation) ([]*ConsensusResult, error) { |
|
// Group proposals by name |
|
proposalsByName := make(map[string][]*RegistrationProposal) |
|
for _, proposal := range proposals { |
|
proposalsByName[proposal.Name] = append(proposalsByName[proposal.Name], proposal) |
|
} |
|
|
|
results := make([]*ConsensusResult, 0) |
|
|
|
// Process each name's proposals independently |
|
for name, nameProposals := range proposalsByName { |
|
// Filter attestations for this name's proposals |
|
proposalIDs := make(map[string]bool) |
|
for _, p := range nameProposals { |
|
proposalIDs[hex.Enc(p.Event.ID)] = true |
|
} |
|
|
|
nameAttestations := make([]*Attestation, 0) |
|
for _, att := range attestations { |
|
if proposalIDs[att.ProposalID] { |
|
nameAttestations = append(nameAttestations, att) |
|
} |
|
} |
|
|
|
// Compute consensus for this name |
|
result, err := ce.ComputeConsensus(nameProposals, nameAttestations) |
|
if chk.E(err) { |
|
// Log error but continue processing other names |
|
result = &ConsensusResult{ |
|
Conflicted: true, |
|
Reason: fmt.Sprintf("error: %v", err), |
|
} |
|
} |
|
|
|
// Add name to result for tracking |
|
if result.Winner != nil { |
|
result.Winner.Name = name |
|
} |
|
|
|
results = append(results, result) |
|
} |
|
|
|
return results, nil |
|
}
|
|
|