From 5c7f4867ccbe1750b31862e9a4d5576621ebb3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20R=C3=B3=C5=BCa=C5=84ski?= Date: Wed, 28 Dec 2022 20:16:34 +0000 Subject: [PATCH] Query poet proofs instead of relying on broadcasting (#3865) ## Motivation Part of https://github.com/spacemeshos/pm/issues/173 Closes #3746 Closes #3814 ## Changes - removed broadcasting method from `GatewayService` - removed p2p listeners for broadcasted poet proofs - changed `NIPostBuilder` to query poets for proofs after the rounds end ## Test Plan - added a system test in which nodes use different poets to verify if poet proofs are properly propagated between nodes ## TODO - [ ] Bump poet to a released version in go.mod after https://github.com/spacemeshos/poet/pull/187 is merged ## DevOps Notes - [x] This PR does not require configuration changes (e.g., environment variables, GitHub secrets, VM resources) - [ ] ~This PR does not affect public APIs~ Proof broadcasting was removed - [ ] ~This PR does not rely on a new version of external services (PoET, elasticsearch, etc.)~ - It relies on a new Poet version - [ ] ~This PR does not make changes to log messages (which monitoring infrastructure may rely on)~ Co-authored-by: moshababo --- activation/activation.go | 4 +- activation/activation_test.go | 4 +- activation/challenge_verifier.go | 2 +- activation/interface.go | 6 - activation/mocks.go | 65 ---- activation/nipost.go | 163 ++++++---- activation/nipost_mocks.go | 51 ++-- activation/nipost_test.go | 362 ++++++++++------------- activation/poet.go | 90 +++++- activation/poet_test.go | 15 +- activation/poetdb.go | 2 + activation/poetdb_test.go | 8 +- activation/poetlistener.go | 66 ----- activation/poetlistener_test.go | 51 ---- activation/test_resources/poet.proof | Bin 15754 -> 44493 bytes api/grpcserver/gateway_service.go | 35 +-- api/grpcserver/grpcserver_test.go | 25 +- beacon/state.go | 2 +- cmd/node/node.go | 8 +- cmd/node/node_test.go | 6 +- common/types/activation.go | 80 ++++- common/types/activation_scale.go | 19 +- common/types/activation_test.go | 17 ++ common/types/nipost.go | 4 +- fetch/mesh_data.go | 24 +- go.mod | 4 +- go.sum | 8 +- log/zap.go | 4 + p2p/pubsub/pubsub.go | 7 +- systest/Makefile | 2 +- systest/parameters/fastnet/poet.conf | 6 +- systest/parameters/fastnet/smesher.json | 6 +- systest/parameters/longfast/poet.conf | 2 +- systest/parameters/longfast/smesher.json | 8 +- systest/tests/poets_test.go | 99 ++++++- systest/tests/smeshing_test.go | 14 +- 36 files changed, 681 insertions(+), 588 deletions(-) delete mode 100644 activation/poetlistener.go delete mode 100644 activation/poetlistener_test.go diff --git a/activation/activation.go b/activation/activation.go index d3f3979670..850afeeeeb 100644 --- a/activation/activation.go +++ b/activation/activation.go @@ -658,7 +658,9 @@ func (b *Builder) createAtx(ctx context.Context) (*types.ActivationTx, error) { challenge.InitialPost = b.initialPost challenge.InitialPostMetadata = b.initialPostMeta } - nipost, postDuration, err := b.nipostBuilder.BuildNIPost(ctx, &challenge, poetProofDeadline) + buildingNipostCtx, cancel := context.WithDeadline(ctx, nextPoetRoundStart) + defer cancel() + nipost, postDuration, err := b.nipostBuilder.BuildNIPost(buildingNipostCtx, &challenge, poetProofDeadline) if err != nil { return nil, fmt.Errorf("failed to build NIPost: %w", err) } diff --git a/activation/activation_test.go b/activation/activation_test.go index 8a6ee2a9df..b8bba96072 100644 --- a/activation/activation_test.go +++ b/activation/activation_test.go @@ -1408,7 +1408,7 @@ func TestBuilder_UpdatePoets(t *testing.T) { atxHdlr := newAtxHandler(t, cdb) b := newBuilder(t, cdb, atxHdlr, WithPoETClientInitializer(func(string) PoetProvingServiceClient { poet := NewMockPoetProvingServiceClient(gomock.NewController(t)) - poet.EXPECT().PoetServiceID(gomock.Any()).Times(1).Return([]byte("poetid"), nil) + poet.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return([]byte("poetid"), nil) return poet })) @@ -1430,7 +1430,7 @@ func TestBuilder_UpdatePoetsUnstable(t *testing.T) { atxHdlr := newAtxHandler(t, cdb) b := newBuilder(t, cdb, atxHdlr, WithPoETClientInitializer(func(string) PoetProvingServiceClient { poet := NewMockPoetProvingServiceClient(gomock.NewController(t)) - poet.EXPECT().PoetServiceID(gomock.Any()).Times(1).Return([]byte("poetid"), errors.New("ERROR")) + poet.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return([]byte("poetid"), errors.New("ERROR")) return poet })) diff --git a/activation/challenge_verifier.go b/activation/challenge_verifier.go index 666bb61f17..1e463b5895 100644 --- a/activation/challenge_verifier.go +++ b/activation/challenge_verifier.go @@ -84,7 +84,7 @@ func (v *challengeVerifier) Verify(ctx context.Context, challengeBytes, signatur } func (v *challengeVerifier) verifyChallenge(ctx context.Context, challenge *types.PoetChallenge, nodeID types.NodeID) error { - log.With().Info("verifying challenge", log.Object("challenge", challenge)) + log.GetLogger().WithContext(ctx).With().Info("Verifying challenge", log.Object("challenge", challenge)) if err := validateNumUnits(&v.cfg, challenge.NumUnits); err != nil { return fmt.Errorf("%w: %v", ErrChallengeInvalid, err) diff --git a/activation/interface.go b/activation/interface.go index a646629289..9afb013ef4 100644 --- a/activation/interface.go +++ b/activation/interface.go @@ -13,12 +13,6 @@ type atxReceiver interface { OnAtx(*types.ActivationTxHeader) } -type poetValidatorPersister interface { - HasProof(types.PoetProofRef) bool - Validate(types.PoetProof, []byte, string, []byte) error - StoreProof(context.Context, types.PoetProofRef, *types.PoetProofMessage) error -} - type nipostValidator interface { Validate(nodeId types.NodeID, atxId types.ATXID, NIPost *types.NIPost, expectedChallenge types.Hash32, numUnits uint32) (uint64, error) ValidatePost(nodeId types.NodeID, atxId types.ATXID, Post *types.Post, PostMetadata *types.PostMetadata, numUnits uint32) error diff --git a/activation/mocks.go b/activation/mocks.go index c0db11134a..0c32d3c4f4 100644 --- a/activation/mocks.go +++ b/activation/mocks.go @@ -48,71 +48,6 @@ func (mr *MockatxReceiverMockRecorder) OnAtx(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnAtx", reflect.TypeOf((*MockatxReceiver)(nil).OnAtx), arg0) } -// MockpoetValidatorPersister is a mock of poetValidatorPersister interface. -type MockpoetValidatorPersister struct { - ctrl *gomock.Controller - recorder *MockpoetValidatorPersisterMockRecorder -} - -// MockpoetValidatorPersisterMockRecorder is the mock recorder for MockpoetValidatorPersister. -type MockpoetValidatorPersisterMockRecorder struct { - mock *MockpoetValidatorPersister -} - -// NewMockpoetValidatorPersister creates a new mock instance. -func NewMockpoetValidatorPersister(ctrl *gomock.Controller) *MockpoetValidatorPersister { - mock := &MockpoetValidatorPersister{ctrl: ctrl} - mock.recorder = &MockpoetValidatorPersisterMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockpoetValidatorPersister) EXPECT() *MockpoetValidatorPersisterMockRecorder { - return m.recorder -} - -// HasProof mocks base method. -func (m *MockpoetValidatorPersister) HasProof(arg0 types.PoetProofRef) bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HasProof", arg0) - ret0, _ := ret[0].(bool) - return ret0 -} - -// HasProof indicates an expected call of HasProof. -func (mr *MockpoetValidatorPersisterMockRecorder) HasProof(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasProof", reflect.TypeOf((*MockpoetValidatorPersister)(nil).HasProof), arg0) -} - -// StoreProof mocks base method. -func (m *MockpoetValidatorPersister) StoreProof(arg0 context.Context, arg1 types.PoetProofRef, arg2 *types.PoetProofMessage) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StoreProof", arg0, arg1, arg2) - ret0, _ := ret[0].(error) - return ret0 -} - -// StoreProof indicates an expected call of StoreProof. -func (mr *MockpoetValidatorPersisterMockRecorder) StoreProof(arg0, arg1, arg2 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreProof", reflect.TypeOf((*MockpoetValidatorPersister)(nil).StoreProof), arg0, arg1, arg2) -} - -// Validate mocks base method. -func (m *MockpoetValidatorPersister) Validate(arg0 types.PoetProof, arg1 []byte, arg2 string, arg3 []byte) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Validate", arg0, arg1, arg2, arg3) - ret0, _ := ret[0].(error) - return ret0 -} - -// Validate indicates an expected call of Validate. -func (mr *MockpoetValidatorPersisterMockRecorder) Validate(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockpoetValidatorPersister)(nil).Validate), arg0, arg1, arg2, arg3) -} - // MocknipostValidator is a mock of nipostValidator interface. type MocknipostValidator struct { ctrl *gomock.Controller diff --git a/activation/nipost.go b/activation/nipost.go index eb74d58077..9ea312a26c 100644 --- a/activation/nipost.go +++ b/activation/nipost.go @@ -27,7 +27,9 @@ type PoetProvingServiceClient interface { Submit(ctx context.Context, challenge []byte, signature []byte) (*types.PoetRound, error) // PoetServiceID returns the public key of the PoET proving service. - PoetServiceID(context.Context) ([]byte, error) + PoetServiceID(context.Context) (types.PoetServiceID, error) + + GetProof(ctx context.Context, roundID string) (*types.PoetProofMessage, error) } func (nb *NIPostBuilder) load(challenge types.Hash32) { @@ -62,9 +64,8 @@ type NIPostBuilder struct { } type poetDbAPI interface { - GetMembershipMap(proofRef types.PoetProofRef) (map[types.Hash32]bool, error) GetProof(types.PoetProofRef) (*types.PoetProof, error) - GetProofRef(poetID []byte, roundID string) (types.PoetProofRef, error) + ValidateAndStore(ctx context.Context, proofMessage *types.PoetProofMessage) error } // NewNIPostBuilder returns a NIPostBuilder. @@ -129,10 +130,10 @@ func (nb *NIPostBuilder) BuildNIPost(ctx context.Context, challenge *types.PoetC validPoetRequests := make([]types.PoetRequest, 0, len(poetRequests)) for _, req := range poetRequests { - if !bytes.Equal(req.PoetRound.ChallengeHash, challengeHash[:]) { + if !bytes.Equal(req.PoetRound.ChallengeHash[:], challengeHash[:]) { nb.log.With().Info( "poet returned invalid challenge hash", - log.Binary("hash", req.PoetRound.ChallengeHash), + req.PoetRound.ChallengeHash, log.String("poet_id", hex.EncodeToString(req.PoetServiceID)), ) } else { @@ -148,17 +149,16 @@ func (nb *NIPostBuilder) BuildNIPost(ctx context.Context, challenge *types.PoetC nb.persist() } - // Phase 1: receive proofs from PoET services + // Phase 1: query PoET services for proofs if nb.state.PoetProofRef == nil { - select { - case <-time.After(time.Until(poetProofDeadline)): - case <-ctx.Done(): - return nil, 0, ctx.Err() + getProofsCtx, cancel := context.WithDeadline(ctx, poetProofDeadline) + defer cancel() + poetProofRef, err := nb.getBestProof(getProofsCtx, challengeHash) + if err != nil { + return nil, 0, &PoetSvcUnstableError{msg: "getBestProof failed", source: err} } - poetProofRef := nb.getBestProof(ctx, challengeHash) if poetProofRef == nil { - // Time is up - ATX challenge is expired. - return nil, 0, ErrPoetProofNotReceived + return nil, 0, &PoetSvcUnstableError{source: ErrPoetProofNotReceived} } nb.state.PoetProofRef = poetProofRef nb.persist() @@ -194,26 +194,20 @@ func (nb *NIPostBuilder) BuildNIPost(ctx context.Context, challenge *types.PoetC } // Submit the challenge to a single PoET. -func submitPoetChallenge(ctx context.Context, logger log.Log, poet PoetProvingServiceClient, challenge []byte, signature []byte) (*types.PoetRequest, error) { +func (nb *NIPostBuilder) submitPoetChallenge(ctx context.Context, poet PoetProvingServiceClient, challenge []byte, signature []byte) (*types.PoetRequest, error) { poetServiceID, err := poet.PoetServiceID(ctx) if err != nil { return nil, &PoetSvcUnstableError{msg: "failed to get PoET service ID", source: err} } - - logger.With().Debug("submitting challenge to poet proving service", - log.String("poet_id", hex.EncodeToString(poetServiceID))) + logger := nb.log.WithFields(log.String("poet_id", hex.EncodeToString(poetServiceID))) + logger.Debug("submitting challenge to poet proving service") round, err := poet.Submit(ctx, challenge, signature) if err != nil { - logger.With().Error("failed to submit challenge to poet proving service", - log.String("poet_id", hex.EncodeToString(poetServiceID)), - log.Err(err)) return nil, &PoetSvcUnstableError{msg: "failed to submit challenge to poet service", source: err} } - logger.With().Info("challenge submitted to poet proving service", - log.String("poet_id", hex.EncodeToString(poetServiceID)), - log.String("round_id", round.ID)) + logger.With().Info("challenge submitted to poet proving service", log.String("round", round.ID)) return &types.PoetRequest{ PoetRound: round, @@ -228,7 +222,7 @@ func (nb *NIPostBuilder) submitPoetChallenges(ctx context.Context, challenge []b for _, poetProver := range nb.poetProvers { poet := poetProver g.Go(func() error { - if poetRequest, err := submitPoetChallenge(ctx, nb.log, poet, challenge, signature); err == nil { + if poetRequest, err := nb.submitPoetChallenge(ctx, poet, challenge, signature); err == nil { poetRequestsChannel <- *poetRequest } else { nb.log.With().Warning("failed to submit challenge to PoET", log.Err(err)) @@ -246,47 +240,110 @@ func (nb *NIPostBuilder) submitPoetChallenges(ctx context.Context, challenge []b return poetRequests } -func (nb *NIPostBuilder) getBestProof(ctx context.Context, challenge *types.Hash32) types.PoetProofRef { - type poetProof struct { - ref types.PoetProofRef - leafCount uint64 +func (nb *NIPostBuilder) getPoetClient(ctx context.Context, id types.PoetServiceID) PoetProvingServiceClient { + for _, client := range nb.poetProvers { + if clientId, err := client.PoetServiceID(ctx); err == nil && bytes.Equal(id, clientId) { + return client + } } - var bestProof *poetProof + return nil +} - for _, poetSubmission := range nb.state.PoetRequests { - ref, err := nb.poetDB.GetProofRef(poetSubmission.PoetServiceID, poetSubmission.PoetRound.ID) - if err != nil { - continue +func membersContain(members [][]byte, challenge *types.Hash32) bool { + for _, member := range members { + if bytes.Equal(member, challenge.Bytes()) { + return true } - // We are interested only in proofs that we are members of - membership, err := nb.poetDB.GetMembershipMap(ref) - if err != nil { - nb.log.With().Panic("failed to fetch membership for poet proof", log.Binary("challenge", challenge[:])) + } + return false +} + +func (nb *NIPostBuilder) getProofWithRetry(ctx context.Context, client PoetProvingServiceClient, roundID string, retryInterval time.Duration) (*types.PoetProofMessage, error) { + for { + proof, err := client.GetProof(ctx, roundID) + switch { + case err == nil: + return proof, nil + case errors.Is(err, ErrUnavailable) || errors.Is(err, ErrNotFound): + nb.log.With().Debug("Proof not found, retrying", log.Duration("interval", retryInterval)) + select { + case <-ctx.Done(): + return nil, fmt.Errorf("retry was canceled: %w", ctx.Err()) + case <-time.After(retryInterval): + } + default: + return nil, err } - if !membership[*challenge] { - nb.log.With().Debug("poet proof membership doesn't contain the challenge", log.Binary("challenge", challenge[:])) + } +} + +func (nb *NIPostBuilder) getBestProof(ctx context.Context, challenge *types.Hash32) (types.PoetProofRef, error) { + proofs := make(chan *types.PoetProofMessage, len(nb.state.PoetRequests)) + + var eg errgroup.Group + for _, r := range nb.state.PoetRequests { + logger := nb.log.WithFields(log.String("poet_id", hex.EncodeToString(r.PoetServiceID)), log.String("round", r.PoetRound.ID)) + client := nb.getPoetClient(ctx, r.PoetServiceID) + if client == nil { + logger.Warning("Poet client not found") continue } - proof, err := nb.poetDB.GetProof(ref) - if err != nil { - nb.log.Panic("Inconsistent state of poetDB. Received poetProofRef which doesn't exist in poetDB.") - } - nb.log.With().Info("Got a new PoET proof", log.Uint64("leafCount", proof.LeafCount), log.Binary("ref", ref)) + round := r.PoetRound.ID + // Time to wait before quering for the proof + // The additional second is an optimization to be nicer to poet + // and don't accidentially ask it to soon and have to retry. + waitTime := time.Until(r.PoetRound.End.IntoTime()) + time.Second + eg.Go(func() error { + logger.With().Info("Waiting till poet round end", log.Duration("wait time", waitTime)) + select { + case <-ctx.Done(): + logger.With().Info("Waiting interrupted", log.Err(ctx.Err())) + return ctx.Err() + case <-time.After(waitTime): + } + proof, err := nb.getProofWithRetry(ctx, client, round, time.Second) + if err != nil { + logger.With().Warning("Failed to get proof from Poet", log.Err(err)) + return nil + } - if bestProof == nil || bestProof.leafCount < proof.LeafCount { - bestProof = &poetProof{ - ref: ref, - leafCount: proof.LeafCount, + if err := nb.poetDB.ValidateAndStore(ctx, proof); err != nil && !errors.Is(err, ErrObjectExists) { + logger.With().Warning("Failed to validate and store proof", log.Err(err), log.Object("proof", proof)) + return nil } + + // We are interested only in proofs that we are members of + if !membersContain(proof.Members, challenge) { + logger.With().Warning("poet proof membership doesn't contain the challenge", challenge) + return nil + } + + proofs <- proof + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, fmt.Errorf("querying for proofs failed: %w", err) + } + close(proofs) + + var bestProof *types.PoetProofMessage + + for proof := range proofs { + nb.log.With().Info("Got a new PoET proof", log.Uint64("leafCount", proof.LeafCount)) + if bestProof == nil || bestProof.LeafCount < proof.LeafCount { + bestProof = proof } } if bestProof != nil { - nb.log.With().Debug("Selected the best PoET proof", - log.Uint64("leafCount", bestProof.leafCount), - log.Binary("ref", bestProof.ref)) - return bestProof.ref + ref, err := bestProof.Ref() + if err != nil { + return nil, fmt.Errorf("failed to get proof ref: %w", err) + } + nb.log.With().Info("Selected the best proof", log.Uint64("leafCount", bestProof.LeafCount), log.Binary("ref", ref)) + return ref, nil } - return nil + return nil, ErrPoetProofNotReceived } diff --git a/activation/nipost_mocks.go b/activation/nipost_mocks.go index f4956ca618..1fcbd07907 100644 --- a/activation/nipost_mocks.go +++ b/activation/nipost_mocks.go @@ -35,11 +35,26 @@ func (m *MockPoetProvingServiceClient) EXPECT() *MockPoetProvingServiceClientMoc return m.recorder } +// GetProof mocks base method. +func (m *MockPoetProvingServiceClient) GetProof(ctx context.Context, roundID string) (*types.PoetProofMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProof", ctx, roundID) + ret0, _ := ret[0].(*types.PoetProofMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProof indicates an expected call of GetProof. +func (mr *MockPoetProvingServiceClientMockRecorder) GetProof(ctx, roundID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProof", reflect.TypeOf((*MockPoetProvingServiceClient)(nil).GetProof), ctx, roundID) +} + // PoetServiceID mocks base method. -func (m *MockPoetProvingServiceClient) PoetServiceID(arg0 context.Context) ([]byte, error) { +func (m *MockPoetProvingServiceClient) PoetServiceID(arg0 context.Context) (types.PoetServiceID, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PoetServiceID", arg0) - ret0, _ := ret[0].([]byte) + ret0, _ := ret[0].(types.PoetServiceID) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -88,21 +103,6 @@ func (m *MockpoetDbAPI) EXPECT() *MockpoetDbAPIMockRecorder { return m.recorder } -// GetMembershipMap mocks base method. -func (m *MockpoetDbAPI) GetMembershipMap(proofRef types.PoetProofRef) (map[types.Hash32]bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMembershipMap", proofRef) - ret0, _ := ret[0].(map[types.Hash32]bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetMembershipMap indicates an expected call of GetMembershipMap. -func (mr *MockpoetDbAPIMockRecorder) GetMembershipMap(proofRef interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMembershipMap", reflect.TypeOf((*MockpoetDbAPI)(nil).GetMembershipMap), proofRef) -} - // GetProof mocks base method. func (m *MockpoetDbAPI) GetProof(arg0 types.PoetProofRef) (*types.PoetProof, error) { m.ctrl.T.Helper() @@ -118,17 +118,16 @@ func (mr *MockpoetDbAPIMockRecorder) GetProof(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProof", reflect.TypeOf((*MockpoetDbAPI)(nil).GetProof), arg0) } -// GetProofRef mocks base method. -func (m *MockpoetDbAPI) GetProofRef(poetID []byte, roundID string) (types.PoetProofRef, error) { +// ValidateAndStore mocks base method. +func (m *MockpoetDbAPI) ValidateAndStore(ctx context.Context, proofMessage *types.PoetProofMessage) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetProofRef", poetID, roundID) - ret0, _ := ret[0].(types.PoetProofRef) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "ValidateAndStore", ctx, proofMessage) + ret0, _ := ret[0].(error) + return ret0 } -// GetProofRef indicates an expected call of GetProofRef. -func (mr *MockpoetDbAPIMockRecorder) GetProofRef(poetID, roundID interface{}) *gomock.Call { +// ValidateAndStore indicates an expected call of ValidateAndStore. +func (mr *MockpoetDbAPIMockRecorder) ValidateAndStore(ctx, proofMessage interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProofRef", reflect.TypeOf((*MockpoetDbAPI)(nil).GetProofRef), poetID, roundID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateAndStore", reflect.TypeOf((*MockpoetDbAPI)(nil).ValidateAndStore), ctx, proofMessage) } diff --git a/activation/nipost_test.go b/activation/nipost_test.go index 5173568d52..22bee1742a 100644 --- a/activation/nipost_test.go +++ b/activation/nipost_test.go @@ -53,10 +53,6 @@ func (p *postSetupProviderMock) Status() *PostSetupStatus { return status } -func (p *postSetupProviderMock) StatusChan() <-chan *PostSetupStatus { - return nil -} - func (p *postSetupProviderMock) ComputeProviders() []PostSetupComputeProvider { return nil } @@ -104,52 +100,45 @@ func (p *postSetupProviderMock) Config() PostConfig { return postCfg } -func defaultPoetServiceMock(tb testing.TB) (*MockPoetProvingServiceClient, *gomock.Controller) { - poetClient, controller := newPoetServiceMock(tb) +func defaultPoetServiceMock(tb testing.TB, id []byte) *MockPoetProvingServiceClient { + tb.Helper() + poetClient := NewMockPoetProvingServiceClient(gomock.NewController(tb)) poetClient.EXPECT().Submit(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( func(_ context.Context, challenge, _ []byte) (*types.PoetRound, error) { return &types.PoetRound{ - ChallengeHash: getSerializedChallengeHash(challenge).Bytes(), + ChallengeHash: *getSerializedChallengeHash(challenge), }, nil }) - poetClient.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return([]byte{}, nil) - return poetClient, controller + poetClient.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return(id, nil) + return poetClient } -func newPoetServiceMock(tb testing.TB) (*MockPoetProvingServiceClient, *gomock.Controller) { - tb.Helper() - controller := gomock.NewController(tb) - poetClient := NewMockPoetProvingServiceClient(controller) - return poetClient, controller -} +func TestNIPostBuilderWithMocks(t *testing.T) { + t.Parallel() + assert := require.New(t) -func defaultPoetDbMockForChallenge(t *testing.T, challenge types.PoetChallenge) *MockpoetDbAPI { + challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} hash, err := challenge.Hash() require.NoError(t, err) - poetDb := NewMockpoetDbAPI(gomock.NewController(t)) - poetDb.EXPECT().GetProofRef(gomock.Any(), gomock.Any()).AnyTimes().Return([]byte("ref"), nil) - poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{hash.Bytes()}}, nil) - poetDb.EXPECT().GetMembershipMap(gomock.Any()).AnyTimes().Return(map[types.Hash32]bool{*hash: true}, nil) - return poetDb -} - -func TestNIPostBuilderWithMocks(t *testing.T) { - assert := require.New(t) postProvider := &postSetupProviderMock{} - poetProvider, _ := defaultPoetServiceMock(t) + poetProvider := defaultPoetServiceMock(t, []byte("poet")) + poetProvider.EXPECT().GetProof(gomock.Any(), "").Return(&types.PoetProofMessage{ + PoetProof: types.PoetProof{Members: [][]byte{hash.Bytes()}}, + }, nil) - challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} - poetDb := defaultPoetDbMockForChallenge(t, challenge) + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) nb := NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProvider}, poetDb, sql.InMemory(), logtest.New(t), sig) - nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) + nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Hour)) assert.NoError(err) assert.NotNil(nipost) } func TestPostSetup(t *testing.T) { + t.Parallel() r := require.New(t) cdb := newCachedDB(t) @@ -158,8 +147,16 @@ func TestPostSetup(t *testing.T) { r.NotNil(postSetupProvider) challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} - poetProvider, _ := defaultPoetServiceMock(t) - poetDb := defaultPoetDbMockForChallenge(t, challenge) + hash, err := challenge.Hash() + require.NoError(t, err) + + poetProvider := defaultPoetServiceMock(t, []byte("poet")) + poetProvider.EXPECT().GetProof(gomock.Any(), "").Return(&types.PoetProofMessage{ + PoetProof: types.PoetProof{Members: [][]byte{hash.Bytes()}}, + }, nil) + + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) nb := NewNIPostBuilder(minerID, postSetupProvider, []PoetProvingServiceClient{poetProvider}, poetDb, sql.InMemory(), logtest.New(t), sig) @@ -167,7 +164,7 @@ func TestPostSetup(t *testing.T) { r.NoError(postSetupProvider.StartSession(context.Background(), getPostSetupOpts(t), goldenATXID)) t.Cleanup(func() { assert.NoError(t, postSetupProvider.Reset()) }) - nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) + nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Hour)) r.NoError(err) r.NotNil(nipost) } @@ -176,11 +173,16 @@ func TestNIPostBuilderWithClients(t *testing.T) { if testing.Short() { t.Skip() } - + logtest.SetupGlobal(t) r := require.New(t) challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} - poetDb := defaultPoetDbMockForChallenge(t, challenge) + hash, err := challenge.Hash() + r.NoError(err) + + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{hash.Bytes()}}, nil) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) challengeHash, err := challenge.Hash() r.NoError(err) @@ -197,7 +199,10 @@ func buildNIPost(tb testing.TB, r *require.Assertions, postCfg PostConfig, nipos tb.Cleanup(func() { assert.NoError(tb, eg.Wait()) }) tb.Cleanup(gtw.Stop) - poetProver, err := NewHTTPPoetHarness(true, WithGateway(gtw.Target())) + epoch := time.Second + ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) + defer cancel() + poetProver, err := NewHTTPPoetHarness(ctx, WithGateway(gtw.Target()), WithGenesis(time.Now()), WithEpochDuration(epoch)) r.NoError(err) r.NotNil(poetProver) tb.Cleanup(func() { assert.NoError(tb, poetProver.Teardown(true), "failed to tear down harness") }) @@ -214,7 +219,7 @@ func buildNIPost(tb testing.TB, r *require.Assertions, postCfg PostConfig, nipos nb := NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProver}, poetDb, sql.InMemory(), logtest.New(tb), signer) - nipost, _, err := nb.BuildNIPost(context.Background(), &nipostChallenge, time.Time{}) + nipost, _, err := nb.BuildNIPost(context.Background(), &nipostChallenge, time.Now().Add(3*epoch)) r.NoError(err) return nipost } @@ -239,6 +244,7 @@ func (*gatewayService) VerifyChallenge(ctx context.Context, req *pb.VerifyChalle } func TestNewNIPostBuilderNotInitialized(t *testing.T) { + t.Parallel() if testing.Short() { t.Skip() } @@ -262,7 +268,10 @@ func TestNewNIPostBuilderNotInitialized(t *testing.T) { t.Cleanup(func() { assert.NoError(t, eg.Wait()) }) t.Cleanup(gtw.Stop) - poetProver, err := NewHTTPPoetHarness(true, WithGateway(gtw.Target())) + epoch := time.Second + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + poetProver, err := NewHTTPPoetHarness(ctx, WithGateway(gtw.Target()), WithGenesis(time.Now()), WithEpochDuration(epoch)) r.NoError(err) r.NotNil(poetProver) t.Cleanup(func() { @@ -270,9 +279,8 @@ func TestNewNIPostBuilderNotInitialized(t *testing.T) { }) poetDb := NewMockpoetDbAPI(gomock.NewController(t)) - poetDb.EXPECT().GetMembershipMap(gomock.Any()).AnyTimes().Return(map[types.Hash32]bool{*challengeHash: true}, nil) - poetDb.EXPECT().GetProofRef(gomock.Any(), gomock.Any()).AnyTimes().Return([]byte("ref"), nil) poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{challengeHash.Bytes()}}, nil) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) signer, err := signing.NewEdSigner() r.NoError(err) @@ -286,7 +294,7 @@ func TestNewNIPostBuilderNotInitialized(t *testing.T) { r.NoError(postProvider.StartSession(context.Background(), getPostSetupOpts(t), goldenATXID)) - nipost, _, err = nb.BuildNIPost(context.Background(), &nipostChallenge, time.Time{}) + nipost, _, err = nb.BuildNIPost(context.Background(), &nipostChallenge, time.Now().Add(3*epoch)) r.NoError(err) r.NotNil(nipost) @@ -294,6 +302,7 @@ func TestNewNIPostBuilderNotInitialized(t *testing.T) { } func TestNIPostBuilder_BuildNIPost(t *testing.T) { + t.Parallel() req := require.New(t) postProvider := &postSetupProviderMock{} @@ -301,57 +310,32 @@ func TestNIPostBuilder_BuildNIPost(t *testing.T) { challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} challengeHash, err := challenge.Hash() req.NoError(err) + challenge2 := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{Sequence: 1}} + challenge2Hash, err := challenge2.Hash() + req.NoError(err) - poetProver, _ := newPoetServiceMock(t) - poetProver.EXPECT().PoetServiceID(gomock.Any()).Times(3).Return([]byte{}, nil) - poetProver.EXPECT().Submit(gomock.Any(), gomock.Any(), gomock.Any()).Times(3).DoAndReturn( - func(_ context.Context, challenge, _ []byte) (*types.PoetRound, error) { - return &types.PoetRound{ - ChallengeHash: getSerializedChallengeHash(challenge).Bytes(), - }, nil - }) + poetProver := defaultPoetServiceMock(t, []byte("poet")) + poetProver.EXPECT().GetProof(gomock.Any(), "").AnyTimes().Return(&types.PoetProofMessage{ + PoetProof: types.PoetProof{Members: [][]byte{challengeHash.Bytes(), challenge2Hash.Bytes()}}, + }, nil) ctrl := gomock.NewController(t) poetDb := NewMockpoetDbAPI(ctrl) - poetDb.EXPECT().GetMembershipMap(gomock.Any()).AnyTimes().Return(map[types.Hash32]bool{*challengeHash: true}, nil) - poetDb.EXPECT().GetProofRef(gomock.Any(), gomock.Any()).AnyTimes().Return([]byte("ref"), nil) - poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{challengeHash.Bytes()}}, nil) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) sig, err := signing.NewEdSigner() req.NoError(err) nb := NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProver}, poetDb, sql.InMemory(), logtest.New(t), sig) - nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) + nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Hour)) req.NoError(err) req.NotNil(nipost) db := sql.InMemory() req.Equal(types.NIPostBuilderState{NIPost: &types.NIPost{}}, *nb.state) - // fail after getting proof ref poetDb = NewMockpoetDbAPI(ctrl) - poetDb.EXPECT().GetProofRef(gomock.Any(), gomock.Any()).AnyTimes().Return([]byte("ref"), nil) - poetDb.EXPECT().GetMembershipMap(gomock.Any()).AnyTimes().Return(map[types.Hash32]bool{}, nil) - - sig, err = signing.NewEdSigner() - req.NoError(err) - nb = NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProver}, poetDb, db, logtest.New(t), sig) - nipost, _, err = nb.BuildNIPost(context.Background(), &challenge, time.Time{}) - req.Nil(nipost) - req.Error(err) - - // check that proof ref is not called again - sig, err = signing.NewEdSigner() - req.NoError(err) - nb = NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProver}, poetDb, db, logtest.New(t), sig) - nipost, _, err = nb.BuildNIPost(context.Background(), &challenge, time.Time{}) - req.Nil(nipost) - req.Error(err) - - poetDb = NewMockpoetDbAPI(ctrl) - poetDb.EXPECT().GetMembershipMap(gomock.Any()).Return(map[types.Hash32]bool{*challengeHash: true}, nil) - poetDb.EXPECT().GetProofRef(gomock.Any(), gomock.Any()).AnyTimes().Return([]byte("ref"), nil) - poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{challengeHash.Bytes()}}, nil) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) // fail post exec sig, err = signing.NewEdSigner() @@ -359,160 +343,116 @@ func TestNIPostBuilder_BuildNIPost(t *testing.T) { nb = NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProver}, poetDb, db, logtest.New(t), sig) postProvider.setError = true // check that proof ref is not called again - nipost, _, err = nb.BuildNIPost(context.Background(), &challenge, time.Time{}) + nipost, _, err = nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Hour)) req.Nil(nipost) req.Error(err) // fail post exec - challenge2 := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{Sequence: 1}} - challenge2Hash, err := challenge2.Hash() - req.NoError(err) - poetDb = NewMockpoetDbAPI(ctrl) - poetDb.EXPECT().GetMembershipMap(gomock.Any()).AnyTimes().Return(map[types.Hash32]bool{*challengeHash: true, *challenge2Hash: true}, nil) - poetDb.EXPECT().GetProofRef(gomock.Any(), gomock.Any()).AnyTimes().Return([]byte("ref"), nil) - poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{challengeHash.Bytes(), challenge2Hash.Bytes()}}, nil) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) sig, err = signing.NewEdSigner() req.NoError(err) nb = NewNIPostBuilder(minerID, postProvider, []PoetProvingServiceClient{poetProver}, poetDb, db, logtest.New(t), sig) - // poetDb.errOn = false postProvider.setError = false // check that proof ref is not called again - nipost, _, err = nb.BuildNIPost(context.Background(), &challenge, time.Time{}) + nipost, _, err = nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Hour)) req.NotNil(nipost) req.NoError(err) req.Equal(3, postProvider.called) // test state not loading if other challenge provided - nipost, _, err = nb.BuildNIPost(context.Background(), &challenge2, time.Time{}) + nipost, _, err = nb.BuildNIPost(context.Background(), &challenge2, time.Now().Add(time.Hour)) req.NoError(err) req.NotNil(nipost) req.Equal(4, postProvider.called) } -func createMockPoetService(t *testing.T, id []byte) PoetProvingServiceClient { - t.Helper() - poet := NewMockPoetProvingServiceClient(gomock.NewController(t)) - poet.EXPECT().PoetServiceID(gomock.Any()).Times(1).Return(id, nil) - poet.EXPECT().Submit(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( - func(_ context.Context, challenge, _ []byte) (*types.PoetRound, error) { - return &types.PoetRound{ - ChallengeHash: getSerializedChallengeHash(challenge).Bytes(), - }, nil - }) - return poet -} - func TestNIPostBuilder_ManyPoETs_DeadlineReached(t *testing.T) { t.Parallel() // Arrange req := require.New(t) - poetDb := NewPoetDb(sql.InMemory(), logtest.New(t)) - poets := make([]PoetProvingServiceClient, 0, 2) - poets = append(poets, createMockPoetService(t, []byte("poet0"))) - poets = append(poets, createMockPoetService(t, []byte("poet1"))) - - sig, err := signing.NewEdSigner() - req.NoError(err) - nb := NewNIPostBuilder(minerID, &postSetupProviderMock{}, poets, poetDb, sql.InMemory(), logtest.New(t), sig) - - resultChan := make(chan *types.NIPost) - deadline := time.Now().Add(time.Millisecond * 10) - challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} challengeHash, err := challenge.Hash() req.NoError(err) - var wg errgroup.Group - wg.Go(func() error { - nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, deadline) - resultChan <- nipost - return err - }) + proof := types.PoetProofMessage{PoetProof: types.PoetProof{Members: [][]byte{challengeHash.Bytes()}}} + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) - // Act - // Store proofMsg only from poet1 - proofMsg := types.PoetProofMessage{ - PoetServiceID: []byte("poet1"), - PoetProof: types.PoetProof{ - Members: [][]byte{challengeHash[:]}, - LeafCount: 1, - }, + poets := make([]PoetProvingServiceClient, 0, 2) + { + poet := defaultPoetServiceMock(t, []byte("poet0")) + poet.EXPECT().GetProof(gomock.Any(), gomock.Any()).Return(&proof, nil) + poets = append(poets, poet) } - ref, err := proofMsg.Ref() + { + poet := defaultPoetServiceMock(t, []byte("poet1")) + poet.EXPECT().GetProof(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, _ string) (*types.PoetProofMessage, error) { + // Hang up after the context expired + <-ctx.Done() + return nil, ctx.Err() + }) + poets = append(poets, poet) + } + + sig, err := signing.NewEdSigner() req.NoError(err) - poetDb.StoreProof(context.Background(), ref, &proofMsg) + nb := NewNIPostBuilder(minerID, &postSetupProviderMock{}, poets, poetDb, sql.InMemory(), logtest.New(t), sig) - time.Sleep(time.Until(deadline)) + // Act + nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Second)) + req.NoError(err) // Verify - result := <-resultChan - req.NoError(wg.Wait()) - req.Equal(challengeHash, result.Challenge) - proof, err := poetDb.GetProof(result.PostMetadata.Challenge) - req.NoError(err) - req.EqualValues(proof.LeafCount, 1) + req.Equal(challengeHash, nipost.Challenge) + ref, _ := proof.Ref() + req.EqualValues(ref, nipost.PostMetadata.Challenge) } func TestNIPostBuilder_ManyPoETs_AllFinished(t *testing.T) { t.Parallel() // Arrange req := require.New(t) - poetDb := NewPoetDb(sql.InMemory(), logtest.New(t)) - poets := make([]PoetProvingServiceClient, 0, 2) - poets = append(poets, createMockPoetService(t, []byte("poet0"))) - poets = append(poets, createMockPoetService(t, []byte("poet1"))) - - sig, err := signing.NewEdSigner() - req.NoError(err) - nb := NewNIPostBuilder(minerID, &postSetupProviderMock{}, poets, poetDb, sql.InMemory(), logtest.New(t), sig) challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} challengeHash, err := challenge.Hash() - require.NoError(t, err) + req.NoError(err) - resultChan := make(chan *types.NIPost) - deadline := time.Now().Add(time.Millisecond * 10) + proofWorse := types.PoetProofMessage{ + PoetProof: types.PoetProof{Members: [][]byte{challengeHash.Bytes()}, LeafCount: 111}, + } + proofBetter := types.PoetProofMessage{ + PoetProof: types.PoetProof{Members: [][]byte{challengeHash.Bytes()}, LeafCount: 999}, + } - var eg errgroup.Group - eg.Go(func() error { - nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, deadline) - resultChan <- nipost - return err - }) + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Times(2).Return(nil) - // Act - // Store proofs from both poets - proofMsg := types.PoetProofMessage{ - PoetServiceID: []byte("poet0"), - PoetProof: types.PoetProof{ - Members: [][]byte{challengeHash[:]}, - LeafCount: 4, - }, + poets := make([]PoetProvingServiceClient, 0, 2) + { + poet := defaultPoetServiceMock(t, []byte("poet0")) + poet.EXPECT().GetProof(gomock.Any(), "").Return(&proofWorse, nil) + poets = append(poets, poet) } - ref, err := proofMsg.Ref() - req.NoError(err) - poetDb.StoreProof(context.Background(), ref, &proofMsg) - - proofMsg = types.PoetProofMessage{ - PoetServiceID: []byte("poet1"), - PoetProof: types.PoetProof{ - Members: [][]byte{challengeHash[:]}, - LeafCount: 1, - }, + { + poet := defaultPoetServiceMock(t, []byte("poet1")) + poet.EXPECT().GetProof(gomock.Any(), "").Return(&proofBetter, nil) + poets = append(poets, poet) } - ref, err = proofMsg.Ref() + + sig, err := signing.NewEdSigner() req.NoError(err) - poetDb.StoreProof(context.Background(), ref, &proofMsg) + nb := NewNIPostBuilder(minerID, &postSetupProviderMock{}, poets, poetDb, sql.InMemory(), logtest.New(t), sig) - // Verify - result := <-resultChan - req.NoError(eg.Wait()) - req.Equal(challengeHash, result.Challenge) - proof, err := poetDb.GetProof(result.PostMetadata.Challenge) + // Act + nipost, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Hour)) req.NoError(err) - req.EqualValues(proof.LeafCount, 4) + + // Verify + req.Equal(challengeHash, nipost.Challenge) + ref, _ := proofBetter.Ref() + req.EqualValues(ref, nipost.PostMetadata.Challenge) } func TestValidator_Validate(t *testing.T) { @@ -525,7 +465,9 @@ func TestValidator_Validate(t *testing.T) { challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} challengeHash, err := challenge.Hash() r.NoError(err) - poetDb := defaultPoetDbMockForChallenge(t, challenge) + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().GetProof(gomock.Any()).AnyTimes().Return(&types.PoetProof{Members: [][]byte{challengeHash.Bytes()}}, nil) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) nipost := buildNIPost(t, r, postCfg, challenge, poetDb) numUnits := getPostSetupOpts(t).NumUnits @@ -579,12 +521,13 @@ func validateNIPost(minerID types.NodeID, commitmentAtx types.ATXID, nipost *typ } func TestNIPostBuilder_Close(t *testing.T) { + t.Parallel() r := require.New(t) postProvider := &postSetupProviderMock{} - poetProver, _ := defaultPoetServiceMock(t) + poetProver := defaultPoetServiceMock(t, []byte("poet")) + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} - poetDb := defaultPoetDbMockForChallenge(t, challenge) sig, err := signing.NewEdSigner() r.NoError(err) @@ -601,45 +544,68 @@ func TestNIPostBuilder_Close(t *testing.T) { func TestNIPSTBuilder_PoetUnstable(t *testing.T) { t.Parallel() postProver := &postSetupProviderMock{} - poetProver, controller := newPoetServiceMock(t) - defer controller.Finish() - challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{}} - poetDb := defaultPoetDbMockForChallenge(t, challenge) + + poetDb := NewMockpoetDbAPI(gomock.NewController(t)) + poetDb.EXPECT().ValidateAndStore(gomock.Any(), gomock.Any()).Return(nil) sig, err := signing.NewEdSigner() require.NoError(t, err) - nb := NewNIPostBuilder(minerID, postProver, []PoetProvingServiceClient{poetProver}, - poetDb, sql.InMemory(), logtest.New(t), sig) - t.Run("PoetServiceID", func(t *testing.T) { - poetProver.EXPECT().PoetServiceID(gomock.Any()).Return(nil, errors.New("test")) + t.Run("PoetServiceID fails", func(t *testing.T) { + t.Parallel() + poetProver := NewMockPoetProvingServiceClient(gomock.NewController(t)) + poetProver.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return(nil, errors.New("test")) + + nb := NewNIPostBuilder(minerID, postProver, []PoetProvingServiceClient{poetProver}, + poetDb, sql.InMemory(), logtest.New(t), sig) nipst, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) require.ErrorIs(t, err, ErrPoetServiceUnstable) require.Nil(t, nipst) }) - t.Run("Submit", func(t *testing.T) { - poetProver.EXPECT().PoetServiceID(gomock.Any()).Return([]byte{}, nil) + t.Run("Submit fails", func(t *testing.T) { + t.Parallel() + poetProver := NewMockPoetProvingServiceClient(gomock.NewController(t)) + poetProver.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return([]byte{}, nil) poetProver.EXPECT().Submit(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("test")) + + nb := NewNIPostBuilder(minerID, postProver, []PoetProvingServiceClient{poetProver}, + poetDb, sql.InMemory(), logtest.New(t), sig) nipst, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) require.ErrorIs(t, err, ErrPoetServiceUnstable) require.Nil(t, nipst) }) t.Run("Submit returns invalid hash", func(t *testing.T) { - poetProver.EXPECT().PoetServiceID(gomock.Any()).Return([]byte{}, nil) + t.Parallel() + poetProver := NewMockPoetProvingServiceClient(gomock.NewController(t)) + poetProver.EXPECT().PoetServiceID(gomock.Any()).AnyTimes().Return([]byte{}, nil) poetProver.EXPECT().Submit(gomock.Any(), gomock.Any(), gomock.Any()).Return(&types.PoetRound{}, nil) + + nb := NewNIPostBuilder(minerID, postProver, []PoetProvingServiceClient{poetProver}, + poetDb, sql.InMemory(), logtest.New(t), sig) nipst, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) require.ErrorIs(t, err, ErrPoetServiceUnstable) require.Nil(t, nipst) }) + t.Run("GetProof fails", func(t *testing.T) { + t.Parallel() + poetProver := defaultPoetServiceMock(t, []byte("poet")) + poetProver.EXPECT().GetProof(gomock.Any(), "").Return(nil, errors.New("failed")) + + nb := NewNIPostBuilder(minerID, postProver, []PoetProvingServiceClient{poetProver}, + poetDb, sql.InMemory(), logtest.New(t), sig) + nipst, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Second)) + require.ErrorIs(t, err, ErrPoetProofNotReceived) + require.Nil(t, nipst) + }) t.Run("Challenge is not included in proof members", func(t *testing.T) { - // The proof in poetDB will not include this challenge - challenge := types.PoetChallenge{NIPostChallenge: &types.NIPostChallenge{Sequence: 1}} - hash, err := challenge.Hash() - require.NoError(t, err) - poetProver.EXPECT().PoetServiceID(gomock.Any()).Return([]byte{}, nil) - poetProver.EXPECT().Submit(gomock.Any(), gomock.Any(), gomock.Any()).Return(&types.PoetRound{ChallengeHash: hash.Bytes()}, nil) - nipst, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Time{}) + t.Parallel() + poetProver := defaultPoetServiceMock(t, []byte("poet")) + poetProver.EXPECT().GetProof(gomock.Any(), "").Return(&types.PoetProofMessage{PoetProof: types.PoetProof{}}, nil) + + nb := NewNIPostBuilder(minerID, postProver, []PoetProvingServiceClient{poetProver}, + poetDb, sql.InMemory(), logtest.New(t), sig) + nipst, _, err := nb.BuildNIPost(context.Background(), &challenge, time.Now().Add(time.Second)) require.ErrorIs(t, err, ErrPoetProofNotReceived) require.Nil(t, nipst) }) diff --git a/activation/poet.go b/activation/poet.go index 6737a75282..5295590df7 100644 --- a/activation/poet.go +++ b/activation/poet.go @@ -3,6 +3,7 @@ package activation import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -10,10 +11,17 @@ import ( "github.com/spacemeshos/poet/integration" rpcapi "github.com/spacemeshos/poet/release/proto/go/rpc/api" + "github.com/spacemeshos/poet/shared" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "github.com/spacemeshos/go-spacemesh/common/types" + "github.com/spacemeshos/go-spacemesh/log" +) + +var ( + ErrNotFound = errors.New("not found") + ErrUnavailable = errors.New("unavailable") ) // HTTPPoetHarness utilizes a local self-contained poet server instance @@ -35,14 +43,25 @@ func WithGateway(endpoint string) HTTPPoetOpt { } } +func WithGenesis(genesis time.Time) HTTPPoetOpt { + return func(cfg *integration.ServerConfig) { + cfg.Genesis = genesis + } +} + +func WithEpochDuration(epoch time.Duration) HTTPPoetOpt { + return func(cfg *integration.ServerConfig) { + cfg.EpochDuration = epoch + } +} + // NewHTTPPoetHarness returns a new instance of HTTPPoetHarness. -func NewHTTPPoetHarness(disableBroadcast bool, opts ...HTTPPoetOpt) (*HTTPPoetHarness, error) { +func NewHTTPPoetHarness(ctx context.Context, opts ...HTTPPoetOpt) (*HTTPPoetHarness, error) { cfg, err := integration.DefaultConfig() if err != nil { return nil, fmt.Errorf("default integration config: %w", err) } - cfg.DisableBroadcast = disableBroadcast cfg.Reset = true cfg.Genesis = time.Now().Add(5 * time.Second) cfg.EpochDuration = 4 * time.Second @@ -50,8 +69,6 @@ func NewHTTPPoetHarness(disableBroadcast bool, opts ...HTTPPoetOpt) (*HTTPPoetHa opt(cfg) } - ctx, cancel := context.WithDeadline(context.Background(), cfg.Genesis) - defer cancel() h, err := integration.NewHarness(ctx, cfg) if err != nil { return nil, fmt.Errorf("new harness: %w", err) @@ -69,8 +86,9 @@ func NewHTTPPoetHarness(disableBroadcast bool, opts ...HTTPPoetOpt) (*HTTPPoetHa // HTTPPoetClient implements PoetProvingServiceClient interface. type HTTPPoetClient struct { - baseURL string - ctxFactory func(ctx context.Context) (context.Context, context.CancelFunc) + baseURL string + ctxFactory func(ctx context.Context) (context.Context, context.CancelFunc) + poetServiceID *types.PoetServiceID } func defaultPoetClientFunc(target string) PoetProvingServiceClient { @@ -108,19 +126,60 @@ func (c *HTTPPoetClient) Submit(ctx context.Context, challenge []byte, signature if err := c.req(ctx, "POST", "/submit", &request, &resBody); err != nil { return nil, err } - - return &types.PoetRound{ID: resBody.RoundId, ChallengeHash: resBody.Hash}, nil + roundEnd := time.Time{} + if resBody.RoundEnd != nil { + roundEnd = time.Now().Add(resBody.RoundEnd.AsDuration()) + } + if len(resBody.Hash) != types.Hash32Length { + return nil, fmt.Errorf("invalid hash len (%d instead of %d)", len(resBody.Hash), types.Hash32Length) + } + hash := types.Hash32{} + hash.SetBytes(resBody.Hash) + return &types.PoetRound{ID: resBody.RoundId, ChallengeHash: hash, End: types.RoundEnd(roundEnd)}, nil } // PoetServiceID returns the public key of the PoET proving service. -func (c *HTTPPoetClient) PoetServiceID(ctx context.Context) ([]byte, error) { +func (c *HTTPPoetClient) PoetServiceID(ctx context.Context) (types.PoetServiceID, error) { + if c.poetServiceID != nil { + return *c.poetServiceID, nil + } resBody := rpcapi.GetInfoResponse{} if err := c.req(ctx, "GET", "/info", nil, &resBody); err != nil { return nil, err } - return resBody.ServicePubKey, nil + id := types.PoetServiceID(resBody.ServicePubKey) + c.poetServiceID = &id + return id, nil +} + +// GetProof implements PoetProvingServiceClient. +func (c *HTTPPoetClient) GetProof(ctx context.Context, roundID string) (*types.PoetProofMessage, error) { + resBody := rpcapi.GetProofResponse{} + + if err := c.req(ctx, "GET", fmt.Sprintf("/proofs/%s", roundID), nil, &resBody); err != nil { + return nil, fmt.Errorf("get proof: %w", err) + } + + proof := types.PoetProofMessage{ + PoetProof: types.PoetProof{ + MerkleProof: shared.MerkleProof{ + Root: resBody.Proof.GetProof().GetRoot(), + ProvenLeaves: resBody.Proof.GetProof().GetProvenLeaves(), + ProofNodes: resBody.Proof.GetProof().GetProofNodes(), + }, + Members: resBody.Proof.GetMembers(), + LeafCount: resBody.Proof.GetLeaves(), + }, + PoetServiceID: resBody.Pubkey, + RoundID: roundID, + } + if c.poetServiceID == nil { + c.poetServiceID = &proof.PoetServiceID + } + + return &proof, nil } func (c *HTTPPoetClient) req(ctx context.Context, method string, endURL string, reqBody proto.Message, resBody proto.Message) error { @@ -151,8 +210,15 @@ func (c *HTTPPoetClient) req(ctx context.Context, method string, endURL string, if err != nil { return fmt.Errorf("failed to read response body (%w)", err) } - if res.StatusCode != http.StatusOK { - return fmt.Errorf("response status code: %d, body: %s", res.StatusCode, string(data)) + + log.GetLogger().WithContext(ctx).With().Debug("response from poet", log.String("status", res.Status), log.String("body", string(data))) + + switch res.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return fmt.Errorf("%w: response status code: %s, body: %s", ErrNotFound, res.Status, string(data)) + case http.StatusServiceUnavailable: + return fmt.Errorf("%w: response status code: %s, body: %s", ErrUnavailable, res.Status, string(data)) } if resBody != nil { diff --git a/activation/poet_test.go b/activation/poet_test.go index d3b03cd5e6..169474bbca 100644 --- a/activation/poet_test.go +++ b/activation/poet_test.go @@ -3,6 +3,7 @@ package activation_test import ( "context" "testing" + "time" pb "github.com/spacemeshos/api/release/go/spacemesh/v1" "github.com/stretchr/testify/assert" @@ -19,9 +20,15 @@ type gatewayService struct { pb.UnimplementedGatewayServiceServer } +func hash32FromBytes(b []byte) types.Hash32 { + hash := types.Hash32{} + hash.SetBytes(b) + return hash +} + func (*gatewayService) VerifyChallenge(ctx context.Context, req *pb.VerifyChallengeRequest) (*pb.VerifyChallengeResponse, error) { return &pb.VerifyChallengeResponse{ - Hash: []byte("hash"), + Hash: hash32FromBytes([]byte("hash")).Bytes(), }, nil } @@ -38,7 +45,9 @@ func TestHTTPPoet(t *testing.T) { t.Cleanup(func() { r.NoError(eg.Wait()) }) t.Cleanup(gtw.Stop) - c, err := activation.NewHTTPPoetHarness(true, activation.WithGateway(gtw.Target())) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + c, err := activation.NewHTTPPoetHarness(ctx, activation.WithGateway(gtw.Target())) r.NoError(err) r.NotNil(c) @@ -55,5 +64,5 @@ func TestHTTPPoet(t *testing.T) { poetRound, err := c.Submit(context.Background(), ch.Bytes(), signer.Sign(ch.Bytes())) r.NoError(err) r.NotNil(poetRound) - r.Equal([]byte("hash"), poetRound.ChallengeHash) + r.Equal(hash32FromBytes([]byte("hash")), poetRound.ChallengeHash) } diff --git a/activation/poetdb.go b/activation/poetdb.go index a90a2ccf9c..244dda463a 100644 --- a/activation/poetdb.go +++ b/activation/poetdb.go @@ -16,6 +16,8 @@ import ( "github.com/spacemeshos/go-spacemesh/sql/poets" ) +var ErrObjectExists = sql.ErrObjectExists + // PoetDb is a database for PoET proofs. type PoetDb struct { sqlDB *sql.Database diff --git a/activation/poetdb_test.go b/activation/poetdb_test.go index 3d5e24794b..5716b18ddc 100644 --- a/activation/poetdb_test.go +++ b/activation/poetdb_test.go @@ -17,6 +17,8 @@ import ( "github.com/spacemeshos/go-spacemesh/sql" ) +var memberHash = []byte{0x17, 0x51, 0xac, 0x12, 0xe7, 0xe, 0x15, 0xb4, 0xf7, 0x6c, 0x16, 0x77, 0x5c, 0xd3, 0x29, 0xae, 0x55, 0x97, 0x3b, 0x61, 0x25, 0x21, 0xda, 0xb2, 0xde, 0x82, 0x8a, 0x5c, 0xdb, 0x6c, 0x8a, 0xb3} + func readPoetProofFromDisk(t *testing.T) *types.PoetProofMessage { file, err := os.Open(filepath.Join("test_resources", "poet.proof")) require.NoError(t, err) @@ -24,7 +26,7 @@ func readPoetProofFromDisk(t *testing.T) *types.PoetProofMessage { var poetProof types.PoetProof _, err = codec.DecodeFrom(file, &poetProof) require.NoError(t, err) - require.EqualValues(t, [][]byte{[]byte("1"), []byte("2"), []byte("3")}, poetProof.Members) + require.EqualValues(t, [][]byte{memberHash}, poetProof.Members) poetID := []byte("poet_id_123456") roundID := "1337" return &types.PoetProofMessage{ @@ -55,7 +57,7 @@ func TestPoetDbHappyFlow(t *testing.T) { membership, err := poetDb.GetMembershipMap(ref) require.NoError(t, err) - assert.True(t, membership[types.BytesToHash([]byte("1"))]) + assert.True(t, membership[types.BytesToHash(memberHash)]) assert.False(t, membership[types.BytesToHash([]byte("5"))]) } @@ -70,7 +72,7 @@ func TestPoetDbPoetProofNoMembers(t *testing.T) { var poetProof types.PoetProof _, err = codec.DecodeFrom(file, &poetProof) r.NoError(err) - r.EqualValues([][]byte{[]byte("1"), []byte("2"), []byte("3")}, poetProof.Members) + r.EqualValues([][]byte{memberHash}, poetProof.Members) poetID := []byte("poet_id_123456") roundID := "1337" poetProof.Root = []byte("some other root") diff --git a/activation/poetlistener.go b/activation/poetlistener.go deleted file mode 100644 index 127fc96c05..0000000000 --- a/activation/poetlistener.go +++ /dev/null @@ -1,66 +0,0 @@ -package activation - -import ( - "context" - "errors" - - "github.com/spacemeshos/go-spacemesh/codec" - "github.com/spacemeshos/go-spacemesh/common/types" - "github.com/spacemeshos/go-spacemesh/log" - "github.com/spacemeshos/go-spacemesh/p2p" - "github.com/spacemeshos/go-spacemesh/p2p/pubsub" - "github.com/spacemeshos/go-spacemesh/sql" -) - -// PoetListener handles PoET gossip messages. -type PoetListener struct { - log log.Log - poetDb poetValidatorPersister -} - -// HandlePoetProofMessage is a receiver for broadcast messages. -func (l *PoetListener) HandlePoetProofMessage(ctx context.Context, _ p2p.Peer, msg []byte) pubsub.ValidationResult { - var proofMessage types.PoetProofMessage - if err := codec.Decode(msg, &proofMessage); err != nil { - l.log.WithContext(ctx).With().Error("failed to unmarshal poet membership proof", log.Err(err)) - return pubsub.ValidationReject - } - - ref, err := proofMessage.Ref() - if err != nil { - l.log.WithContext(ctx).With().Error("failed to get poet proof", log.Err(err)) - return pubsub.ValidationIgnore - } - - if l.poetDb.HasProof(ref) { - // don't spam the network - return pubsub.ValidationIgnore - } - - if err := l.poetDb.Validate(proofMessage.PoetProof, proofMessage.PoetServiceID, - proofMessage.RoundID, proofMessage.Signature); err != nil { - if types.IsProcessingError(err) { - l.log.WithContext(ctx).With().Error("failed to validate poet proof", log.Err(err)) - } else { - l.log.WithContext(ctx).With().Warning("poet proof not valid", log.Err(err)) - } - return pubsub.ValidationIgnore - } - - if err := l.poetDb.StoreProof(ctx, ref, &proofMessage); err != nil { - if errors.Is(err, sql.ErrObjectExists) { - // don't spam the network - return pubsub.ValidationIgnore - } - l.log.WithContext(ctx).With().Error("failed to store poet proof", log.Err(err)) - } - return pubsub.ValidationAccept -} - -// NewPoetListener returns a new PoetListener. -func NewPoetListener(poetDb poetValidatorPersister, logger log.Log) *PoetListener { - return &PoetListener{ - log: logger, - poetDb: poetDb, - } -} diff --git a/activation/poetlistener_test.go b/activation/poetlistener_test.go deleted file mode 100644 index fc5c42e6b3..0000000000 --- a/activation/poetlistener_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package activation - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - - "github.com/spacemeshos/go-spacemesh/codec" - "github.com/spacemeshos/go-spacemesh/log/logtest" - "github.com/spacemeshos/go-spacemesh/p2p/pubsub" - "github.com/spacemeshos/go-spacemesh/sql" -) - -func TestNewPoetListener(t *testing.T) { - ctrl := gomock.NewController(t) - poetDb := NewMockpoetValidatorPersister(ctrl) - lg := logtest.New(t) - listener := NewPoetListener(poetDb, lg) - - msg := readPoetProofFromDisk(t) - data, err := codec.Encode(msg) - require.NoError(t, err) - ref, err := msg.Ref() - require.NoError(t, err) - - poetDb.EXPECT().HasProof(ref).Return(false) - poetDb.EXPECT().Validate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) - poetDb.EXPECT().StoreProof(gomock.Any(), ref, gomock.Any()).Return(nil) - require.Equal(t, pubsub.ValidationAccept, listener.HandlePoetProofMessage(context.Background(), "test", data)) - - poetDb.EXPECT().HasProof(ref).Return(false) - poetDb.EXPECT().Validate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) - poetDb.EXPECT().StoreProof(gomock.Any(), ref, gomock.Any()).Return(errors.New("unknown")) - require.Equal(t, pubsub.ValidationAccept, listener.HandlePoetProofMessage(context.Background(), "test", data)) - - poetDb.EXPECT().HasProof(ref).Return(false) - poetDb.EXPECT().Validate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) - poetDb.EXPECT().StoreProof(gomock.Any(), ref, gomock.Any()).Return(sql.ErrObjectExists) - require.Equal(t, pubsub.ValidationIgnore, listener.HandlePoetProofMessage(context.Background(), "test", data)) - - poetDb.EXPECT().HasProof(ref).Return(false) - poetDb.EXPECT().Validate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("bad poet message")) - require.Equal(t, pubsub.ValidationIgnore, listener.HandlePoetProofMessage(context.Background(), "test", data)) - - poetDb.EXPECT().HasProof(ref).Return(true) - require.Equal(t, pubsub.ValidationIgnore, listener.HandlePoetProofMessage(context.Background(), "test", data)) -} diff --git a/activation/test_resources/poet.proof b/activation/test_resources/poet.proof index 69a2af2a8e5dad064a7a7be0e4b477397ac7f53f..03e5cc92b52b08e3000f5401c62dec9bffcfb415 100644 GIT binary patch literal 44493 zcmWKXV_O((07kQ0wrwtBxmGRPwp->CmTj)u^0KvTw_K~1ZTo%y!Tt4I_c>q?%9V2- z;o`-e6a9-Fod8Zq$*n(z0`*VT-0f6xM2~JAD6m2OYv^H18vi#hI)P!MEz#_RY^2N; znS-xcWZ(|dTpu8oYfLgmA^*E<)ZlAVGr$l6jX#YZ&=feZoP1!PWoi!6H}ndw;rMQJ z^_Cr3BL^m1p5H|;L>5bE)uva8!B8x~JE`>BaY~P{Vfs9c#81@IMBE~*bgksAz@5wv zCz)ysMr=cbB(0u$3go#`bjKhHD#F6B7h77BJZ@gyKZdM-19FtXebXWL!Q12Z?8(0M zo?h5g7JCrLo<0Z0?gT*?+yKVhg5pQ5mItnagf7>&qMOzR5~tRB1@?5pzz_(;Av{p& zxALcV46%??iK)Eo{yf{oad(+2iP7@ml*yMqdfptsREb+4n}0^&K{Og9qJhtcMi%dH zE`LY1u_xpac#KB^0tkbF@oFUl4=|?R9lG(y`Ho(WVNQ)OI1F0$#)#bjnE#ktBE#o1 zS@6r?_Wo30BX2Rc_*9JFt2vE2D=7O96lj5@3cR@ct|r3E>i1!PaLPEcP#PUMQ?SCt ze~i0$%pBNt(te9ix`Q4LUt3g6^G!TQB9pM7)%ism7<6DCT@?*J{6sQui{poDO4#&D zlkbAd54CCSZZ(T!m5#f4dSo^ST6RCadPy@RBs>m0v9b0Bk3DvoISMeNnuhlyT(H-%Sehw0YjrWCMpLCh%ed2g0qd}QX z|8vbHab%bQpBFf{=K4e>;5~iojMvheOP(^dB$h}nvBzwa#e9m~0^izO-Oc=g5Ile! zXD%FySN8fPf#a2S_%vTZZAlWSqacZML@6=S8t^1)i!i|=nO=We+hIrHtllg^IA#CK|zZO!1$;c6+4y({XG~_lODUL3KeD1fQI9>UL?XS|Pc;(dUF5@Z; znaA7Kz&;(iPTQndhrveVbGXanM06<9dLWBiC(tF3(fi5PF(gKVAU-CbNn`c>s%{k!8sE>KH2AYPWsPG4(F^q2?-i-kI=X!+&{P&ldcII+W zzhViiGPWe3y;5Y-?}73B&xJSJAoTU?tXP(1%2&e_Fru&*gXOTFH=Z(@_x%iqu(qWz zh&f(EK|xqx@P|@-tBQ8;SKW z6~Xx_bt{O}168Wg^ZEJ^tI-=i{QXk`Z~QUn2nrPD(WJ8?SqXEy-iJQ)!i=4XP;^H) z51+L6c3Bhx8*b}2h|>Rv`dN+449Vp;>f0Owh&Wt>iDbaYlI(g?#L3g`eLU=Yg$}Qov@-7 zwGxp@MKY6Amd*paXIp{{k~zo5a;8i=g*>Pc7SS3Bex?Q0r%damYTSN^U ztoHx%<8U1(Y)Z64?}oW3bFbrJzx1t?UNu>eN<1?GmLod;&ba}q1?p5t_U!AK-Ktqb z&moG-?K_ApwTX_wAd}CRz>5@`9i6m4?T_a`NqW4zuomXq3#G4C{fR3g7O=j&Ng-61 z)voHT6LRU0hUIBt(IhZLWqtPM@7_{NHv}V<8;+^$tp}}c_**%TQdj$7(F=T+*xqz^ zPBtU9xLly)2T@F@`YdfiMJiVU^Tz>ibdRuXlVF^a`ENd+d^#p~p{p#wKH)+>kVInCu`MJ|72&{9Qk3QHM!k)=6{zrs&@fm&lbIa&SK_v zc*uL4e=&PwZ`8(hza>@z>H|%|iAIFn_KR{fXv+wYP>0cK>^s1rdD||WD7tM5S7xjw z_wPPk^fx7#0URGy@&oNWaNHIOz9iu!Z**S1A$=*cPYrvG!2LA`Nd!7KY^iMD+F0WG zgGnU|i%HYO_qt}-@|wCA?k${mf;qviE|(!ugp*aupA_1WQcJDuSzAbyS@USi?o$sj zSBFi&P@zFlvI{GF3u^yqeQ@ISS?8}b#_wYud|R~`B3&E?0s$noM-`AXN!nhWuT-@# z?I?+_u8=}&%Txq=>$%m%fTdI=XQ5=>GAF>znmRz~Hy&1&`hsbzts>0GDKuL-0hpBe z0g3ijjBI(GxHNgE0SD_(M*68lqteW--|CNkF5ZZVJUPHhTgCL_0hMO;d2v>89a}}parR?uc@g>&-FGe7^GZJJTWuf z_hHCE)^7bOk4AD8KqHKdN&1bqyWsd$9FR$^28QuPSZuFDx3;DemubXN=SFzCAr0}z zq~4F!{{$FThl8W_If6A%4A#i&uIJmT8t-EL4%T;hO9xQ-kyQWz%Lse-ep7eTw>Xm> z_pe-X@KB7hF+2K1QM*rtF%Cm@Ft-g6PNdJOspPcc}=v1}zFXlRL9N zsYZ!u!-E00I%CBK?x2uaJf}t!?NV{4+Bhk*>LF9uYv@s5re>mk+D)U zPZoY|jO9$j@EgbbsX6x#)Wk`~@g@aaCa+$=<$pr@DQKm{k&i^bEvs*aX_xOl*2CPG z>G_%ZkeF8zv=iL7TQ7Q;?(!Ib;iio`&PE5#oMuY+zes5iJup)D02(v-rRmqrl8K7O z&}ILEm>$3>aesm+aHFd+zPkTT1d@2u|L7|5^UK*>;wvCfEB%_9x0~qdx9hz?VZVmm z)dmizt?KuE3dkjqbv}%!Pi;Mh?YtNPuw#T3xfI#DOVFVHRD8P1?ucn`ARt^&Ei&BI zf&9p2q)VJGEq@6B5|!-? zc+;zC1K4NU0WAKCL$lPCDVKC+6`qxikZr04fS=o1*iM<;NnW(fDmvw>vxra}-albE z>?z(RaswRMIG{j!ZrfoIg_`QJ3>!v>YR{B@Rf$29-EXGi8)c?eVg<-aOzuSD16`ro zomp2(SG*QJGLTpjbp>?2L2S-?&lrK%P~u!H14mVA$CdV^*%0Z$s&(VKR!!1xC1TwX z1qXz{qpc;Xcb|# zv>b=X5wR&&X;DaFg!Dr<)q2y4Rzzr)TCD>bx{8S9w>0ykS;xuzBY#wm~*r$xWIy_M*sHBcSms?)Y+L2;R(%uGOza#&iiFt-hBV$ zD~fWE3Zw@nJlq9vmfoWvS4HL4X?BHB534q=p=;@FS31l`Spbe27NO{5 zIvyvb!qJW%CA*>X=cObz1iGb+*TPw|u|Gh$CSc<`zSS!vjt1war(E%TzPxUWSBu%PM zD0bi_sV{9rIoq%t&pw$w>ZZOi{LdEz7lnK8+|p(@WZO`3*`kfVjnO`K9?;SGSJF{9 zx&WoVqW9PuXq}rS3Vh?xYaeO%292=Ru^URr&=CRzL|p)+Dg5qnEof&`gVhBuLKNdW znL}$9rl_M}SgNOGJqYm{U{qQ+W!N!?B?9z^fvzR;ZW;g_yR}(D}V7=Lttez4XEe=Cxc@)*t=8A%zS*W;9 zWrG07>!KkTCRC0sCgpJ7G;D}k^^mw>6IY_!s+DBRac z%5&7Bj?hl+jKJi?EW-Sp$ZaH!{es(w!< zOIP&e*Xh5|wh9?us$^`)5r5OlYc&Dks7DvT$J~QF5R`m zsdWScGLTDodWaH2hYl&BTKK8P{|DlzFc6Xmt%CMnS%WiS{5%NJOit}A+%=0h-oi8x zuTmbHszRFe?YDe>GlE)#pxFXvjYguwEvNc1?WX8gL%&pM+Y)!tKAOS7bm3=PU?qtF zRn|Hled)AZ;b=M>TQ6}fA5AAM$Dj|q$TdN~rpX4K!99e%Dyu8^U`W=TK!E_m)8rc5 z&tVjNl334|)|lfyeh{drbtPRarWwf4ge!`Z-EY#*L-1|o1emSKDPMW?|;wh zD^A_RsN*_dcfr?FO%D@`t=?$UBt9UcEZrgxil91IvagkBaoGPEuq5w6@b*vlWaMNt zKbdH|l3`f>;TlRv5TtRxe`oV=2UNevQ8mP~$Z*a{_q_2ah1ZYtfB)>)xue<(DTOn3 z@&c*r=^_bIGOoG{#l@vbTjo4-HO5hbc}btJeWni6oZ$hN42LW0pY6{ut~_HQ}h?}W`L?;)U;J?S;=zzUyT zpZ`^bhG)9`r*h4gE5eX3@Hd+CbUBNtub-$U`@vR@{mQRrmd;I4rAP8l9c(Gsf_dc% zuT|`kFC)I_PP$-!{d+s3Z;}dO#F(_%u$ha{tG5&}YmO!p%gA2|+7oJUUQ}0h9S{HX z#R5G4F>EX<SpS4wJ1&mr>DFj5y8_au6QDLzLa4t_NpaI_#d@ z6$p9+d@tihS8io5`GW7w$4*ZlR;TV>{4m|fQpU@}?+uJzLjOKO~;0tkJ zi?m_Jp}P9R*T}Z}s&0o#x_me^sSzDyU;osrIdNS`(h0zG^)W0J@?poyO*^A-ZgH^N zcW_e%f74e>uqPh^5}C}`jiY6f@+fa=Q@2i&^YUuo!Mhw)Ob zHl4U6$)<5XlrJM&fyv1Kih$y{_SX{`0)*6ysni(T;NQdb=c3Q=D|Rc^RI5|T)V`qZ zd+zf^OxPUNk>_&Ki7JeJm!0ET+97`e-^A~NyJiQFZsXI+EO0B{tt8ud+pB6Q_TbQt zWBnc{F!VkDTCOeyFpVj5xO8>Ap9azv514Xzw+GH87x$!U$Djs3ylbFNgZ3v(<8YW3 zU-n2&bMA!nWO>9nleC8(Q6|kzS_a4HXuy08xkI`43AT&wSCKszT3B)#lFmpq>kmd` z7HIC8AF5zfAyRBrJ;iLM!3x_*u(1=4v*7byiMLf(AHtSv&w?`;&{|Q3(kfh2{Pg6x zYf>uOx1wyCObKhL@273?-8skbg|LDLCw5pM3%s(JsPf`u5~>v{0~$mXeSA zASAHqE5|aMt*<+Dzm26XnrBHdz3iAvw~%6eGqGVeH1N{y_*o+FZu^+-Q~RbOR1G=f zq}*jleih;I*O$AmjTI0xbkgd}qwx9ME!C1194E6~1)9)6Rv6jI2Ggf947M0!5tjs++MTBnb;Nah zyHIpKbrbC^Kw)H%R?u0UxhpAH8YW|^pz}7u77nF@ME9P>Hb3A139fd+bd2$zsRYaT z51F{TdbgeAHSHJQ-m!$C?%-6C3jl*z5mOLnbht0=2JWt%?Yo|ltS}dGF=u4ubM5U9 zcp6~p?krl`u)CLGCJ^E@W@kAVr&oJT(ur$?K4ZYIGm`?eSU)!QM!#)lE(SKRpSdTi z?W8TRxH1+XQcu_Kw46l&9sY}WrmIQo@fDA=t3Th#mP5XzmnKiCPie!))%1D_fKAOW zAE@R`hzR$ic;C(jnND}8OTYA!*}6sTd!v0!e*j0mQWBCy->rVJzj?-?C=vY5bsQ6j zrYQF<(EGevng0L=1vSLF9EyFaFo&@xn;9Vt6ZSvY{VLTZUHN_CB*%INT;R;5^n-~> zWqOSgIJX&BAunNU24c;PPDfROr@Tlt08|6Rf{R&330ia*=4*0d(WiPxri!Ouq8Kz+ zR3qkkT7VHq{wTBL-1+H@*3M*5Gny)qGT2T2zPcuGy&Nl;1Os5UhP#6KTb^+;9Y6N` zy-~b8X(Y%MZISs@Ztc6dKIj54Cu9cuptGy@Yt3n+hLh!kmeI4byZVdqJ|@=@iivN7 zb4(oH>-p)h2Pn5q!>jcG;-;)U#jCmBSPtInTSxNk;4{}zT@3{aRtz%MhgLmijGHqKrS%3f)uXm76Rn*H+I>)K+uNEM|0IAgCt zI#E2CH|?Pw26;Aa8H%HQ3f@0PuIys|+6Dt}48jclBl>x(kMNc1p)zt~X|Ra(EF48@ zA=ji`UuKGcC{H9qxnjaAvOi|a`w zN)mIolDtNVF?`5daQGqTEA1Jmr7UxRd{+_!`deb(p9Dkv5ZNT!3cB8KTB6QwxVOQDvqzD6^Vr}mwbi!ya+W!40Nf=oA zS6%k7OxrARC+ZYl#)-K8+_)JX-wx9%!I%vv$Q=munCcu@VNW;Q;08ntEI5ol!6zb**;(f2p!yfD;mbC+l!&QFXtvj*I(uQb6S24nq<$#_3JY`0CSz`Ud*hp z?at`K2RW((zLPK#WImC+&mqU^fx*{@a$wsT(5g22%n?k;RY*xUWgG84KL0LeyYD%UNh}z0G5-q;s17H1 zi_*7AD#W<3rxF@scE*Y@-QiIzEo|9CXo>y-`$l)w8!D|?MNn3}_2knUXzO zHpl9cp^i2e04ny4E5^52ZnuZqfW#Of0VEpH?qM7~#_H&Q_P6b-xIlV6A&nP(euoFr zyYZ2evuvBMS69Vg@WrtKnX!IQwFe00;N3Rm8|2> zrFjrxoPvD4?? zYLyLE(3}FxNNtU0GfEF*s1(?wQx|Z!mS!CDtXM7*RM!w}l$VbR14=@5O*3qDH4^Z7 zl443pvy=)0{%!4~)Qpg{mE8_dgiJ3TUiqU^A9sQ1y}Wc2?~Y8To9p*VyFa7T<|xn> zr0`UQUuh**hW`?Sw=CG8&JXdpxts|jVmH{_KFge@HeQI<=9#Ma9sWAvLMFYV z>vR9h`V(}C_NApbt4>KUny=p)h!9Pzon5>>DKbQ{?mQ0WpGW|VJD#?0SU%a7blZ(k z5GZ^`=3Yz*<>fN;j~NYfUK)k}Z-BSgZu;aV$(bO7tcu4_fJwoD28wc<0_8rV~6_dT+L5wCFF+k&BA2gm@;atmFN z>D#eoz=(lV1!kz)Wn4&eLVe+=wtF(kBIgNU_8nLF`BM|>CxM~Ex|nPKgvm%sU?W5_ z^^Ccw^#zaxQpcJiXS7C+A-_m^k?kVNfNBP;W^buQnJ-0cICc7nkFudAC$U zmB_YQA6go-PFnu%7;`L&V5(XcVuGb+=#ZRdL5sz?;*4Kr^>s9s$Wb>7w}i*_tVh*I z4c=fdHQWE*Y9?1hpoZWjZ~DsTLLN3a$0{+0G-qn7sJ0Q%SZ<4Ho^E#}iO3%hkn|d@ zM2S(Sr?Mh~u`32z9-BabXFHjTb<-9iu*tgO(oLk+eM~1r6jt%%=gVr%6-@sMqBbn? z5}GNhi?>2(Ad{YMZRy1bS{6~6B&Gen%1V=Kf(;K;Qp`UsWl3(@Ut%Fd5KVtgo(F%QsTqbjtv5Yz^O5_5!t#H(Oe8v*s$(q=y@}qJSStZ7av__uy+Lvn zrAl#z!&~B#LEWPrP3?9hJ`ZGD9OhmiYUxui`@P>DGViMWHijzXM=gaj)-a#5z-C@C zDT%cLP)6b5tTug13VfZUQS=oyRBnCqv;RIn+(KZ^Zp4q?06@@BsHkE9!R=qi(3Vu` zSEGr{;QCb;Zc){~b+tiJ0PHy$dZUXP<*-(^S{?}VDO!uh5=kFu3?*b7trlrUOUYu;%Y$lP6`O?y!&X|8~3sfd>C3V&>XG zl>Xw${0L=HJ>_VEM~0nr#U`c_g$n7(Du`D;N;Zx}Krj2l!mdSq#fvpc;L`ZgRON># z^pU;GBNHf?pot~_fP$Ayly`F06UZAeU1~`4G{6i|ocR~U6deb6lA)lZO@g?uEoc4f zNs$JmsH=82<*qUKyXd6ruiIe3wy@#)O8thgyes5BPs8AVguHo)>+5}w)eqF_+sevS zfcuC~@VMRt!OoSRH~C0fw^zYa&ON3l>-3Ml*H^RR1IRc-#5QEt9&X?ha`eb4eYZt+BYzk!Mff zCy8=|s(f3)-(Uy{`bsu(t*tcrDz)9(Bi&JJq@ahAWue1kHG4fA(8kLY;kXRrymKG@ z^2n>;c3A2D=(oqwBowsntNTo>0JMj^yCHb7l;CE}o7J|P&1WyWTMyQj4Ua0Dv=6mq zQh+=m>wpB(Jqs@^R4^n5`3_vKqi<6F&_daxmJ)&tlQ{UzF{EsFND=qtv#c{rqH3gq z0QB3oU%b{}PM6ddhp1N&8%Jb;5kpweIeNF2@cs8MZoV!NTWie5Y2t~I=>VkyFrh+z za>W!m4<)hDFAMu>Z2lUDVNRveLxZdDPT(}t52#;0-~as9+K`|QlTIGc$kBKA>)Fbr zf-HY(eWASJ`vg!t!bfh&P^2IFK(eUtt@MTLhmMt=u*Vl0qkICn=7(9pb?Q*ucO*Sc zi5Ju!v!w#>@Bl9byZ+;SR!^0+w+-$NG^k1RNqkym_E*9FWTz14fSVtqUs}T#p-QiV zQp5Mq4?Z%r3L%RLB>cXXVFXP*MlFgL*3Zi$B2r^o;t|p!c7OqAxgh6NH$vpnRz2J~ z+p;|lerzVrZuH7utDm z(C=8{2%MhFR>uiq!0KdftksIxbo~*RanHY%z6TZ1M_*T4L9qYn49ukc<>s#Tqt z&m9ei%N~G|pfeJ62Gx!Lv99<1(&9ADuXZ=GSiIhb<=vv>f8~NiI2j;aEAmajWuRm( zKy5;i3>}*CGxl(7VR!nU^GqPp?aThp-HciFvOj*m#gNRxO=^HsYzRUngWeu7j~QN! zSj`l0u+GxkE%QYS=SiYD1a>*--vNx9hMd1a(=g<-z)o=>knB^eXlL#=orFQ}m{oG< zOUhvP?!ku?RdM(u@7$Zy4K(+>FYXU*gx$6kWrN_zpbm@mFc5!Q!P$86OfU>CRsxH} z5H^N_yVrSwx$^Hs8^^-k4CMz+cjxyLVc)K~3|Bz%p`yt90o3|4qi5WX?;xZDsyr^k zfku*pHCe5daLiZ0UqTjB9+17MI4O?dU^f`kKxWhr=w!`BucdRG6I|~r)1mFH$Ho`1u8Ky zL*^ypgmeMz*JQh+i?XjosHR{$3L0YJO81VaFNSKl!Z!X8j>~X=e>x&{NC0>S(EE7B;D>8W}Y4lO$OlXq~2=Pnrp}XnK#2~&F{Q}TWN~G1`5)P zay(|cRVy33ZKByfu_B-jDI|P8H?X1x$eY6=BxA=_GR24m$i=OIjhu153p0|Y#LF@U zDCH@K;`e3Cng~D2U9mP=jQxBE)CH>lc4>do#o>gZtwg8=zB`?;aeSPew)-?@2g5SG z0sj20?YLug3aK{v%V;Ft;`L#vM4)#FDn`UImQ&F*DiQ?YdBiWl^+V%sAoU2>2g`{S zyP8z6I7h%|7s1LFcP)V`e!Grmh=(I*<9mt4y2dQ3p4laYPtXWw8xvdWk-MfK0R65& zQu(<2SbOlBl$_=zv46#%uewPmD#aACkjk~80ElgwY2x?_5W}}}Dmx)Wr29&tfHV0U zYFK5}0$ML_Knkzzc)6U*wVp6Z@v8sl5$zwRSuP7 zTt2|(T=BR?X^IxcM{Kbx;9Q*V_=#d8quTbPbR;*N^SRn(x1K4x< zWmRHpumVp?Fo@LVW_PV11B$0HxJAvmo%BbSF(&C1Yp<{{RT+|DfbA;cp#F|H5V4!< zOnjrs#Ac9Rm0BNoNXgPe`P7E>S8~EJ)d7l;1gO!oJtD2V;J${7F8;}o`hxxKd$v6) zs$6PS0uHWQ787W4+CRmAhO}O`Tq=LUahC()WIDzTu2CWseqh@IT3UW(lhYzuk^Ci6(&`@fJ$?A=VV26#DYJ5Bvf%czq!`vras?RQ6g!Nlm1FZJSaDdli5@D^V?UcdVCy5aL zywwu73WKD`O}CQS@L#^A`y3EAqwXr`T@UF&pjCcZnSGSoV}$@E)ftq-l|N`FJ5xcf z>@W}P`c~Lg$3I<>@2Mk>6GW6&zdYME&LXYjaWYQ9ziRuSrt-f(?gisXN%8CJ&91nZ zTRv8+4T`dDcd+Gs0Wb8dT{*d$8$oiYvg%HaNBU!bvg>MeLU%lh9m9{RdO;m-ha>g( zG}To_`M>&H&GeHnZ`r4Au)j0Iyb$NEvm`-H;SKCuYm;r3&4eF-)GBZ48BZV>JGn7{ zVTFs0Uef^13Mh~j$Wq^wSQe2-y2De1SE=6_ld_>Lw(L#NnTBjY3<|1#8KS`VY}mQK zx~EL`cTCX!m-j7y5jYXw9N($~fe!sMc^|C>T~a+a^@o22MqQz6ymBHxJi0rg!ZT<5dqmnR&L{0%7SD{%j<>BxVt z_EY(?GCxBeb?7(*ISB)bnOy&RMne$%3-tLl9ycuTGi3CS3&-T?J^tPEh9v9_1MVNz zw!JWQz{Pl>jM18>Y$C5`i;Salrb$Md2x-r$Ifi~_RJmGW03;Vw={+gOF{iaLl?1)ooU^ds5om;J5XT)x7&Gg8NR9cnF-CoBRI8)=p6tFlT1|xeY$h zB;$%r{Jsa;n7hD{$U~c6Hpi9om1^ z*3lIhuSmed@+wc$ApW%mVPM*9xAS3Oi(ReGZVj}=hx!HLA9mPaTjN1*YeW?D!=RK1 z#3dR(xe7Tkad5NWJi9E6v5?XzKpdWoS2Om9$!&QYqGLJ8R)+4gU4|ExZl|%eq{51* z17hD_NRC6Jz6$O_isUtJ@AZdDcw+?%tt|}%p~1~umVkXx^2M4rEmHJkLiobcrIYVe zJm!1y48lX5qG?$KHdcTTyDpBEx|Z<-6kt>48=jmJ$&0f*Azk}UA%~*Ov*Lq+i2Z>e ztd-`Ksqf9Ah)5wd_BVs+Ur!$opLGxT=r`d2TaL*jCM4##`H@ZYVuKyTnr^cVBju>N zc5*bSHqoRx7&(k&7Zzcieahwfjn74Cq@^o}&SuP)`RP|SWTa7LCAje0-eaeg&cIkz zWz3CGJCicv@5FYcIMQm!qfBKlSYG9{6zKjs9ZodLhDH9udQZ62x%BzvY>>S%cKY8}I?!3U z`&Kg^OGhv#%d_CmDozxQY7BZH&6L@?&@TO*7d#Z`%h{&zedNBjBHA^ z3AZHqbAUiF0R0;xdyHF4KX8qMwG=QV|1-@eQtfE$Y|Kj$V*_#{JqU=A%`Sb+As1yKaQQSgs$Ov={;#` z6MVL8(eQYzGMg>T0v^$92E$|lCJU+ckY{G4!?N)B zARs&LDTcN>`Wmn|DCVJP=3VWXp^Jd{~f3 zfrI3@mF(O?=|ogPj19m&vcKEmTxH&8<2*yq=7FVia;o`Z-7&?9@7)(=YF`0vd34mX zadDD(?bhT=jXN-?-3M7by5Ymqig=sZu$1RP=E?9JepEp88F&8F1;ij3Jrg>Dnnc(| zWUDw{%ueJocv%#0VCsQ{bac}9OvAe&(sPFEZ+h)pSZ?F3{adpE5ArK_6vLNB(dd-W zk?T;_$Y%G6g!(uaO*p8b$-dgKx{&iAQ^uC5{~acsuiu~Dp284n}F{yG#CIKxRz>F|*HX6g4TcBKaC zb`Z)Y3_a~Y=CGmjzlFJiA$80vBkvh{*NAPXFi5Dk_C`&lZ5#;v>Jdh&a4A(G@JBbe zP*tyU^q=IB;Qwf^w_UBzQ4=aR>j4|!9>ya-31Y6cDNqphpm%1>;(O6ng*X!wy+N6m!%(ch0jqH!%{I_SsDU2{HLT+pkUG5BK<5i~he(vRl*S<%fOuwpEMtd!v^$ zP(vfBtRKNe%v9u4d$~<#yDgL{6k5d49?nq3iml6F0sK-YKeibE9#(HQ#_JJQlj}d& z__<{7KYF-?W+p7W>;nYmLRNN-@SaH9?_R_2quh=$ZmzXbX(8O>i`W)4S&_h9?FYXK zUgQxM2s*nsrFuD>p_R<&qKjEbPl$8vsD5WaVEf_e5XngC<#*MOHN!Q&gK_#K=H*`z zUxpAIy}7)i!Lcyg$p4xbHOr0vLnO{igbmhP{zzvmfodruixyVk2zW!Qh(x9|It?Q% zmYEg`JX z9pVl+WSlCn62DZmR&DQNM2CE;&cUtQ!_FK|rC+7QFq|s`S9KbXTh~<_Z7{6ofeFh^ ziGjmWp(aDs9F)Z9g0v$vAhJ)xM0ih(qOzr&{X+~nljW)1PI7@hH1u2j5$2J%I3O*S zNpwa`b($pocD$OwE)yM>7lK7-95cpaMu@<2#tj;OkFxpUC8uEe0k7gmDs_v=~ojJ+N z0j{YL$m+V*eP+UtcgwfzcFT1rDNE7>*+Daie(_J=;TB&@`5Lt$Yhh?`UrXe?@JW8( z*$hAJgR`eGgnu})L%gQ>f6gpyY4VQ`An`luNBXIgy(`E+OattO%8gr&1B( zzG7oEkF3UzJJA&QfYUTzC3`pqE5uYRPB;IN)zp;yg;b%-hn@YMU#-I(>;INT>`z5@ zV`bOMiurvW|I~zel-N8IAkiA2qr5uV^#u4cJ{Z4MULLa{s_%ZAwGhd%onu{@8mmvV zx_d$Bmx}_Np>v<3SnGKiu}D^%KYrPwRAiiwbbh+)Go{l}aty`56MRiTWHz5KbY}vg4JL-HLnD!uw)#Oi$fNud5#K|AJ>geZWX4GltE|3aTqKUv|HD-+F(i=P z8xV=sV=T_r&cUMlupf!!C^Fj?vU#7VPPH7*wRem!+X2bTJ9!@$6SZ~D@Jru=TM9Ys zZ$GTJo8#>dg8EaV*rGw5c5;<+eBF=Hvonx_=Bz7g&;D6Ls;cX3^4W(rIW7};W?d>k znO-Ff5a1{gl#1IS?sFY{8(BxE>WK>2QLF4#1x4vCw zw|!zBTg03eyw=P-pbxOrOy4uk?BF$CyErjLs9UpT!rF($$Xx330O=yc?-O)?K5qulwm*yZtP$i8n%sv;E`(U zCIW2goF$HF=~vSX2=Tb0J?q#?1b&YE1-s7>hb7kAQaKA^Bi7aV)*`HYIQWgRE1{Q> z7P2P!n>e&1odp+eY@SlVfNrkWu{F&av0)1fLGL*vf^gu8m$UO>@idVuUoi^{*iWn# zh>Exo=yic3cQjSGlj5>+7^%yaiqDR~t$h*lu2pj2>h$aC`S z>D}PqaQiBeQ&A}yK|QtVU*d{mZZR1!`{zWDZTr1LM7**-p4JzgPyDlQkY($kAZGub zuw5<#*pqwv02@ZbRU*quM&%UvVM%bG!73mlB!A7i$Q&zL1VphD8=%&;ybMdYPoxNx zMexgZ^6x6?AME9s^sS2EhX8((JMm}xRI<_~VnBvy-2zK2fz^KA#w&>>o0wkSy#w@x zCen3w<&b8^g2bJ;AjBTaowLfaRjS%4#>2Ay)bE3mbEohDP3y(2h{{FWg~!&fs%_KE1+^-OJ7Mh9 zyCUgSFOU#7<>#b@TBT+HIDCyUbu&yi=%n{5&<7mN0{9dX3ct^3Ha^ zu@ducdAPr-j|FpoGolxZc*e84X*Tt&_~B!p5OU<|$9n~niK zdJDk7{VI3~@$5Ech%Q(n9|B_@Y022wHeDKnNpv5$p7<-NY1X-3yHT(}XqGW&;GT){k#!#O zL+^5fpEr5yW0jG*O$2v2N!H&sV3X0SbQQ2L6Hz&G>5xt!Pw+^q9iPR~^e#KV=^UrU z2)NRI>OSr?k|*|wK$bK(2`h5OUF=e*DJ+USCa z)U=v4^_KpK7TVjWT;RL3bY-9x(SksjwUd z;zgK#^Aa7s<&PJ=2$5^tNHn-#)ws8?@3^Ghjv2lA)XW z^^fZG!Yu;Q6{c^(e2H)(D*1O(lfS2)sF(aX%9`rMmG4hmrw;K!{4N7p0rdNA@=K+t zhZ6Sp{o}=12`-(qEHlMh@qzs{t{!HLK=G2KtHavi+Q}a?l9P#P@r+ho+laa_I z1K%ank}pEWf|{qx^ja?r`XvEr6kkDb>Hg6Y!OT%^6v**1x(%q1Ck&Y~fEo=nhKykC z3jepy3HdJ|GJbL*Q2@+hnh5GkcWE{IBQSVB9-_@ESwj0=plV--Vu_dO5_^M#eYKfK z{ZM*)xS%=l8(HQ!YdsguKnQ~?gJ|iciq;_D`-jj>vE?v|mi3J4OVQAYVOg3J=4bE! zqp0$lJB^W7AbBV#J>$`z#)>+Q+Z=l z9S<QN^QAwE{y6s1oG#Lq42LRQ?}I#pD=zC?ipE*atpyJ0DW%$ z6h1@*A(jtEU2tm$r)j6xs~=WU!|TqMb88Pk1}Ht-wngw!%kMRjA7<%xLLm@KmTk>j zjY84WypYfd-2ehi%|gfOZ3i$ZuP~(Qn|6;e8e?^m_g>H)hg9Ebu)@KQzp-T-fe|;_ zX^=CNaXqt1PK~_~Zmx>aVVLN|K__AALM-L@* z?TG~pKzu>SW1D}+6QS}p$w1!BTXcbQ^Hi!Y53YE;HpX5-2KAA3eVMGqk{7c=f3vth zJK?IWmi+Mw6L%eaZgGbyIswtokYPLR5fpKq3mxF^c+Y<(Gmc}Y<{j&picnijV_)Kv7Ytl(; zMpwItvy)Y0_wW}(pmMu6`DwzsbIAc>t$Z)_{Hwi|pMsyMl0oKQCxpmY z_DbOvq%^P6BQ$df3hId6&_4%_1iIN-Phwy%oua~DBDxv}ZjVNJRc_`E&1&>=%Kx*r zy$L_t*1`^m!IblYAiSr_r4}I(Ck~)Mo8Cu`vLIa^6<&px(Q2**Ra(-$vn-AiS8^Kg zp?U}WFj`hi*1kxCF7k zT=7K9cke8Y)ZY9i*#FfhCN`p%}n$mrwPqu&z(N~WBW zCAwOIA6Nt#g1YTd=@TV60p;#y2-)W{(3feQV7~clbXAz~J?m=^MGx6(N;O`pr=I%G zHy;NSCA_z)P|!1L<&9Rne%&@=WF}RX8L%-RCL(2fHrdv_I>?^-%M5@8#iJ|gUZg?e zmK}(rYAE#i2oL{o_Qq$fZxP~K>o`EjOU`d;NvBag7uT6Jf>d#Yr>{V83c?T9X@O{m zqW%SJ)kRezJ(U%5S-A7;|IMy}z_UG<`uagCo);^HI%s1VbXj3jBEo@hdd*P7lFD7f zwKPON1YcBmt`x>lbLNQ3fH8?@|G{;j=q5UCL>Eq!(cb>Y)loDtvO3Gx!r+%wMqpX5 zl?x-CUKD0{wOK}{lxEw=*-c&L1f@_#?R?4A+zWQCsKj68)Yl-1bql|r8DnC;a4xGd zlh9gnPmu=q=(PenstPAmCVsYDUj+B%_#zmW`{WvfgTkD7Ba6kE%HrDJeqkgAa_)7x$8Yl{6czp#XQO<3vl`su8nj=SkVBv9d&EaFFjzZe9s! zb19;Dvo@huk|PBlbU$LeleV$jh50{va6O7tmd;Lsy+3Yt4GL=bT}#iH)1n=jwp@{t z^vz;g+@IgjdGkGxK_PCHwy7aP_lntD4ZcWMTrmn4fv9uiEh4WlsV8jfM?mM`GRuT8 z_`Su?({}peS1?OR^037tb3GT!Q0L~8vnlw2?vnbnIJ~>hoIC*SpM=h;DE`X{2NpDZ z9a?(9$pS!a?I4LzkUW!Zm6VY3t4<~Rlob#w`_W1HxBd^xx9U4^qP6(aV?Y>}=UOu} z`pms}mx3DhD7?_c*^gfdJ0qzHq*lTZ6e3*j839Y4L>+cZwy&&q4*kikbmM!9d{#B4&FoJl zU2XU@gg!+9urd+{56c&6p`$nQPu)VeAI}5>wjDMY(naV)bp~!o6krRxse&1N4PnMJ zRB(8pas;&t^@+fU+Df#GY)%GCrv zB}1dVC*WGsE$vmnH z;qsdGrMoX%P>$0QG7b0v>JE!2>NKW`lRFWeoPV#NY~XRw(B66RVDs+l#Vty70iDnE ztn%|AAwT;$r*_Q4tl_Dko~8x==(1tyINPs`z=1Tz>wp}DCuNy4*@Vs1!K*KD$A-QU{TfG_zsAR}CMXDfQ!C{zu+Yjb$JzB;p| z*ZcJ@HXjhH(gmC$wx>gFCfij1oaYY#L9ksRGXlTZ_>0vjWY~7O3pyFI;G+UXx+@6n z&iCu@!O9)Hz?b9)`nHb?D1nFiPBlc*34WV&@tJ3ESgDOzLBQ#7TVjy2s2^>ZfuqBy z(#T7IFybZ*m&=A^G^V^1ISuF~=eo4k1H^@((Zk|(eHlb_xm{y3x*o_N9GWI&UU3k! z_QH}Rw#uHg*K@HYgIw_a_x^O&S;>H3o0HMbZZ;?QBp#$jC$naBeR#WA#ZtNJr`oN& zL!2~oEiF{sWhB`LR*$4bs-EpfFTFNA9$V&YXPSj^8)v7LrdChv%Pv#Cf{6M&)e;Rl zD)>0KI=sb&7CkkeiZ-lQE<3+I?SeGV3&4qx6Q)HVuqu<)F!cWxl^4Syt)=$&1u7Hw zAIku)5l;}D6UitOs~HomvCm(57u=Q1%$1lM_p;<6-fUDeGD-@3i%z;GPOC=6y)>&3 zSG45A2PgTXw_T7<3I<;BnA&9lqP~h1db?w#?~Q;?R-)i)Up^1aGNqArx4V!Yk_!R~ z5Ol6d$#-M)GLwSW&`(z^o$VOVivRi&C7T%(6JV?H19ZWz-(jfX5>|IYs0LGz4VFd^ z-u7TEW)6oyM`{$&h=AHO)6d4tcFV(tL*Cv0x{}^G2ljvJHTY}zjK(A#(bu#}YOz<~Uoqx{<9GU5WC}9KLBtqU?vXtN<8S3# zZI;^8_&8ykApIpsuWLK{V9*xzJaAm9Ns0eM$^}<~&OLTZ$1kd$S-|8aR(#9;ob1|tMTt3VI8YuT&Ke2?Q07*_Cmzk2T zSC4=yepHVZMAlqRYj30%0J*<^5OT{?m+?2bQ|fLi;WP8>%`CX$${pm<7h1{IQN*;_D9c2Iq&RRinog-soE8O(mKsc;lQ0Ss7Tpx$gfW8-9E;@c}d2F1N(uu99+LAsUB?u%qED{FkDiPM63%<2y>{uR5*AY^xgUZtCw%We#*YvONwhU?&}X zKFnzNe+~rZ%kDB)%nnDo_gXQJG?z8J5cldIO*bbj&tv2CS`Q^a{Gqw=i-XCAyC6E%X{xUVVTYl<%6b76yL8J6D2^su`gtjjo+VHYu z3P@I5pFJ7Xbq6gT*&%@sUv=r%pg;AHnja;`eD5!^7IfRB%C3HwCYC>K%QWr-&1ylj z3v?$oJYqfuE!OT6?F=>TXdq2P&|9n&yU+F!p!OhjWsmUud)ArxQ@S@4Yj{?n$Ci0NK(2Ea}A`JF%#(@O}H(x zHYuA9TzP`fFavBl(k}&gi{mjhmxJ)oAb|f5xN2MWh935AF$Z&QqEq_#6e&@QOYgln@0BJe|POHa9{XN+GRfm%LsMen; z*9dERE2);aJl;wv*^T`0V0Hy9qK)M5)%YjZ= z(3En@&zU!_ewo6uK6yk6;TF{BQ=pHdU#9ZoEiE#c%RkIOtSmq!wRfY%fohHKmJVrd z*c9wtjeO_&x+y#rr7+T3eN1FbO~l*?*`fd2T~W`BtyK_EmHoq}t~8c!i&*Y65v1~6 zoLLMteQfyIQpf&**!KTr(e^z&gWJ|lM&IZkG$r&`0_`)$ZL9^puPw>YuxLA1r-0+z z`7*v?+Ad=zEvn&~_8tZouiCA@Gy_>iJCSm23j(0Ne2$u`jJbepfIS3ikZ&-fE%34o ziI`^A%8Ght7uTTH=#HyUA9nx{E*o5;CUjn8cgGf>^Fo>av4)glpA-jSMu`UGlehW4)L;jersRx3>d$i*#vGmLKhX@KG(-2%*@z&Vea1%zS4*KDECKegA28oe6D8R(F-t%F{@|%%fx=*=XL?%J=!any1S(o>P8qY| zLM$Rt)m}Ogam5~D{oDN4x_bT^vPrL8xdV1@hP)I?ZECu|eufAV=OGJEw@wEfThIDG(kvc|{J zb2bmQ_Tg8%kwC8;ufK5B?}kRzN8b2cxqcN3*jeeNjw9Vj8@6#(OB`TvRhO^5*g z_&49M**^B=AyR$Wk{HoL0;>PP#RpK#fp#Q;f-|>@?^F2rpqA-E>D3r~Bgdd;8Tu{j zIJ*&iIFG8&g1m_uhB4yEq6)}iQ}Ewe!+8~YA)>{8Z*;%`&EC__F60S(-#n4{Fd5ox zLk(%GJTq56nuCH($)d(opi?;e#hE=d+`+kFgp#tjk~8|=Le449!T5>`YIGz)6>y?( z+~O}Ur(8>0&_8eZX$zK`>#tACIz}5U!Q2ku{4Yj2O;@LV$u0BH(M02KfUH%4fg&E8 zEHg=BWp)YcNPGdMlM(d`ugMi*51nDcV?=hM_pax$=cf>?0!DJy1iIky(5)h9h4DK_0F05 zjTfSA20&pi)oEs1vWcpRjWBP6n*p?SjT~oFhnHvCgKp9|J7T-hB?QoFi74Pih{d*vn#Z3SK3K&on8uZ!<}VkQDI z-6Hz~%XKsVa-?|qOZ%9DUY6Vc=v>OhVw|6>`l?gld%2!egK?W)B8dk;(&*70ZBMI~`xWioux>&Q1P(`^aXdZOUav+7No`#@2 zAvW;>^JwO1V05$-54*~sEk;B}Qln&UDi2%<`y5B{``HE$3d$3*sjOA?EG08EXdEcF zJ|XCR2^$0F-9e|ZPti`kddGLl?yvbFcQ`Sw;$~j@TE`Kb_mipMF-UgjX7v3!yb86E z=a|x;e>}E8|mPLqhd4TMU@kZ^}AbKNj*;F_TE*yT5x6s+Z6wZ)7Dm@8wHx z5Euoz5_f|={g>-}2+Xc%jfL?g23^7B0`o~*tlJLm_1CPn_#axAD(C8E2f>zC#Er6b^ zHGA(AJDzn{(yp&d(k$LL_G7Z>Ds)fWqS|OMrUz5~0{5&`3Iv_6a24mwx*iNMqb56Y zA5|qH{#8;U?$v;~owFF8zz0>`#zvW^=PkeDsR~A%wUu1879E*hf|A&rU@Trl|I^5qSE9J|V zP07byMsUzlbB8*f;fgST`V2_V7lEndM0Q1GXpuS!--~j=yEA3I>?NNQWK3m^iIfKA zEuYrAuR2mP*m_KK2nQ+AalAe+?BC=95mE-1|ICsOp!I10h; z8iSRU5Z9oENn6CO#32&e+>%7bwsU4-XKd^wk(N+sgXj)7-Z%jSVnF$!LXzR%ym=*F zj|A9gt_EL!C{N6v>!h_L|48%*{?Th*<>mMB)o$9Fm+>jKf2ixiuJX;=4dqSJ_xpWq z4LHI=!bI0lO6Zgvpis;vRsUz~CywtDs)SHoz4x_E*TAPxRFMvc##sSd@(M{q55*#| zQF@8o{4SRq_KATFHwlR81?CHq4HZam7$Ge|goUY33j9IEpdIC{6{`%2{+$Z!W2M&5 zcn*Are_xlX+~?M3u%G&5`N1cp$siu3>rzE4K=~7$AZJ4lir_q3d-`^0;(}i$^*I?aZmR{o0TDGkJ@`8AthaK6o;)5r z#QN&oav1BU_mz0Om@DLlhRBXsL;`TWj{59}3=d!Oh&5yu?eg$UPj{w4e#^~KiO>~( zX0Qh~fdhyG=aw*BVK)EP_G2~UKg{B2CSUrc1h)FGlaX8i3zlUyv`aBV`bMG$x_NNE zq*fD*>9k);;?B9RSX%uJptaS(MZPd*B3z65MCrTVo+AncIxFxsn4N#2zpbD_0&nC| zW7nmVccfmgZEg(wo_RhIvQr#rAwN-*8K3s>Ai<)v%)BqFtI_ZMqAxQ#y^ttSG$m!9 za2Hu7@YBBA8k_*p?1N8ckLWi_a`IddJHbPn*|My1lizH0l1frPL&4L52SrDh+_`(x z6)7w(e%EPEodPyAz?4br8#VN=02T`c0F^i2*v>$=(RV(LX(g2lTFaeC3>XvPfRh2wyLl=3{%KqGz%{*=cWik>Qj9w)cLJ$DCnV>Q5V*yxaG$U@ z6%uqNvT0v<#ZZ0{A;2WO^*Bu<3sdqhX#v-3F~67|>lxTT8~U3zAN8mK5)@+*y>kgHqJJsE zE2*{{m)goi?1FMU&$&cV_Hs0NAa(noNHU5<pLMI zGHBTNX~`0_#GpIZw!y>;@#eFs5**6oj{UIu$FSCp#vSC><3zRu@%72bnW=Lzp0I8| z7rBs~Xe&kdFK)J-UDpEs!4K&twl{D%~t~ zCZ}vSHwx*03BtB;hM-~bQ}`_N_hdj!HnSKHnei~9npd-Bwr%5iLKyGUotmghw}id= zg;@kRROPI-e+~W^QZGSp2Z`4F=WPbb(;g*Vm~}-_xbaa6barT~6yMCgnXF{3b|@0T zO5i{qy0Z58e?8~ZvgGF-1AOvRp9_|@cMkaeI>Se)1lRDZl4o)o93R~dtRt;GPJ5IBIu!1r(0M!y#+Z2BH|H*Gurw5&F_ zWB#$#kEYJ_S7uz$OSxLs-few%RLgv7VoVukUE<2C=fDLWd25HJ6FuAn_-;S(?mG|c zaha`gQCLph^Zt^^lP!CX$f^6jhOcHd3Uox#Vq-ln|Ch%>YWEM%Ogr?oLvuCnoqPx= zd##%CJOivyg{|!#2q9FqK1kXDIsuZZX@lbt~HnE65^wE0$OSCj#q{f8J3 z4!I}cZHk=h{N6WWN@6hQh`jH8E8%M$EiNPhQ$FAyG-kdpSi)0b*~ zc81m63KxKbAl%_{f z3QU5{peeZ15AzK-M6Se4XC%`Z8Aoc)J$|GTWkMcP;(KpEhQatV{(%hW3-o0e%Nc>4`?ku%p|e#v`>1u+tUBexV^J~-Mjak7gs`%o9n1G3ZS z2Z>j^`O=Jh>vG^{1qj{ASBr!2tN#j-fjs8ZUcleV!8a>bOe!ZCMX_2}^jwq6SVqZ| zWPNJ%HhTUQX(;QIJ~LtqTii=r#Srl&+RB zU|9$)9I=zCzT8!;sY4yjO%EDluix(iCMM~kDx|+>1<0M@u63woyS{rVZACuo2FJDI z2)myl!GY58%xMq_S+X(d7CqHg;(7QpUZ%uFVgi?M39B7`GSHBci8d&TF2VGze2-T? ze&!aKNH^Zc4A}n41p@Omqd|_~9NkpT)6y*;W>FrNFz}%9rd z^ui;Wa(Buo+u2tP8M0*cK;9A33TyZI(qRSLMs?gS;bbQ*ZJx#7uWyS|86j~0mkBTk z$KsXE%KyUanOn9!89*gWy||VcJJ;La+MafnHUADoeCPV58wzY873j)`JM!I`PGx^f zN8Mm)U<@PK4eg78+zIPJu!&2U4HimXk-0fc2S{Wuo{0kqq5AR-_5>xssm1qaj2bb& zaF`Ty--3mNPNR)ro$WOx87PKxr=9BoK+xO`^&#SG(VBGq_56|J%S$?nd${fiiJ3l- zQ_*o;0(>vQGKxc(HL%gGbNYP(c$EA)=3A$m$x|0})y(3EblX^2`Zr#{Y)rr|OIrTZ`SJ+St9y8J%MRznQjL_FUt zZO0XD(}Fd{{8~M>z=9>d(RpfCbD+=22vQ1JmP$nWi3_>57S##`*|RT8IUuTNT?>Pd zVf!afcr445Tz-Gm^eXaA>xxg)nlT{`%?fm1kB>8X&1-gLjl#$wa!ev$tc*v}@p(!= z5RiY{A!GpP-}FWvFRgg;xaD*gRvqz$Ua#WNAcos$!zpnYnHma#j%yJsfEcZ5J}&Nd zbZ4UqWEI%V->F?4=Q9st`}%)mS5z7{Ob8E&9syF!i}$CbPq5glfs6GMDL3?EjF!t- z9SHJ)7@tVV#p7C%2%MFltU<^ME-*nV5EvuiagbJ-h5!hfZbkeLT(h@7F+Z~OO_r4} z2_5i7f0I7(C0D=e&3Xb=zUViG!g6H6fei!6@*vNExxa?7ls$p9^SSc9Q3g*vseyb3qol<44Pcgu zzKT-3E!jvkso!ve8r7TcfA`$q$AD-B^Yor2132)aSvujSQEVocY_6fVL$@mJ8pQOS z#JM%>{yy``oM{vkAWLkJvc|`MGB3Q_HhwFcvVd1C@D^7@9c=T5V(O6r%~FR`{1@|^ zNu*5XMBKco9Hg3Eqf>m4OtxZMJKc640D+*!N1>aB1==Z38uo6?t3?HinH16d-BEeX zfjqr+4DeOAhy)&EeC{;SEq5gR`u9Pzs2ee&Y(du!hAqtg$sM3QIrZq*pozOiO5h6l zz~%bg=Z$+8X8dcjP88Dm_FX8LN_SLiSgkW#%}6ixgirtZ4UVNex9oS&U@c;IL`cOh zK=vI}S3eF!7Pls?Fk}DWL&XtjES+^(^Bd_?=A@rsH%N~4sV?Oym{6})IY3p{61=3k7>=wYX34fp`PTd9z2gAtw4da8 zycbl_?oAsLwaC>WLm0L#1S@Ew>gE*@qaDGZ`S^q5k5DtG<11{b-*bsNC2}P>Y9go^ zPrHpC|L|tu!OP(iec`xuQ6ODS&qgA%Hwt6l^>eAxTUPP@>ni%JF@R};&Y)Ryl7w9+ zSa`*QfzG9ADCwBTnO|}mhIf_yTLLx~?{XKBr``vgPrNJQoSy4lMfxi+AR}U(7`p*U zk}p8CNnz3^)^!)woLwjIY!nOn)l0j5R~$-^t3qhQo6!tbf4u!z9kw+7UT$!=*zu?4 zao1GLld1io)4YbDeMoE^9OV5W`71t6;k(aKc?;vRP;*`Seb%H@(I!fzg7J=A9wfb> z@+xre8{Y*p{a6fb)UIxxvXfmMW7CA4`*oHqC=3QCEYvq`d4A&L?p=wV60^djOdRv* zVcPR#A5p@F+}DCmXzhDd4>ucQV7EwSKX!2e-{soh5Q6PZS^3;8kGl*~@B~8@&F)+G ziU~+mwA1DJYK*sb2DGx#<7UH)X=*b;n6XMDAx=VM7p9Ut-Qp>ILL`?f7t5c(XlSg% zOenqsK!Z_|2tzp>nx=J`65(y3gP2(47l@Cw`9U8ZKO;$04s`lp8nfY`#!E=>Tr=rs z2`P~CpNUdr)+BoQPr|4fsQ~q)LI@l7tvBWo>Z#FsW}#W-zw>{Xcq+ z(7N3?qTHE9FFOVRrqubR7`hK zT6PV2O@lSn7!3x8Uw6E{_d`&Bw}%|j_c()cDDUXsucAYw3AMly)U`3>&PB$&Oo8*y zW}K*`o+q|Iga+C{?c8~*_2ZwQ{G4sB)4SC>9`~o0-wvTD{4Xa$-qjB%4y|84WKr`tIVUSSL80`s zsUAO8baqS|v*rR?Nyb48b@wN*Vun9@ z#Va@Dn0(%RUzfPB*whA6ien!|a>nf4n0m^=_x~Ns*CmjFjEvRp8RXZdEtcsaEZ!uc z66jPH`L{|~AeDcqIAKujyo=^0n7^x+;EiMRiC@~xW=KsMPRilt61ULr5-AX5 zvf5*(a6v3%6=fSLT8ml#{UD)}hOhqpis2tpj}|__@3=TI%!Rkhc}m|ONhK)yI#{D= zU%JIF$U@ummSNEd#5r(1H<8&y@AUueDf<4p@kHY*K|a`kg6f$w(P{nM4R#xi5Q@Vh zr*>y<#)gd-2l+*Dx|ucD+0X7X^Xz19^+0Twnm^Hi&+za=(BR04e&`bG3v4K zp@BTW<^y8>+|j;JoX}Gdre$S|>z}@wb6tJLk#>M#PYd18bz=pdDt@O0MK6z?x@JAYn^ZWyDH;l$P~Tw}dZ^2By>sXicorO+ zK}2Q$@a=`;kM)z7I8@6fck;Ks%=XQicFC|#v1?*eeIP3*5TWPTC`~X7vxRZcSB#4Y zziW3J&jVxHQnZQizv)*a@+nYWY7X*t!)%6T?{tmSIJh-yzO?m7-<|ck#FGQqLlFSl z#?*%3K#|{tX2s5F%$&d1{bAy=&+9W+;X@O_aM?xXTx&^BzXjqCB}*JQWu& zTL@n(?F{yPTt36B=?Vbgy;JkLa|0rWyCEWXKEGp6Y^V&_ePQqXWrrf^QTzWf+JG3l z>!Q=tySdFcOTUKc3%q(`Ix`24q45)~^N=&6Feo*F@PapwWI7fmHwmX;aQOSnrKDWdi zT^ZJW>bcaC6QTITZy?^Eo(POIc}TkpC(IGj<~yFue&V@tuRrmx?d&GdzFsisG#&yG zr@!hj`DvR||0Ilb@&C@>rDQv;;pK#1(>Qp&lj(2(i;*=kxh5Jr-rw^xl1zy@+YFMN zjt{=Lw<72ZSgK`Wz{x1V@P8=F?!=Yk)zVy*@MUE!x>CvV#KJ&isbs-!40wr@MPGCx z)r@z@-*$R1Ho>~6HkN7n3d3$KqBGqJiGiLG=fSDJvT`&&w8a#G6NSp8fg1E^f71u* zxI>lp4Yq+Bt_Be+YM&V|oN;o^pS~>B;(AOvSAC>%3;UmzX^VZJQr z)!3E4vpIXCuF=Lq+sJ_BCH}u;#(D(@-)h$$na=<>50{^01wVQ3tG9i+p%?{F`khqQ z&^s1(=D`OwCwmxg-a}v)v(DPAW+!zY(X_?`Lj1lwqE&pZqN5c$a34dZsx5r})*5-ds<45nS;8)gMlwWFpo551*VAt5aOK8sQ5y9~rZl^F%tp4!?MM9TyNd7K;I0ih@ zBgkS8;I`c~*Zb$r;!4F?6GGpvrZx75c`oTxv`By|wUR)04ZFD)sWKSNnp6$RW9kX+ zb)vH0@Eb?cA`ZUIMRNDVv%39^&VE|NDdMPcpC-kus@KO>Z@4`!m5iM|q<(%`OhBx({QbsBz7@?#KXS zTcD3$A?e%mKw&7&G~=vjM!+8{3=swL#C2*Q9I7${1Fx=rk$;UM%;YA*0jh&_W=tvU z-qKyra81&|BE*a8;3fpCR{;4JiEw*=|J`U-SmO4j{u>$)^@kI z2Ti~vb2b;pcP4+h#xrcHEVER$A}v4De-F%!)(BjLS0-+^#{A?B%NLFL zt++KXPUsU@Yg$3cwQN1*Fkzk}Sf^VWCn8@CPlm#D9PnP+UV@WC@iX3sL0W68Z>&))A3V!vKoAy2}_*~V&yZysp zAu!TX{ex4=QFfX5Ur+o?NX1#InF>vbEHND7?zjN{a-zl?LK- zwI7kMzY}Zn-#WK92%DkeyaGRTOy72o=x=4Z_%lZ=vH20rXNWXsQEm0SIv!}^Z>2yq z@Tuk05aqq;d;nj@Fn1mE|B!ctzV3NFQlzObM?)*nj4N)emi!okljug*Slr$~6XK0t zTAW6kXnMmo2%n__ywEYdBhEgc5?V}MZtS9MIVt7Gtql{)A;Q$(;4Y?FMC^`g*H zL2Eq0s$VMUZ8%{`zFwj>Uc~NYg)%m>aiDtph95QHw@x!f%d#Wc`ZnQbHmHpegD{bW zf91lJ_CcT{zq#tWNTI}r3@vp-T@WXm?mF`dGbj4EyC?&GxJ@2V_{I|&@_}x&(x&4R z^Nmo^BKihD8jC{8)cyQE6y&i3h{e_5$SGW>LVacy*yZV!$Tmk)mKa!aYQ&LXm_!<`>`=+mdW+E@39$-Al(?#p7&oBCTP-0&#V;+(WS);~@*|uuV;C zpxo;^uBn@X7Lm7?VdhhyRaWi!|F5dRjxbUNf`9&qT)?k=Y;5X1G}=lID)-@o{v}ju zB7Xb}zMS4>0H{bH^l3wITv;Erq@{My{{3wxf4BR4r7@(|q3|t_H5pKR8>Gd=T-AKW z{=dPXrkBwZ`7`PTd+W_BwFVG~UF1l6{m}0x>r)jj@kvmMs zB-~WC0Mft>Ugj-f45~%Xx%w_d?whQL=A$K6+IS-l-X0;k&Hw4gnIw9~#gZ&}bQkw1dx?FA|FW9KeJdM@k9d{ z;A(vk`}!@)CB`*b?J}y|GV=vU7`BYG4N72~1hrv-t09f#_YlDk#-7#+r7ITyMzB*> zYl|?*%Wb{ZUsmRf02vE7=n>`*%&H}E|0!TU{E;&=Rj_BF{~tsrrt^Yi3Jc1$%z7y~T zQf$5(K{9|qmii+_rmfX;Y<%M*Ud7F%O}}^pwB^XpD_S`gn3j#e#ywPr-Zn81)veIa zN}AKD&4Trhoxc!a|Ce5ZB$0?%P|WIxjby7$nVbG%Tb3_O>?`UlKerNz zbK2?NKXXeC{XtSZ%>ry$8hB`>+6B#BPy{CReQ|2(YwhhP2&i-Y>3*@kw^3->yY5!1E zevjC!-NTJ*seuUj%PuX?5!h({%LtOuU zUnz!-pgyP4X)P#-T?r@E_y3DR6fgD+mfQL+HF@liCW7C;PHFl?i$LGcPvo*ZQ-=YG zQLL}YS@W-N)bd|8_-nZb-6=}Q`5`?2&F^*gu6&UM1w@?u1K&L4RPS0a+PPW^vB%pQ ze}7x4N!qdTlAZedzj!U=+H-n8u;w&HNF!hEgBbE#{FN~R9>*nh>Yj#PD;3cE=(onS z{hA#V_88{aD&VtD`hbyXGe+Xx*4U;~^!);`A?C9)g|!@8$ul>+YCVU3&bBf`b}Z0N z4Xq7YWtZmxdFyOBU8kWq?lZ4twQgC}ztk=BRM(Fa*dL$1(SA-D|t?Ci=ky}m!p^TlFqxFQ!bXzvdY zGTq=l;|s%^!XE3V723dHB&El^R@7d{p9ZjzCuSFX@U&)NQOdI=n9zff>*Hw)wf4}F zaiENsXd>c%sBxjA7b0gD=w%ZT>-)VY=uL4^Q|{5C-DWNrCpG)T+?1VQ?f(;U0!-1M zuP?qTXuh;(;F-g4>7~qG(urz3Qq55<993+#NC6XU_=KSQt*;{bT(YJ6G<|{_-2)U_ zBktAH(=Lx6h7kclG#z6r8=(qU@t6hDlnn7-`2nGd@+o5q*_N)_2fB5jjo|E=FY6Ej z9YTrHBgrg7Sh|uE=>K1D=hPks*KN@ljg2OKW81dVSdG<~O&c`o4jS9GZQHifhK+3} zU;e?lJNtUw@AW)u&M|aM91F5q++|s2z$NtUloZv4!6#Wfbp^ihm+oH(%GSK7X*svn zwR8t(3qa0_fSQEZuAQNYGo^Ewp87MNJw$y{@2J;W!!MY8xO(7x$9-PdU!r)z7(<|$ zR>H_Ua(5Z`AmO6_x=5ak=F1E=%N6dkHY0N`J!e)O*2d&ZMq~}?2!8rht}g%O?h6qH zV^O;8%0liiQRt1aM~(}>N-I>H=rkExC>Bru>Jq*utYChO<&g81A^lK(iw;$;FGn!9 zjEG}K8}sU>{n*p?+6|aJvMPpsSwol)aq)DgS&kFrEm4L@8Q4xs6k-S19(vxt#q)jS zh;X=swZ?*PU*(Sb_RqT)23X<#_Vh0dz8lK`H)qdJg*t8YZt`|rx4_=+>Tg1k0G+YoCS-9_^0FZjC|+BO|C)p8GLgYJJhars!{@|Hd74 z+rG|ctAAS9hF0To8PDwvt+o)x6!ivxL`nj#30vtb)vr(qS@SIz z1jTKd9G(szAF6&Ud1O%6O@$yfI^*sug|EHN0MmdBlDtg#yVL-jiFc;ci!)jgPI+LJgb_w8z($ zWceTBCO_}<^92CU?rpIV6me9<#(8rcD|`1K8YD=*rJV|y^RG{W`#J{z!S$T^A%d5& zv*Vt-buWaaa$BKM>ua#1=W_6-xmH;)Pmj_RjtYfbz~~vwrn5F zXybiLg1~KijgU%0wXWYk2ay2tK)#B}X%|A}CiKLYp1Aqo){m*Xkqjsf5fT5A`dn+k zEVJ4S{&D!!)3ETlb{N5WoI6gK_e+gNo?U&$3*uZRaMvS1$++Eh3EuV?YWNva80wPA z%@u*oK^bun+r{!s0H8<-An9vajkx0|V`i{f#-eMVXyLe7(pd{?Y6h7fcYw%ee9G{V zif0PNzDA3Y&-W4-R|?AbGMd#0Y=YM8=!n4Zx8pt8!;*2W;_#Ib5)V>+<5_t4OJjJR z7q%oDTt4Hw1n|5X>5;j-&p35Gi6hw)O*qrGTe=tokn`F^1&M`$LiJ*eDN8YQ9)r>m zo$i1V!FDq??zwMFssb^koOM(0?y4T+F4#lLWd)Y&MdO<8D|(r!je=urY4VoeoNT;M z{@}F#9JdmB((u@iKxn=mr6T|G`P+Npv6m<4jv=>=VQ)NSv`Tvdj zMXh7F{BD$EJdc1p{ay-sD3(3TV{@O$-Dy<+;r-bhndc}~xX4dnfILFF`_J*+91$xj zj8-S&ET-&ufkQna!J)RRnCFo1D%_&$^&e^J0^rHTy71#zyb~oy?90*k#g`Jlye6#e zXb%hd7wPL~atv^r51Z$LKW%j)=^2OK)sdlKcjjsx%0A^uw@)HxY=aD}G^|=6!Z5vv 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 CcY9v| literal 15754 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 diff --git a/api/grpcserver/gateway_service.go b/api/grpcserver/gateway_service.go index bad11b6b9f..6d719d0095 100644 --- a/api/grpcserver/gateway_service.go +++ b/api/grpcserver/gateway_service.go @@ -5,21 +5,17 @@ import ( "errors" pb "github.com/spacemeshos/api/release/go/spacemesh/v1" - "google.golang.org/genproto/googleapis/rpc/code" - rpcstatus "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/spacemeshos/go-spacemesh/activation" "github.com/spacemeshos/go-spacemesh/api" "github.com/spacemeshos/go-spacemesh/log" - "github.com/spacemeshos/go-spacemesh/p2p/pubsub" ) // GatewayService exposes transaction data, and a submit tx endpoint. type GatewayService struct { - publisher api.Publisher - verifier api.ChallengeVerifier + verifier api.ChallengeVerifier } // RegisterService registers this service with a grpc server instance. @@ -28,39 +24,18 @@ func (s *GatewayService) RegisterService(server *Server) { } // NewGatewayService creates a new grpc service using config data. -func NewGatewayService(publisher api.Publisher, verifier api.ChallengeVerifier) *GatewayService { - return &GatewayService{ - publisher: publisher, - verifier: verifier, - } -} - -// BroadcastPoet accepts a binary poet message to broadcast to the network. -func (s *GatewayService) BroadcastPoet(ctx context.Context, in *pb.BroadcastPoetRequest) (*pb.BroadcastPoetResponse, error) { - log.Info("GRPC GatewayService.BroadcastPoet") - - if len(in.Data) == 0 { - return nil, status.Error(codes.InvalidArgument, "`Data` payload empty") - } - - // Note that we broadcast a poet message regardless of whether or not we are currently in sync - if err := s.publisher.Publish(ctx, pubsub.PoetProofProtocol, in.Data); err != nil { - log.Error("failed to broadcast poet message: %s", err) - return nil, status.Errorf(codes.Internal, "failed to broadcast message") - } - - log.Info("GRPC GatewayService.BroadcastPoet broadcast succeeded") - - return &pb.BroadcastPoetResponse{Status: &rpcstatus.Status{Code: int32(code.Code_OK)}}, nil +func NewGatewayService(verifier api.ChallengeVerifier) *GatewayService { + return &GatewayService{verifier: verifier} } // VerifyChallenge implements v1.GatewayServiceServer. func (s *GatewayService) VerifyChallenge(ctx context.Context, in *pb.VerifyChallengeRequest) (*pb.VerifyChallengeResponse, error) { - log.Info("GRPC GatewayService.VerifyChallenge") + ctx = log.WithNewRequestID(ctx) result, err := s.verifier.Verify(ctx, in.Challenge, in.Signature) if err == nil { return &pb.VerifyChallengeResponse{Hash: result.Hash.Bytes(), NodeId: result.NodeID.Bytes()}, nil } + log.GetLogger().WithContext(ctx).With().Info("Challenge verification failed", log.Err(err)) if errors.Is(err, &activation.VerifyError{}) { return nil, status.Error(codes.Unavailable, err.Error()) diff --git a/api/grpcserver/grpcserver_test.go b/api/grpcserver/grpcserver_test.go index 09adcf7428..5c7a167d63 100644 --- a/api/grpcserver/grpcserver_test.go +++ b/api/grpcserver/grpcserver_test.go @@ -2684,10 +2684,10 @@ func TestDebugService(t *testing.T) { func TestGatewayService(t *testing.T) { logtest.SetupGlobal(t) ctrl := gomock.NewController(t) - publisher := pubsubmocks.NewMockPublisher(ctrl) verifier := mocks.NewMockChallengeVerifier(ctrl) + verifier.EXPECT().Verify(gomock.Any(), gomock.Any(), gomock.Any()).Return(&activation.ChallengeVerificationResult{}, nil) - svc := NewGatewayService(publisher, verifier) + svc := NewGatewayService(verifier) shutDown := launchServer(t, svc) defer shutDown() @@ -2696,22 +2696,11 @@ func TestGatewayService(t *testing.T) { conn := dialGrpc(ctx, t, cfg) c := pb.NewGatewayServiceClient(conn) - // This should fail - poetMessage := []byte("") - req := &pb.BroadcastPoetRequest{Data: poetMessage} - res, err := c.BroadcastPoet(ctx, req) - require.Nil(t, res, "expected request to fail") - require.Error(t, err, "expected request to fail") - - // This should work. Any nonzero byte string should work as we don't perform any additional validation. - poetMessage = []byte("123") - req.Data = poetMessage - - publisher.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Eq(poetMessage)).Return(nil) - res, err = c.BroadcastPoet(ctx, req) - require.NotNil(t, res, "expected request to succeed") - require.Equal(t, int32(code.Code_OK), res.Status.Code) - require.NoError(t, err, "expected request to succeed") + req := &pb.VerifyChallengeRequest{} + _, err := c.VerifyChallenge(ctx, req) + s, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.OK, s.Code()) } func TestEventsReceived(t *testing.T) { diff --git a/beacon/state.go b/beacon/state.go index e93d91cada..01227eec02 100644 --- a/beacon/state.go +++ b/beacon/state.go @@ -76,7 +76,7 @@ func (s *state) addVote(proposal string, vote uint, voteWeight *big.Int) { if _, ok := s.votesMargin[proposal]; !ok { // voteMargin is updated during the proposal phase. // ignore votes on proposals not in the original proposals. - s.logger.Warning("ignoring vote for unknown proposal", log.Binary("proposal", []byte(proposal))) + s.logger.With().Warning("ignoring vote for unknown proposal", log.Binary("proposal", []byte(proposal))) return } if vote == up { diff --git a/cmd/node/node.go b/cmd/node/node.go index 1542cf8083..5e401cc3b4 100644 --- a/cmd/node/node.go +++ b/cmd/node/node.go @@ -86,7 +86,6 @@ const ( TxHandlerLogger = "txHandler" ProposalBuilderLogger = "proposalBuilder" ProposalListenerLogger = "proposalListener" - PoetListenerLogger = "poetListener" NipostBuilderLogger = "nipostBuilder" Fetcher = "fetcher" TimeSyncLogger = "timesync" @@ -298,7 +297,6 @@ type App struct { postSetupMgr *activation.PostSetupManager atxBuilder *activation.Builder atxHandler *activation.Handler - poetListener *activation.PoetListener edSgn *signing.EdSigner keyExtractor *signing.PubKeyExtractor beaconProtocol *beacon.ProtocolDriver @@ -669,8 +667,6 @@ func (app *App) initServices(ctx context.Context, miner.WithHdist(app.Config.Tortoise.Hdist), miner.WithLogger(app.addLogger(ProposalBuilderLogger, lg))) - poetListener := activation.NewPoetListener(poetDb, app.addLogger(PoetListenerLogger, lg)) - postSetupMgr, err := activation.NewPostSetupManager(nodeID, app.Config.POST, app.addLogger(PostLogger, lg), cdb, goldenATXID) if err != nil { app.log.Panic("failed to create post setup manager: %v", err) @@ -727,7 +723,6 @@ func (app *App) initServices(ctx context.Context, }, atxHandler.HandleGossipAtx)) app.host.Register(pubsub.TxProtocol, pubsub.ChainGossipHandler(syncHandler, txHandler.HandleGossipTransaction)) - app.host.Register(pubsub.PoetProofProtocol, poetListener.HandlePoetProofMessage) app.host.Register(pubsub.HareProtocol, pubsub.ChainGossipHandler(syncHandler, app.hare.GetHareMsgHandler())) app.host.Register(pubsub.BlockCertify, pubsub.ChainGossipHandler(syncHandler, app.certifier.HandleCertifyMessage)) @@ -737,7 +732,6 @@ func (app *App) initServices(ctx context.Context, app.syncer = newSyncer app.clock = clock app.svm = state - app.poetListener = poetListener app.atxBuilder = atxBuilder app.postSetupMgr = postSetupMgr app.atxHandler = atxHandler @@ -823,7 +817,7 @@ func (app *App) startAPIServices(ctx context.Context) { } if apiConf.StartGatewayService { verifier := activation.NewChallengeVerifier(&app.atxDB, app.keyExtractor, app.Config.POST, types.ATXID(app.Config.Genesis.GenesisID().ToHash32()), app.Config.LayersPerEpoch) - registerService(grpcserver.NewGatewayService(app.host, verifier)) + registerService(grpcserver.NewGatewayService(verifier)) } if apiConf.StartGlobalStateService { registerService(grpcserver.NewGlobalStateService(app.mesh, app.conState)) diff --git a/cmd/node/node_test.go b/cmd/node/node_test.go index 1adf3fbe9d..413a4f5b8e 100644 --- a/cmd/node/node_test.go +++ b/cmd/node/node_test.go @@ -525,7 +525,9 @@ func TestSpacemeshApp_NodeService(t *testing.T) { require.NoError(t, err) cfg := getTestDefaultConfig() - poetHarness, err := activation.NewHTTPPoetHarness(false) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + poetHarness, err := activation.NewHTTPPoetHarness(ctx) require.NoError(t, err) t.Cleanup(func() { err := poetHarness.Teardown(true) @@ -565,7 +567,7 @@ func TestSpacemeshApp_NodeService(t *testing.T) { require.NoError(t, app.Start(appCtx)) } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Run the app in a goroutine. As noted above, it blocks if it succeeds. diff --git a/common/types/activation.go b/common/types/activation.go index 248033dd40..50c5d8e64a 100644 --- a/common/types/activation.go +++ b/common/types/activation.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strconv" + "time" "github.com/spacemeshos/go-scale" poetShared "github.com/spacemeshos/poet/shared" @@ -235,7 +236,7 @@ func (atx *ActivationTx) MarshalLogObject(encoder log.ObjectEncoder) error { if err == nil && h != nil { encoder.AddString("challenge", h.String()) } - encoder.AddString("id", atx.id.String()) + atx.id.Field().AddTo(encoder) encoder.AddString("sender_id", atx.nodeID.String()) encoder.AddString("prev_atx_id", atx.PrevATXID.String()) encoder.AddString("pos_atx_id", atx.PositioningATX.String()) @@ -347,6 +348,35 @@ type PoetProof struct { LeafCount uint64 } +func (p *PoetProof) MarshalLogObject(encoder log.ObjectEncoder) error { + if p == nil { + return nil + } + encoder.AddUint64("LeafCount", p.LeafCount) + encoder.AddArray("Indicies", log.ArrayMarshalerFunc(func(encoder log.ArrayEncoder) error { + for _, member := range p.Members { + encoder.AppendString(hex.EncodeToString(member)) + } + return nil + })) + + encoder.AddString("MerkleProof.Root", hex.EncodeToString(p.Root)) + encoder.AddArray("MerkleProof.ProvenLeaves", log.ArrayMarshalerFunc(func(encoder log.ArrayEncoder) error { + for _, v := range p.ProvenLeaves { + encoder.AppendString(hex.EncodeToString(v)) + } + return nil + })) + encoder.AddArray("MerkleProof.ProofNodes", log.ArrayMarshalerFunc(func(encoder log.ArrayEncoder) error { + for _, v := range p.ProofNodes { + encoder.AppendString(hex.EncodeToString(v)) + } + return nil + })) + + return nil +} + // PoetProofMessage is the envelope which includes the PoetProof, service ID, round ID and signature. type PoetProofMessage struct { PoetProof @@ -355,11 +385,23 @@ type PoetProofMessage struct { Signature []byte } +func (p *PoetProofMessage) MarshalLogObject(encoder log.ObjectEncoder) error { + if p == nil { + return nil + } + encoder.AddObject("PoetProof", &p.PoetProof) + encoder.AddString("PoetServiceID", hex.EncodeToString(p.PoetServiceID)) + encoder.AddString("RoundID", p.RoundID) + encoder.AddString("Signature", hex.EncodeToString(p.Signature)) + + return nil +} + // Ref returns the reference to the PoET proof message. It's the sha256 sum of the entire proof message. -func (proofMessage PoetProofMessage) Ref() (PoetProofRef, error) { +func (proofMessage *PoetProofMessage) Ref() (PoetProofRef, error) { poetProofBytes, err := codec.Encode(&proofMessage.PoetProof) if err != nil { - return nil, fmt.Errorf("failed to marshal poet proof for poetId %x round %v: %v", + return nil, fmt.Errorf("failed to marshal poet proof for poetId %x round %v: %w", proofMessage.PoetServiceID, proofMessage.RoundID, err) } ref := hash.Sum(poetProofBytes) @@ -367,10 +409,40 @@ func (proofMessage PoetProofMessage) Ref() (PoetProofRef, error) { return h.Bytes(), nil } +type RoundEnd time.Time + +func (re *RoundEnd) IntoTime() time.Time { + return (time.Time)(*re) +} + +func (p *RoundEnd) EncodeScale(enc *scale.Encoder) (total int, err error) { + t := p.IntoTime() + n, err := scale.EncodeString(enc, t.Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + return n, nil +} + +// DecodeScale implements scale codec interface. +func (p *RoundEnd) DecodeScale(dec *scale.Decoder) (total int, err error) { + field, n, err := scale.DecodeString(dec) + if err != nil { + return 0, err + } + t, err := time.Parse(time.RFC3339Nano, field) + if err != nil { + return n, err + } + *p = (RoundEnd)(t) + return n, nil +} + // PoetRound includes the PoET's round ID. type PoetRound struct { ID string - ChallengeHash []byte + ChallengeHash Hash32 + End RoundEnd } // NIPost is Non-Interactive Proof of Space-Time. diff --git a/common/types/activation_scale.go b/common/types/activation_scale.go index 9ee7dee231..a130968f83 100644 --- a/common/types/activation_scale.go +++ b/common/types/activation_scale.go @@ -431,7 +431,14 @@ func (t *PoetRound) EncodeScale(enc *scale.Encoder) (total int, err error) { total += n } { - n, err := scale.EncodeByteSlice(enc, t.ChallengeHash) + n, err := scale.EncodeByteArray(enc, t.ChallengeHash[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := t.End.EncodeScale(enc) if err != nil { return total, err } @@ -450,12 +457,18 @@ func (t *PoetRound) DecodeScale(dec *scale.Decoder) (total int, err error) { t.ID = string(field) } { - field, n, err := scale.DecodeByteSlice(dec) + n, err := scale.DecodeByteArray(dec, t.ChallengeHash[:]) + if err != nil { + return total, err + } + total += n + } + { + n, err := t.End.DecodeScale(dec) if err != nil { return total, err } total += n - t.ChallengeHash = field } return total, nil } diff --git a/common/types/activation_test.go b/common/types/activation_test.go index e5234a1a3c..9b8f68fbf3 100644 --- a/common/types/activation_test.go +++ b/common/types/activation_test.go @@ -1,9 +1,13 @@ package types_test import ( + "bytes" "testing" + "time" + "github.com/spacemeshos/go-scale" "github.com/spacemeshos/go-scale/tester" + "github.com/stretchr/testify/require" "github.com/spacemeshos/go-spacemesh/common/types" ) @@ -12,6 +16,19 @@ func FuzzActivationConsistency(f *testing.F) { tester.FuzzConsistency[types.ActivationTx](f) } +func TestRoundEndSerialization(t *testing.T) { + end := types.RoundEnd(time.Now()) + var data bytes.Buffer + _, err := end.EncodeScale(scale.NewEncoder(&data)) + require.NoError(t, err) + + var deserialized types.RoundEnd + _, err = deserialized.DecodeScale(scale.NewDecoder(&data)) + require.NoError(t, err) + + require.EqualValues(t, end.IntoTime().Unix(), deserialized.IntoTime().Unix()) +} + func FuzzActivationTxStateSafety(f *testing.F) { tester.FuzzSafety[types.ActivationTx](f) } diff --git a/common/types/nipost.go b/common/types/nipost.go index 8518192636..a6266cba5b 100644 --- a/common/types/nipost.go +++ b/common/types/nipost.go @@ -2,12 +2,14 @@ package types //go:generate scalegen -types NIPostBuilderState,PoetRequest +type PoetServiceID = []byte + // PoetRequest describes an in-flight challenge submission for a poet proof. type PoetRequest struct { // PoetRound is the round of the PoET proving service in which the PoET challenge was included in. PoetRound *PoetRound // PoetServiceID returns the public key of the PoET proving service. - PoetServiceID []byte + PoetServiceID PoetServiceID } // NIPostBuilderState is a builder state. diff --git a/fetch/mesh_data.go b/fetch/mesh_data.go index 0eca78241d..79f0e11f67 100644 --- a/fetch/mesh_data.go +++ b/fetch/mesh_data.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/go-multierror" + "github.com/spacemeshos/go-spacemesh/activation" "github.com/spacemeshos/go-spacemesh/codec" "github.com/spacemeshos/go-spacemesh/common/types" "github.com/spacemeshos/go-spacemesh/datastore" @@ -126,14 +127,23 @@ func (f *Fetch) GetPoetProof(ctx context.Context, id types.Hash32) error { case <-ctx.Done(): return ctx.Err() case <-pm.completed: - if pm.err != nil { - f.logger.WithContext(ctx).With().Warning("failed to get hash", - log.String("hint", string(datastore.POETDB)), - log.Stringer("hash", id), - log.Err(pm.err)) - } } - return pm.err + switch { + case pm.err == nil: + return nil + case errors.Is(pm.err, activation.ErrObjectExists): + // PoET proofs are concurrently stored in DB in two places: + // fetcher and nipost builder. Hence it might happen that + // a proof had been inserted into the DB while the fetcher + // was fetching. + return nil + default: + f.logger.WithContext(ctx).With().Warning("failed to get hash", + log.String("hint", string(datastore.POETDB)), + log.Stringer("hash", id), + log.Err(pm.err)) + return pm.err + } } // GetLayerData get layer data from peers. diff --git a/go.mod b/go.mod index 886cb9a21f..995a84b3f7 100644 --- a/go.mod +++ b/go.mod @@ -30,13 +30,13 @@ require ( github.com/prometheus/common v0.39.0 github.com/pyroscope-io/pyroscope v0.36.0 github.com/seehuhn/mt19937 v1.0.0 - github.com/spacemeshos/api/release/go v1.5.5 + github.com/spacemeshos/api/release/go v1.5.6 github.com/spacemeshos/economics v0.0.0-20220930194415-799d50b0431d github.com/spacemeshos/ed25519 v0.1.1 github.com/spacemeshos/fixed v0.0.0-20210523192743-8d17e03c169a github.com/spacemeshos/go-scale v1.1.2 github.com/spacemeshos/merkle-tree v0.1.0 - github.com/spacemeshos/poet v0.3.0 + github.com/spacemeshos/poet v0.4.0 github.com/spacemeshos/post v0.3.0 github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index af7a9239e1..5170c07e57 100644 --- a/go.sum +++ b/go.sum @@ -546,8 +546,8 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= -github.com/spacemeshos/api/release/go v1.5.5 h1:XfnRjf2OPXRQS1LKfz8qMG/ReH6bmDL9eyyTaFHv9+U= -github.com/spacemeshos/api/release/go v1.5.5/go.mod h1:F87cJ5P8jBbqxHe9wlWmOqnm+v/J9qe+iA1P8ibEAcY= +github.com/spacemeshos/api/release/go v1.5.6 h1:ubcUppvafyRLyq+yvOzXS3u7//rxEkJ85kmcOQWQwUc= +github.com/spacemeshos/api/release/go v1.5.6/go.mod h1:4EIC5bex4jpz6RbP3i1KhacBLn+2g5xGA4Rw1JfYfZI= github.com/spacemeshos/bitstream v0.1.0 h1:p49/dC7dH2Istgas9TJPQ02ky6UU8GtQwSx+Ph0hKps= github.com/spacemeshos/bitstream v0.1.0/go.mod h1:iXo4HfT712ox6fxwoqVeGRKD+GeU1igYZqrU2OW+XcY= github.com/spacemeshos/economics v0.0.0-20220930194415-799d50b0431d h1:ivLphqrvVa4Spg1PdjOALDJc4q+gtivJ9JwRk5LUcPI= @@ -560,8 +560,8 @@ github.com/spacemeshos/go-scale v1.1.2 h1:XYGvgyyYB0bvnbx1WoYQoakt+7mhlBb8A39DyK github.com/spacemeshos/go-scale v1.1.2/go.mod h1:sGaC6ImP82aAsAZA3RgcQbo4gkOhZUshH7/y5/53nYI= github.com/spacemeshos/merkle-tree v0.1.0 h1:3oGOfJab60BPy0qn05gHYQpqvpA/Szr7CgegkZKDKYw= github.com/spacemeshos/merkle-tree v0.1.0/go.mod h1:uGtTEKksCgRdSMyeDdHM1W2d/rI7CxYlzNnKP1GU5Mw= -github.com/spacemeshos/poet v0.3.0 h1:NNnu2XO5OOuNd17wY5Kx/I495N6gzmWQmN8CG+0ojug= -github.com/spacemeshos/poet v0.3.0/go.mod h1:of/woCHGguU4EgOlEU+zLBCzIamQnPZq2tVKVzGnE9o= +github.com/spacemeshos/poet v0.4.0 h1:cS+aYzFF2pPthqSmWR0G1sI/Ks8mmlr0UpoyMQUN36c= +github.com/spacemeshos/poet v0.4.0/go.mod h1:ZDf3nq3JX9Q1yfqwyyghSOf9ohyYVuEW/MzDkCSMO9w= github.com/spacemeshos/post v0.3.0 h1:G21a/K9/CnHWyraUZ86PfeCpnc2s3QjYOSGFE5lsUG0= github.com/spacemeshos/post v0.3.0/go.mod h1:wH2tJxaokZKIdzbEPLe9kLqVmD5l5MGwcIDb2oBaUcY= github.com/spacemeshos/smutil v0.0.0-20220819180433-6aaadca3eb1d h1:09R53k+V+cPCwTH3AnvOJO2OmCb4dJlG6hfgqQlTm6w= diff --git a/log/zap.go b/log/zap.go index 3d2936a6fa..c347d65ba2 100644 --- a/log/zap.go +++ b/log/zap.go @@ -74,6 +74,10 @@ func FieldNamed(name string, field LoggableField) Field { return f } +func (f Field) AddTo(enc ObjectEncoder) { + (zapcore.Field)(f).AddTo(enc) +} + // String returns a string Field. func String(name, val string) Field { return Field(zap.String(name, val)) diff --git a/p2p/pubsub/pubsub.go b/p2p/pubsub/pubsub.go index e456b2f065..1f5dc5ab20 100644 --- a/p2p/pubsub/pubsub.go +++ b/p2p/pubsub/pubsub.go @@ -52,8 +52,6 @@ const ( // AtxProtocol is the protocol id for ATXs. AtxProtocol = "ax1" - // PoetProofProtocol is the protocol id for PoetProof. - PoetProofProtocol = "pt1" // ProposalProtocol is the protocol id for block proposals. ProposalProtocol = "pp1" // TxProtocol iis the protocol id for transactions. @@ -192,9 +190,8 @@ func getOptions(cfg Config) []pubsub.Option { RetainScore: 6 * time.Hour, Topics: map[string]*pubsub.TopicScoreParams{ - AtxProtocol: defaultTopicParam(), - PoetProofProtocol: defaultTopicParam(), - ProposalProtocol: defaultTopicParam(), + AtxProtocol: defaultTopicParam(), + ProposalProtocol: defaultTopicParam(), }, // TODO: add TopicScoreParams }, diff --git a/systest/Makefile b/systest/Makefile index 55f20126d8..92395eb08c 100644 --- a/systest/Makefile +++ b/systest/Makefile @@ -4,7 +4,7 @@ date := $(shell date +'%s') test_name ?= TestSmeshing org ?= spacemeshos image_name ?= $(org)/systest:$(version_info) -poet_image ?= spacemeshos/poet:bb9358fce4c54fcc533993129e489563a7f5748e +poet_image ?= spacemeshos/poet:301d2bb2ec5f913a198607d82a43e49bdb763c2f smesher_image ?= $(org)/go-spacemesh-dev:$(version_info) test_id ?= systest-$(version_info) test_job_name ?= systest-$(version_info)-$(date) diff --git a/systest/parameters/fastnet/poet.conf b/systest/parameters/fastnet/poet.conf index b4e19a59fd..e50ece9519 100644 --- a/systest/parameters/fastnet/poet.conf +++ b/systest/parameters/fastnet/poet.conf @@ -1,3 +1,7 @@ epoch-duration="60s" phase-shift="20s" -cycle-gap="10s" \ No newline at end of file +cycle-gap="20s" +gtw-connection-timeout="2m" + +jsonlog="true" +debuglog="true" diff --git a/systest/parameters/fastnet/smesher.json b/systest/parameters/fastnet/smesher.json index 97283a435f..dc41244231 100644 --- a/systest/parameters/fastnet/smesher.json +++ b/systest/parameters/fastnet/smesher.json @@ -2,10 +2,10 @@ "preset": "fastnet", "poet": { "phase-shift": "20s", - "cycle-gap": "10s", - "grace-period": "3s" + "cycle-gap": "20s", + "grace-period": "5s" }, "logging": { "trtl": "debug" } -} \ No newline at end of file +} diff --git a/systest/parameters/longfast/poet.conf b/systest/parameters/longfast/poet.conf index 60808e2c28..33bdf8b8e0 100644 --- a/systest/parameters/longfast/poet.conf +++ b/systest/parameters/longfast/poet.conf @@ -1,3 +1,3 @@ epoch-duration="30m" phase-shift="10m" -cycle-gap="10s" \ No newline at end of file +cycle-gap="1m" diff --git a/systest/parameters/longfast/smesher.json b/systest/parameters/longfast/smesher.json index 2c3ea7bd5d..939846f20f 100644 --- a/systest/parameters/longfast/smesher.json +++ b/systest/parameters/longfast/smesher.json @@ -6,8 +6,8 @@ }, "poet": { "phase-shift": "10m", - "cycle-gap": "10s", - "grace-period": "3s" + "cycle-gap": "1m", + "grace-period": "5s" }, "tortoise": { "tortoise-zdist": 4, @@ -16,7 +16,7 @@ "hare": { "hare-wakeup-delta": 5, "hare-round-duration-sec": 2, - "hare-limit-iterations": 4 + "hare-limit-iterations": 4 }, "hare-eligibility": { "eligibility-confidence-param": 50 @@ -29,4 +29,4 @@ "beacon-voting-round-duration": "10s", "beacon-weak-coin-round-duration": "10s" } -} \ No newline at end of file +} diff --git a/systest/tests/poets_test.go b/systest/tests/poets_test.go index e912200727..6b4b5fcb38 100644 --- a/systest/tests/poets_test.go +++ b/systest/tests/poets_test.go @@ -1,11 +1,15 @@ package tests import ( + "encoding/hex" + "fmt" + "math" "testing" pb "github.com/spacemeshos/api/release/go/spacemesh/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" "golang.org/x/sync/errgroup" "github.com/spacemeshos/go-spacemesh/systest/cluster" @@ -55,10 +59,11 @@ func testPoetDies(t *testing.T, tctx *testcontext.Context, cl *cluster.Cluster) } if proposal.Status == pb.Proposal_Created { - tctx.Log.Debugw("received proposal event", - "client", client.Name, - "layer", proposal.Layer.Number, - "eligibilities", len(proposal.Eligibilities), + tctx.Log.Desugar().Debug("received proposal event", + zap.String("client", client.Name), + zap.Uint32("layer", proposal.Layer.Number), + zap.String("smesher", prettyHex(proposal.Smesher.Id)), + zap.Int("eligibilities", len(proposal.Eligibilities)), ) createdch <- proposal } @@ -99,13 +104,97 @@ func testPoetDies(t *testing.T, tctx *testcontext.Context, cl *cluster.Cluster) if _, exist := beacons[proposal.Epoch.Value]; !exist { beacons[proposal.Epoch.Value] = map[string]struct{}{} } + tctx.Log.Desugar().Debug("new beacon", + zap.String("smesher", prettyHex(proposal.Smesher.Id)), + zap.String("beacon", prettyHex(edata.Beacon)), + zap.Uint64("epoch", proposal.Epoch.Value), + ) beacons[proposal.Epoch.Value][prettyHex(edata.Beacon)] = struct{}{} } } - requireEqualEligibilities(t, created) + requireEqualEligibilities(tctx, t, created) for epoch := range beacons { assert.Len(t, beacons[epoch], 1, "epoch=%d", epoch) } } + +func TestNodesUsingDifferentPoets(t *testing.T) { + tctx := testcontext.New(t, testcontext.Labels("sanity")) + if tctx.PoetSize < 2 { + t.Skip("Skipping test for using different poets - test configured with less then 2 poets") + } + logger := tctx.Log.Named("TestNodesUsingDifferentPoets") + + cl, err := cluster.Reuse(tctx, cluster.WithKeys(10)) + require.NoError(t, err) + logger.Debug("Obtained cluster") + + poetEndpoints := make([]string, 0, tctx.PoetSize) + for i := 0; i < tctx.PoetSize; i++ { + poetEndpoints = append(poetEndpoints, cluster.MakePoetEndpoint(i)) + } + + for i := 0; i < cl.Total(); i++ { + node := cl.Client(i) + endpoint := poetEndpoints[i%len(poetEndpoints)] + logger.Debugw("updating node's poet server", "node", node.Name, "poet", endpoint) + updated, err := updatePoetServers(tctx, node, []string{endpoint}) + require.NoError(t, err) + require.True(t, updated) + } + + layersCount := uint32(layersToCheck.Get(tctx.Parameters)) + first := nextFirstLayer(currentLayer(tctx, t, cl.Client(0)), layersPerEpoch) + last := first + layersCount - 1 + logger.Debugw("watching layers between", "first", first, "last", last) + + createdch := make(chan *pb.Proposal, cl.Total()*(int(layersCount))) + + eg, ctx := errgroup.WithContext(tctx) + for i := 0; i < cl.Total(); i++ { + clientId := i + client := cl.Client(clientId) + logger.Debugw("watching", "client", client.Name, "clientId", clientId) + watchProposals(ctx, eg, client, func(proposal *pb.Proposal) (bool, error) { + if proposal.Layer.Number < first { + return true, nil + } + if proposal.Layer.Number > last { + logger.Debugw("proposal watcher is done", "client", client.Name) + return false, nil + } + + if proposal.Status == pb.Proposal_Created { + logger.Debugw("received proposal event", + "client", client.Name, + "id", hex.EncodeToString(proposal.Smesher.Id), + "layer", proposal.Layer.Number, + "eligibilities", len(proposal.Eligibilities), + ) + createdch <- proposal + } + return true, nil + }) + } + + require.NoError(t, eg.Wait()) + close(createdch) + + type EpochSet = map[uint64]struct{} + smeshers := map[string]EpochSet{} + for proposal := range createdch { + if smesher, ok := smeshers[string(proposal.Smesher.Id)]; !ok { + smeshers[string(proposal.Smesher.Id)] = EpochSet{proposal.Epoch.Value: struct{}{}} + } else { + smesher[proposal.Epoch.Value] = struct{}{} + } + } + + firstEpochWithEligibility := uint32(math.Max(2.0, float64(first/layersPerEpoch))) + epochsInTest := (last/layersPerEpoch - firstEpochWithEligibility + 1) + for id, eligibleEpochs := range smeshers { + assert.EqualValues(t, epochsInTest, len(eligibleEpochs), fmt.Sprintf("smesher ID: %v, its epochs: %v", hex.EncodeToString([]byte(id)), eligibleEpochs)) + } +} diff --git a/systest/tests/smeshing_test.go b/systest/tests/smeshing_test.go index 694da63b63..4737c74554 100644 --- a/systest/tests/smeshing_test.go +++ b/systest/tests/smeshing_test.go @@ -8,6 +8,8 @@ import ( pb "github.com/spacemeshos/api/release/go/spacemesh/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" "golang.org/x/sync/errgroup" "github.com/spacemeshos/go-spacemesh/systest/cluster" @@ -90,7 +92,7 @@ func testSmeshing(t *testing.T, tctx *testcontext.Context, cl *cluster.Cluster) beaconSet[prettyHex(edata.Beacon)] = struct{}{} } } - requireEqualEligibilities(t, created) + requireEqualEligibilities(tctx, t, created) requireEqualProposals(t, created, includedAll) for epoch := range beacons { require.Len(t, beacons[epoch], 1, "epoch=%d", epoch) @@ -121,7 +123,7 @@ func requireEqualProposals(tb testing.TB, reference map[uint32][]*pb.Proposal, r } } -func requireEqualEligibilities(tb testing.TB, proposals map[uint32][]*pb.Proposal) { +func requireEqualEligibilities(tctx *testcontext.Context, tb testing.TB, proposals map[uint32][]*pb.Proposal) { tb.Helper() aggregated := map[string]int{} @@ -130,6 +132,14 @@ func requireEqualEligibilities(tb testing.TB, proposals map[uint32][]*pb.Proposa aggregated[string(proposal.Smesher.Id)] += len(proposal.Eligibilities) } } + + tctx.Log.Desugar().Info("aggregated eligibilities", zap.Object("per-smesher", zapcore.ObjectMarshalerFunc(func(enc zapcore.ObjectEncoder) error { + for smesher, eligibilities := range aggregated { + enc.AddInt(prettyHex([]byte(smesher)), eligibilities) + } + return nil + }))) + referenceEligibilities := -1 for smesher, eligibilities := range aggregated { if referenceEligibilities < 0 {