diff --git a/nip13/nip13.go b/nip13/nip13.go new file mode 100644 index 0000000..35a7d1f --- /dev/null +++ b/nip13/nip13.go @@ -0,0 +1,77 @@ +// Package nip13 implements NIP-13 +// See https://github.com/nostr-protocol/nips/blob/master/13.md for details. +package nip13 + +import ( + "encoding/hex" + "errors" + "math/bits" + "strconv" + "time" + + nostr "github.com/nbd-wtf/go-nostr" +) + +var ( + ErrDifficultyTooLow = errors.New("nip13: insufficient difficulty") + ErrGenerateTimeout = errors.New("nip13: generating proof of work took too long") +) + +// Difficulty counts the number of leading zero bits in an event ID. +// It returns a negative number if the event ID is malformed. +func Difficulty(eventID string) int { + if len(eventID) != 64 { + return -1 + } + var zeros int + for i := 0; i < 64; i += 2 { + if eventID[i:i+2] == "00" { + zeros += 8 + continue + } + var b [1]byte + if _, err := hex.Decode(b[:], []byte{eventID[i], eventID[i+1]}); err != nil { + return -1 + } + zeros += bits.LeadingZeros8(b[0]) + break + } + return zeros +} + +// Check reports whether the event ID demonstrates a sufficient proof of work difficulty. +// Note that Check performs no validation other than counting leading zero bits +// in an event ID. It is up to the callers to verify the event with other methods, +// such as [nostr.Event.CheckSignature]. +func Check(eventID string, minDifficulty int) error { + if Difficulty(eventID) < minDifficulty { + return ErrDifficultyTooLow + } + return nil +} + +// Generate performs proof of work on the specified event until either the target +// difficulty is reached or the function runs for longer than the timeout. +// The latter case results in ErrGenerateTimeout. +// +// Upon success, the returned event always contains a "nonce" tag with the target difficulty +// commitment, and an updated event.CreatedAt. +func Generate(event *nostr.Event, targetDifficulty int, timeout time.Duration) (*nostr.Event, error) { + tag := nostr.Tag{"nonce", "", strconv.Itoa(targetDifficulty)} + event.Tags = append(event.Tags, tag) + var nonce uint64 + start := time.Now() + for { + nonce++ + tag[1] = strconv.FormatUint(nonce, 10) + event.CreatedAt = time.Now() + if Difficulty(event.GetID()) >= targetDifficulty { + return event, nil + } + // benchmarks show one iteration is approx 3000ns on i7-8565U @ 1.8GHz. + // so, check every 3ms; arbitrary + if nonce%1000 == 0 && time.Since(start) > timeout { + return nil, ErrGenerateTimeout + } + } +} diff --git a/nip13/nip13_test.go b/nip13/nip13_test.go new file mode 100644 index 0000000..b1e4409 --- /dev/null +++ b/nip13/nip13_test.go @@ -0,0 +1,149 @@ +package nip13 + +import ( + "errors" + "fmt" + "strconv" + "testing" + "time" + + nostr "github.com/nbd-wtf/go-nostr" +) + +func TestCheck(t *testing.T) { + const eventID = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d" + tests := []struct { + minDifficulty int + wantErr error + }{ + {-1, nil}, + {0, nil}, + {1, nil}, + {35, nil}, + {36, nil}, + {37, ErrDifficultyTooLow}, + {42, ErrDifficultyTooLow}, + } + for i, tc := range tests { + if err := Check(eventID, tc.minDifficulty); err != tc.wantErr { + t.Errorf("%d: Check(%q, %d) returned %v; want err: %v", i, eventID, tc.minDifficulty, err, tc.wantErr) + } + } +} + +func TestGenerateShort(t *testing.T) { + event := &nostr.Event{ + Kind: 1, + Content: "It's just me mining my own business", + PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", + } + pow, err := Generate(event, 0, 3*time.Second) + if err != nil { + t.Fatal(err) + } + testNonceTag(t, pow, 0) +} + +func TestGenerateLong(t *testing.T) { + if testing.Short() { + t.Skip("too consuming for short mode") + } + for _, difficulty := range []int{8, 16} { + difficulty := difficulty + t.Run(fmt.Sprintf("%dbits", difficulty), func(t *testing.T) { + t.Parallel() + event := &nostr.Event{ + Kind: 1, + Content: "It's just me mining my own business", + PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", + } + pow, err := Generate(event, difficulty, time.Minute) + if err != nil { + t.Fatal(err) + } + if err := Check(pow.GetID(), difficulty); err != nil { + t.Error(err) + } + testNonceTag(t, pow, difficulty) + }) + } +} + +func testNonceTag(t *testing.T, event *nostr.Event, commitment int) { + t.Helper() + tagptr := event.Tags.GetFirst([]string{"nonce"}) + if tagptr == nil { + t.Fatal("no nonce tag") + } + tag := *tagptr + if tag[0] != "nonce" { + t.Errorf("tag[0] = %q; want 'nonce'", tag[0]) + } + if n, err := strconv.ParseInt(tag[1], 10, 64); err != nil || n < 1 { + t.Errorf("tag[1] = %q; want an int greater than 0", tag[1]) + } + if n, err := strconv.Atoi(tag[2]); err != nil || n != commitment { + t.Errorf("tag[2] = %q; want %d", tag[2], commitment) + } +} + +func TestGenerateTimeout(t *testing.T) { + event := &nostr.Event{ + Kind: 1, + Content: "It's just me mining my own business", + PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", + } + done := make(chan error) + go func() { + _, err := Generate(event, 256, time.Millisecond) + done <- err + }() + select { + case <-time.After(time.Second): + t.Error("Generate took too long to timeout") + case err := <-done: + if !errors.Is(err, ErrGenerateTimeout) { + t.Errorf("Generate returned %v; want ErrGenerateTimeout", err) + } + } +} + +func BenchmarkCheck(b *testing.B) { + for i := 0; i < b.N; i++ { + Check("000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d", 36) + } +} + +func BenchmarkGenerateOneIteration(b *testing.B) { + for i := 0; i < b.N; i++ { + event := &nostr.Event{ + Kind: 1, + Content: "It's just me mining my own business", + PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", + } + if _, err := Generate(event, 0, time.Minute); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkGenerate(b *testing.B) { + if testing.Short() { + b.Skip("too consuming for short mode") + } + for _, difficulty := range []int{8, 16, 24} { + difficulty := difficulty + b.Run(fmt.Sprintf("%dbits", difficulty), func(b *testing.B) { + for i := 0; i < b.N; i++ { + event := &nostr.Event{ + Kind: 1, + Content: "It's just me mining my own business", + PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", + } + if _, err := Generate(event, difficulty, time.Minute); err != nil { + b.Fatal(err) + } + } + }) + } +}