diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82aeb61df4..eb3d731d8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: - name: lint run: make lint-github-action - build-tools: + build: runs-on: ${{ matrix.os }} needs: filter-changes if: ${{ needs.filter-changes.outputs.nondocchanges == 'true' }} @@ -160,6 +160,7 @@ jobs: with: check-latest: true go-version: ${{ env.go-version }} + cache: ${{ runner.arch != 'arm64' }} - name: setup env run: make install - name: build merge-nodes @@ -168,52 +169,6 @@ jobs: run: make gen-p2p-identity - name: build bootstrapper run: make bootstrapper - - build: - runs-on: ${{ matrix.os }} - needs: filter-changes - if: ${{ needs.filter-changes.outputs.nondocchanges == 'true' }} - strategy: - fail-fast: true - matrix: - os: - - ubuntu-22.04 - - ubuntu-latest-arm-8-cores - - macos-13 - - [self-hosted, macOS, ARM64, go-spacemesh] - - windows-2022 - steps: - - name: Add OpenCL support - Ubuntu - if: ${{ matrix.os == 'ubuntu-22.04' || matrix.os == 'ubuntu-latest-arm-8-cores' }} - run: sudo apt-get update -q && sudo apt-get install -qy ocl-icd-opencl-dev libpocl2 - - name: disable Windows Defender - Windows - if: ${{ matrix.os == 'windows-2022' }} - run: | - Set-MpPreference -DisableRealtimeMonitoring $true - - name: Set new git config - Windows - if: ${{ matrix.os == 'windows-2022' }} - run: | - git config --global pack.window 1 - git config --global core.compression 0 - git config --global http.postBuffer 524288000 - - name: checkout - uses: actions/checkout@v4 - with: - ssh-key: ${{ secrets.GH_ACTION_PRIVATE_KEY }} - - uses: extractions/netrc@v2 - with: - machine: github.com - username: ${{ secrets.GH_ACTION_TOKEN_USER }} - password: ${{ secrets.GH_ACTION_TOKEN }} - if: vars.GOPRIVATE - - name: set up go - uses: actions/setup-go@v5 - with: - check-latest: true - go-version: ${{ env.go-version }} - cache: ${{ runner.arch != 'arm64' }} - - name: setup env - run: make install - name: build timeout-minutes: 5 run: make build @@ -307,7 +262,6 @@ jobs: - filter-changes - quicktests - lint - - build-tools - build - unittests runs-on: ubuntu-22.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bc503248..462b887df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -708,7 +708,7 @@ and permanent ineligibility for rewards. * [#5470](https://github.com/spacemeshos/go-spacemesh/pull/5470) Fixed a bug in event reporting where the node reports a disconnection from the PoST service as a "PoST failed" event. - Disconnections cannot be avoided completely and do not interrupt the PoST proofing process. As long as the PoST + Disconnections cannot be avoided completely and do not interrupt the PoST proving process. As long as the PoST service reconnects within a reasonable time, the node will continue to operate normally without reporting any errors via the event API. diff --git a/Makefile b/Makefile index c1853c7560..777c1d6007 100644 --- a/Makefile +++ b/Makefile @@ -97,7 +97,7 @@ clear-test-cache: .PHONY: clear-test-cache test: get-libs - @$(ULIMIT) CGO_LDFLAGS="$(CGO_TEST_LDFLAGS)" gotestsum -- -race -timeout 8m -p 1 $(UNIT_TESTS) + @$(ULIMIT) CGO_LDFLAGS="$(CGO_TEST_LDFLAGS)" gotestsum -- -race -timeout 8m $(UNIT_TESTS) .PHONY: test generate: get-libs @@ -145,7 +145,7 @@ lint-github-action: get-libs .PHONY: lint-github-action cover: get-libs - @$(ULIMIT) CGO_LDFLAGS="$(CGO_TEST_LDFLAGS)" go test -coverprofile=cover.out -timeout 0 -p 1 -coverpkg=./... $(UNIT_TESTS) + @$(ULIMIT) CGO_LDFLAGS="$(CGO_TEST_LDFLAGS)" go test -coverprofile=cover.out -timeout 30m -coverpkg=./... $(UNIT_TESTS) .PHONY: cover list-versions: diff --git a/README.md b/README.md index 3f2bfde5a2..2f3fb38e7c 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ the build folder you need to ensure that you have the gpu setup dynamic library binary. The simplest way to do this is just copy the library file to be in the same directory as the go-spacemesh binary. Alternatively you can modify your system's library search paths (e.g. LD_LIBRARY_PATH) to ensure that the -library is found._ +library is found. go-spacemesh is p2p software which is designed to form a decentralized network by connecting to other instances of go-spacemesh running on remote computers. diff --git a/activation/activation_errors.go b/activation/activation_errors.go index 63027b0c81..e2e2215e5f 100644 --- a/activation/activation_errors.go +++ b/activation/activation_errors.go @@ -3,6 +3,7 @@ package activation import ( "errors" "fmt" + "strings" ) var ( @@ -21,8 +22,31 @@ type PoetSvcUnstableError struct { source error } -func (e *PoetSvcUnstableError) Error() string { +func (e PoetSvcUnstableError) Error() string { return fmt.Sprintf("poet service is unstable: %s (%v)", e.msg, e.source) } func (e *PoetSvcUnstableError) Unwrap() error { return e.source } + +type PoetRegistrationMismatchError struct { + registrations []string + configuredPoets []string +} + +func (e PoetRegistrationMismatchError) Error() string { + var sb strings.Builder + sb.WriteString("builder: none of configured poets matches the existing registrations.\n") + sb.WriteString("registrations:\n") + for _, r := range e.registrations { + sb.WriteString("\t") + sb.WriteString(r) + sb.WriteString("\n") + } + sb.WriteString("\nconfigured poets:\n") + for _, p := range e.configuredPoets { + sb.WriteString("\t") + sb.WriteString(p) + sb.WriteString("\n") + } + return sb.String() +} diff --git a/activation/builder_v2_test.go b/activation/builder_v2_test.go index 209570b7c4..0054147f4e 100644 --- a/activation/builder_v2_test.go +++ b/activation/builder_v2_test.go @@ -68,7 +68,7 @@ func TestBuilder_BuildsInitialAtxV2(t *testing.T) { require.Empty(t, atx.Marriages) require.Equal(t, posEpoch+1, atx.PublishEpoch) require.Equal(t, sig.NodeID(), atx.SmesherID) - require.True(t, signing.NewEdVerifier().Verify(signing.ATX, atx.SmesherID, atx.SignedBytes(), atx.Signature)) + require.True(t, signing.NewEdVerifier().Verify(signing.ATX, atx.SmesherID, atx.ID().Bytes(), atx.Signature)) } func TestBuilder_SwitchesToBuildV2(t *testing.T) { @@ -106,5 +106,5 @@ func TestBuilder_SwitchesToBuildV2(t *testing.T) { require.Empty(t, atx2.Marriages) require.Equal(t, atx1.PublishEpoch+1, atx2.PublishEpoch) require.Equal(t, sig.NodeID(), atx2.SmesherID) - require.True(t, signing.NewEdVerifier().Verify(signing.ATX, atx2.SmesherID, atx2.SignedBytes(), atx2.Signature)) + require.True(t, signing.NewEdVerifier().Verify(signing.ATX, atx2.SmesherID, atx2.ID().Bytes(), atx2.Signature)) } diff --git a/activation/e2e/atx_merge_test.go b/activation/e2e/atx_merge_test.go index fda7593427..49f3f6d0a0 100644 --- a/activation/e2e/atx_merge_test.go +++ b/activation/e2e/atx_merge_test.go @@ -246,7 +246,7 @@ func Test_MarryAndMerge(t *testing.T) { GracePeriod: epoch / 4, } - client := ae2e.NewTestPoetClient(2) + client := ae2e.NewTestPoetClient(2, poetCfg) poetSvc := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) clock, err := timesync.NewClock( diff --git a/activation/e2e/builds_atx_v2_test.go b/activation/e2e/builds_atx_v2_test.go index 9c56b565be..2baf326060 100644 --- a/activation/e2e/builds_atx_v2_test.go +++ b/activation/e2e/builds_atx_v2_test.go @@ -92,7 +92,7 @@ func TestBuilder_SwitchesToBuildV2(t *testing.T) { require.NoError(t, err) t.Cleanup(clock.Close) - client := ae2e.NewTestPoetClient(1) + client := ae2e.NewTestPoetClient(1, poetCfg) poetClient := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) localDB := localsql.InMemory() diff --git a/activation/e2e/checkpoint_merged_test.go b/activation/e2e/checkpoint_merged_test.go index 3984d926f8..f06c319f3d 100644 --- a/activation/e2e/checkpoint_merged_test.go +++ b/activation/e2e/checkpoint_merged_test.go @@ -81,7 +81,7 @@ func Test_CheckpointAfterMerge(t *testing.T) { GracePeriod: epoch / 4, } - client := ae2e.NewTestPoetClient(2) + client := ae2e.NewTestPoetClient(2, poetCfg) poetSvc := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) clock, err := timesync.NewClock( diff --git a/activation/e2e/checkpoint_test.go b/activation/e2e/checkpoint_test.go index 048469b2ff..4e825d944c 100644 --- a/activation/e2e/checkpoint_test.go +++ b/activation/e2e/checkpoint_test.go @@ -71,7 +71,7 @@ func TestCheckpoint_PublishingSoloATXs(t *testing.T) { CycleGap: 3 * epoch / 4, GracePeriod: epoch / 4, } - client := ae2e.NewTestPoetClient(1) + client := ae2e.NewTestPoetClient(1, poetCfg) poetService := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) // ensure that genesis aligns with layer timings diff --git a/activation/e2e/nipost_test.go b/activation/e2e/nipost_test.go index 2a135e3256..74a9e29b57 100644 --- a/activation/e2e/nipost_test.go +++ b/activation/e2e/nipost_test.go @@ -198,7 +198,7 @@ func TestNIPostBuilderWithClients(t *testing.T) { err = nipost.AddPost(localDb, sig.NodeID(), *fullPost(post, info, shared.ZeroChallenge)) require.NoError(t, err) - client := ae2e.NewTestPoetClient(1) + client := ae2e.NewTestPoetClient(1, poetCfg) poetService := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) localDB := localsql.InMemory() @@ -272,7 +272,7 @@ func Test_NIPostBuilderWithMultipleClients(t *testing.T) { } poetDb := activation.NewPoetDb(db, logger.Named("poetDb")) - client := ae2e.NewTestPoetClient(len(signers)) + client := ae2e.NewTestPoetClient(len(signers), poetCfg) poetService := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) mclock := activation.NewMocklayerClock(ctrl) diff --git a/activation/e2e/poet_client.go b/activation/e2e/poet_client.go index c025ed5302..01fee564ec 100644 --- a/activation/e2e/poet_client.go +++ b/activation/e2e/poet_client.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/url" "strconv" "sync" "time" @@ -20,15 +19,17 @@ import ( ) type TestPoet struct { - mu sync.Mutex - round int + mu sync.Mutex + round int + poetCfg activation.PoetConfig expectedMembers int registrations chan []byte } -func NewTestPoetClient(expectedMembers int) *TestPoet { +func NewTestPoetClient(expectedMembers int, poetCfg activation.PoetConfig) *TestPoet { return &TestPoet{ + poetCfg: poetCfg, expectedMembers: expectedMembers, registrations: make(chan []byte, expectedMembers), } @@ -66,8 +67,15 @@ func (p *TestPoet) Submit( return &types.PoetRound{ID: strconv.Itoa(round), End: time.Now()}, nil } -func (p *TestPoet) CertifierInfo(ctx context.Context) (*url.URL, []byte, error) { - return nil, nil, errors.New("not supported") +func (p *TestPoet) CertifierInfo(ctx context.Context) (*types.CertifierInfo, error) { + return nil, errors.New("CertifierInfo: not supported") +} + +func (p *TestPoet) Info(ctx context.Context) (*types.PoetInfo, error) { + return &types.PoetInfo{ + PhaseShift: p.poetCfg.PhaseShift, + CycleGap: p.poetCfg.CycleGap, + }, nil } // Build a proof. diff --git a/activation/e2e/poet_test.go b/activation/e2e/poet_test.go index 3aef153168..27d50f18e0 100644 --- a/activation/e2e/poet_test.go +++ b/activation/e2e/poet_test.go @@ -259,10 +259,10 @@ func TestCertifierInfo(t *testing.T) { ) require.NoError(t, err) - url, pubkey, err := client.CertifierInfo(context.Background()) + certInfo, err := client.CertifierInfo(context.Background()) r.NoError(err) - r.Equal("http://localhost:8080", url.String()) - r.Equal([]byte("pubkey"), pubkey) + r.Equal("http://localhost:8080", certInfo.Url.String()) + r.Equal([]byte("pubkey"), certInfo.Pubkey) } func TestNoCertifierInfo(t *testing.T) { @@ -291,6 +291,6 @@ func TestNoCertifierInfo(t *testing.T) { ) require.NoError(t, err) - _, _, err = client.CertifierInfo(context.Background()) + _, err = client.CertifierInfo(context.Background()) r.ErrorContains(err, "poet doesn't support certificates") } diff --git a/activation/e2e/validation_test.go b/activation/e2e/validation_test.go index 124a5762f3..dde1ff6162 100644 --- a/activation/e2e/validation_test.go +++ b/activation/e2e/validation_test.go @@ -51,7 +51,7 @@ func TestValidator_Validate(t *testing.T) { } poetDb := activation.NewPoetDb(sql.InMemory(), logger.Named("poetDb")) - client := ae2e.NewTestPoetClient(1) + client := ae2e.NewTestPoetClient(1, poetCfg) poetService := activation.NewPoetServiceWithClient(poetDb, client, poetCfg, logger) mclock := activation.NewMocklayerClock(ctrl) diff --git a/activation/handler_v2.go b/activation/handler_v2.go index 155b5db6d1..0f0b22a853 100644 --- a/activation/handler_v2.go +++ b/activation/handler_v2.go @@ -18,6 +18,7 @@ import ( "github.com/spacemeshos/go-spacemesh/activation/wire" "github.com/spacemeshos/go-spacemesh/atxsdata" + "github.com/spacemeshos/go-spacemesh/codec" "github.com/spacemeshos/go-spacemesh/common/types" "github.com/spacemeshos/go-spacemesh/datastore" "github.com/spacemeshos/go-spacemesh/events" @@ -626,9 +627,7 @@ func (h *HandlerV2) syntacticallyValidateDeps( zap.Int("index", invalidIdx.Index), ) // TODO(mafa): finish proof - proof := &wire.ATXProof{ - ProofType: wire.InvalidPost, - } + var proof wire.Proof if err := h.malPublisher.Publish(ctx, id, proof); err != nil { return nil, fmt.Errorf("publishing malfeasance proof for invalid post: %w", err) } @@ -669,7 +668,7 @@ func (h *HandlerV2) checkMalicious( return nil } - malicious, err = h.checkDoubleMarry(ctx, tx, watx.ID(), marrying) + malicious, err = h.checkDoubleMarry(ctx, tx, watx, marrying) if err != nil { return fmt.Errorf("checking double marry: %w", err) } @@ -704,7 +703,7 @@ func (h *HandlerV2) checkMalicious( func (h *HandlerV2) checkDoubleMarry( ctx context.Context, tx *sql.Tx, - atxID types.ATXID, + atx *wire.ActivationTxV2, marrying []marriage, ) (bool, error) { for _, m := range marrying { @@ -712,10 +711,23 @@ func (h *HandlerV2) checkDoubleMarry( if err != nil { return false, fmt.Errorf("checking if ID is married: %w", err) } - if mATX != atxID { - // TODO(mafa): finish proof - proof := &wire.ATXProof{ - ProofType: wire.DoubleMarry, + if mATX != atx.ID() { + var blob sql.Blob + v, err := atxs.LoadBlob(ctx, tx, mATX.Bytes(), &blob) + if err != nil { + return true, fmt.Errorf("creating double marry proof: %w", err) + } + if v != types.AtxV2 { + h.logger.Fatal("Failed to create double marry malfeasance proof: ATX is not v2", + zap.Stringer("atx_id", mATX), + ) + } + var otherAtx wire.ActivationTxV2 + codec.MustDecode(blob.Bytes, &otherAtx) + + proof, err := wire.NewDoubleMarryProof(tx, atx, &otherAtx, m.id) + if err != nil { + return true, fmt.Errorf("creating double marry proof: %w", err) } return true, h.malPublisher.Publish(ctx, m.id, proof) } @@ -747,9 +759,7 @@ func (h *HandlerV2) checkDoublePost( zap.Uint32("epoch", atx.PublishEpoch.Uint32()), ) // TODO(mafa): finish proof - proof := &wire.ATXProof{ - ProofType: wire.DoublePublish, - } + var proof wire.Proof return true, h.malPublisher.Publish(ctx, id, proof) } return false, nil @@ -776,10 +786,7 @@ func (h *HandlerV2) checkDoubleMerge(ctx context.Context, tx *sql.Tx, watx *wire zap.Stringer("smesher_id", watx.SmesherID), ) - // TODO(mafa): finish proof - proof := &wire.ATXProof{ - ProofType: wire.DoubleMerge, - } + var proof wire.Proof return true, h.malPublisher.Publish(ctx, watx.SmesherID, proof) } diff --git a/activation/handler_v2_test.go b/activation/handler_v2_test.go index 06f52f9b69..3397b709ac 100644 --- a/activation/handler_v2_test.go +++ b/activation/handler_v2_test.go @@ -17,6 +17,7 @@ import ( "github.com/spacemeshos/go-spacemesh/activation/wire" "github.com/spacemeshos/go-spacemesh/atxsdata" + "github.com/spacemeshos/go-spacemesh/codec" "github.com/spacemeshos/go-spacemesh/common/types" "github.com/spacemeshos/go-spacemesh/datastore" "github.com/spacemeshos/go-spacemesh/fetch" @@ -1561,17 +1562,7 @@ func TestHandlerV2_SyntacticallyValidateDeps(t *testing.T) { gomock.Any(), ). Return(verifying.ErrInvalidIndex{Index: 7}) - atxHandler.mMalPublish.EXPECT().Publish( - gomock.Any(), - sig.NodeID(), - gomock.Cond(func(data any) bool { - proof, ok := data.(*wire.ATXProof) - if !ok { - return false - } - return proof.ProofType == wire.InvalidPost - }), - ) + atxHandler.mMalPublish.EXPECT().Publish(gomock.Any(), sig.NodeID(), gomock.Any()) _, err := atxHandler.syntacticallyValidateDeps(context.Background(), atx) vErr := &verifying.ErrInvalidIndex{} require.ErrorAs(t, err, vErr) @@ -1719,13 +1710,18 @@ func Test_Marriages(t *testing.T) { gomock.Any(), sig.NodeID(), gomock.Cond(func(data any) bool { - proof, ok := data.(*wire.ATXProof) - if !ok { - return false - } - return proof.ProofType == wire.DoubleMarry + _, ok := data.(*wire.ProofDoubleMarry) + return ok }), - ) + ).DoAndReturn(func(ctx context.Context, id types.NodeID, proof wire.Proof) error { + malProof := proof.(*wire.ProofDoubleMarry) + nId, err := malProof.Valid(atxHandler.edVerifier) + require.NoError(t, err) + require.Equal(t, sig.NodeID(), nId) + b := codec.MustEncode(malProof) + _ = b + return nil + }) err = atxHandler.processATX(context.Background(), "", atx2, time.Now()) require.NoError(t, err) diff --git a/activation/interface.go b/activation/interface.go index 32c81572c1..21c414e9c5 100644 --- a/activation/interface.go +++ b/activation/interface.go @@ -101,7 +101,7 @@ type syncer interface { // Additionally the publisher will only gossip proofs when the node is in sync, otherwise it will only store them. // and mark the associated identity as malfeasant. type malfeasancePublisher interface { - Publish(ctx context.Context, id types.NodeID, proof *wire.ATXProof) error + Publish(ctx context.Context, id types.NodeID, proof wire.Proof) error } type atxProvider interface { diff --git a/activation/malfeasance2.go b/activation/malfeasance2.go index d4d3a40bc7..ff44452b35 100644 --- a/activation/malfeasance2.go +++ b/activation/malfeasance2.go @@ -10,7 +10,7 @@ import ( // MalfeasancePublisher is the publisher for ATX proofs. type MalfeasancePublisher struct{} -func (p *MalfeasancePublisher) Publish(ctx context.Context, id types.NodeID, proof *wire.ATXProof) error { +func (p *MalfeasancePublisher) Publish(ctx context.Context, id types.NodeID, proof wire.Proof) error { // TODO(mafa): implement me return nil } diff --git a/activation/mocks.go b/activation/mocks.go index dd1c6f637d..38a1a47206 100644 --- a/activation/mocks.go +++ b/activation/mocks.go @@ -1109,7 +1109,7 @@ func (m *MockmalfeasancePublisher) EXPECT() *MockmalfeasancePublisherMockRecorde } // Publish mocks base method. -func (m *MockmalfeasancePublisher) Publish(ctx context.Context, id types.NodeID, proof *wire.ATXProof) error { +func (m *MockmalfeasancePublisher) Publish(ctx context.Context, id types.NodeID, proof wire.Proof) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Publish", ctx, id, proof) ret0, _ := ret[0].(error) @@ -1135,13 +1135,13 @@ func (c *MockmalfeasancePublisherPublishCall) Return(arg0 error) *Mockmalfeasanc } // Do rewrite *gomock.Call.Do -func (c *MockmalfeasancePublisherPublishCall) Do(f func(context.Context, types.NodeID, *wire.ATXProof) error) *MockmalfeasancePublisherPublishCall { +func (c *MockmalfeasancePublisherPublishCall) Do(f func(context.Context, types.NodeID, wire.Proof) error) *MockmalfeasancePublisherPublishCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockmalfeasancePublisherPublishCall) DoAndReturn(f func(context.Context, types.NodeID, *wire.ATXProof) error) *MockmalfeasancePublisherPublishCall { +func (c *MockmalfeasancePublisherPublishCall) DoAndReturn(f func(context.Context, types.NodeID, wire.Proof) error) *MockmalfeasancePublisherPublishCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/activation/nipost.go b/activation/nipost.go index bb29387a14..ec9f8ac9c5 100644 --- a/activation/nipost.go +++ b/activation/nipost.go @@ -12,6 +12,7 @@ import ( "github.com/spacemeshos/poet/shared" postshared "github.com/spacemeshos/post/shared" "go.uber.org/zap" + "golang.org/x/exp/maps" "golang.org/x/sync/errgroup" "github.com/spacemeshos/go-spacemesh/activation/metrics" @@ -86,8 +87,7 @@ func NewNIPostBuilder( opts ...NIPostBuilderOption, ) (*NIPostBuilder, error) { b := &NIPostBuilder{ - localDB: db, - + localDB: db, postService: postService, logger: lg, poetCfg: poetCfg, @@ -210,7 +210,7 @@ func (nb *NIPostBuilder) BuildNIPost( poetRoundStart := nb.layerClock.LayerToTime((postChallenge.PublishEpoch - 1).FirstLayer()). Add(nb.poetCfg.PhaseShift) - poetRoundEnd := nb.layerClock.LayerToTime(postChallenge.PublishEpoch.FirstLayer()). + curPoetRoundEnd := nb.layerClock.LayerToTime(postChallenge.PublishEpoch.FirstLayer()). Add(nb.poetCfg.PhaseShift). Add(-nb.poetCfg.CycleGap) @@ -224,41 +224,31 @@ func (nb *NIPostBuilder) BuildNIPost( logger.Info("building nipost", zap.Time("poet round start", poetRoundStart), - zap.Time("poet round end", poetRoundEnd), + zap.Time("poet round end", curPoetRoundEnd), zap.Time("publish epoch end", publishEpochEnd), zap.Uint32("publish epoch", postChallenge.PublishEpoch.Uint32()), ) // Phase 0: Submit challenge to PoET services. - count, err := nipost.PoetRegistrationCount(nb.localDB, signer.NodeID()) - if err != nil { - return nil, fmt.Errorf("failed to get poet registration count: %w", err) - } - if count == 0 { - now := time.Now() - // Deadline: start of PoET round for publish epoch. PoET won't accept registrations after that. - if poetRoundStart.Before(now) { - return nil, fmt.Errorf( - "%w: poet round has already started at %s (now: %s)", - ErrATXChallengeExpired, - poetRoundStart, - now, - ) - } - - submitCtx, cancel := context.WithDeadline(ctx, poetRoundStart) - defer cancel() - err := nb.submitPoetChallenges(submitCtx, signer, poetProofDeadline, challenge.Bytes()) - if err != nil { - return nil, fmt.Errorf("submitting to poets: %w", err) - } - count, err := nipost.PoetRegistrationCount(nb.localDB, signer.NodeID()) - if err != nil { - return nil, fmt.Errorf("failed to get poet registration count: %w", err) - } - if count == 0 { - return nil, &PoetSvcUnstableError{msg: "failed to submit challenge to any PoET", source: submitCtx.Err()} - } + // Deadline: start of PoET round: we will not accept registrations after that + submittedRegistrations, err := nb.submitPoetChallenges( + ctx, + signer, + poetProofDeadline, + poetRoundStart, challenge.Bytes(), + ) + regErr := &PoetRegistrationMismatchError{} + switch { + case errors.As(err, ®Err): + logger.Fatal( + "None of the poets listed in the config matches the existing registrations. "+ + "Verify your config and local database state.", + zap.Strings("registrations", regErr.registrations), + zap.Strings("configured_poets", regErr.configuredPoets), + ) + return nil, err + case err != nil: + return nil, fmt.Errorf("submitting to poets: %w", err) } // Phase 1: query PoET services for proofs @@ -280,8 +270,8 @@ func (nb *NIPostBuilder) BuildNIPost( ) } - events.EmitPoetWaitProof(signer.NodeID(), postChallenge.PublishEpoch, poetRoundEnd) - poetProofRef, membership, err = nb.getBestProof(ctx, signer.NodeID(), challenge, postChallenge.PublishEpoch) + events.EmitPoetWaitProof(signer.NodeID(), postChallenge.PublishEpoch, curPoetRoundEnd) + poetProofRef, membership, err = nb.getBestProof(ctx, signer.NodeID(), challenge, submittedRegistrations) if err != nil { return nil, &PoetSvcUnstableError{msg: "getBestProof failed", source: err} } @@ -315,13 +305,17 @@ func (nb *NIPostBuilder) BuildNIPost( defer cancel() nb.logger.Info("starting post execution", zap.Binary("challenge", poetProofRef[:])) + startTime := time.Now() proof, postInfo, err := nb.Proof(postCtx, signer.NodeID(), poetProofRef[:], postChallenge) if err != nil { return nil, fmt.Errorf("failed to generate Post: %w", err) } + postGenDuration := time.Since(startTime) + nb.logger.Info("finished post execution", zap.Duration("duration", postGenDuration)) + metrics.PostDuration.Set(float64(postGenDuration.Nanoseconds())) public.PostSeconds.Set(postGenDuration.Seconds()) @@ -363,7 +357,7 @@ func (nb *NIPostBuilder) submitPoetChallenge( client PoetService, prefix, challenge []byte, signature types.EdSignature, -) error { +) (nipost.PoETRegistration, error) { logger := nb.logger.With( log.ZContext(ctx), zap.String("poet", client.Address()), @@ -377,64 +371,143 @@ func (nb *NIPostBuilder) submitPoetChallenge( round, err := client.Submit(submitCtx, deadline, prefix, challenge, signature, nodeID) if err != nil { - return &PoetSvcUnstableError{msg: "failed to submit challenge to poet service", source: err} + return nipost.PoETRegistration{}, + &PoetSvcUnstableError{msg: "failed to submit challenge to poet service", source: err} } logger.Info("challenge submitted to poet proving service", zap.String("round", round.ID)) - return nipost.AddPoetRegistration(nb.localDB, nodeID, nipost.PoETRegistration{ + + registration := nipost.PoETRegistration{ ChallengeHash: types.Hash32(challenge), Address: client.Address(), RoundID: round.ID, RoundEnd: round.End, - }) + } + + if err := nipost.AddPoetRegistration(nb.localDB, nodeID, registration); err != nil { + return nipost.PoETRegistration{}, err + } + + return registration, err } -// Submit the challenge to all registered PoETs. +// submitPoetChallenges submit the challenge to registered PoETs +// if some registrations are missing and PoET round didn't start. func (nb *NIPostBuilder) submitPoetChallenges( ctx context.Context, signer *signing.EdSigner, - deadline time.Time, + poetProofDeadline time.Time, + curPoetRoundStartDeadline time.Time, challenge []byte, -) error { - signature := signer.Sign(signing.POET, challenge) - prefix := bytes.Join([][]byte{signer.Prefix(), {byte(signing.POET)}}, nil) +) ([]nipost.PoETRegistration, error) { + // check if some registrations missing or were removed nodeID := signer.NodeID() - g, ctx := errgroup.WithContext(ctx) - errChan := make(chan error, len(nb.poetProvers)) - for _, client := range nb.poetProvers { - g.Go(func() error { - errChan <- nb.submitPoetChallenge(ctx, nodeID, deadline, client, prefix, challenge, signature) - return nil - }) + registrations, err := nipost.PoetRegistrations(nb.localDB, nodeID) + if err != nil { + return nil, fmt.Errorf("failed to get poet registrations from db: %w", err) } - g.Wait() - close(errChan) - allInvalid := true - for err := range errChan { - if err == nil { - allInvalid = false - continue + registrationsMap := make(map[string]nipost.PoETRegistration) + for _, reg := range registrations { + registrationsMap[reg.Address] = reg + } + + existingRegistrationsMap := make(map[string]nipost.PoETRegistration) + var missingRegistrations []PoetService + for addr, poet := range nb.poetProvers { + if val, ok := registrationsMap[addr]; ok { + existingRegistrationsMap[addr] = val + } else { + missingRegistrations = append(missingRegistrations, poet) } + } - nb.logger.Warn("failed to submit challenge to poet", zap.Error(err), log.ZShortStringer("smesherID", nodeID)) - if !errors.Is(err, ErrInvalidRequest) { - allInvalid = false + misconfiguredRegistrations := make(map[string]struct{}) + for addr := range registrationsMap { + if _, ok := existingRegistrationsMap[addr]; !ok { + misconfiguredRegistrations[addr] = struct{}{} } } - if allInvalid { - nb.logger.Warn("all poet submits were too late. ATX challenge expires", log.ZShortStringer("smesherID", nodeID)) - return ErrATXChallengeExpired + + if len(misconfiguredRegistrations) != 0 { + nb.logger.Warn( + "Found existing registrations for poets not listed in the config. Will not fetch proof from them.", + zap.Strings("registrations_addresses", maps.Keys(misconfiguredRegistrations)), + log.ZShortStringer("smesherID", nodeID), + ) } - return nil -} -func (nb *NIPostBuilder) getPoetService(ctx context.Context, address string) PoetService { - for _, service := range nb.poetProvers { - if address == service.Address() { - return service + existingRegistrations := maps.Values(existingRegistrationsMap) + if len(missingRegistrations) == 0 { + return existingRegistrations, nil + } + + now := time.Now() + + if curPoetRoundStartDeadline.Before(now) { + switch { + case len(existingRegistrations) == 0 && len(registrations) == 0: + // no existing registration at all, drop current registration challenge + return nil, fmt.Errorf( + "%w: poet round has already started at %s (now: %s)", + ErrATXChallengeExpired, + curPoetRoundStartDeadline, + now, + ) + case len(existingRegistrations) == 0: + // no existing registration for given poets set + return nil, &PoetRegistrationMismatchError{ + registrations: maps.Keys(registrationsMap), + configuredPoets: maps.Keys(nb.poetProvers), + } + default: + return existingRegistrations, nil } } - return nil + + // send registrations to missing addresses + signature := signer.Sign(signing.POET, challenge) + prefix := bytes.Join([][]byte{signer.Prefix(), {byte(signing.POET)}}, nil) + + submitCtx, cancel := context.WithDeadline(ctx, curPoetRoundStartDeadline) + defer cancel() + + eg, ctx := errgroup.WithContext(submitCtx) + submittedRegistrationsChan := make(chan nipost.PoETRegistration, len(missingRegistrations)) + + for _, client := range missingRegistrations { + eg.Go(func() error { + registration, err := nb.submitPoetChallenge( + ctx, nodeID, + poetProofDeadline, + client, prefix, challenge, signature, + ) + if err != nil { + nb.logger.Warn("failed to submit challenge to poet", + zap.Error(err), + log.ZShortStringer("smesherID", nodeID), + ) + } else { + submittedRegistrationsChan <- registration + } + return nil + }) + } + + eg.Wait() + close(submittedRegistrationsChan) + + for registration := range submittedRegistrationsChan { + existingRegistrations = append(existingRegistrations, registration) + } + + if len(existingRegistrations) == 0 { + if curPoetRoundStartDeadline.Before(time.Now()) { + return nil, ErrATXChallengeExpired + } + return nil, &PoetSvcUnstableError{msg: "failed to submit challenge to any PoET", source: ctx.Err()} + } + + return existingRegistrations, nil } // membersContainChallenge verifies that the challenge is included in proof's members. @@ -451,16 +524,12 @@ func (nb *NIPostBuilder) getBestProof( ctx context.Context, nodeID types.NodeID, challenge types.Hash32, - publishEpoch types.EpochID, + registrations []nipost.PoETRegistration, ) (types.PoetProofRef, *types.MerkleProof, error) { type poetProof struct { poet *types.PoetProof membership *types.MerkleProof } - registrations, err := nipost.PoetRegistrations(nb.localDB, nodeID) - if err != nil { - return types.PoetProofRef{}, nil, fmt.Errorf("getting poet registrations: %w", err) - } proofs := make(chan *poetProof, len(registrations)) var eg errgroup.Group @@ -471,11 +540,13 @@ func (nb *NIPostBuilder) getBestProof( zap.String("poet_address", r.Address), zap.String("round", r.RoundID), ) - client := nb.getPoetService(ctx, r.Address) - if client == nil { + + client, ok := nb.poetProvers[r.Address] + if !ok { logger.Warn("poet client not found") continue } + round := r.RoundID waitDeadline := proofDeadline(r.RoundEnd, nb.poetCfg.CycleGap) eg.Go(func() error { diff --git a/activation/nipost_test.go b/activation/nipost_test.go index deb0fe588a..41c5b8c782 100644 --- a/activation/nipost_test.go +++ b/activation/nipost_test.go @@ -214,7 +214,7 @@ func Test_NIPost_PostClientHandling(t *testing.T) { }) t.Run("connect, disconnect, then cancel before reconnect", func(t *testing.T) { - // post client connects, starts post, disconnects in between and proofing is canceled before reconnection + // post client connects, starts post, disconnects in between and proving is canceled before reconnection sig, err := signing.NewEdSigner() require.NoError(t, err) @@ -723,7 +723,7 @@ func TestNIPSTBuilder_PoetUnstable(t *testing.T) { require.ErrorAs(t, err, &poetErr) require.Nil(t, nipst) }) - t.Run("Submit hangs", func(t *testing.T) { + t.Run("Submit hangs, no registrations submitted", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mclock := defaultLayerClockMock(ctrl) @@ -761,8 +761,7 @@ func TestNIPSTBuilder_PoetUnstable(t *testing.T) { challenge, &types.NIPostChallenge{PublishEpoch: postGenesisEpoch + 2}, ) - poetErr := &PoetSvcUnstableError{} - require.ErrorAs(t, err, &poetErr) + require.ErrorIs(t, err, ErrATXChallengeExpired) require.Nil(t, nipst) }) t.Run("GetProof fails", func(t *testing.T) { @@ -817,6 +816,302 @@ func TestNIPSTBuilder_PoetUnstable(t *testing.T) { }) } +// TestNIPoSTBuilder_PoETConfigChange checks if +// it properly detects added/deleted PoET services and re-registers if needed. +func TestNIPoSTBuilder_PoETConfigChange(t *testing.T) { + t.Parallel() + + sig, err := signing.NewEdSigner() + require.NoError(t, err) + + challenge := types.NIPostChallenge{ + PublishEpoch: postGenesisEpoch + 2, + } + + challengeHash := wire.NIPostChallengeToWireV1(&challenge).Hash() + + const ( + poetProverAddr = "http://localhost:9999" + poetProverAddr2 = "http://localhost:9988" + ) + + t.Run("1 poet deleted BEFORE round started -> continue with submitted registration", func(t *testing.T) { + db := localsql.InMemory() + ctrl := gomock.NewController(t) + + poet := NewMockPoetService(ctrl) + poet.EXPECT(). + Submit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes(). + Return(&types.PoetRound{}, nil) + poet.EXPECT().Address().Return(poetProverAddr).AnyTimes() + + // successfully registered to 2 poets + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + require.NoError(t, err) + + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr2, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + + nb, err := NewNIPostBuilder( + db, + nil, + zaptest.NewLogger(t), + PoetConfig{}, + nil, + nil, + WithPoetServices(poet), // add only 1 poet prover + ) + require.NoError(t, err) + + existingRegistrations, err := nb.submitPoetChallenges( + context.Background(), + sig, + time.Now().Add(10*time.Second), + time.Now().Add(5*time.Second), + challengeHash.Bytes()) + + require.NoError(t, err) + require.Len(t, existingRegistrations, 1) + require.Equal(t, poetProverAddr, existingRegistrations[0].Address) + }) + + t.Run("1 poet added BEFORE round started -> register to missing poet", func(t *testing.T) { + db := localsql.InMemory() + ctrl := gomock.NewController(t) + + poetProver := NewMockPoetService(ctrl) + poetProver.EXPECT().Address().Return(poetProverAddr).AnyTimes() + + addedPoetProver := NewMockPoetService(ctrl) + addedPoetProver.EXPECT(). + Submit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.PoetRound{}, nil) + addedPoetProver.EXPECT().Address().Return(poetProverAddr2).AnyTimes() + + // successfully registered to 1 poet + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + require.NoError(t, err) + + // successful post exec + nb, err := NewNIPostBuilder( + db, + nil, + zaptest.NewLogger(t), + PoetConfig{}, + nil, + nil, + WithPoetServices(poetProver, addedPoetProver), // add both poet provers + ) + require.NoError(t, err) + + existingRegistrations, err := nb.submitPoetChallenges( + context.Background(), + sig, + time.Now().Add(10*time.Second), + time.Now().Add(5*time.Second), + challengeHash.Bytes()) + + require.NoError(t, err) + require.Len(t, existingRegistrations, 2) + require.Equal(t, poetProverAddr, existingRegistrations[0].Address) + require.Equal(t, poetProverAddr2, existingRegistrations[1].Address) + }) + + t.Run("completely changed poet service BEFORE round started -> register new poet", func(t *testing.T) { + db := localsql.InMemory() + ctrl := gomock.NewController(t) + + addedPoetProver := NewMockPoetService(ctrl) + addedPoetProver.EXPECT(). + Submit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.PoetRound{}, nil) + addedPoetProver.EXPECT().Address().Return(poetProverAddr2).AnyTimes() + + // successfully registered to removed poet + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + require.NoError(t, err) + + nb, err := NewNIPostBuilder( + db, + nil, + zaptest.NewLogger(t), + PoetConfig{}, + nil, + nil, + WithPoetServices(addedPoetProver), // add new poet + ) + require.NoError(t, err) + + existingRegistrations, err := nb.submitPoetChallenges( + context.Background(), + sig, + time.Now().Add(10*time.Second), + time.Now().Add(5*time.Second), + challengeHash.Bytes()) + + require.NoError(t, err) + require.Len(t, existingRegistrations, 1) + require.Equal(t, poetProverAddr2, existingRegistrations[0].Address) + }) + + t.Run("1 poet added AFTER round started -> too late to register to added poet", + func(t *testing.T) { + db := localsql.InMemory() + ctrl := gomock.NewController(t) + + poetProver := NewMockPoetService(ctrl) + poetProver.EXPECT().Address().Return(poetProverAddr).AnyTimes() + + addedPoetProver := NewMockPoetService(ctrl) + addedPoetProver.EXPECT().Address().Return(poetProverAddr2).AnyTimes() + + // successfully registered to 1 poet + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + require.NoError(t, err) + + // successful post exec + nb, err := NewNIPostBuilder( + db, + nil, + zaptest.NewLogger(t), + PoetConfig{}, + nil, + nil, + WithPoetServices(poetProver, addedPoetProver), + ) + require.NoError(t, err) + + existingRegistrations, err := nb.submitPoetChallenges( + context.Background(), + sig, + time.Now().Add(10*time.Second), + time.Now().Add(-5*time.Second), // poet round started + challengeHash.Bytes()) + + require.NoError(t, err) + require.Len(t, existingRegistrations, 1) + require.Equal(t, poetProverAddr, existingRegistrations[0].Address) + }) + + t.Run("1 poet removed AFTER round started -> too late to register to added poet", + func(t *testing.T) { + db := localsql.InMemory() + ctrl := gomock.NewController(t) + + poetProver := NewMockPoetService(ctrl) + poetProver.EXPECT().Address().Return(poetProverAddr).AnyTimes() + + addedPoetProver := NewMockPoetService(ctrl) + addedPoetProver.EXPECT().Address().Return(poetProverAddr2).AnyTimes() + + // successfully registered to 2 poets + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + require.NoError(t, err) + + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr2, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + + nb, err := NewNIPostBuilder( + db, + nil, + zaptest.NewLogger(t), + PoetConfig{}, + nil, + nil, + WithPoetServices(poetProver), + ) + require.NoError(t, err) + + existingRegistrations, err := nb.submitPoetChallenges( + context.Background(), + sig, + time.Now().Add(10*time.Second), + time.Now().Add(-5*time.Second), // poet round started + challengeHash.Bytes()) + + require.NoError(t, err) + require.Len(t, existingRegistrations, 1) + require.Equal(t, poetProverAddr, existingRegistrations[0].Address) + }) + + t.Run("completely changed poet service AFTER round started -> fail, too late to register again", + func(t *testing.T) { + db := localsql.InMemory() + ctrl := gomock.NewController(t) + + poetProver := NewMockPoetService(ctrl) + poetProver.EXPECT().Address().Return(poetProverAddr).AnyTimes() + + // successfully registered to removed poet + err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ + ChallengeHash: challengeHash, + Address: poetProverAddr2, + RoundID: "1", + RoundEnd: time.Now().Add(1 * time.Second), + }) + require.NoError(t, err) + + logger := zaptest.NewLogger(t) + + nb, err := NewNIPostBuilder( + db, + nil, + logger, + PoetConfig{}, + nil, + nil, + WithPoetServices(poetProver), + ) + require.NoError(t, err) + + _, err = nb.submitPoetChallenges( + context.Background(), + sig, + time.Now().Add(10*time.Second), + time.Now().Add(-5*time.Second), // poet round started + challengeHash.Bytes(), + ) + poetErr := &PoetRegistrationMismatchError{} + require.ErrorAs(t, err, &poetErr) + require.ElementsMatch(t, poetErr.configuredPoets, []string{poetProverAddr}) + require.ElementsMatch(t, poetErr.registrations, []string{poetProverAddr2}) + }) +} + // TestNIPoSTBuilder_StaleChallenge checks if // it properly detects that the challenge is stale and the poet round has already started. func TestNIPoSTBuilder_StaleChallenge(t *testing.T) { @@ -828,12 +1123,14 @@ func TestNIPoSTBuilder_StaleChallenge(t *testing.T) { sig, err := signing.NewEdSigner() require.NoError(t, err) + const poetAddr = "http://localhost:9999" + // Act & Verify t.Run("no requests, poet round started", func(t *testing.T) { ctrl := gomock.NewController(t) mclock := NewMocklayerClock(ctrl) poetProver := NewMockPoetService(ctrl) - poetProver.EXPECT().Address().Return("http://localhost:9999") + poetProver.EXPECT().Address().Return(poetAddr).AnyTimes() mclock.EXPECT().LayerToTime(gomock.Any()).DoAndReturn( func(got types.LayerID) time.Time { return genesis.Add(layerDuration * time.Duration(got)) @@ -862,7 +1159,7 @@ func TestNIPoSTBuilder_StaleChallenge(t *testing.T) { ctrl := gomock.NewController(t) mclock := NewMocklayerClock(ctrl) poetProver := NewMockPoetService(ctrl) - poetProver.EXPECT().Address().Return("http://localhost:9999") + poetProver.EXPECT().Address().Return(poetAddr).AnyTimes() mclock.EXPECT().LayerToTime(gomock.Any()).DoAndReturn( func(got types.LayerID) time.Time { return genesis.Add(layerDuration * time.Duration(got)) @@ -889,7 +1186,7 @@ func TestNIPoSTBuilder_StaleChallenge(t *testing.T) { // successfully registered to at least one poet err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ ChallengeHash: challengeHash, - Address: "http://poet1.com", + Address: poetAddr, RoundID: "1", RoundEnd: time.Now().Add(10 * time.Second), }) @@ -904,7 +1201,7 @@ func TestNIPoSTBuilder_StaleChallenge(t *testing.T) { ctrl := gomock.NewController(t) mclock := NewMocklayerClock(ctrl) poetProver := NewMockPoetService(ctrl) - poetProver.EXPECT().Address().Return("http://localhost:9999") + poetProver.EXPECT().Address().Return(poetAddr).AnyTimes() mclock.EXPECT().LayerToTime(gomock.Any()).DoAndReturn( func(got types.LayerID) time.Time { return genesis.Add(layerDuration * time.Duration(got)) @@ -931,7 +1228,7 @@ func TestNIPoSTBuilder_StaleChallenge(t *testing.T) { // successfully registered to at least one poet err = nipost.AddPoetRegistration(db, sig.NodeID(), nipost.PoETRegistration{ ChallengeHash: challengeHash, - Address: "http://poet1.com", + Address: poetAddr, RoundID: "1", RoundEnd: time.Now().Add(10 * time.Second), }) diff --git a/activation/poet.go b/activation/poet.go index 0a30b32953..af9f1f09a1 100644 --- a/activation/poet.go +++ b/activation/poet.go @@ -31,6 +31,7 @@ var ( ErrInvalidRequest = errors.New("invalid request") ErrUnauthorized = errors.New("unauthorized") ErrCertificatesNotSupported = errors.New("poet doesn't support certificates") + ErrIncompatiblePhaseShift = errors.New("fetched poet phase_shift is incompatible with configured phase_shift") ) type PoetPowParams struct { @@ -53,7 +54,7 @@ type PoetClient interface { Address() string PowParams(ctx context.Context) (*PoetPowParams, error) - CertifierInfo(ctx context.Context) (*url.URL, []byte, error) + CertifierInfo(ctx context.Context) (*types.CertifierInfo, error) Submit( ctx context.Context, deadline time.Time, @@ -63,6 +64,7 @@ type PoetClient interface { auth PoetAuth, ) (*types.PoetRound, error) Proof(ctx context.Context, roundID string) (*types.PoetProofMessage, []types.Hash32, error) + Info(ctx context.Context) (*types.PoetInfo, error) } // HTTPPoetClient implements PoetProvingServiceClient interface. @@ -184,20 +186,15 @@ func (c *HTTPPoetClient) PowParams(ctx context.Context) (*PoetPowParams, error) }, nil } -func (c *HTTPPoetClient) CertifierInfo(ctx context.Context) (*url.URL, []byte, error) { - info, err := c.info(ctx) +func (c *HTTPPoetClient) CertifierInfo(ctx context.Context) (*types.CertifierInfo, error) { + info, err := c.Info(ctx) if err != nil { - return nil, nil, err - } - certifierInfo := info.GetCertifier() - if certifierInfo == nil { - return nil, nil, ErrCertificatesNotSupported + return nil, err } - url, err := url.Parse(certifierInfo.Url) - if err != nil { - return nil, nil, fmt.Errorf("parsing certifier address: %w", err) + if info.Certifier == nil { + return nil, ErrCertificatesNotSupported } - return url, certifierInfo.Pubkey, nil + return info.Certifier, nil } // Submit registers a challenge in the proving service current open round. @@ -242,12 +239,30 @@ func (c *HTTPPoetClient) Submit( return &types.PoetRound{ID: resBody.RoundId, End: roundEnd}, nil } -func (c *HTTPPoetClient) info(ctx context.Context) (*rpcapi.InfoResponse, error) { +func (c *HTTPPoetClient) Info(ctx context.Context) (*types.PoetInfo, error) { resBody := rpcapi.InfoResponse{} if err := c.req(ctx, http.MethodGet, "/v1/info", nil, &resBody); err != nil { return nil, fmt.Errorf("getting poet info: %w", err) } - return &resBody, nil + + var certifierInfo *types.CertifierInfo + if resBody.GetCertifier() != nil { + url, err := url.Parse(resBody.GetCertifier().Url) + if err != nil { + return nil, fmt.Errorf("parsing certifier address: %w", err) + } + certifierInfo = &types.CertifierInfo{ + Url: url, + Pubkey: resBody.GetCertifier().Pubkey, + } + } + + return &types.PoetInfo{ + ServicePubkey: resBody.ServicePubkey, + PhaseShift: resBody.PhaseShift.AsDuration(), + CycleGap: resBody.CycleGap.AsDuration(), + Certifier: certifierInfo, + }, nil } // Proof implements PoetProvingServiceClient. @@ -332,11 +347,6 @@ func (c *HTTPPoetClient) req(ctx context.Context, method, path string, reqBody, return nil } -type certifierInfo struct { - url *url.URL - pubkey []byte -} - type cachedData[T any] struct { mu sync.Mutex data T @@ -373,7 +383,10 @@ type poetService struct { certifier certifierService - certifierInfoCache cachedData[*certifierInfo] + certifierInfoCache cachedData[*types.CertifierInfo] + mtx sync.Mutex + expectedPhaseShift time.Duration + fetchedPhaseShift time.Duration powParamsCache cachedData[*PoetPowParams] } @@ -394,7 +407,7 @@ func NewPoetService( ) (*poetService, error) { client, err := NewHTTPPoetClient(server, cfg, WithLogger(logger)) if err != nil { - return nil, fmt.Errorf("creating HTTP poet client %s: %w", server.Address, err) + return nil, err } return NewPoetServiceWithClient( db, @@ -412,21 +425,50 @@ func NewPoetServiceWithClient( logger *zap.Logger, opts ...PoetServiceOpt, ) *poetService { - poetClient := &poetService{ + service := &poetService{ db: db, logger: logger, client: client, requestTimeout: cfg.RequestTimeout, - certifierInfoCache: cachedData[*certifierInfo]{ttl: cfg.CertifierInfoCacheTTL}, + certifierInfoCache: cachedData[*types.CertifierInfo]{ttl: cfg.CertifierInfoCacheTTL}, powParamsCache: cachedData[*PoetPowParams]{ttl: cfg.PowParamsCacheTTL}, proofMembers: make(map[string][]types.Hash32, 1), + expectedPhaseShift: cfg.PhaseShift, } - for _, opt := range opts { - opt(poetClient) + opt(service) + } + + err := service.verifyPhaseShiftConfiguration(context.Background()) + switch { + case errors.Is(err, ErrIncompatiblePhaseShift): + logger.Fatal("failed to create poet service", zap.String("poet", client.Address())) + return nil + case err != nil: + logger.Warn("failed to fetch poet phase shift", + zap.String("poet", client.Address()), + zap.Error(err), + ) } + return service +} + +func (c *poetService) verifyPhaseShiftConfiguration(ctx context.Context) error { + c.mtx.Lock() + defer c.mtx.Unlock() - return poetClient + if c.fetchedPhaseShift != 0 { + return nil + } + resp, err := c.client.Info(ctx) + if err != nil { + return err + } else if resp.PhaseShift != c.expectedPhaseShift { + return ErrIncompatiblePhaseShift + } + + c.fetchedPhaseShift = resp.PhaseShift + return nil } func (c *poetService) Address() string { @@ -452,6 +494,7 @@ func (c *poetService) authorize( // Fallback to PoW // TODO: remove this fallback once we migrate to certificates fully. logger.Info("falling back to PoW authorization") + powCtx, cancel := withConditionalTimeout(ctx, c.requestTimeout) defer cancel() powParams, err := c.powParams(powCtx) @@ -483,11 +526,10 @@ func (c *poetService) reauthorize( ctx context.Context, id types.NodeID, challenge []byte, - logger *zap.Logger, ) (*PoetAuth, error) { if c.certifier != nil { - if _, pubkey, err := c.getCertifierInfo(ctx); err == nil { - if err := c.certifier.DeleteCertificate(id, pubkey); err != nil { + if info, err := c.getCertifierInfo(ctx); err == nil { + if err := c.certifier.DeleteCertificate(id, info.Pubkey); err != nil { return nil, fmt.Errorf("deleting cert: %w", err) } } @@ -508,7 +550,16 @@ func (c *poetService) Submit( log.ZShortStringer("smesherID", nodeID), ) - // Try obtain a certificate + err := c.verifyPhaseShiftConfiguration(ctx) + switch { + case errors.Is(err, ErrIncompatiblePhaseShift): + logger.Fatal("failed to submit challenge", zap.String("poet", c.client.Address())) + return nil, err + case err != nil: + return nil, err + } + + // Try to obtain a certificate auth, err := c.authorize(ctx, nodeID, challenge, logger) if err != nil { return nil, fmt.Errorf("authorizing: %w", err) @@ -524,7 +575,7 @@ func (c *poetService) Submit( return round, nil case errors.Is(err, ErrUnauthorized): logger.Warn("failed to submit challenge as unauthorized - authorizing again", zap.Error(err)) - auth, err := c.reauthorize(ctx, nodeID, challenge, logger) + auth, err := c.reauthorize(ctx, nodeID, challenge) if err != nil { return nil, fmt.Errorf("authorizing: %w", err) } @@ -568,26 +619,25 @@ func (c *poetService) Certify(ctx context.Context, id types.NodeID) (*certifier. if c.certifier == nil { return nil, errors.New("certifier not configured") } - url, pubkey, err := c.getCertifierInfo(ctx) + info, err := c.getCertifierInfo(ctx) if err != nil { return nil, err } - return c.certifier.Certificate(ctx, id, url, pubkey) + return c.certifier.Certificate(ctx, id, info.Url, info.Pubkey) } -func (c *poetService) getCertifierInfo(ctx context.Context) (*url.URL, []byte, error) { - info, err := c.certifierInfoCache.get(func() (*certifierInfo, error) { - url, pubkey, err := c.client.CertifierInfo(ctx) +func (c *poetService) getCertifierInfo(ctx context.Context) (*types.CertifierInfo, error) { + info, err := c.certifierInfoCache.get(func() (*types.CertifierInfo, error) { + certifierInfo, err := c.client.CertifierInfo(ctx) if err != nil { return nil, fmt.Errorf("getting certifier info: %w", err) } - return &certifierInfo{url: url, pubkey: pubkey}, nil + return certifierInfo, nil }) if err != nil { - return nil, nil, err + return nil, err } - - return info.url, info.pubkey, nil + return info, nil } func (c *poetService) powParams(ctx context.Context) (*PoetPowParams, error) { diff --git a/activation/poet_client_test.go b/activation/poet_client_test.go index 139c02eb2d..397fbc06b0 100644 --- a/activation/poet_client_test.go +++ b/activation/poet_client_test.go @@ -16,6 +16,7 @@ import ( "github.com/spacemeshos/poet/server" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "go.uber.org/zap" "go.uber.org/zap/zaptest" "golang.org/x/sync/errgroup" "google.golang.org/protobuf/encoding/protojson" @@ -190,26 +191,27 @@ func TestPoetClient_CachesProof(t *testing.T) { } func TestPoetClient_QueryProofTimeout(t *testing.T) { - block := make(chan struct{}) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-block - })) - defer ts.Close() - defer close(block) - - server := types.PoetServer{ - Address: ts.URL, - Pubkey: types.NewBase64Enc([]byte("pubkey")), - } cfg := PoetConfig{ RequestTimeout: time.Millisecond * 100, + PhaseShift: 10 * time.Second, } - client, err := NewHTTPPoetClient(server, cfg, withCustomHttpClient(ts.Client())) - require.NoError(t, err) + client := NewMockPoetClient(gomock.NewController(t)) + // first call on info returns the expected value + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{ + PhaseShift: cfg.PhaseShift, + }, nil) poet := NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t)) + // any additional call on Info will block + client.EXPECT().Proof(gomock.Any(), "1").DoAndReturn( + func(ctx context.Context, _ string) (*types.PoetProofMessage, []types.Hash32, error) { + <-ctx.Done() + return nil, nil, ctx.Err() + }, + ).AnyTimes() + start := time.Now() - eg := errgroup.Group{} + var eg errgroup.Group for range 50 { eg.Go(func() error { _, _, err := poet.Proof(context.Background(), "1") @@ -435,6 +437,7 @@ func TestPoetClient_FallbacksToPowWhenCannotRecertify(t *testing.T) { client, err := NewHTTPPoetClient(server, cfg, withCustomHttpClient(ts.Client())) require.NoError(t, err) + poet := NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t), WithCertifier(mCertifier)) _, err = poet.Submit(context.Background(), time.Time{}, nil, nil, types.RandomEdSignature(), sig.NodeID()) @@ -457,18 +460,24 @@ func TestPoetService_CachesCertifierInfo(t *testing.T) { cfg.CertifierInfoCacheTTL = tc.ttl client := NewMockPoetClient(gomock.NewController(t)) db := NewPoetDb(sql.InMemory(), zaptest.NewLogger(t)) + + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{}, nil) + poet := NewPoetServiceWithClient(db, client, cfg, zaptest.NewLogger(t)) + url := &url.URL{Host: "certifier.hello"} pubkey := []byte("pubkey") - exp := client.EXPECT().CertifierInfo(gomock.Any()).Return(url, pubkey, nil) + exp := client.EXPECT().CertifierInfo(gomock.Any()). + Return(&types.CertifierInfo{Url: url, Pubkey: pubkey}, nil) if tc.ttl == 0 { exp.Times(5) } for range 5 { - gotUrl, gotPubkey, err := poet.getCertifierInfo(context.Background()) + info, err := poet.getCertifierInfo(context.Background()) require.NoError(t, err) - require.Equal(t, url, gotUrl) - require.Equal(t, pubkey, gotPubkey) + require.Equal(t, url, info.Url) + require.Equal(t, pubkey, info.Pubkey) } }) } @@ -488,6 +497,10 @@ func TestPoetService_CachesPowParams(t *testing.T) { cfg := DefaultPoetConfig() cfg.PowParamsCacheTTL = tc.ttl client := NewMockPoetClient(gomock.NewController(t)) + + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{}, nil) + client.EXPECT().Address().Return("some_address").AnyTimes() + poet := NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t)) params := PoetPowParams{ @@ -506,3 +519,115 @@ func TestPoetService_CachesPowParams(t *testing.T) { }) } } + +func TestPoetService_FetchPoetPhaseShift(t *testing.T) { + t.Parallel() + const phaseShift = time.Second + + t.Run("poet service created: expected and fetched phase shift are matching", + func(t *testing.T) { + cfg := DefaultPoetConfig() + cfg.PhaseShift = phaseShift + + client := NewMockPoetClient(gomock.NewController(t)) + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{ + PhaseShift: phaseShift, + }, nil) + + NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t)) + }) + + t.Run("poet service created: phase shift is not fetched", + func(t *testing.T) { + cfg := DefaultPoetConfig() + cfg.PhaseShift = phaseShift + + client := NewMockPoetClient(gomock.NewController(t)) + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(nil, errors.New("some error")) + + NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t)) + }) + + t.Run("poet service creation failed: expected and fetched phase shift are not matching", + func(t *testing.T) { + cfg := DefaultPoetConfig() + cfg.PhaseShift = phaseShift + + client := NewMockPoetClient(gomock.NewController(t)) + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{ + PhaseShift: phaseShift * 2, + }, nil) + + log := zaptest.NewLogger(t).WithOptions(zap.WithFatalHook(calledFatal(t))) + NewPoetServiceWithClient(nil, client, cfg, log) + }) + + t.Run("fetch phase shift before submitting challenge: success", + func(t *testing.T) { + cfg := DefaultPoetConfig() + cfg.PhaseShift = phaseShift + + client := NewMockPoetClient(gomock.NewController(t)) + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(nil, errors.New("some error")) + + poet := NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t)) + sig, err := signing.NewEdSigner() + require.NoError(t, err) + + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{PhaseShift: phaseShift}, nil) + client.EXPECT().PowParams(gomock.Any()).Return(&PoetPowParams{}, nil) + client.EXPECT(). + Submit( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.PoetRound{}, nil) + + _, err = poet.Submit(context.Background(), time.Time{}, nil, nil, types.RandomEdSignature(), sig.NodeID()) + require.NoError(t, err) + }) + + t.Run("fetch phase shift before submitting challenge: failed to fetch poet info", + func(t *testing.T) { + cfg := DefaultPoetConfig() + cfg.PhaseShift = phaseShift + + client := NewMockPoetClient(gomock.NewController(t)) + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(nil, errors.New("some error")) + + poet := NewPoetServiceWithClient(nil, client, cfg, zaptest.NewLogger(t)) + sig, err := signing.NewEdSigner() + require.NoError(t, err) + + expectedErr := errors.New("some error") + client.EXPECT().Info(gomock.Any()).Return(nil, expectedErr) + + _, err = poet.Submit(context.Background(), time.Time{}, nil, nil, types.RandomEdSignature(), sig.NodeID()) + require.ErrorIs(t, err, expectedErr) + }) + + t.Run("fetch phase shift before submitting challenge: fetched and expected phase shift do not match", + func(t *testing.T) { + cfg := DefaultPoetConfig() + cfg.PhaseShift = phaseShift + + client := NewMockPoetClient(gomock.NewController(t)) + client.EXPECT().Address().Return("some_addr").AnyTimes() + client.EXPECT().Info(gomock.Any()).Return(nil, errors.New("some error")) + + log := zaptest.NewLogger(t).WithOptions(zap.WithFatalHook(calledFatal(t))) + poet := NewPoetServiceWithClient(nil, client, cfg, log) + sig, err := signing.NewEdSigner() + require.NoError(t, err) + + client.EXPECT().Info(gomock.Any()).Return(&types.PoetInfo{ + PhaseShift: phaseShift * 2, + }, nil) + + poet.Submit(context.Background(), time.Time{}, nil, nil, types.RandomEdSignature(), sig.NodeID()) + }) +} diff --git a/activation/poet_mocks.go b/activation/poet_mocks.go index ff746b4de6..885105fef3 100644 --- a/activation/poet_mocks.go +++ b/activation/poet_mocks.go @@ -11,7 +11,6 @@ package activation import ( context "context" - url "net/url" reflect "reflect" time "time" @@ -81,13 +80,12 @@ func (c *MockPoetClientAddressCall) DoAndReturn(f func() string) *MockPoetClient } // CertifierInfo mocks base method. -func (m *MockPoetClient) CertifierInfo(ctx context.Context) (*url.URL, []byte, error) { +func (m *MockPoetClient) CertifierInfo(ctx context.Context) (*types.CertifierInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CertifierInfo", ctx) - ret0, _ := ret[0].(*url.URL) - ret1, _ := ret[1].([]byte) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret0, _ := ret[0].(*types.CertifierInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 } // CertifierInfo indicates an expected call of CertifierInfo. @@ -103,19 +101,19 @@ type MockPoetClientCertifierInfoCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockPoetClientCertifierInfoCall) Return(arg0 *url.URL, arg1 []byte, arg2 error) *MockPoetClientCertifierInfoCall { - c.Call = c.Call.Return(arg0, arg1, arg2) +func (c *MockPoetClientCertifierInfoCall) Return(arg0 *types.CertifierInfo, arg1 error) *MockPoetClientCertifierInfoCall { + c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockPoetClientCertifierInfoCall) Do(f func(context.Context) (*url.URL, []byte, error)) *MockPoetClientCertifierInfoCall { +func (c *MockPoetClientCertifierInfoCall) Do(f func(context.Context) (*types.CertifierInfo, error)) *MockPoetClientCertifierInfoCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockPoetClientCertifierInfoCall) DoAndReturn(f func(context.Context) (*url.URL, []byte, error)) *MockPoetClientCertifierInfoCall { +func (c *MockPoetClientCertifierInfoCall) DoAndReturn(f func(context.Context) (*types.CertifierInfo, error)) *MockPoetClientCertifierInfoCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -158,6 +156,45 @@ func (c *MockPoetClientIdCall) DoAndReturn(f func() []byte) *MockPoetClientIdCal return c } +// Info mocks base method. +func (m *MockPoetClient) Info(ctx context.Context) (*types.PoetInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Info", ctx) + ret0, _ := ret[0].(*types.PoetInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Info indicates an expected call of Info. +func (mr *MockPoetClientMockRecorder) Info(ctx any) *MockPoetClientInfoCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockPoetClient)(nil).Info), ctx) + return &MockPoetClientInfoCall{Call: call} +} + +// MockPoetClientInfoCall wrap *gomock.Call +type MockPoetClientInfoCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockPoetClientInfoCall) Return(arg0 *types.PoetInfo, arg1 error) *MockPoetClientInfoCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockPoetClientInfoCall) Do(f func(context.Context) (*types.PoetInfo, error)) *MockPoetClientInfoCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockPoetClientInfoCall) DoAndReturn(f func(context.Context) (*types.PoetInfo, error)) *MockPoetClientInfoCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // PowParams mocks base method. func (m *MockPoetClient) PowParams(ctx context.Context) (*PoetPowParams, error) { m.ctrl.T.Helper() diff --git a/activation/wire/malfeasance.go b/activation/wire/malfeasance.go index d8e60a4127..019a52d6cc 100644 --- a/activation/wire/malfeasance.go +++ b/activation/wire/malfeasance.go @@ -1,27 +1,60 @@ package wire import ( + "github.com/spacemeshos/go-scale" + "github.com/spacemeshos/go-spacemesh/common/types" + "github.com/spacemeshos/go-spacemesh/signing" ) //go:generate scalegen +// MerkleTreeIndex is the index of the leaf containing the given field in the merkle tree. +type MerkleTreeIndex uint16 + +const ( + PublishEpochIndex MerkleTreeIndex = iota + PositioningATXIndex + CoinbaseIndex + InitialPostIndex + PreviousATXsRootIndex + NIPostsRootIndex + VRFNonceIndex + MarriagesRootIndex + MarriageATXIndex +) + // ProofType is an identifier for the type of proof that is encoded in the ATXProof. type ProofType byte const ( - DoublePublish ProofType = iota + 1 - DoubleMarry - DoubleMerge - InvalidPost + // TODO(mafa): legacy types for future migration to new malfeasance proofs. + LegacyDoublePublish ProofType = 0x00 + LegacyInvalidPost ProofType = 0x01 + LegacyInvalidPrevATX ProofType = 0x02 + + DoublePublish ProofType = 0x10 + DoubleMarry ProofType = 0x11 + DoubleMerge ProofType = 0x12 + InvalidPost ProofType = 0x13 ) +// ProofVersion is an identifier for the version of the proof that is encoded in the ATXProof. +type ProofVersion byte + type ATXProof struct { - // LayerID is the layer in which the proof was created. This can be used to implement different versions of the ATX - // proof in the future. - Layer types.LayerID + // Version is the version identifier of the proof. This can be used to extend the ATX proof in the future. + Version ProofVersion // ProofType is the type of proof that is being provided. ProofType ProofType // Proof is the actual proof. Its type depends on the ProofType. Proof []byte `scale:"max=1048576"` // max size of proof is 1MiB } + +// Proof is an interface for all types of proofs that can be provided in an ATXProof. +// Generally the proof should be able to validate itself and be scale encoded. +type Proof interface { + scale.Encodable + + Valid(edVerifier *signing.EdVerifier) (types.NodeID, error) +} diff --git a/activation/wire/malfeasance_double_marry.go b/activation/wire/malfeasance_double_marry.go new file mode 100644 index 0000000000..ea946b29ea --- /dev/null +++ b/activation/wire/malfeasance_double_marry.go @@ -0,0 +1,221 @@ +package wire + +import ( + "errors" + "fmt" + "slices" + + "github.com/spacemeshos/merkle-tree" + + "github.com/spacemeshos/go-spacemesh/common/types" + "github.com/spacemeshos/go-spacemesh/signing" + "github.com/spacemeshos/go-spacemesh/sql" + "github.com/spacemeshos/go-spacemesh/sql/atxs" +) + +//go:generate scalegen + +// ProofDoubleMarry is a proof that two distinct ATXs contain a marriage certificate signed by the same identity. +// +// We are proving the following: +// 1. The ATXs have different IDs. +// 2. Both ATXs have a valid signature. +// 3. Both ATXs contain a marriage certificate created by the same identity. +// 4. Both marriage certificates have valid signatures. +// +// HINT: this works if the identity that publishes the marriage ATX marries themselves. +type ProofDoubleMarry struct { + // NodeID is the node ID that married twice. + NodeID types.NodeID + + Proofs [2]MarryProof +} + +var _ Proof = &ProofDoubleMarry{} + +func NewDoubleMarryProof(db sql.Executor, atx1, atx2 *ActivationTxV2, nodeID types.NodeID) (*ProofDoubleMarry, error) { + if atx1.ID() == atx2.ID() { + return nil, errors.New("ATXs have the same ID") + } + + proof1, err := createMarryProof(db, atx1, nodeID) + if err != nil { + return nil, fmt.Errorf("proof for atx1: %w", err) + } + + proof2, err := createMarryProof(db, atx2, nodeID) + if err != nil { + return nil, fmt.Errorf("proof for atx2: %w", err) + } + + proof := &ProofDoubleMarry{ + NodeID: nodeID, + Proofs: [2]MarryProof{proof1, proof2}, + } + return proof, nil +} + +func createMarryProof(db sql.Executor, atx *ActivationTxV2, nodeID types.NodeID) (MarryProof, error) { + marriageProof, err := marriageProof(atx) + if err != nil { + return MarryProof{}, fmt.Errorf("failed to create proof for ATX 1: %w", err) + } + + marriageIndex := slices.IndexFunc(atx.Marriages, func(cert MarriageCertificate) bool { + if cert.ReferenceAtx == types.EmptyATXID && atx.SmesherID == nodeID { + // special case of the self signed certificate of the ATX publisher + return true + } + refATX, err := atxs.Get(db, cert.ReferenceAtx) + if err != nil { + return false + } + return refATX.SmesherID == nodeID + }) + if marriageIndex == -1 { + return MarryProof{}, fmt.Errorf("does not contain a marriage certificate signed by %s", nodeID.ShortString()) + } + certProof, err := certificateProof(atx.Marriages, uint64(marriageIndex)) + if err != nil { + return MarryProof{}, fmt.Errorf("failed to create certificate proof for ATX 1: %w", err) + } + + proof := MarryProof{ + ATXID: atx.ID(), + + MarriageRoot: types.Hash32(atx.Marriages.Root()), + MarriageProof: marriageProof, + + CertificateReference: atx.Marriages[marriageIndex].ReferenceAtx, + CertificateSignature: atx.Marriages[marriageIndex].Signature, + CertificateIndex: uint64(marriageIndex), + CertificateProof: certProof, + + SmesherID: atx.SmesherID, + Signature: atx.Signature, + } + return proof, nil +} + +func marriageProof(atx *ActivationTxV2) ([]types.Hash32, error) { + tree, err := merkle.NewTreeBuilder(). + WithLeavesToProve(map[uint64]bool{uint64(MarriagesRootIndex): true}). + WithHashFunc(atxTreeHash). + Build() + if err != nil { + return nil, err + } + atx.merkleTree(tree) + proof := tree.Proof() + + proofHashes := make([]types.Hash32, len(proof)) + for i, p := range proof { + proofHashes[i] = types.Hash32(p) + } + return proofHashes, nil +} + +func certificateProof(certs MarriageCertificates, index uint64) ([]types.Hash32, error) { + tree, err := merkle.NewTreeBuilder(). + WithLeavesToProve(map[uint64]bool{index: true}). + WithHashFunc(atxTreeHash). + Build() + if err != nil { + return nil, err + } + certs.merkleTree(tree) + proof := tree.Proof() + + proofHashes := make([]types.Hash32, len(proof)) + for i, p := range proof { + proofHashes[i] = types.Hash32(p) + } + return proofHashes, nil +} + +func (p ProofDoubleMarry) Valid(edVerifier *signing.EdVerifier) (types.NodeID, error) { + if p.Proofs[0].ATXID == p.Proofs[1].ATXID { + return types.EmptyNodeID, errors.New("proofs have the same ATX ID") + } + + if err := p.Proofs[0].Valid(edVerifier, p.NodeID); err != nil { + return types.EmptyNodeID, fmt.Errorf("proof 1 is invalid: %w", err) + } + if err := p.Proofs[1].Valid(edVerifier, p.NodeID); err != nil { + return types.EmptyNodeID, fmt.Errorf("proof 2 is invalid: %w", err) + } + return p.NodeID, nil +} + +type MarryProof struct { + // ATXID is the ID of the ATX being proven. + ATXID types.ATXID + + // MarriageRoot and its proof that it is contained in the ATX. + MarriageRoot types.Hash32 + MarriageProof []types.Hash32 `scale:"max=32"` + + // The signature of the certificate and the proof that the certificate is contained in the MarriageRoot at + // the given index. + CertificateReference types.ATXID + CertificateSignature types.EdSignature + CertificateIndex uint64 + CertificateProof []types.Hash32 `scale:"max=32"` + + // SmesherID is the ID of the smesher that published the ATX. + SmesherID types.NodeID + // Signature is the signature of the ATXID by the smesher. + Signature types.EdSignature +} + +func (p MarryProof) Valid(edVerifier *signing.EdVerifier, nodeID types.NodeID) error { + if !edVerifier.Verify(signing.ATX, p.SmesherID, p.ATXID.Bytes(), p.Signature) { + return errors.New("invalid ATX signature") + } + + if !edVerifier.Verify(signing.MARRIAGE, nodeID, p.SmesherID.Bytes(), p.CertificateSignature) { + return errors.New("invalid certificate signature") + } + + proof := make([][]byte, len(p.MarriageProof)) + for i, h := range p.MarriageProof { + proof[i] = h.Bytes() + } + ok, err := merkle.ValidatePartialTree( + []uint64{uint64(MarriagesRootIndex)}, + [][]byte{p.MarriageRoot.Bytes()}, + proof, + p.ATXID.Bytes(), + atxTreeHash, + ) + if err != nil { + return fmt.Errorf("validate marriage proof: %w", err) + } + if !ok { + return errors.New("invalid marriage proof") + } + + mc := MarriageCertificate{ + ReferenceAtx: p.CertificateReference, + Signature: p.CertificateSignature, + } + + certProof := make([][]byte, len(p.CertificateProof)) + for i, h := range p.CertificateProof { + certProof[i] = h.Bytes() + } + ok, err = merkle.ValidatePartialTree( + []uint64{p.CertificateIndex}, + [][]byte{mc.Root()}, + certProof, + p.MarriageRoot.Bytes(), + atxTreeHash, + ) + if err != nil { + return fmt.Errorf("validate certificate proof: %w", err) + } + if !ok { + return errors.New("invalid certificate proof") + } + return nil +} diff --git a/activation/wire/malfeasance_double_marry_scale.go b/activation/wire/malfeasance_double_marry_scale.go new file mode 100644 index 0000000000..03f70c95fa --- /dev/null +++ b/activation/wire/malfeasance_double_marry_scale.go @@ -0,0 +1,182 @@ +// Code generated by github.com/spacemeshos/go-scale/scalegen. DO NOT EDIT. + +// nolint +package wire + +import ( + "github.com/spacemeshos/go-scale" + "github.com/spacemeshos/go-spacemesh/common/types" +) + +func (t *ProofDoubleMarry) EncodeScale(enc *scale.Encoder) (total int, err error) { + { + n, err := scale.EncodeByteArray(enc, t.NodeID[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeStructArray(enc, t.Proofs[:]) + if err != nil { + return total, err + } + total += n + } + return total, nil +} + +func (t *ProofDoubleMarry) DecodeScale(dec *scale.Decoder) (total int, err error) { + { + n, err := scale.DecodeByteArray(dec, t.NodeID[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.DecodeStructArray(dec, t.Proofs[:]) + if err != nil { + return total, err + } + total += n + } + return total, nil +} + +func (t *MarryProof) EncodeScale(enc *scale.Encoder) (total int, err error) { + { + n, err := scale.EncodeByteArray(enc, t.ATXID[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeByteArray(enc, t.MarriageRoot[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeStructSliceWithLimit(enc, t.MarriageProof, 32) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeByteArray(enc, t.CertificateReference[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeByteArray(enc, t.CertificateSignature[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeCompact64(enc, uint64(t.CertificateIndex)) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeStructSliceWithLimit(enc, t.CertificateProof, 32) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeByteArray(enc, t.SmesherID[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.EncodeByteArray(enc, t.Signature[:]) + if err != nil { + return total, err + } + total += n + } + return total, nil +} + +func (t *MarryProof) DecodeScale(dec *scale.Decoder) (total int, err error) { + { + n, err := scale.DecodeByteArray(dec, t.ATXID[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.DecodeByteArray(dec, t.MarriageRoot[:]) + if err != nil { + return total, err + } + total += n + } + { + field, n, err := scale.DecodeStructSliceWithLimit[types.Hash32](dec, 32) + if err != nil { + return total, err + } + total += n + t.MarriageProof = field + } + { + n, err := scale.DecodeByteArray(dec, t.CertificateReference[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.DecodeByteArray(dec, t.CertificateSignature[:]) + if err != nil { + return total, err + } + total += n + } + { + field, n, err := scale.DecodeCompact64(dec) + if err != nil { + return total, err + } + total += n + t.CertificateIndex = uint64(field) + } + { + field, n, err := scale.DecodeStructSliceWithLimit[types.Hash32](dec, 32) + if err != nil { + return total, err + } + total += n + t.CertificateProof = field + } + { + n, err := scale.DecodeByteArray(dec, t.SmesherID[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := scale.DecodeByteArray(dec, t.Signature[:]) + if err != nil { + return total, err + } + total += n + } + return total, nil +} diff --git a/activation/wire/malfeasance_double_marry_test.go b/activation/wire/malfeasance_double_marry_test.go new file mode 100644 index 0000000000..351aa265ed --- /dev/null +++ b/activation/wire/malfeasance_double_marry_test.go @@ -0,0 +1,290 @@ +package wire + +import ( + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/spacemeshos/go-spacemesh/common/types" + "github.com/spacemeshos/go-spacemesh/signing" + "github.com/spacemeshos/go-spacemesh/sql" + "github.com/spacemeshos/go-spacemesh/sql/atxs" +) + +func Test_DoubleMarryProof(t *testing.T) { + sig, err := signing.NewEdSigner() + require.NoError(t, err) + + otherSig, err := signing.NewEdSigner() + require.NoError(t, err) + + t.Run("valid", func(t *testing.T) { + db := sql.InMemory() + otherAtx := &types.ActivationTx{} + otherAtx.SetID(types.RandomATXID()) + otherAtx.SmesherID = otherSig.NodeID() + require.NoError(t, atxs.Add(db, otherAtx, types.AtxBlob{})) + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(otherSig, otherAtx.ID(), sig.NodeID()), + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, otherSig.NodeID()), + withMarriageCertificate(sig, atx1.ID(), otherSig.NodeID()), + ) + atx2.Sign(otherSig) + + proof, err := NewDoubleMarryProof(db, atx1, atx2, otherSig.NodeID()) + require.NoError(t, err) + require.NotNil(t, proof) + + verifier := signing.NewEdVerifier() + id, err := proof.Valid(verifier) + require.NoError(t, err) + require.Equal(t, otherSig.NodeID(), id) + }) + + t.Run("does not contain same certificate owner", func(t *testing.T) { + db := sql.InMemory() + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, otherSig.NodeID()), + ) + atx2.Sign(otherSig) + + proof, err := NewDoubleMarryProof(db, atx1, atx2, otherSig.NodeID()) + require.ErrorContains(t, err, fmt.Sprintf( + "proof for atx1: does not contain a marriage certificate signed by %s", otherSig.NodeID().ShortString(), + )) + require.Nil(t, proof) + + proof, err = NewDoubleMarryProof(db, atx1, atx2, sig.NodeID()) + require.ErrorContains(t, err, fmt.Sprintf( + "proof for atx2: does not contain a marriage certificate signed by %s", sig.NodeID().ShortString(), + )) + require.Nil(t, proof) + }) + + t.Run("same ATX ID", func(t *testing.T) { + atx1 := newActivationTxV2() + atx1.Sign(sig) + + db := sql.InMemory() + proof, err := NewDoubleMarryProof(db, atx1, atx1, sig.NodeID()) + require.ErrorContains(t, err, "ATXs have the same ID") + require.Nil(t, proof) + + // manually construct an invalid proof + proof = &ProofDoubleMarry{ + Proofs: [2]MarryProof{ + { + ATXID: atx1.ID(), + }, + { + ATXID: atx1.ID(), + }, + }, + } + + verifier := signing.NewEdVerifier() + id, err := proof.Valid(verifier) + require.ErrorContains(t, err, "same ATX ID") + require.Equal(t, types.EmptyNodeID, id) + }) + + t.Run("invalid marriage proof", func(t *testing.T) { + db := sql.InMemory() + otherAtx := &types.ActivationTx{} + otherAtx.SetID(types.RandomATXID()) + otherAtx.SmesherID = otherSig.NodeID() + require.NoError(t, atxs.Add(db, otherAtx, types.AtxBlob{})) + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(otherSig, otherAtx.ID(), sig.NodeID()), + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, otherSig.NodeID()), + withMarriageCertificate(sig, atx1.ID(), otherSig.NodeID()), + ) + atx2.Sign(otherSig) + + // manually construct an invalid proof + proof1, err := createMarryProof(db, atx1, otherSig.NodeID()) + require.NoError(t, err) + proof2, err := createMarryProof(db, atx2, otherSig.NodeID()) + require.NoError(t, err) + + proof := &ProofDoubleMarry{ + NodeID: otherSig.NodeID(), + Proofs: [2]MarryProof{ + proof1, proof2, + }, + } + + verifier := signing.NewEdVerifier() + proof.Proofs[0].MarriageProof = slices.Clone(proof1.MarriageProof) + proof.Proofs[0].MarriageProof[0] = types.RandomHash() + id, err := proof.Valid(verifier) + require.ErrorContains(t, err, "proof 1 is invalid: invalid marriage proof") + require.Equal(t, types.EmptyNodeID, id) + + proof.Proofs[0].MarriageProof[0] = proof1.MarriageProof[0] + proof.Proofs[1].MarriageProof = slices.Clone(proof2.MarriageProof) + proof.Proofs[1].MarriageProof[0] = types.RandomHash() + id, err = proof.Valid(verifier) + require.ErrorContains(t, err, "proof 2 is invalid: invalid marriage proof") + require.Equal(t, types.EmptyNodeID, id) + }) + + t.Run("invalid certificate proof", func(t *testing.T) { + db := sql.InMemory() + otherAtx := &types.ActivationTx{} + otherAtx.SetID(types.RandomATXID()) + otherAtx.SmesherID = otherSig.NodeID() + require.NoError(t, atxs.Add(db, otherAtx, types.AtxBlob{})) + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(otherSig, otherAtx.ID(), sig.NodeID()), + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, otherSig.NodeID()), + withMarriageCertificate(sig, atx1.ID(), otherSig.NodeID()), + ) + atx2.Sign(otherSig) + + // manually construct an invalid proof + proof1, err := createMarryProof(db, atx1, otherSig.NodeID()) + require.NoError(t, err) + proof2, err := createMarryProof(db, atx2, otherSig.NodeID()) + require.NoError(t, err) + + proof := &ProofDoubleMarry{ + NodeID: otherSig.NodeID(), + Proofs: [2]MarryProof{ + proof1, proof2, + }, + } + + verifier := signing.NewEdVerifier() + proof.Proofs[0].CertificateProof = slices.Clone(proof1.CertificateProof) + proof.Proofs[0].CertificateProof[0] = types.RandomHash() + id, err := proof.Valid(verifier) + require.ErrorContains(t, err, "proof 1 is invalid: invalid certificate proof") + require.Equal(t, types.EmptyNodeID, id) + + proof.Proofs[0].CertificateProof[0] = proof1.CertificateProof[0] + proof.Proofs[1].CertificateProof = slices.Clone(proof2.CertificateProof) + proof.Proofs[1].CertificateProof[0] = types.RandomHash() + id, err = proof.Valid(verifier) + require.ErrorContains(t, err, "proof 2 is invalid: invalid certificate proof") + require.Equal(t, types.EmptyNodeID, id) + }) + + t.Run("invalid atx signature", func(t *testing.T) { + db := sql.InMemory() + otherAtx := &types.ActivationTx{} + otherAtx.SetID(types.RandomATXID()) + otherAtx.SmesherID = otherSig.NodeID() + require.NoError(t, atxs.Add(db, otherAtx, types.AtxBlob{})) + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(otherSig, otherAtx.ID(), sig.NodeID()), + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(sig, atx1.ID(), sig.NodeID()), + ) + atx2.Sign(otherSig) + + proof, err := NewDoubleMarryProof(db, atx1, atx2, otherSig.NodeID()) + require.NoError(t, err) + + verifier := signing.NewEdVerifier() + + proof.Proofs[0].Signature = types.RandomEdSignature() + id, err := proof.Valid(verifier) + require.ErrorContains(t, err, "proof 1 is invalid: invalid ATX signature") + require.Equal(t, types.EmptyNodeID, id) + + proof.Proofs[0].Signature = atx1.Signature + proof.Proofs[1].Signature = types.RandomEdSignature() + id, err = proof.Valid(verifier) + require.ErrorContains(t, err, "proof 2 is invalid: invalid ATX signature") + require.Equal(t, types.EmptyNodeID, id) + }) + + t.Run("invalid certificate signature", func(t *testing.T) { + db := sql.InMemory() + otherAtx := &types.ActivationTx{} + otherAtx.SetID(types.RandomATXID()) + otherAtx.SmesherID = otherSig.NodeID() + require.NoError(t, atxs.Add(db, otherAtx, types.AtxBlob{})) + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(otherSig, otherAtx.ID(), sig.NodeID()), + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(sig, atx1.ID(), sig.NodeID()), + ) + atx2.Sign(otherSig) + + proof, err := NewDoubleMarryProof(db, atx1, atx2, otherSig.NodeID()) + require.NoError(t, err) + + verifier := signing.NewEdVerifier() + + proof.Proofs[0].CertificateSignature = types.RandomEdSignature() + id, err := proof.Valid(verifier) + require.ErrorContains(t, err, "proof 1 is invalid: invalid certificate signature") + require.Equal(t, types.EmptyNodeID, id) + + proof.Proofs[0].CertificateSignature = atx1.Marriages[1].Signature + proof.Proofs[1].CertificateSignature = types.RandomEdSignature() + id, err = proof.Valid(verifier) + require.ErrorContains(t, err, "proof 2 is invalid: invalid certificate signature") + require.Equal(t, types.EmptyNodeID, id) + }) + + t.Run("unknown reference ATX", func(t *testing.T) { + db := sql.InMemory() + + atx1 := newActivationTxV2( + withMarriageCertificate(sig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(otherSig, types.RandomATXID(), sig.NodeID()), // unknown reference ATX + ) + atx1.Sign(sig) + + atx2 := newActivationTxV2( + withMarriageCertificate(otherSig, types.EmptyATXID, sig.NodeID()), + withMarriageCertificate(sig, atx1.ID(), sig.NodeID()), + ) + atx2.Sign(otherSig) + + proof, err := NewDoubleMarryProof(db, atx1, atx2, otherSig.NodeID()) + require.Error(t, err) + require.Nil(t, proof) + }) +} diff --git a/activation/wire/malfeasance_scale.go b/activation/wire/malfeasance_scale.go index b329626623..a2cb9dbed7 100644 --- a/activation/wire/malfeasance_scale.go +++ b/activation/wire/malfeasance_scale.go @@ -5,12 +5,11 @@ package wire import ( "github.com/spacemeshos/go-scale" - "github.com/spacemeshos/go-spacemesh/common/types" ) func (t *ATXProof) EncodeScale(enc *scale.Encoder) (total int, err error) { { - n, err := scale.EncodeCompact32(enc, uint32(t.Layer)) + n, err := scale.EncodeCompact8(enc, uint8(t.Version)) if err != nil { return total, err } @@ -35,12 +34,12 @@ func (t *ATXProof) EncodeScale(enc *scale.Encoder) (total int, err error) { func (t *ATXProof) DecodeScale(dec *scale.Decoder) (total int, err error) { { - field, n, err := scale.DecodeCompact32(dec) + field, n, err := scale.DecodeCompact8(dec) if err != nil { return total, err } total += n - t.Layer = types.LayerID(field) + t.Version = ProofVersion(field) } { field, n, err := scale.DecodeCompact8(dec) diff --git a/activation/wire/wire_v2.go b/activation/wire/wire_v2.go index 7cf29c842e..fd5cccbecf 100644 --- a/activation/wire/wire_v2.go +++ b/activation/wire/wire_v2.go @@ -32,7 +32,7 @@ type ActivationTxV2 struct { // All new IDs that are married to this ID are added to the equivocation set // that this ID belongs to. // It must contain a self-marriage certificate (needed for malfeasance proofs). - Marriages []MarriageCertificate `scale:"max=256"` + Marriages MarriageCertificates `scale:"max=256"` // The ID of the ATX containing marriage for the included IDs. // Only required when the ATX includes married IDs. @@ -46,10 +46,6 @@ type ActivationTxV2 struct { blob []byte } -func (atx *ActivationTxV2) SignedBytes() []byte { - return atx.ID().Bytes() -} - func (atx *ActivationTxV2) Blob() types.AtxBlob { if len(atx.blob) == 0 { atx.blob = codec.MustEncode(atx) @@ -71,9 +67,9 @@ func DecodeAtxV2(blob []byte) (*ActivationTxV2, error) { } func (atx *ActivationTxV2) merkleTree(tree *merkle.Tree) { - publishEpoch := make([]byte, 4) - binary.LittleEndian.PutUint32(publishEpoch, atx.PublishEpoch.Uint32()) - tree.AddLeaf(publishEpoch) + var publishEpoch types.Hash32 + binary.LittleEndian.PutUint32(publishEpoch[:], atx.PublishEpoch.Uint32()) + tree.AddLeaf(publishEpoch.Bytes()) tree.AddLeaf(atx.PositioningATX.Bytes()) tree.AddLeaf(atx.Coinbase.Bytes()) @@ -111,23 +107,11 @@ func (atx *ActivationTxV2) merkleTree(tree *merkle.Tree) { } tree.AddLeaf(niPostTree.Root()) - vrfNonce := make([]byte, 8) - binary.LittleEndian.PutUint64(vrfNonce, atx.VRFNonce) - tree.AddLeaf(vrfNonce) + var vrfNonce types.Hash32 + binary.LittleEndian.PutUint64(vrfNonce[:], atx.VRFNonce) + tree.AddLeaf(vrfNonce.Bytes()) - marriagesTree, err := merkle.NewTreeBuilder(). - WithHashFunc(atxTreeHash). - Build() - if err != nil { - panic(err) - } - for _, marriage := range atx.Marriages { - marriagesTree.AddLeaf(marriage.Root()) - } - for i := len(atx.Marriages); i < 256; i++ { - marriagesTree.AddLeaf(types.EmptyHash32.Bytes()) - } - tree.AddLeaf(marriagesTree.Root()) + tree.AddLeaf(atx.Marriages.Root()) if atx.MarriageATX != nil { tree.AddLeaf(atx.MarriageATX.Bytes()) @@ -154,7 +138,7 @@ func (atx *ActivationTxV2) ID() types.ATXID { func (atx *ActivationTxV2) Sign(signer *signing.EdSigner) { atx.SmesherID = signer.NodeID() - atx.Signature = signer.Sign(signing.ATX, atx.SignedBytes()) + atx.Signature = signer.Sign(signing.ATX, atx.ID().Bytes()) } func (atx *ActivationTxV2) TotalNumUnits() uint32 { @@ -167,6 +151,28 @@ func (atx *ActivationTxV2) TotalNumUnits() uint32 { return total } +type MarriageCertificates []MarriageCertificate + +func (mcs MarriageCertificates) Root() []byte { + marriagesTree, err := merkle.NewTreeBuilder(). + WithHashFunc(atxTreeHash). + Build() + if err != nil { + panic(err) + } + mcs.merkleTree(marriagesTree) + return marriagesTree.Root() +} + +func (mcs MarriageCertificates) merkleTree(tree *merkle.Tree) { + for _, marriage := range mcs { + tree.AddLeaf(marriage.Root()) + } + for i := len(mcs); i < 256; i++ { + tree.AddLeaf(types.EmptyHash32.Bytes()) + } +} + type InitialAtxPartsV2 struct { CommitmentATX types.ATXID Post PostV1 diff --git a/activation/wire/wire_v2_test.go b/activation/wire/wire_v2_test.go index 596be06091..e0303affb0 100644 --- a/activation/wire/wire_v2_test.go +++ b/activation/wire/wire_v2_test.go @@ -1,17 +1,59 @@ package wire import ( - "encoding/binary" "math/rand/v2" "testing" fuzz "github.com/google/gofuzz" - "github.com/spacemeshos/merkle-tree" "github.com/stretchr/testify/require" "github.com/spacemeshos/go-spacemesh/common/types" + "github.com/spacemeshos/go-spacemesh/signing" ) +type testAtxV2Opt func(*ActivationTxV2) + +func withMarriageCertificate(sig *signing.EdSigner, refAtx types.ATXID, atxPublisher types.NodeID) testAtxV2Opt { + return func(atx *ActivationTxV2) { + certificate := MarriageCertificate{ + ReferenceAtx: refAtx, + Signature: sig.Sign(signing.MARRIAGE, atxPublisher.Bytes()), + } + atx.Marriages = append(atx.Marriages, certificate) + } +} + +func newActivationTxV2(opts ...testAtxV2Opt) *ActivationTxV2 { + atx := &ActivationTxV2{ + PublishEpoch: rand.N(types.EpochID(255)), + PositioningATX: types.RandomATXID(), + PreviousATXs: make([]types.ATXID, 1+rand.IntN(255)), + NiPosts: []NiPostsV2{ + { + Membership: MerkleProofV2{ + Nodes: make([]types.Hash32, 32), + }, + Challenge: types.RandomHash(), + Posts: []SubPostV2{ + { + MarriageIndex: rand.Uint32N(256), + PrevATXIndex: 0, + Post: PostV1{ + Nonce: 0, + Indices: make([]byte, 800), + Pow: 0, + }, + }, + }, + }, + }, + } + for _, opt := range opts { + opt(atx) + } + return atx +} + func Benchmark_ATXv2ID(b *testing.B) { f := fuzz.New() b.ResetTimer() @@ -83,79 +125,3 @@ func Test_NoATXv2IDCollisions(t *testing.T) { atxIDs = append(atxIDs, id) } } - -const PublishEpochIndex = 0 - -func Test_GenerateDoublePublishProof(t *testing.T) { - atx := &ActivationTxV2{ - PublishEpoch: 10, - PositioningATX: types.RandomATXID(), - PreviousATXs: make([]types.ATXID, 1), - NiPosts: []NiPostsV2{ - { - Membership: MerkleProofV2{ - Nodes: make([]types.Hash32, 32), - }, - Challenge: types.RandomHash(), - Posts: []SubPostV2{ - { - MarriageIndex: rand.Uint32N(256), - PrevATXIndex: 0, - Post: PostV1{ - Nonce: 0, - Indices: make([]byte, 800), - Pow: 0, - }, - }, - }, - }, - }, - } - - proof, err := generatePublishEpochProof(atx) - require.NoError(t, err) - require.NotNil(t, proof) - - // a malfeasance proof for double publish will contain - // - the value of the PublishEpoch (here 10) - 4 bytes - // - the two ATX IDs - 32 bytes each - // - the two signatures (atx.Signature + atx.NodeID) - 64 bytes each - // - two merkle proofs - one per ATX - that is 128 bytes each (4 * 32) - // total: 452 bytes instead of two full ATXs (> 20 kB each in the worst case) - - publishEpoch := make([]byte, 4) - binary.LittleEndian.PutUint32(publishEpoch, atx.PublishEpoch.Uint32()) - ok, err := merkle.ValidatePartialTree( - []uint64{PublishEpochIndex}, - [][]byte{publishEpoch}, - proof, - atx.ID().Bytes(), - atxTreeHash, - ) - require.NoError(t, err) - require.True(t, ok) - - // different PublishEpoch doesn't validate - publishEpoch = []byte{0xFF, 0x00, 0x00, 0x00} - ok, err = merkle.ValidatePartialTree( - []uint64{PublishEpochIndex}, - [][]byte{publishEpoch}, - proof, - atx.ID().Bytes(), - atxTreeHash, - ) - require.NoError(t, err) - require.False(t, ok) -} - -func generatePublishEpochProof(atx *ActivationTxV2) ([][]byte, error) { - tree, err := merkle.NewTreeBuilder(). - WithLeavesToProve(map[uint64]bool{PublishEpochIndex: true}). - WithHashFunc(atxTreeHash). - Build() - if err != nil { - return nil, err - } - atx.merkleTree(tree) - return tree.Proof(), nil -} diff --git a/common/types/poet.go b/common/types/poet.go index 0d1d1177ea..f04b613517 100644 --- a/common/types/poet.go +++ b/common/types/poet.go @@ -3,6 +3,7 @@ package types import ( "encoding/hex" "fmt" + "net/url" "time" poetShared "github.com/spacemeshos/poet/shared" @@ -97,3 +98,15 @@ type PoetRound struct { ID string `scale:"max=32"` End time.Time } + +type PoetInfo struct { + ServicePubkey []byte + PhaseShift time.Duration + CycleGap time.Duration + Certifier *CertifierInfo +} + +type CertifierInfo struct { + Url *url.URL + Pubkey []byte +} diff --git a/hare3/hare_test.go b/hare3/hare_test.go index dd8139ab12..0df5b7f726 100644 --- a/hare3/hare_test.go +++ b/hare3/hare_test.go @@ -148,8 +148,8 @@ func (n *node) reuseSigner(signer *signing.EdSigner) *node { return n } -func (n *node) withDb() *node { - n.db = sql.InMemory() +func (n *node) withDb(tb testing.TB) *node { + n.db = sql.InMemoryTest(tb) n.atxsdata = atxsdata.New() n.proposals = store.New() return n @@ -342,7 +342,7 @@ func (cl *lockstepCluster) addActive(n int) *lockstepCluster { for i := last; i < last+n; i++ { cl.addNode((&node{t: cl.t, i: i}). withController().withSyncer().withPublisher(). - withClock().withDb().withSigner().withAtx(cl.units.min, cl.units.max). + withClock().withDb(cl.t).withSigner().withAtx(cl.units.min, cl.units.max). withOracle().withHare()) } return cl @@ -353,7 +353,7 @@ func (cl *lockstepCluster) addInactive(n int) *lockstepCluster { for i := last; i < last+n; i++ { cl.addNode((&node{t: cl.t, i: i}). withController().withSyncer().withPublisher(). - withClock().withDb().withSigner(). + withClock().withDb(cl.t).withSigner(). withOracle().withHare()) } return cl @@ -366,7 +366,7 @@ func (cl *lockstepCluster) addEquivocators(n int) *lockstepCluster { cl.addNode((&node{t: cl.t, i: i}). reuseSigner(cl.nodes[i-last].signer). withController().withSyncer().withPublisher(). - withClock().withDb().withAtx(cl.units.min, cl.units.max). + withClock().withDb(cl.t).withAtx(cl.units.min, cl.units.max). withOracle().withHare()) } return cl diff --git a/hare3/malfeasance_test.go b/hare3/malfeasance_test.go index 0f2a0f1491..7ff1cf52b1 100644 --- a/hare3/malfeasance_test.go +++ b/hare3/malfeasance_test.go @@ -26,7 +26,7 @@ type testMalfeasanceHandler struct { } func newTestMalfeasanceHandler(tb testing.TB) *testMalfeasanceHandler { - db := sql.InMemory() + db := sql.InMemoryTest(tb) observer, observedLogs := observer.New(zapcore.WarnLevel) logger := zaptest.NewLogger(tb, zaptest.WrapOptions(zap.WrapCore( func(core zapcore.Core) zapcore.Core { diff --git a/hare4/eligibility/oracle_test.go b/hare4/eligibility/oracle_test.go index d6ae2e4a58..5dfdf877b6 100644 --- a/hare4/eligibility/oracle_test.go +++ b/hare4/eligibility/oracle_test.go @@ -53,7 +53,7 @@ type testOracle struct { } func defaultOracle(tb testing.TB) *testOracle { - db := sql.InMemory() + db := sql.InMemoryTest(tb) atxsdata := atxsdata.New() ctrl := gomock.NewController(tb) diff --git a/hare4/hare_test.go b/hare4/hare_test.go index 70f363a8f6..31707ac31d 100644 --- a/hare4/hare_test.go +++ b/hare4/hare_test.go @@ -159,8 +159,8 @@ func (n *node) reuseSigner(signer *signing.EdSigner) *node { return n } -func (n *node) withDb() *node { - n.db = sql.InMemory() +func (n *node) withDb(tb testing.TB) *node { + n.db = sql.InMemoryTest(tb) n.atxsdata = atxsdata.New() n.proposals = store.New() return n @@ -391,7 +391,7 @@ func (cl *lockstepCluster) addActive(n int) *lockstepCluster { for i := last; i < last+n; i++ { nn := (&node{t: cl.t, i: i}). withController().withSyncer().withPublisher(). - withClock().withDb().withSigner().withAtx(cl.units.min, cl.units.max). + withClock().withDb(cl.t).withSigner().withAtx(cl.units.min, cl.units.max). withStreamRequester().withOracle().withHare() if cl.mockVerify { nn = nn.withVerifier() @@ -406,7 +406,7 @@ func (cl *lockstepCluster) addInactive(n int) *lockstepCluster { for i := last; i < last+n; i++ { cl.addNode((&node{t: cl.t, i: i}). withController().withSyncer().withPublisher(). - withClock().withDb().withSigner(). + withClock().withDb(cl.t).withSigner(). withStreamRequester().withOracle().withHare()) } return cl @@ -419,7 +419,7 @@ func (cl *lockstepCluster) addEquivocators(n int) *lockstepCluster { cl.addNode((&node{t: cl.t, i: i}). reuseSigner(cl.nodes[i-last].signer). withController().withSyncer().withPublisher(). - withClock().withDb().withAtx(cl.units.min, cl.units.max). + withClock().withDb(cl.t).withAtx(cl.units.min, cl.units.max). withStreamRequester().withOracle().withHare()) } return cl @@ -598,12 +598,15 @@ func (cl *lockstepCluster) waitStopped() { // drainInteractiveMessages will make sure that the channels that signal // that interactive messages came in on the tracer are read from. func (cl *lockstepCluster) drainInteractiveMessages() { + done := make(chan struct{}) + cl.t.Cleanup(func() { close(done) }) for _, n := range cl.nodes { go func() { for { select { case <-n.tracer.compactReq: case <-n.tracer.compactResp: + case <-done: } } }() diff --git a/malfeasance/handler.go b/malfeasance/handler.go index 50890eee62..4949cb4a56 100644 --- a/malfeasance/handler.go +++ b/malfeasance/handler.go @@ -44,7 +44,6 @@ const ( InvalidActivation MalfeasanceType = iota + 10 InvalidBallot InvalidHareMsg - DoubleMarry = MalfeasanceType(wire.DoubleMarry) ) // Handler processes MalfeasanceProof from gossip and, if deems it valid, propagates it to peers. diff --git a/malfeasance/wire/malfeasance.go b/malfeasance/wire/malfeasance.go index b4132ea568..0ffd8e4228 100644 --- a/malfeasance/wire/malfeasance.go +++ b/malfeasance/wire/malfeasance.go @@ -15,7 +15,7 @@ import ( "github.com/spacemeshos/go-spacemesh/common/types" ) -//go:generate scalegen -types MalfeasanceProof,MalfeasanceGossip,AtxProof,BallotProof,HareProof,AtxProofMsg,BallotProofMsg,HareProofMsg,HareMetadata,InvalidPostIndexProof,InvalidPrevATXProof,DoubleMarryProof +//go:generate scalegen -types MalfeasanceProof,MalfeasanceGossip,AtxProof,BallotProof,HareProof,AtxProofMsg,BallotProofMsg,HareProofMsg,HareMetadata,InvalidPostIndexProof,InvalidPrevATXProof const ( MultipleATXs byte = iota + 1 @@ -23,7 +23,6 @@ const ( HareEquivocation InvalidPostIndex InvalidPrevATX - DoubleMarry ) type MalfeasanceProof struct { @@ -325,12 +324,6 @@ type InvalidPrevATXProof struct { func (p *InvalidPrevATXProof) isProof() {} -type DoubleMarryProof struct { - // TODO: implement -} - -func (p *DoubleMarryProof) isProof() {} - func MalfeasanceInfo(smesher types.NodeID, mp *MalfeasanceProof) string { var b strings.Builder b.WriteString(fmt.Sprintf("generate layer: %v\n", mp.Layer)) diff --git a/malfeasance/wire/malfeasance_scale.go b/malfeasance/wire/malfeasance_scale.go index 6e23fd2175..3ec88a1acc 100644 --- a/malfeasance/wire/malfeasance_scale.go +++ b/malfeasance/wire/malfeasance_scale.go @@ -422,11 +422,3 @@ func (t *InvalidPrevATXProof) DecodeScale(dec *scale.Decoder) (total int, err er } return total, nil } - -func (t *DoubleMarryProof) EncodeScale(enc *scale.Encoder) (total int, err error) { - return total, nil -} - -func (t *DoubleMarryProof) DecodeScale(dec *scale.Decoder) (total int, err error) { - return total, nil -} diff --git a/node/node.go b/node/node.go index a512018bd0..95a0b05dec 100644 --- a/node/node.go +++ b/node/node.go @@ -1042,7 +1042,7 @@ func (app *App) initServices(ctx context.Context) error { activation.WithCertifier(certifier), ) if err != nil { - app.log.Panic("failed to create poet client: %v", err) + app.log.Panic("failed to create poet client with address %v: %v", server.Address, err) } poetClients = append(poetClients, client) } diff --git a/node/node_test.go b/node/node_test.go index 3a8f5a2407..274ed57ca7 100644 --- a/node/node_test.go +++ b/node/node_test.go @@ -997,7 +997,7 @@ func TestAdminEvents(t *testing.T) { select { case <-app.Started(): - case <-time.After(10 * time.Second): + case <-time.After(15 * time.Second): require.Fail(t, "app did not start in time") } @@ -1090,7 +1090,7 @@ func TestAdminEvents_MultiSmesher(t *testing.T) { select { case <-app.Started(): - case <-time.After(10 * time.Second): + case <-time.After(15 * time.Second): require.Fail(t, "app did not start in time") } diff --git a/sql/database.go b/sql/database.go index 5f86f0bda5..e20a393b63 100644 --- a/sql/database.go +++ b/sql/database.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "sync/atomic" + "testing" "time" sqlite "github.com/go-llsqlite/crawshaw" @@ -176,6 +177,7 @@ func WithQueryCacheSizes(sizes map[QueryCacheKind]int) Opt { type Opt func(c *conf) // InMemory database for testing. +// Please use InMemoryTest for automatic closing of the returned db during `tb.Cleanup`. func InMemory(opts ...Opt) *Database { opts = append(opts, WithConnections(1)) db, err := Open("file::memory:?mode=memory", opts...) @@ -185,6 +187,17 @@ func InMemory(opts ...Opt) *Database { return db } +// InMemoryTest returns an in-mem database for testing and ensures database is closed during `tb.Cleanup`. +func InMemoryTest(tb testing.TB, opts ...Opt) *Database { + opts = append(opts, WithConnections(1)) + db, err := Open("file::memory:?mode=memory", opts...) + if err != nil { + panic(err) + } + tb.Cleanup(func() { db.Close() }) + return db +} + // Open database with options. // // Database is opened in WAL mode and pragma synchronous=normal. diff --git a/sql/localsql/nipost/poet_registration.go b/sql/localsql/nipost/poet_registration.go index 50aa83ac10..743de7b4cc 100644 --- a/sql/localsql/nipost/poet_registration.go +++ b/sql/localsql/nipost/poet_registration.go @@ -37,23 +37,6 @@ func AddPoetRegistration( return nil } -func PoetRegistrationCount(db sql.Executor, nodeID types.NodeID) (int, error) { - var count int - enc := func(stmt *sql.Statement) { - stmt.BindBytes(1, nodeID.Bytes()) - } - dec := func(stmt *sql.Statement) bool { - count = int(stmt.ColumnInt64(0)) - return true - } - query := `select count(*) from poet_registration where id = ?1;` - _, err := db.Exec(query, enc, dec) - if err != nil { - return 0, fmt.Errorf("get poet registration count for node id %s: %w", nodeID.ShortString(), err) - } - return count, nil -} - func ClearPoetRegistrations(db sql.Executor, nodeID types.NodeID) error { enc := func(stmt *sql.Statement) { stmt.BindBytes(1, nodeID.Bytes()) @@ -66,9 +49,11 @@ func ClearPoetRegistrations(db sql.Executor, nodeID types.NodeID) error { func PoetRegistrations(db sql.Executor, nodeID types.NodeID) ([]PoETRegistration, error) { var registrations []PoETRegistration + enc := func(stmt *sql.Statement) { stmt.BindBytes(1, nodeID.Bytes()) } + dec := func(stmt *sql.Statement) bool { registration := PoETRegistration{ Address: stmt.ColumnText(1), @@ -79,10 +64,13 @@ func PoetRegistrations(db sql.Executor, nodeID types.NodeID) ([]PoETRegistration registrations = append(registrations, registration) return true } - query := `select hash, address, round_id, round_end from poet_registration where id = ?1;` + + query := `SELECT hash, address, round_id, round_end FROM poet_registration WHERE id = ?1;` + _, err := db.Exec(query, enc, dec) if err != nil { return nil, fmt.Errorf("get poet registrations for node id %s: %w", nodeID.ShortString(), err) } + return registrations, nil } diff --git a/sql/localsql/nipost/poet_registration_test.go b/sql/localsql/nipost/poet_registration_test.go index 9d130043ae..a4228a6371 100644 --- a/sql/localsql/nipost/poet_registration_test.go +++ b/sql/localsql/nipost/poet_registration_test.go @@ -31,18 +31,14 @@ func Test_AddPoetRegistration(t *testing.T) { err := AddPoetRegistration(db, nodeID, reg1) require.NoError(t, err) - count, err := PoetRegistrationCount(db, nodeID) + registrations, err := PoetRegistrations(db, nodeID) require.NoError(t, err) - require.Equal(t, 1, count) + require.Len(t, registrations, 1) err = AddPoetRegistration(db, nodeID, reg2) require.NoError(t, err) - count, err = PoetRegistrationCount(db, nodeID) - require.NoError(t, err) - require.Equal(t, 2, count) - - registrations, err := PoetRegistrations(db, nodeID) + registrations, err = PoetRegistrations(db, nodeID) require.NoError(t, err) require.Len(t, registrations, 2) require.Equal(t, reg1, registrations[0]) @@ -51,9 +47,9 @@ func Test_AddPoetRegistration(t *testing.T) { err = ClearPoetRegistrations(db, nodeID) require.NoError(t, err) - count, err = PoetRegistrationCount(db, nodeID) + registrations, err = PoetRegistrations(db, nodeID) require.NoError(t, err) - require.Equal(t, 0, count) + require.Empty(t, registrations) } func Test_AddPoetRegistration_NoDuplicates(t *testing.T) { @@ -70,14 +66,14 @@ func Test_AddPoetRegistration_NoDuplicates(t *testing.T) { err := AddPoetRegistration(db, nodeID, reg) require.NoError(t, err) - count, err := PoetRegistrationCount(db, nodeID) + registrations, err := PoetRegistrations(db, nodeID) require.NoError(t, err) - require.Equal(t, 1, count) + require.Len(t, registrations, 1) err = AddPoetRegistration(db, nodeID, reg) require.ErrorIs(t, err, sql.ErrObjectExists) - count, err = PoetRegistrationCount(db, nodeID) + registrations, err = PoetRegistrations(db, nodeID) require.NoError(t, err) - require.Equal(t, 1, count) + require.Len(t, registrations, 1) } diff --git a/systest/Makefile b/systest/Makefile index 08829590b3..b1f4d07f11 100644 --- a/systest/Makefile +++ b/systest/Makefile @@ -39,8 +39,6 @@ endif command := tests -test.v -test.count=$(count) -test.timeout=60m -test.run=$(test_name) -test.parallel=$(clusters) \ -test.failfast=$(failfast) -clusters=$(clusters) -level=$(level) -configname=$(configname) - - .PHONY: docker docker: @DOCKER_BUILDKIT=1 docker build \